[WEB-4951] [WEB-4884] feat: work item filters revamp (#7810)

This commit is contained in:
Prateek Shourya 2025-09-19 18:27:36 +05:30 committed by GitHub
parent e6a7ca4c72
commit 9aef5d4aa9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
160 changed files with 5879 additions and 4881 deletions

View file

@ -1,14 +1,13 @@
"use client";
import { FC, Fragment, useCallback, useMemo, useState } from "react";
import isEqual from "lodash/isEqual";
import { FC, Fragment, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import { AlertCircle, ChevronUp, ChevronDown } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
import { EIssueFilterType, EEstimateSystem } from "@plane/constants";
import { EEstimateSystem } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EIssuesStoreType, IIssueFilterOptions, TModulePlotType } from "@plane/types";
import { EIssuesStoreType, TModulePlotType } from "@plane/types";
import { CustomSelect, Spinner } from "@plane/ui";
// components
// constants
@ -18,8 +17,8 @@ import ProgressChart from "@/components/core/sidebar/progress-chart";
import { ModuleProgressStats } from "@/components/modules";
// hooks
import { useProjectEstimates } from "@/hooks/store/estimates";
import { useIssues } from "@/hooks/store/use-issues";
import { useModule } from "@/hooks/store/use-module";
import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters";
// plane web constants
type TModuleAnalyticsProgress = {
workspaceSlug: string;
@ -38,31 +37,30 @@ export const ModuleAnalyticsProgress: FC<TModuleAnalyticsProgress> = observer((p
// router
const searchParams = useSearchParams();
const peekModule = searchParams.get("peekModule") || undefined;
// plane hooks
const { t } = useTranslation();
// hooks
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
const { getPlotTypeByModuleId, setPlotType, getModuleById, fetchModuleDetails, fetchArchivedModuleDetails } =
useModule();
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.MODULE);
const { getFilter, updateFilterValueFromSidebar } = useWorkItemFilters();
// state
const [loader, setLoader] = useState(false);
const { t } = useTranslation();
// derived values
const moduleFilter = getFilter(EIssuesStoreType.MODULE, moduleId);
const selectedAssignees = moduleFilter?.findFirstConditionByPropertyAndOperator("assignee_id", "in");
const selectedLabels = moduleFilter?.findFirstConditionByPropertyAndOperator("label_id", "in");
const selectedStateGroups = moduleFilter?.findFirstConditionByPropertyAndOperator("state_group", "in");
const moduleDetails = getModuleById(moduleId);
const plotType: TModulePlotType = getPlotTypeByModuleId(moduleId);
const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false;
const estimateDetails =
isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS;
const completedIssues = moduleDetails?.completed_issues || 0;
const totalIssues = moduleDetails?.total_issues || 0;
const completedEstimatePoints = moduleDetails?.completed_estimate_points || 0;
const totalEstimatePoints = moduleDetails?.total_estimate_points || 0;
const progressHeaderPercentage = moduleDetails
? plotType === "points"
? completedEstimatePoints != 0 && totalEstimatePoints != 0
@ -72,11 +70,9 @@ export const ModuleAnalyticsProgress: FC<TModuleAnalyticsProgress> = observer((p
? Math.round((completedIssues / totalIssues) * 100)
: 0
: 0;
const chartDistributionData =
plotType === "points" ? moduleDetails?.estimate_distribution : moduleDetails?.distribution || undefined;
const completionChartDistributionData = chartDistributionData?.completion_chart || undefined;
const groupedIssues = useMemo(
() => ({
backlog: plotType === "points" ? moduleDetails?.backlog_estimate_points || 0 : moduleDetails?.backlog_issues || 0,
@ -90,7 +86,6 @@ export const ModuleAnalyticsProgress: FC<TModuleAnalyticsProgress> = observer((p
}),
[plotType, moduleDetails]
);
const moduleStartDate = getDate(moduleDetails?.start_date);
const moduleEndDate = getDate(moduleDetails?.target_date);
const isModuleStartDateValid = moduleStartDate && moduleStartDate <= new Date();
@ -116,37 +111,6 @@ export const ModuleAnalyticsProgress: FC<TModuleAnalyticsProgress> = observer((p
}
};
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !projectId) return;
let newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) {
if (key === "state") {
if (isEqual(newValues, value)) newValues = [];
else newValues = value;
} else {
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
}
} else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{ [key]: newValues },
moduleId
);
},
[workspaceSlug, projectId, moduleId, issueFilters, updateFilters]
);
if (!moduleDetails) return <></>;
return (
<div className="border-t border-custom-border-200 space-y-4 py-4 px-3">
@ -234,17 +198,25 @@ export const ModuleAnalyticsProgress: FC<TModuleAnalyticsProgress> = observer((p
{chartDistributionData && (
<div className="w-full border-t border-custom-border-200 pt-5">
<ModuleProgressStats
moduleId={moduleId}
plotType={plotType}
distribution={chartDistributionData}
groupedIssues={groupedIssues}
totalIssuesCount={plotType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
isEditable={Boolean(!peekModule)}
size="xs"
roundedTab={false}
handleFiltersUpdate={updateFilterValueFromSidebar.bind(
updateFilterValueFromSidebar,
EIssuesStoreType.MODULE,
moduleId
)}
isEditable={Boolean(!peekModule) && moduleFilter !== undefined}
moduleId={moduleId}
noBackground={false}
filters={issueFilters}
handleFiltersUpdate={handleFiltersUpdate}
plotType={plotType}
roundedTab={false}
selectedFilters={{
assignees: selectedAssignees,
labels: selectedLabels,
stateGroups: selectedStateGroups,
}}
size="xs"
totalIssuesCount={plotType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
/>
</div>
)}

View file

@ -2,278 +2,65 @@
import { FC } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import { Tab } from "@headlessui/react";
import { useTranslation } from "@plane/i18n";
import { StateGroupIcon } from "@plane/propel/icons";
import {
IIssueFilterOptions,
IIssueFilters,
TModuleDistribution,
TModuleEstimateDistribution,
TModulePlotType,
TStateGroups,
} from "@plane/types";
import { Avatar } from "@plane/ui";
import { cn, getFileURL } from "@plane/utils";
import { TWorkItemFilterCondition } from "@plane/shared-state";
import { TModuleDistribution, TModuleEstimateDistribution, TModulePlotType } from "@plane/types";
import { cn, toFilterArray } from "@plane/utils";
// components
import { SingleProgressStats } from "@/components/core/sidebar/single-progress-stats";
// helpers
import { AssigneeStatComponent, TAssigneeData } from "@/components/core/sidebar/progress-stats/assignee";
import { LabelStatComponent, TLabelData } from "@/components/core/sidebar/progress-stats/label";
import {
createFilterUpdateHandler,
PROGRESS_STATS,
TSelectedFilterProgressStats,
} from "@/components/core/sidebar/progress-stats/shared";
import { StateGroupStatComponent, TStateGroupData } from "@/components/core/sidebar/progress-stats/state_group";
// hooks
import { useProjectState } from "@/hooks/store/use-project-state";
import useLocalStorage from "@/hooks/use-local-storage";
// public
import emptyLabel from "@/public/empty-state/empty_label.svg";
import emptyMembers from "@/public/empty-state/empty_members.svg";
// assignee types
type TAssigneeData = {
id: string | undefined;
title: string | undefined;
avatar_url: string | undefined;
completed: number;
total: number;
}[];
type TAssigneeStatComponent = {
distribution: TAssigneeData;
isEditable?: boolean;
filters?: IIssueFilters | undefined;
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
};
// labelTypes
type TLabelData = {
id: string | undefined;
title: string | undefined;
color: string | undefined;
completed: number;
total: number;
}[];
type TLabelStatComponent = {
distribution: TLabelData;
isEditable?: boolean;
filters?: IIssueFilters | undefined;
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
};
// stateTypes
type TStateData = {
state: string | undefined;
completed: number;
total: number;
}[];
type TStateStatComponent = {
distribution: TStateData;
totalIssuesCount: number;
isEditable?: boolean;
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
};
export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => {
const { distribution, isEditable, filters, handleFiltersUpdate } = props;
const { t } = useTranslation();
return (
<div>
{distribution && distribution.length > 0 ? (
distribution.map((assignee, index) => {
if (assignee?.id)
return (
<SingleProgressStats
key={assignee?.id}
title={
<div className="flex items-center gap-2">
<Avatar name={assignee?.title ?? undefined} src={getFileURL(assignee?.avatar_url ?? "")} />
<span>{assignee?.title ?? ""}</span>
</div>
}
completed={assignee?.completed ?? 0}
total={assignee?.total ?? 0}
{...(isEditable && {
onClick: () => handleFiltersUpdate("assignees", assignee.id ?? ""),
selected: filters?.filters?.assignees?.includes(assignee.id ?? ""),
})}
/>
);
else
return (
<SingleProgressStats
key={`unassigned-${index}`}
title={
<div className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
</div>
<span>{t("no_assignee")}</span>
</div>
}
completed={assignee?.completed ?? 0}
total={assignee?.total ?? 0}
/>
);
})
) : (
<div className="flex h-full flex-col items-center justify-center gap-2">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
<Image src={emptyMembers} className="h-12 w-12" alt="empty members" />
</div>
<h6 className="text-base text-custom-text-300">{t("no_assignees_yet")}</h6>
</div>
)}
</div>
);
});
export const LabelStatComponent = observer((props: TLabelStatComponent) => {
const { distribution, isEditable, filters, handleFiltersUpdate } = props;
return (
<div>
{distribution && distribution.length > 0 ? (
distribution.map((label, index) => {
if (label.id) {
return (
<SingleProgressStats
key={label.id}
title={
<div className="flex items-center gap-2 truncate">
<div
className="h-3 w-3 rounded-full flex-shrink-0"
style={{
backgroundColor: label.color ?? "transparent",
}}
/>
<p className="text-xs text-ellipsis truncate">{label.title ?? "No labels"}</p>
</div>
}
completed={label.completed}
total={label.total}
{...(isEditable && {
onClick: () => handleFiltersUpdate("labels", label.id ?? ""),
selected: filters?.filters?.labels?.includes(label.id ?? `no-label-${index}`),
})}
/>
);
} else {
return (
<SingleProgressStats
key={`no-label-${index}`}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: label.color ?? "transparent",
}}
/>
<span className="text-xs">{label.title ?? "No labels"}</span>
</div>
}
completed={label.completed}
total={label.total}
/>
);
}
})
) : (
<div className="flex h-full flex-col items-center justify-center gap-2">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
<Image src={emptyLabel} className="h-12 w-12" alt="empty label" />
</div>
<h6 className="text-base text-custom-text-300">No labels yet</h6>
</div>
)}
</div>
);
});
export const StateStatComponent = observer((props: TStateStatComponent) => {
const { distribution, isEditable, totalIssuesCount, handleFiltersUpdate } = props;
// hooks
const { groupedProjectStates } = useProjectState();
// derived values
const getStateGroupState = (stateGroup: string) => {
const stateGroupStates = groupedProjectStates?.[stateGroup];
const stateGroupStatesId = stateGroupStates?.map((state) => state.id);
return stateGroupStatesId;
};
return (
<div>
{distribution.map((group, index) => (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<StateGroupIcon stateGroup={group.state as TStateGroups} />
<span className="text-xs capitalize">{group.state}</span>
</div>
}
completed={group.completed}
total={totalIssuesCount}
{...(isEditable && {
onClick: () => group.state && handleFiltersUpdate("state", getStateGroupState(group.state) ?? []),
})}
/>
))}
</div>
);
});
const progressStats = [
{
key: "stat-assignees",
title: "Assignees",
},
{
key: "stat-labels",
title: "Labels",
},
{
key: "stat-states",
title: "States",
},
];
type TModuleProgressStats = {
moduleId: string;
plotType: TModulePlotType;
distribution: TModuleDistribution | TModuleEstimateDistribution | undefined;
groupedIssues: Record<string, number>;
totalIssuesCount: number;
handleFiltersUpdate: (condition: TWorkItemFilterCondition) => void;
isEditable?: boolean;
filters?: IIssueFilters | undefined;
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
size?: "xs" | "sm";
roundedTab?: boolean;
moduleId: string;
noBackground?: boolean;
plotType: TModulePlotType;
roundedTab?: boolean;
selectedFilters: TSelectedFilterProgressStats;
size?: "xs" | "sm";
totalIssuesCount: number;
};
export const ModuleProgressStats: FC<TModuleProgressStats> = observer((props) => {
const {
moduleId,
plotType,
distribution,
groupedIssues,
totalIssuesCount,
isEditable = false,
filters,
handleFiltersUpdate,
size = "sm",
roundedTab = false,
isEditable = false,
moduleId,
noBackground = false,
plotType,
roundedTab = false,
selectedFilters,
size = "sm",
totalIssuesCount,
} = props;
// plane imports
const { t } = useTranslation();
// hooks
const { storedValue: currentTab, setValue: setModuleTab } = useLocalStorage(
`module-analytics-tab-${moduleId}`,
"stat-assignees"
);
// derived values
const currentTabIndex = (tab: string): number => progressStats.findIndex((stat) => stat.key === tab);
const currentTabIndex = (tab: string): number => PROGRESS_STATS.findIndex((stat) => stat.key === tab);
const currentDistribution = distribution as TModuleDistribution;
const currentEstimateDistribution = distribution as TModuleEstimateDistribution;
const selectedAssigneeIds = toFilterArray(selectedFilters?.assignees?.value || []) as string[];
const selectedLabelIds = toFilterArray(selectedFilters?.labels?.value || []) as string[];
const selectedStateGroups = toFilterArray(selectedFilters?.stateGroups?.value || []) as string[];
const distributionAssigneeData: TAssigneeData =
plotType === "burndown"
@ -309,12 +96,24 @@ export const ModuleProgressStats: FC<TModuleProgressStats> = observer((props) =>
total: label.total_estimates,
}));
const distributionStateData: TStateData = Object.keys(groupedIssues || {}).map((state) => ({
const distributionStateData: TStateGroupData = Object.keys(groupedIssues || {}).map((state) => ({
state: state,
completed: groupedIssues?.[state] || 0,
total: totalIssuesCount || 0,
}));
const handleAssigneeFiltersUpdate = createFilterUpdateHandler(
"assignee_id",
selectedAssigneeIds,
handleFiltersUpdate
);
const handleLabelFiltersUpdate = createFilterUpdateHandler("label_id", selectedLabelIds, handleFiltersUpdate);
const handleStateGroupFiltersUpdate = createFilterUpdateHandler(
"state_group",
selectedStateGroups,
handleFiltersUpdate
);
return (
<div>
<Tab.Group defaultIndex={currentTabIndex(currentTab ? currentTab : "stat-assignees")}>
@ -327,7 +126,7 @@ export const ModuleProgressStats: FC<TModuleProgressStats> = observer((props) =>
size === "xs" ? `text-xs` : `text-sm`
)}
>
{progressStats.map((stat) => (
{PROGRESS_STATS.map((stat) => (
<Tab
className={cn(
`p-1 w-full text-custom-text-100 outline-none focus:outline-none cursor-pointer transition-all`,
@ -339,7 +138,7 @@ export const ModuleProgressStats: FC<TModuleProgressStats> = observer((props) =>
key={stat.key}
onClick={() => setModuleTab(stat.key)}
>
{stat.title}
{t(stat.i18n_title)}
</Tab>
))}
</Tab.List>
@ -347,25 +146,26 @@ export const ModuleProgressStats: FC<TModuleProgressStats> = observer((props) =>
<Tab.Panel key={"stat-assignees"}>
<AssigneeStatComponent
distribution={distributionAssigneeData}
handleAssigneeFiltersUpdate={handleAssigneeFiltersUpdate}
isEditable={isEditable}
filters={filters}
handleFiltersUpdate={handleFiltersUpdate}
selectedAssigneeIds={selectedAssigneeIds}
/>
</Tab.Panel>
<Tab.Panel key={"stat-labels"}>
<LabelStatComponent
distribution={distributionLabelData}
handleLabelFiltersUpdate={handleLabelFiltersUpdate}
isEditable={isEditable}
filters={filters}
handleFiltersUpdate={handleFiltersUpdate}
selectedLabelIds={selectedLabelIds}
/>
</Tab.Panel>
<Tab.Panel key={"stat-states"}>
<StateStatComponent
<StateGroupStatComponent
distribution={distributionStateData}
totalIssuesCount={totalIssuesCount}
handleStateGroupFiltersUpdate={handleStateGroupFiltersUpdate}
isEditable={isEditable}
handleFiltersUpdate={handleFiltersUpdate}
selectedStateGroups={selectedStateGroups}
totalIssuesCount={totalIssuesCount}
/>
</Tab.Panel>
</Tab.Panels>