[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
This commit is contained in:
guru_sainath 2024-02-28 19:34:29 +05:30 committed by GitHub
parent 7abfbac479
commit 51f795fbd7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 411 additions and 10 deletions

View File

@ -60,6 +60,8 @@ export type TIssueParams =
| "created_by" | "created_by"
| "subscriber" | "subscriber"
| "labels" | "labels"
| "cycle"
| "module"
| "start_date" | "start_date"
| "target_date" | "target_date"
| "project" | "project"
@ -79,6 +81,8 @@ export interface IIssueFilterOptions {
labels?: string[] | null; labels?: string[] | null;
priority?: string[] | null; priority?: string[] | null;
project?: string[] | null; project?: string[] | null;
cycle?: string[] | null;
module?: string[] | null;
start_date?: string[] | null; start_date?: string[] | null;
state?: string[] | null; state?: string[] | null;
state_group?: string[] | null; state_group?: string[] | null;

View File

@ -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<Props> = 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 (
<div key={cycleId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3 w-3 flex-shrink-0" />
<span className="normal-case">{cycleDetails.name}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(cycleId)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@ -1,12 +1,15 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useRouter } from "next/router";
// hooks // hooks
import { useUser } from "hooks/store"; import { useApplication, useUser } from "hooks/store";
// components // components
import { import {
AppliedCycleFilters,
AppliedDateFilters, AppliedDateFilters,
AppliedLabelsFilters, AppliedLabelsFilters,
AppliedMembersFilters, AppliedMembersFilters,
AppliedModuleFilters,
AppliedPriorityFilters, AppliedPriorityFilters,
AppliedProjectFilters, AppliedProjectFilters,
AppliedStateFilters, AppliedStateFilters,
@ -34,6 +37,9 @@ const dateFilters = ["start_date", "target_date"];
export const AppliedFiltersList: React.FC<Props> = observer((props) => { export const AppliedFiltersList: React.FC<Props> = observer((props) => {
const { appliedFilters, handleClearAllFilters, handleRemoveFilter, labels, states, alwaysAllowEditing } = props; const { appliedFilters, handleClearAllFilters, handleRemoveFilter, labels, states, alwaysAllowEditing } = props;
// store hooks // store hooks
const {
router: { moduleId, cycleId },
} = useApplication();
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
@ -104,6 +110,20 @@ export const AppliedFiltersList: React.FC<Props> = observer((props) => {
values={value} values={value}
/> />
)} )}
{filterKey === "cycle" && !cycleId && (
<AppliedCycleFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter("cycle", val)}
values={value}
/>
)}
{filterKey === "module" && !moduleId && (
<AppliedModuleFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter("module", val)}
values={value}
/>
)}
{isEditingAllowed && ( {isEditingAllowed && (
<button <button
type="button" type="button"

View File

@ -5,5 +5,7 @@ export * from "./label";
export * from "./members"; export * from "./members";
export * from "./priority"; export * from "./priority";
export * from "./project"; export * from "./project";
export * from "./module";
export * from "./cycle";
export * from "./state"; export * from "./state";
export * from "./state-group"; export * from "./state-group";

View File

@ -0,0 +1,44 @@
import { observer } from "mobx-react-lite";
import { X } from "lucide-react";
// hooks
import { useModule } from "hooks/store";
// ui
import { DiceIcon } from "@plane/ui";
type Props = {
handleRemove: (val: string) => void;
values: string[];
editable: boolean | undefined;
};
export const AppliedModuleFilters: React.FC<Props> = observer((props) => {
const { handleRemove, values, editable } = props;
// store hooks
const { getModuleById } = useModule();
return (
<>
{values.map((moduleId) => {
const moduleDetails = getModuleById(moduleId) ?? null;
if (!moduleDetails) return null;
return (
<div key={moduleId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<DiceIcon className="h-3 w-3 flex-shrink-0" />
<span className="normal-case">{moduleDetails.name}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(moduleId)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@ -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<Props> = 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 (
<>
<FilterHeader
title={`Cycle ${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions ? (
filteredOptions.length > 0 ? (
<>
{filteredOptions.slice(0, itemsToRender).map((cycle) => (
<FilterOption
key={cycle.id}
isChecked={appliedFilters?.includes(cycle.id) ? true : false}
onClick={() => handleUpdate(cycle.id)}
icon={
<CycleGroupIcon cycleGroup={cycleStatus(cycle?.status)} className="h-3.5 w-3.5 flex-shrink-0" />
}
title={cycle.name}
activePulse={cycleStatus(cycle?.status) === "current" ? true : false}
/>
))}
{filteredOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === filteredOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View File

@ -1,6 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Search, X } from "lucide-react"; import { Search, X } from "lucide-react";
// hooks
import { useApplication } from "hooks/store";
// components // components
import { import {
FilterAssignees, FilterAssignees,
@ -13,6 +15,8 @@ import {
FilterState, FilterState,
FilterStateGroup, FilterStateGroup,
FilterTargetDate, FilterTargetDate,
FilterCycle,
FilterModule,
} from "components/issues"; } from "components/issues";
// types // types
import { IIssueFilterOptions, IIssueLabel, IState } from "@plane/types"; import { IIssueFilterOptions, IIssueLabel, IState } from "@plane/types";
@ -30,6 +34,10 @@ type Props = {
export const FilterSelection: React.FC<Props> = observer((props) => { export const FilterSelection: React.FC<Props> = observer((props) => {
const { filters, handleFiltersUpdate, layoutDisplayFiltersOptions, labels, memberIds, states } = props; const { filters, handleFiltersUpdate, layoutDisplayFiltersOptions, labels, memberIds, states } = props;
// hooks
const {
router: { moduleId, cycleId },
} = useApplication();
// states // states
const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
@ -102,6 +110,28 @@ export const FilterSelection: React.FC<Props> = observer((props) => {
</div> </div>
)} )}
{/* cycle */}
{isFilterEnabled("cycle") && !cycleId && (
<div className="py-2">
<FilterCycle
appliedFilters={filters.cycle ?? null}
handleUpdate={(val) => handleFiltersUpdate("cycle", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* module */}
{isFilterEnabled("module") && !moduleId && (
<div className="py-2">
<FilterModule
appliedFilters={filters.module ?? null}
handleUpdate={(val) => handleFiltersUpdate("module", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* assignees */} {/* assignees */}
{isFilterEnabled("mentions") && ( {isFilterEnabled("mentions") && (
<div className="py-2"> <div className="py-2">

View File

@ -8,4 +8,6 @@ export * from "./project";
export * from "./start-date"; export * from "./start-date";
export * from "./state-group"; export * from "./state-group";
export * from "./state"; export * from "./state";
export * from "./cycle";
export * from "./module";
export * from "./target-date"; export * from "./target-date";

View File

@ -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<Props> = 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 (
<>
<FilterHeader
title={`Module ${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions ? (
filteredOptions.length > 0 ? (
<>
{filteredOptions.slice(0, itemsToRender).map((cycle) => (
<FilterOption
key={cycle.id}
isChecked={appliedFilters?.includes(cycle.id) ? true : false}
onClick={() => handleUpdate(cycle.id)}
icon={<DiceIcon className="h-3 w-3 flex-shrink-0" />}
title={cycle.name}
/>
))}
{filteredOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === filteredOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View File

@ -8,10 +8,11 @@ type Props = {
title: React.ReactNode; title: React.ReactNode;
onClick?: () => void; onClick?: () => void;
multiple?: boolean; multiple?: boolean;
activePulse?: boolean;
}; };
export const FilterOption: React.FC<Props> = (props) => { export const FilterOption: React.FC<Props> = (props) => {
const { icon, isChecked, multiple = true, onClick, title } = props; const { icon, isChecked, multiple = true, onClick, title, activePulse = false } = props;
return ( return (
<button <button
@ -30,6 +31,9 @@ export const FilterOption: React.FC<Props> = (props) => {
{icon && <div className="grid w-5 flex-shrink-0 place-items-center">{icon}</div>} {icon && <div className="grid w-5 flex-shrink-0 place-items-center">{icon}</div>}
<div className="flex-grow truncate text-xs text-custom-text-200">{title}</div> <div className="flex-grow truncate text-xs text-custom-text-200">{title}</div>
</div> </div>
{activePulse && (
<div className="flex-shrink-0 text-xs w-2 h-2 rounded-full bg-custom-primary-100 animate-pulse ml-auto" />
)}
</button> </button>
); );
}; };

View File

@ -263,7 +263,17 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
}, },
archived_issues: { archived_issues: {
list: { 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_properties: true,
display_filters: { display_filters: {
group_by: ["state", "state_detail.group", "priority", "labels", "assignees", "created_by", null], 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: { draft_issues: {
list: { 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_properties: true,
display_filters: { display_filters: {
group_by: ["state_detail.group", "priority", "project", "labels", null], group_by: ["state_detail.group", "priority", "project", "labels", null],
@ -291,7 +301,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
}, },
}, },
kanban: { 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_properties: true,
display_filters: { display_filters: {
group_by: ["state_detail.group", "priority", "project", "labels"], group_by: ["state_detail.group", "priority", "project", "labels"],
@ -350,7 +360,18 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
}, },
issues: { issues: {
list: { 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_properties: true,
display_filters: { display_filters: {
group_by: ["state", "priority", "labels", "assignees", "created_by", null], group_by: ["state", "priority", "labels", "assignees", "created_by", null],
@ -363,7 +384,18 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
}, },
}, },
kanban: { 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_properties: true,
display_filters: { display_filters: {
group_by: ["state", "priority", "labels", "assignees", "created_by"], group_by: ["state", "priority", "labels", "assignees", "created_by"],
@ -377,7 +409,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
}, },
}, },
calendar: { 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_properties: true,
display_filters: { display_filters: {
type: [null, "active", "backlog"], type: [null, "active", "backlog"],
@ -388,7 +420,18 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
}, },
}, },
spreadsheet: { 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_properties: true,
display_filters: { display_filters: {
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
@ -400,7 +443,18 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
}, },
}, },
gantt_chart: { 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_properties: false,
display_filters: { display_filters: {
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],

View File

@ -84,6 +84,8 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI
const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "issues"); const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "issues");
if (!filteredParams) return undefined; if (!filteredParams) return undefined;
if (filteredParams.includes("cycle")) filteredParams.splice(filteredParams.indexOf("cycle"), 1);
const filteredRouteParams: Partial<Record<TIssueParams, string | boolean>> = this.computedFilteredParams( const filteredRouteParams: Partial<Record<TIssueParams, string | boolean>> = this.computedFilteredParams(
userFilters?.filters as IIssueFilterOptions, userFilters?.filters as IIssueFilterOptions,
userFilters?.displayFilters as IIssueDisplayFilterOptions, userFilters?.displayFilters as IIssueDisplayFilterOptions,

View File

@ -74,6 +74,8 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore {
mentions: filters?.mentions || undefined, mentions: filters?.mentions || undefined,
created_by: filters?.created_by || undefined, created_by: filters?.created_by || undefined,
labels: filters?.labels || undefined, labels: filters?.labels || undefined,
cycle: filters?.cycle || undefined,
module: filters?.module || undefined,
start_date: filters?.start_date || undefined, start_date: filters?.start_date || undefined,
target_date: filters?.target_date || undefined, target_date: filters?.target_date || undefined,
project: filters.project || undefined, project: filters.project || undefined,
@ -107,6 +109,8 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore {
mentions: filters?.mentions || null, mentions: filters?.mentions || null,
created_by: filters?.created_by || null, created_by: filters?.created_by || null,
labels: filters?.labels || null, labels: filters?.labels || null,
cycle: filters?.cycle || null,
module: filters?.module || null,
start_date: filters?.start_date || null, start_date: filters?.start_date || null,
target_date: filters?.target_date || null, target_date: filters?.target_date || null,
project: filters?.project || null, project: filters?.project || null,

View File

@ -84,6 +84,8 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul
const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "issues"); const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "issues");
if (!filteredParams) return undefined; if (!filteredParams) return undefined;
if (filteredParams.includes("module")) filteredParams.splice(filteredParams.indexOf("module"), 1);
const filteredRouteParams: Partial<Record<TIssueParams, string | boolean>> = this.computedFilteredParams( const filteredRouteParams: Partial<Record<TIssueParams, string | boolean>> = this.computedFilteredParams(
userFilters?.filters as IIssueFilterOptions, userFilters?.filters as IIssueFilterOptions,
userFilters?.displayFilters as IIssueDisplayFilterOptions, userFilters?.displayFilters as IIssueDisplayFilterOptions,