[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:
parent
7abfbac479
commit
51f795fbd7
14 changed files with 411 additions and 10 deletions
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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<Props> = 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<Props> = observer((props) => {
|
|||
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 && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -5,5 +5,7 @@ export * from "./label";
|
|||
export * from "./members";
|
||||
export * from "./priority";
|
||||
export * from "./project";
|
||||
export * from "./module";
|
||||
export * from "./cycle";
|
||||
export * from "./state";
|
||||
export * from "./state-group";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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<Props> = 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<Props> = observer((props) => {
|
|||
</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 */}
|
||||
{isFilterEnabled("mentions") && (
|
||||
<div className="py-2">
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -8,10 +8,11 @@ type Props = {
|
|||
title: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
multiple?: boolean;
|
||||
activePulse?: boolean;
|
||||
};
|
||||
|
||||
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 (
|
||||
<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>}
|
||||
<div className="flex-grow truncate text-xs text-custom-text-200">{title}</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue