[WEB-4028] feat: sub work item filters and grouping (#6997)

* feat: added filters for sub issues

* feat: added list groups for sub issues

* chore: updated order for sub work item properties

* feat: filters for sub work items

* feat: added filtering and ordering at frontend

* chore: reverted backend filters

* feat: added empty states

* chore: code improvemnt

---------

Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
This commit is contained in:
Vamsi Krishna 2025-05-09 14:24:06 +05:30 committed by GitHub
parent 39b5736c83
commit e401c9d6e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1005 additions and 336 deletions

View file

@ -1,4 +1,10 @@
import { TIssueGroupByOptions, TIssueOrderByOptions, IIssueDisplayProperties } from "@plane/types";
import {
TIssueGroupByOptions,
TIssueOrderByOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
TIssue,
} from "@plane/types";
export const ALL_ISSUES = "All Issues";
@ -361,3 +367,17 @@ export const SPREADSHEET_PROPERTY_DETAILS: {
icon: "LayersIcon",
},
};
// Map filter keys to their corresponding issue property keys
export const FILTER_TO_ISSUE_MAP: Partial<Record<keyof IIssueFilterOptions, keyof TIssue>> = {
assignees: "assignee_ids",
created_by: "created_by",
labels: "label_ids",
priority: "priority",
cycle: "cycle_id",
module: "module_ids",
project: "project_id",
state: "state_id",
issue_type: "type_id",
state_group: "state__group",
} as const;

View file

@ -1,7 +1,4 @@
import {
ILayoutDisplayFiltersOptions,
TIssueActivityComment,
} from "@plane/types";
import { ILayoutDisplayFiltersOptions, TIssueActivityComment } from "@plane/types";
import {
TIssueFilterPriorityObject,
ISSUE_DISPLAY_PROPERTIES_KEYS,
@ -358,9 +355,10 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
sub_work_items: {
list: {
display_properties: SUB_ISSUES_DISPLAY_PROPERTIES_KEYS,
filters: [],
filters: ["priority", "state", "project", "issue_type", "assignees", "start_date", "target_date"],
display_filters: {
order_by: ["-created_at", "-updated_at", "start_date", "-priority"],
group_by: ["state", "priority", "assignees", null],
},
extra_options: {
access: true,
@ -370,9 +368,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
},
};
export const ISSUE_STORE_TO_FILTERS_MAP: Partial<
Record<EIssuesStoreType, TFiltersByLayout>
> = {
export const ISSUE_STORE_TO_FILTERS_MAP: Partial<Record<EIssuesStoreType, TFiltersByLayout>> = {
[EIssuesStoreType.PROJECT]: ISSUE_DISPLAY_FILTERS_BY_PAGE.issues,
};
@ -383,10 +379,7 @@ export enum EActivityFilterType {
export type TActivityFilters = EActivityFilterType;
export const ACTIVITY_FILTER_TYPE_OPTIONS: Record<
TActivityFilters,
{ labelTranslationKey: string }
> = {
export const ACTIVITY_FILTER_TYPE_OPTIONS: Record<TActivityFilters, { labelTranslationKey: string }> = {
[EActivityFilterType.ACTIVITY]: {
labelTranslationKey: "common.updates",
},
@ -402,17 +395,12 @@ export type TActivityFilterOption = {
onClick: () => void;
};
export const defaultActivityFilters: TActivityFilters[] = [
EActivityFilterType.ACTIVITY,
EActivityFilterType.COMMENT,
];
export const defaultActivityFilters: TActivityFilters[] = [EActivityFilterType.ACTIVITY, EActivityFilterType.COMMENT];
export const filterActivityOnSelectedFilters = (
activity: TIssueActivityComment[],
filters: TActivityFilters[]
): TIssueActivityComment[] =>
activity.filter((activity) =>
filters.includes(activity.activity_type as TActivityFilters)
);
activity.filter((activity) => filters.includes(activity.activity_type as TActivityFilters));
export const ENABLE_ISSUE_DEPENDENCIES = false;

View file

@ -64,6 +64,7 @@ export type TIssue = TBaseIssue & {
tempId?: string;
// sourceIssueId is used to store the original issue id when creating a copy of an issue. Used in cloning property values. It is not a part of the API response.
sourceIssueId?: string;
state__group?: string | null;
};
export type TIssueMap = {

View file

@ -1,6 +1,6 @@
import React, { FC } from "react";
import { cn } from "../../helpers";
import { DropdownIcon } from "../icons";
import { DropdownIcon, ISvgIcons } from "../icons";
type Props = {
isOpen: boolean;
@ -10,6 +10,7 @@ type Props = {
actionItemElement?: React.ReactNode;
className?: string;
titleClassName?: string;
ChevronIcon?: React.FC<ISvgIcons>;
};
export const CollapsibleButton: FC<Props> = (props) => {
@ -21,6 +22,7 @@ export const CollapsibleButton: FC<Props> = (props) => {
actionItemElement,
className = "",
titleClassName = "",
ChevronIcon = DropdownIcon,
} = props;
return (
<div
@ -32,7 +34,7 @@ export const CollapsibleButton: FC<Props> = (props) => {
<div className="flex items-center gap-3.5">
<div className="flex items-center gap-3">
{!hideChevron && (
<DropdownIcon
<ChevronIcon
className={cn("size-2 text-custom-text-300 hover:text-custom-text-200 duration-300", {
"-rotate-90": !isOpen,
})}

View file

@ -1,18 +1,25 @@
"use client";
import { FC } from "react";
import { cn } from "@plane/utils";
type Props = {
icon: React.ReactNode;
title: string;
description?: string;
actionElement?: React.ReactNode;
customClassName?: string;
};
export const SectionEmptyState: FC<Props> = (props) => {
const { title, description, icon, actionElement } = props;
const { title, description, icon, actionElement, customClassName } = props;
return (
<div className="flex flex-col gap-4 items-center justify-center rounded-md border border-custom-border-200 p-10">
<div
className={cn(
"flex flex-col gap-4 items-center justify-center rounded-md border border-custom-border-200 p-10",
customClassName
)}
>
<div className="flex flex-col items-center gap-2">
<div className="flex items-center justify-center size-8 bg-custom-background-80 rounded">{icon}</div>
<span className="text-sm font-medium">{title}</span>

View file

@ -1,7 +1,7 @@
"use client";
import React, { FC, useEffect, useState, useCallback } from "react";
import { observer } from "mobx-react";
import { EIssueServiceType } from "@plane/constants";
import { EIssueServiceType, EIssuesStoreType } from "@plane/constants";
import { TIssue, TIssueServiceType } from "@plane/types";
// components
import { DeleteIssueModal } from "@/components/issues/delete-issue-modal";
@ -117,6 +117,7 @@ export const SubIssuesCollapsibleContent: FC<Props> = observer((props) => {
<>
{subIssueHelpers.issue_visibility.includes(parentIssueId) && (
<SubIssuesListRoot
storeType={EIssuesStoreType.PROJECT}
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}

View file

@ -1,9 +1,11 @@
import { FC } from "react";
import { FC, useMemo } from "react";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { SlidersHorizontal } from "lucide-react";
import { IIssueDisplayFilterOptions, ILayoutDisplayFiltersOptions, IIssueDisplayProperties } from "@plane/types";
import { DisplayPropertiesIcon } from "@plane/ui";
import { FilterDisplayProperties, FilterOrderBy, FiltersDropdown } from "@/components/issues";
import { cn } from "@plane/utils";
import { FilterDisplayProperties, FilterGroupBy, FilterOrderBy, FiltersDropdown } from "@/components/issues";
import { isDisplayFiltersApplied } from "@/components/issues/issue-layouts/utils";
type TSubIssueDisplayFiltersProps = {
displayProperties: IIssueDisplayProperties;
@ -24,20 +26,29 @@ export const SubIssueDisplayFilters: FC<TSubIssueDisplayFiltersProps> = observer
displayFilters,
} = props;
const isFilterApplied = useMemo(
() => isDisplayFiltersApplied({ displayProperties, displayFilters }),
[displayProperties, displayFilters]
);
return (
<>
{layoutDisplayFiltersOptions?.display_filters && layoutDisplayFiltersOptions?.display_properties.length > 0 && (
<FiltersDropdown
placement="bottom-end"
menuButton={<DisplayPropertiesIcon className="h-3.5 w-3.5 text-custom-text-100" />}
menuButton={
<div
className={cn(
"p-1 rounded relative transition-all duration-200",
isFilterApplied && "bg-custom-primary-60/20"
)}
>
{isFilterApplied && <span className="p-1 rounded-full bg-custom-primary-100 absolute -top-1 -right-1" />}
<SlidersHorizontal className="h-3.5 w-3.5 text-custom-text-100" />
</div>
}
>
<div
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
className="vertical-scrollbar scrollbar-sm relative h-full w-full divide-y divide-custom-border-200 overflow-hidden overflow-y-auto px-2.5 max-h-[25rem] text-left"
>
<div className="vertical-scrollbar scrollbar-sm relative h-full w-full divide-y divide-custom-border-200 overflow-hidden overflow-y-auto px-2.5 max-h-[25rem] text-left">
{/* display properties */}
<div className="py-2">
<FilterDisplayProperties
@ -48,6 +59,20 @@ export const SubIssueDisplayFilters: FC<TSubIssueDisplayFiltersProps> = observer
/>
</div>
{/* group by */}
<div className="py-2">
<FilterGroupBy
displayFilters={displayFilters}
groupByOptions={layoutDisplayFiltersOptions?.display_filters.group_by ?? []}
handleUpdate={(val) =>
handleDisplayFiltersUpdate({
group_by: val,
})
}
ignoreGroupedFilters={[]}
/>
</div>
{/* order by */}
{!isEmpty(layoutDisplayFiltersOptions?.display_filters?.order_by) && (
<div className="py-2">

View file

@ -0,0 +1,169 @@
import { FC, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { ListFilter, Search, X } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { IIssueFilterOptions, ILayoutDisplayFiltersOptions, IState } from "@plane/types";
import { cn } from "@plane/utils";
import {
FilterAssignees,
FilterDueDate,
FilterPriority,
FilterProjects,
FiltersDropdown,
FilterStartDate,
FilterState,
FilterStateGroup,
} from "@/components/issues";
import { isFiltersApplied } from "@/components/issues/issue-layouts/utils";
import { FilterIssueTypes } from "@/plane-web/components/issues/filters/issue-types";
type TSubIssueFiltersProps = {
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
filters: IIssueFilterOptions;
memberIds: string[] | undefined;
states?: IState[];
layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined;
};
export const SubIssueFilters: FC<TSubIssueFiltersProps> = observer((props) => {
const { handleFiltersUpdate, filters, memberIds, states, layoutDisplayFiltersOptions } = props;
// states
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
const isFilterEnabled = (filter: keyof IIssueFilterOptions) =>
!!layoutDisplayFiltersOptions?.filters.includes(filter);
const isFilterApplied = useMemo(() => isFiltersApplied(filters), [filters]);
// hooks
const { t } = useTranslation();
return (
<>
<FiltersDropdown
placement="bottom-end"
menuButton={
<div
className={cn(
"p-1 rounded relative transition-all duration-200",
isFilterApplied && "bg-custom-primary-60/20"
)}
>
{isFilterApplied && <span className="p-1 rounded-full bg-custom-primary-100 absolute -top-1 -right-1" />}
<ListFilter className="h-3.5 w-3.5 text-custom-text-100" />
</div>
}
>
<div className="flex max-h-[350px] flex-col overflow-hidden">
<div className="bg-custom-background-100 p-2.5 pb-0">
<div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs">
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
<input
type="text"
className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400"
placeholder={t("common.search.label")}
value={filtersSearchQuery}
onChange={(e) => setFiltersSearchQuery(e.target.value)}
/>
{filtersSearchQuery !== "" && (
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
<X className="text-custom-text-300" size={12} strokeWidth={2} />
</button>
)}
</div>
</div>
<div className="vertical-scrollbar scrollbar-sm h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 text-left">
{/* Priority */}
{isFilterEnabled("priority") && (
<div className="py-2">
<FilterPriority
appliedFilters={filters.priority ?? null}
handleUpdate={(val) => handleFiltersUpdate("priority", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* state group */}
{isFilterEnabled("state_group") && (
<div className="py-2">
<FilterStateGroup
appliedFilters={filters.state_group ?? null}
handleUpdate={(val) => handleFiltersUpdate("state_group", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* State */}
{isFilterEnabled("state") && (
<div className="py-2">
<FilterState
appliedFilters={filters.state ?? null}
handleUpdate={(val) => handleFiltersUpdate("state", val)}
searchQuery={filtersSearchQuery}
states={states}
/>
</div>
)}
{/* Projects */}
{isFilterEnabled("project") && (
<div className="py-2">
<FilterProjects
appliedFilters={filters.project ?? null}
handleUpdate={(val) => handleFiltersUpdate("project", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* work item types */}
{isFilterEnabled("issue_type") && (
<div className="py-2">
<FilterIssueTypes
appliedFilters={filters.issue_type ?? null}
handleUpdate={(val) => handleFiltersUpdate("issue_type", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* Assignees */}
{isFilterEnabled("assignees") && (
<div className="py-2">
<FilterAssignees
appliedFilters={filters.assignees ?? null}
handleUpdate={(val) => handleFiltersUpdate("assignees", val)}
memberIds={memberIds}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* Start Date */}
{isFilterEnabled("start_date") && (
<div className="py-2">
<FilterStartDate
appliedFilters={filters.start_date ?? null}
handleUpdate={(val) => handleFiltersUpdate("start_date", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* Target Date */}
{isFilterEnabled("target_date") && (
<div className="py-2">
<FilterDueDate
appliedFilters={filters.target_date ?? null}
handleUpdate={(val) => handleFiltersUpdate("target_date", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
</div>
</div>
</FiltersDropdown>
</>
);
});

View file

@ -0,0 +1,96 @@
import { FC, useState } from "react";
import { observer } from "mobx-react";
import { ChevronRight, CircleDashed } from "lucide-react";
import { ALL_ISSUES, EIssuesStoreType } from "@plane/constants";
import { IGroupByColumn, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
import { Collapsible } from "@plane/ui";
import { cn } from "@plane/utils";
import { SubIssuesListItem } from "./list-item";
interface TSubIssuesListGroupProps {
workItemIds: string[];
projectId: string;
workspaceSlug: string;
group: IGroupByColumn;
serviceType: TIssueServiceType;
disabled: boolean;
parentIssueId: string;
handleIssueCrudState: (
key: "create" | "existing" | "update" | "delete",
issueId: string,
issue?: TIssue | null
) => void;
subIssueOperations: TSubIssueOperations;
storeType?: EIssuesStoreType;
spacingLeft?: number;
}
export const SubIssuesListGroup: FC<TSubIssuesListGroupProps> = observer((props) => {
const {
group,
serviceType,
disabled,
parentIssueId,
projectId,
workspaceSlug,
handleIssueCrudState,
subIssueOperations,
workItemIds,
storeType = EIssuesStoreType.PROJECT,
spacingLeft = 0,
} = props;
const isAllIssues = group.id === ALL_ISSUES;
// states
const [isCollapsibleOpen, setIsCollapsibleOpen] = useState(isAllIssues);
if (!workItemIds.length) return null;
return (
<>
<Collapsible
isOpen={isCollapsibleOpen}
onToggle={() => setIsCollapsibleOpen(!isCollapsibleOpen)}
title={
!isAllIssues && (
<div className="flex items-center gap-2 p-3">
<ChevronRight
className={cn("size-3.5 transition-all text-custom-text-400", {
"rotate-90": isCollapsibleOpen,
})}
strokeWidth={2.5}
/>
<div className="flex-shrink-0 grid place-items-center overflow-hidden">
{group.icon ?? <CircleDashed className="size-3.5" strokeWidth={2} />}
</div>
<span className="text-sm text-custom-text-100 font-medium">{group.name}</span>
<span className="text-sm text-custom-text-400">{workItemIds.length}</span>
</div>
)
}
buttonClassName={cn("hidden", !isAllIssues && "block")}
>
{/* Work items list */}
<div className="pl-2">
{workItemIds?.map((workItemId) => (
<SubIssuesListItem
key={workItemId}
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}
rootIssueId={parentIssueId}
issueId={workItemId}
disabled={disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
issueServiceType={serviceType}
spacingLeft={spacingLeft}
storeType={storeType}
/>
))}
</div>
</Collapsible>
</>
);
});

View file

@ -3,7 +3,7 @@
import { observer } from "mobx-react";
import { ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } from "lucide-react";
// plane imports
import { EIssueServiceType } from "@plane/constants";
import { EIssueServiceType, EIssuesStoreType } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
import { ControlLink, CustomMenu, Tooltip } from "@plane/ui";
@ -37,6 +37,7 @@ type Props = {
subIssueOperations: TSubIssueOperations;
issueId: string;
issueServiceType?: TIssueServiceType;
storeType?: EIssuesStoreType;
};
export const SubIssuesListItem: React.FC<Props> = observer((props) => {
@ -51,6 +52,7 @@ export const SubIssuesListItem: React.FC<Props> = observer((props) => {
handleIssueCrudState,
subIssueOperations,
issueServiceType = EIssueServiceType.ISSUES,
storeType = EIssuesStoreType.PROJECT,
} = props;
const { t } = useTranslation();
const {
@ -81,7 +83,7 @@ export const SubIssuesListItem: React.FC<Props> = observer((props) => {
// derived values
const subIssueFilters = getSubIssueFilters(parentIssueId);
const displayProperties = subIssueFilters.displayProperties ?? {};
const displayProperties = subIssueFilters?.displayProperties ?? {};
//
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug, issue, isMobile);
@ -265,6 +267,7 @@ export const SubIssuesListItem: React.FC<Props> = observer((props) => {
subIssueCount > 0 &&
!isCurrentIssueRoot && (
<SubIssuesListRoot
storeType={storeType}
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
parentIssueId={issue.id}

View file

@ -43,6 +43,47 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
maxDate?.setDate(maxDate.getDate());
return (
<div className="relative flex items-center gap-2">
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state">
<div className="h-5 flex-shrink-0">
<StateDropdown
value={issue.state_id}
projectId={issue.project_id ?? undefined}
onChange={(val) =>
issue.project_id &&
updateSubIssue(
workspaceSlug,
issue.project_id,
parentIssueId,
issueId,
{
state_id: val,
},
{ ...issue }
)
}
disabled={!disabled}
buttonVariant="border-with-text"
/>
</div>
</WithDisplayPropertiesHOC>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="priority">
<div className="h-5 flex-shrink-0">
<PriorityDropdown
value={issue.priority}
onChange={(val) =>
issue.project_id &&
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
priority: val,
})
}
disabled={!disabled}
buttonVariant="border-without-text"
buttonClassName="border"
/>
</div>
</WithDisplayPropertiesHOC>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="start_date">
<div className="h-5 flex-shrink-0" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateDropdown
@ -97,47 +138,6 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
</div>
</WithDisplayPropertiesHOC>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state">
<div className="h-5 flex-shrink-0">
<StateDropdown
value={issue.state_id}
projectId={issue.project_id ?? undefined}
onChange={(val) =>
issue.project_id &&
updateSubIssue(
workspaceSlug,
issue.project_id,
parentIssueId,
issueId,
{
state_id: val,
},
{ ...issue }
)
}
disabled={!disabled}
buttonVariant="border-with-text"
/>
</div>
</WithDisplayPropertiesHOC>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="priority">
<div className="h-5 flex-shrink-0">
<PriorityDropdown
value={issue.priority}
onChange={(val) =>
issue.project_id &&
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
priority: val,
})
}
disabled={!disabled}
buttonVariant="border-without-text"
buttonClassName="border"
/>
</div>
</WithDisplayPropertiesHOC>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
<div className="h-5 flex-shrink-0">
<MemberDropdown

View file

@ -1,12 +1,16 @@
import { useCallback, useMemo } from "react";
import { observer } from "mobx-react";
// plane imports
import { EIssueServiceType } from "@plane/constants";
import { TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
import { ListFilter } from "lucide-react";
import { EIssueServiceType, EIssuesStoreType } from "@plane/constants";
import { GroupByColumnTypes, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
// hooks
import { Button } from "@plane/ui";
import { SectionEmptyState } from "@/components/empty-state";
import { getGroupByColumns, isWorkspaceLevel } from "@/components/issues/issue-layouts/utils";
import { useIssueDetail } from "@/hooks/store";
// local imports
import { SubIssuesListItem } from "./list-item";
import { SubIssuesListGroup } from "./list-group";
type Props = {
workspaceSlug: string;
projectId: string;
@ -21,6 +25,7 @@ type Props = {
) => void;
subIssueOperations: TSubIssueOperations;
issueServiceType?: TIssueServiceType;
storeType: EIssuesStoreType;
};
export const SubIssuesListRoot: React.FC<Props> = observer((props) => {
@ -29,36 +34,89 @@ export const SubIssuesListRoot: React.FC<Props> = observer((props) => {
projectId,
parentIssueId,
rootIssueId,
spacingLeft = 10,
disabled,
handleIssueCrudState,
subIssueOperations,
issueServiceType = EIssueServiceType.ISSUES,
storeType = EIssuesStoreType.PROJECT,
spacingLeft = 0,
} = props;
// store hooks
const {
subIssues: { subIssuesByIssueId },
subIssues: {
subIssuesByIssueId,
filters: { getSubIssueFilters, getGroupedSubWorkItems, getFilteredSubWorkItems, resetFilters },
},
} = useIssueDetail(issueServiceType);
// derived values
const subIssueIds = subIssuesByIssueId(parentIssueId);
const filters = getSubIssueFilters(parentIssueId);
const isRootLevel = useMemo(() => rootIssueId === parentIssueId, [rootIssueId, parentIssueId]);
const group_by = isRootLevel ? (filters?.displayFilters?.group_by ?? null) : null;
const filteredSubWorkItemsCount = (getFilteredSubWorkItems(rootIssueId, filters.filters ?? {}) ?? []).length;
const groups = getGroupByColumns({
groupBy: group_by as GroupByColumnTypes,
includeNone: true,
isWorkspaceLevel: isWorkspaceLevel(storeType),
isEpic: issueServiceType === EIssueServiceType.EPICS,
projectId,
});
const getWorkItemIds = useCallback(
(groupId: string) => {
if (isRootLevel) {
const groupedSubIssues = getGroupedSubWorkItems(parentIssueId);
return groupedSubIssues?.[groupId] ?? [];
}
const subIssueIds = subIssuesByIssueId(parentIssueId);
return subIssueIds ?? [];
},
[isRootLevel, subIssuesByIssueId, parentIssueId, getGroupedSubWorkItems]
);
const isSubWorkItems = issueServiceType === EIssueServiceType.ISSUES;
return (
<div className="relative">
{subIssueIds?.map((issueId) => (
<SubIssuesListItem
key={issueId}
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}
rootIssueId={rootIssueId}
issueId={issueId}
spacingLeft={spacingLeft}
disabled={disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
issueServiceType={issueServiceType}
{filteredSubWorkItemsCount > 0 ? (
groups?.map((group) => (
<SubIssuesListGroup
key={group.id}
workItemIds={getWorkItemIds(group.id)}
projectId={projectId}
workspaceSlug={workspaceSlug}
group={group}
serviceType={issueServiceType}
disabled={disabled}
parentIssueId={parentIssueId}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
storeType={storeType}
spacingLeft={spacingLeft}
/>
))
) : (
<SectionEmptyState
title={
!isSubWorkItems
? "You don't have work items that match the filters you've applied."
: "You don't have sub-work items that match the filters you've applied."
}
description={
!isSubWorkItems
? "To see all work items, clear all applied filters."
: "To see all sub-work items, clear all applied filters."
}
icon={<ListFilter />}
customClassName={storeType !== EIssuesStoreType.EPIC ? "border-none" : ""}
actionElement={
<Button variant="neutral-primary" size="sm" onClick={() => resetFilters(rootIssueId)}>
Clear filters
</Button>
}
/>
))}
)}
</div>
);
});

View file

@ -1,58 +1,89 @@
import { FC, useCallback } from "react";
import cloneDeep from "lodash/cloneDeep";
import { observer } from "mobx-react";
import { EIssueFilterType, EIssueServiceType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueServiceType } from "@plane/types";
import { useIssueDetail } from "@/hooks/store";
import {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
TIssueServiceType,
} from "@plane/types";
import { useIssueDetail, useMember, useProjectState } from "@/hooks/store";
import { SubIssueDisplayFilters } from "./display-filters";
import { SubIssueFilters } from "./filters";
import { SubIssuesActionButton } from "./quick-action-button";
type TSubWorkItemTitleActionsProps = {
disabled: boolean;
issueServiceType?: TIssueServiceType;
parentId: string;
workspaceSlug: string;
projectId: string;
};
export const SubWorkItemTitleActions: FC<TSubWorkItemTitleActionsProps> = observer((props) => {
const { disabled, issueServiceType = EIssueServiceType.ISSUES, parentId, workspaceSlug, projectId } = props;
const { disabled, issueServiceType = EIssueServiceType.ISSUES, parentId, projectId } = props;
// store hooks
const {
subIssues: {
filters: { getSubIssueFilters, updateSubIssueFilters },
filters: { getSubIssueFilters, updateSubWorkItemFilters },
},
} = useIssueDetail(issueServiceType);
const { getProjectStates } = useProjectState();
const {
project: { getProjectMemberIds },
} = useMember();
// derived values
const subIssueFilters = getSubIssueFilters(parentId);
const projectStates = getProjectStates(projectId);
const projectMemberIds = getProjectMemberIds(projectId, false);
const layoutDisplayFiltersOptions = ISSUE_DISPLAY_FILTERS_BY_PAGE["sub_work_items"].list;
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId) return;
updateSubIssueFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, parentId);
updateSubWorkItemFilters(EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, parentId);
},
[workspaceSlug, projectId, parentId, updateSubIssueFilters]
[updateSubWorkItemFilters, parentId]
);
const handleDisplayPropertiesUpdate = useCallback(
(updatedDisplayProperties: Partial<IIssueDisplayProperties>) => {
if (!workspaceSlug || !projectId) return;
updateSubIssueFilters(
workspaceSlug,
projectId,
EIssueFilterType.DISPLAY_PROPERTIES,
updatedDisplayProperties,
parentId
);
updateSubWorkItemFilters(EIssueFilterType.DISPLAY_PROPERTIES, updatedDisplayProperties, parentId);
},
[workspaceSlug, projectId, parentId, updateSubIssueFilters]
[updateSubWorkItemFilters, parentId]
);
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
const newValues = cloneDeep(subIssueFilters?.filters?.[key]) ?? [];
if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
} else {
if (subIssueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateSubWorkItemFilters(EIssueFilterType.FILTERS, { [key]: newValues }, parentId);
},
[subIssueFilters?.filters, updateSubWorkItemFilters, parentId]
);
return (
<div className="flex items-center gap-2">
// prevent click everywhere
<div
className="flex gap-2 items-center"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<SubIssueDisplayFilters
isEpic={issueServiceType === EIssueServiceType.EPICS}
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
@ -61,6 +92,13 @@ export const SubWorkItemTitleActions: FC<TSubWorkItemTitleActionsProps> = observ
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
handleDisplayFiltersUpdate={handleDisplayFilters}
/>
<SubIssueFilters
handleFiltersUpdate={handleFiltersUpdate}
filters={subIssueFilters?.filters ?? {}}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
/>
{!disabled && (
<SubIssuesActionButton issueId={parentId} disabled={disabled} issueServiceType={issueServiceType} />
)}

View file

@ -33,10 +33,7 @@ export const SubIssuesCollapsibleTitle: FC<Props> = observer((props) => {
const { t } = useTranslation();
// store hooks
const {
subIssues: {
subIssuesByIssueId,
stateDistributionByIssueId,
},
subIssues: { subIssuesByIssueId, stateDistributionByIssueId },
} = useIssueDetail(issueServiceType);
// derived values
const subIssuesDistribution = stateDistributionByIssueId(parentIssueId);
@ -63,7 +60,6 @@ export const SubIssuesCollapsibleTitle: FC<Props> = observer((props) => {
}
actionItemElement={
<SubWorkItemTitleActions
workspaceSlug={workspaceSlug}
projectId={projectId}
parentId={parentIssueId}
disabled={disabled}

View file

@ -2,6 +2,7 @@
import { CSSProperties, FC } from "react";
import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { isEmpty } from "lodash";
import clone from "lodash/clone";
import concat from "lodash/concat";
import isEqual from "lodash/isEqual";
@ -25,6 +26,7 @@ import {
IProjectView,
TGroupedIssues,
IWorkspaceView,
IIssueDisplayFilterOptions,
} from "@plane/types";
// plane ui
import { Avatar, CycleGroupIcon, DiceIcon, ISvgIcons, PriorityIcon, StateGroupIcon } from "@plane/ui";
@ -39,6 +41,7 @@ import { store } from "@/lib/store-context";
import { getTeamProjectColumns, SpreadSheetPropertyIconMap } from "@/plane-web/components/issues/issue-layouts/utils";
// store
import { ISSUE_FILTER_DEFAULT_DATA } from "@/store/issue/helpers/base-issues.store";
import { DEFAULT_DISPLAY_PROPERTIES } from "@/store/issue/issue-details/sub_issues_filter.store";
export const HIGHLIGHT_CLASS = "highlight";
export const HIGHLIGHT_WITH_LINE = "highlight-with-line";
@ -58,6 +61,11 @@ export type IssueUpdates = {
};
};
type TGetColumns = {
isWorkspaceLevel?: boolean;
projectId?: string;
};
export const isWorkspaceLevel = (type: EIssuesStoreType) =>
[EIssuesStoreType.PROFILE, EIssuesStoreType.GLOBAL].includes(type) ? true : false;
@ -66,6 +74,7 @@ type TGetGroupByColumns = {
includeNone: boolean;
isWorkspaceLevel: boolean;
isEpic?: boolean;
projectId?: string;
};
// NOTE: Type of groupBy is different compared to what's being passed from the components.
@ -76,6 +85,7 @@ export const getGroupByColumns = ({
includeNone,
isWorkspaceLevel,
isEpic = false,
projectId,
}: TGetGroupByColumns): IGroupByColumn[] | undefined => {
// If no groupBy is specified and includeNone is true, return "All Issues" group
if (!groupBy && includeNone) {
@ -93,21 +103,24 @@ export const getGroupByColumns = ({
if (!groupBy) return undefined;
// Map of group by options to their corresponding column getter functions
const groupByColumnMap: Record<GroupByColumnTypes, () => IGroupByColumn[] | undefined> = {
const groupByColumnMap: Record<
GroupByColumnTypes,
({ isWorkspaceLevel, projectId }: TGetColumns) => IGroupByColumn[] | undefined
> = {
project: getProjectColumns,
cycle: getCycleColumns,
module: getModuleColumns,
state: getStateColumns,
"state_detail.group": getStateGroupColumns,
priority: getPriorityColumns,
labels: () => getLabelsColumns(isWorkspaceLevel),
labels: getLabelsColumns,
assignees: getAssigneeColumns,
created_by: getCreatedByColumns,
team_project: getTeamProjectColumns,
};
// Get and return the columns for the specified group by option
return groupByColumnMap[groupBy]?.();
return groupByColumnMap[groupBy]?.({ isWorkspaceLevel, projectId });
};
const getProjectColumns = (): IGroupByColumn[] | undefined => {
@ -190,11 +203,12 @@ const getModuleColumns = (): IGroupByColumn[] | undefined => {
return modules;
};
const getStateColumns = (): IGroupByColumn[] | undefined => {
const { projectStates } = store.state;
if (!projectStates) return;
const getStateColumns = ({ projectId }: TGetColumns): IGroupByColumn[] | undefined => {
const { getProjectStates, projectStates } = store.state;
const _states = projectId ? getProjectStates(projectId) : projectStates;
if (!_states) return;
// map project states to group by columns
return projectStates.map((state) => ({
return _states.map((state) => ({
id: state.id,
name: state.name,
icon: (
@ -232,7 +246,7 @@ const getPriorityColumns = (): IGroupByColumn[] => {
}));
};
const getLabelsColumns = (isWorkspaceLevel: boolean = false): IGroupByColumn[] => {
const getLabelsColumns = ({ isWorkspaceLevel }: TGetColumns): IGroupByColumn[] => {
const { workspaceLabels, projectLabels } = store.label;
// map labels to group by columns
const labels = [
@ -250,22 +264,40 @@ const getLabelsColumns = (isWorkspaceLevel: boolean = false): IGroupByColumn[] =
}));
};
const getAssigneeColumns = (): IGroupByColumn[] | undefined => {
const getAssigneeColumns = ({ isWorkspaceLevel, projectId }: TGetColumns): IGroupByColumn[] | undefined => {
const assigneeColumns: IGroupByColumn[] = [];
const {
project: { projectMemberIds },
project: { projectMemberIds, getProjectMemberIds },
getUserDetails,
} = store.memberRoot;
if (!projectMemberIds) return;
// Map project member ids to group by assignee columns
const assigneeColumns: IGroupByColumn[] = projectMemberIds.map((memberId) => {
const member = getUserDetails(memberId);
return {
id: memberId,
name: member?.display_name || "",
icon: <Avatar name={member?.display_name} src={getFileURL(member?.avatar_url ?? "")} size="md" />,
payload: { assignee_ids: [memberId] },
};
});
// if workspace level
if (isWorkspaceLevel) {
const { workspaceMemberIds } = store.memberRoot.workspace;
if (!workspaceMemberIds) return;
workspaceMemberIds.forEach((memberId) => {
const member = getUserDetails(memberId);
assigneeColumns.push({
id: memberId,
name: member?.display_name || "",
icon: <Avatar name={member?.display_name} src={getFileURL(member?.avatar_url ?? "")} size="md" />,
payload: { assignee_ids: [memberId] },
});
});
} else {
// if project level
const _projectMemberIds = projectId ? getProjectMemberIds(projectId, false) : projectMemberIds;
if (!_projectMemberIds) return;
// Map project member ids to group by assignee columns
_projectMemberIds.forEach((memberId) => {
const member = getUserDetails(memberId);
assigneeColumns.push({
id: memberId,
name: member?.display_name || "",
icon: <Avatar name={member?.display_name} src={getFileURL(member?.avatar_url ?? "")} size="md" />,
payload: { assignee_ids: [memberId] },
});
});
}
assigneeColumns.push({ id: "None", name: "None", icon: <Avatar size="md" />, payload: {} });
return assigneeColumns;
};
@ -719,3 +751,37 @@ export const SpreadSheetPropertyIcon: FC<ISvgIcons & { iconKey: string }> = (pro
if (!Icon) return null;
return <Icon {...props} />;
};
/**
* This method returns if the filters are applied
* @param filters
* @returns
*/
export const isDisplayFiltersApplied = (filters: Partial<IIssueFilters>): boolean => {
const isDisplayPropertiesApplied = Object.keys(DEFAULT_DISPLAY_PROPERTIES).some(
(key) => !filters.displayProperties?.[key as keyof IIssueDisplayProperties]
);
const isDisplayFiltersApplied = Object.keys(filters.displayFilters ?? {}).some((key) => {
const value = filters.displayFilters?.[key as keyof IIssueDisplayFilterOptions];
if (!value) return false;
// -create_at is the default order
if (key === "order_by") {
return value !== "-created_at";
}
return true;
});
return isDisplayPropertiesApplied || isDisplayFiltersApplied;
};
/**
* This method returns if the filters are applied
* @param filters
* @returns
*/
export const isFiltersApplied = (filters: IIssueFilterOptions): boolean =>
Object.values(filters).some((value) => {
if (Array.isArray(value)) return value.length > 0;
return value !== undefined && value !== null && value !== "";
});

View file

@ -1,8 +1,24 @@
import cloneDeep from "lodash/cloneDeep";
import groupBy from "lodash/groupBy";
import indexOf from "lodash/indexOf";
import isEmpty from "lodash/isEmpty";
import orderBy from "lodash/orderBy";
import set from "lodash/set";
import uniq from "lodash/uniq";
import { ALL_ISSUES } from "@plane/constants";
import { TIssue } from "@plane/types";
import { EIssueGroupedAction } from "./base-issues.store";
import { runInAction } from "mobx";
import { ALL_ISSUES, EIssueFilterType, FILTER_TO_ISSUE_MAP, ISSUE_PRIORITIES } from "@plane/constants";
import {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
IIssueFilters,
TIssue,
TIssueGroupByOptions,
TIssueOrderByOptions,
} from "@plane/types";
import { checkDateCriteria, convertToISODateString, parseDateFilter } from "@/helpers/date-time.helper";
import { store } from "@/lib/store-context";
import { EIssueGroupedAction, ISSUE_GROUP_BY_KEY } from "./base-issues.store";
/**
* returns,
@ -173,3 +189,192 @@ export const getSortOrderToFilterEmptyValues = (key: string, object: any) => {
// get IssueIds from Issue data List
export const getIssueIds = (issues: TIssue[]) => issues.map((issue) => issue?.id);
/**
* Checks if an issue meets the date filter criteria
* @param issue The issue to check
* @param filterKey The date field to check ('start_date' or 'target_date')
* @param dateFilters Array of date filter strings
* @returns boolean indicating if the issue meets the date criteria
*/
export const checkIssueDateFilter = (
issue: TIssue,
filterKey: "start_date" | "target_date",
dateFilters: string[]
): boolean => {
if (!dateFilters || dateFilters.length === 0) return true;
const issueDate = issue[filterKey];
if (!issueDate) return false;
// Issue should match all the date filters (AND operation)
return dateFilters.every((filterValue) => {
const parsed = parseDateFilter(filterValue);
if (!parsed?.date || !parsed?.type) {
// ignore invalid filter instead of failing the whole evaluation
console.warn(`[filters] Ignoring unparsable date filter "${filterValue}"`);
return true;
}
return checkDateCriteria(new Date(issueDate), parsed.date, parsed.type);
});
};
/**
* Helper method to get previous issues state
* @param issues - The array of issues to get the previous state for.
* @returns The previous state of the issues.
*/
export const getPreviousIssuesState = (issues: TIssue[]) => {
const issueIds = issues.map((issue) => issue.id);
const issuesPreviousState: Record<string, TIssue> = {};
issueIds.forEach((issueId) => {
if (store.issue.issues.issuesMap[issueId]) {
issuesPreviousState[issueId] = cloneDeep(store.issue.issues.issuesMap[issueId]);
}
});
return issuesPreviousState;
};
/**
* Filters the given work items based on the provided filters and display filters.
* @param work items - The array of work items to be filtered.
* @param filters - The filters to be applied to the issues.
* @param displayFilters - The display filters to be applied to the issues.
* @returns The filtered array of issues.
*/
export const getFilteredWorkItems = (workItems: TIssue[], filters: IIssueFilterOptions | undefined): TIssue[] => {
if (!filters) return workItems;
// Get all active filters
const activeFilters = Object.entries(filters).filter(([, value]) => value && value.length > 0);
// If no active filters, return all issues
if (activeFilters.length === 0) {
return workItems;
}
return workItems.filter((workItem) =>
// Check all filter conditions (AND operation between different filters)
activeFilters.every(([filterKey, filterValues]) => {
// Handle date filters separately
if (filterKey === "start_date" || filterKey === "target_date") {
return checkIssueDateFilter(workItem, filterKey as "start_date" | "target_date", filterValues as string[]);
}
// Handle regular filters
const issueKey = FILTER_TO_ISSUE_MAP[filterKey as keyof IIssueFilterOptions];
if (!issueKey) return true; // Skip if no mapping exists
const issueValue = workItem[issueKey as keyof TIssue];
// Handle array-based properties vs single value properties
if (Array.isArray(issueValue)) {
return filterValues!.some((filterValue: any) => issueValue.includes(filterValue));
} else {
return filterValues!.includes(issueValue as string);
}
})
);
};
/**
* Orders the given work items based on the provided order by key.
* @param workItems - The array of work items to be ordered.
* @param orderByKey - The key to order the issues by.
* @returns The ordered array of work items.
*/
export const getOrderedWorkItems = (workItems: TIssue[], orderByKey: TIssueOrderByOptions): string[] => {
switch (orderByKey) {
case "-updated_at":
return getIssueIds(orderBy(workItems, (item) => convertToISODateString(item["updated_at"]), ["desc"]));
case "-created_at":
return getIssueIds(orderBy(workItems, (item) => convertToISODateString(item["created_at"]), ["desc"]));
case "-start_date":
return getIssueIds(
orderBy(
workItems,
[getSortOrderToFilterEmptyValues.bind(null, "start_date"), "start_date"], //preferring sorting based on empty values to always keep the empty values below
["asc", "desc"]
)
);
case "-priority": {
const sortArray = ISSUE_PRIORITIES.map((i) => i.key);
return getIssueIds(
orderBy(workItems, (currentIssue: TIssue) => indexOf(sortArray, currentIssue?.priority), ["asc"])
);
}
default:
return getIssueIds(workItems);
}
};
export const getGroupedWorkItemIds = (
workItems: TIssue[],
groupByKey?: TIssueGroupByOptions,
orderByKey: TIssueOrderByOptions = "-created_at"
): Record<string, string[]> => {
// If group by is not set set default as ALL ISSUES
if (!groupByKey) {
return {
[ALL_ISSUES]: getOrderedWorkItems(workItems, orderByKey),
};
}
// Group work items
const groupKey = ISSUE_GROUP_BY_KEY[groupByKey];
const groupedWorkItems = groupBy(workItems, (item) => {
const value = item[groupKey];
if (Array.isArray(value)) {
if (value.length === 0) return "None";
// Sort & join to build deterministic set-like key
return value.slice().sort().join(",");
}
return value ?? "None";
});
// Convert to Record type
const groupedWorkItemsRecord: Record<string, string[]> = {};
Object.entries(groupedWorkItems).forEach(([key, items]) => {
groupedWorkItemsRecord[key] = getOrderedWorkItems(items as TIssue[], orderByKey);
});
return groupedWorkItemsRecord;
};
/**
* Updates the filters for a given work item.
* @param filtersMap - The map of filters for the work item.
* @param filterType - The type of filter to update.
* @param filters - The filters to update.
* @param workItemId - The ID of the work item to update.
*/
export const updateFilters = (
filtersMap: Record<string, Partial<IIssueFilters>>,
filterType: EIssueFilterType,
filters: IIssueDisplayFilterOptions | IIssueDisplayProperties | IIssueFilterOptions,
workItemId: string
) => {
const existingFilters = filtersMap[workItemId] ?? {};
const _filters = {
filters: existingFilters.filters,
displayFilters: existingFilters.displayFilters,
displayProperties: existingFilters.displayProperties,
};
switch (filterType) {
case EIssueFilterType.FILTERS: {
const updatedFilters = filters as IIssueFilterOptions;
_filters.filters = { ..._filters.filters, ...updatedFilters };
set(filtersMap, [workItemId, "filters"], { ..._filters.filters, ...updatedFilters });
break;
}
case EIssueFilterType.DISPLAY_FILTERS: {
set(filtersMap, [workItemId, "displayFilters"], { ..._filters.displayFilters, ...filters });
break;
}
case EIssueFilterType.DISPLAY_PROPERTIES:
set(filtersMap, [workItemId, "displayProperties"], {
..._filters.displayProperties,
...filters,
});
break;
}
};

View file

@ -118,10 +118,10 @@ export interface IBaseIssuesStore {
}
// This constant maps the group by keys to the respective issue property that the key relies on
const ISSUE_GROUP_BY_KEY: Record<TIssueDisplayFilterOptions, keyof TIssue> = {
export const ISSUE_GROUP_BY_KEY: Record<TIssueDisplayFilterOptions, keyof TIssue> = {
project: "project_id",
state: "state_id",
"state_detail.group": "state_id" as keyof TIssue, // state_detail.group is only being used for state_group display,
"state_detail.group": "state__group" as keyof TIssue, // state_detail.group is only being used for state_group display,
priority: "priority",
labels: "label_ids",
created_by: "created_by",

View file

@ -4,6 +4,7 @@ import set from "lodash/set";
import uniq from "lodash/uniq";
import update from "lodash/update";
import { action, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
import { EIssueServiceType } from "@plane/constants";
// types
import {
@ -14,8 +15,6 @@ import {
TSubIssuesStateDistribution,
TIssueServiceType,
TLoader,
TGroupedIssues,
TGroupedIssueCount,
} from "@plane/types";
// services
import { updatePersistentLayer } from "@/local-db/utils/utils";
@ -51,8 +50,6 @@ export interface IIssueSubIssuesStore extends IIssueSubIssuesStoreActions {
// observables
subIssuesStateDistribution: TIssueSubIssuesStateDistributionMap;
subIssues: TIssueSubIssuesIdMap;
groupedSubIssuesMap: Record<string, TGroupedIssues>;
groupedSubIssuesCount: TGroupedIssueCount;
subIssueHelpers: Record<string, TSubIssueHelpers>; // parent_issue_id -> TSubIssueHelpers
loader: TLoader;
filters: IWorkItemSubIssueFiltersStore;
@ -60,7 +57,6 @@ export interface IIssueSubIssuesStore extends IIssueSubIssuesStoreActions {
stateDistributionByIssueId: (issueId: string) => TSubIssuesStateDistribution | undefined;
subIssuesByIssueId: (issueId: string) => string[] | undefined;
subIssueHelpersByIssueId: (issueId: string) => TSubIssueHelpers;
groupedSubIssuesByIssueId: (issueId: string) => TGroupedIssues | undefined;
// actions
fetchOtherProjectProperties: (workspaceSlug: string, projectIds: string[]) => Promise<void>;
setSubIssueHelpers: (parentIssueId: string, key: TSubIssueHelpersKeys, value: string) => void;
@ -70,8 +66,6 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
// observables
subIssuesStateDistribution: TIssueSubIssuesStateDistributionMap = {};
subIssues: TIssueSubIssuesIdMap = {};
groupedSubIssuesMap: Record<string, TGroupedIssues> = {};
groupedSubIssuesCount: TGroupedIssueCount = {};
subIssueHelpers: Record<string, TSubIssueHelpers> = {};
loader: TLoader = undefined;
@ -88,7 +82,6 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
subIssuesStateDistribution: observable,
subIssues: observable,
subIssueHelpers: observable,
groupedSubIssuesMap: observable,
loader: observable.ref,
// actions
setSubIssueHelpers: action,
@ -98,7 +91,6 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
removeSubIssue: action,
deleteSubIssue: action,
fetchOtherProjectProperties: action,
groupedSubIssuesByIssueId: action,
});
this.filters = new WorkItemSubIssueFiltersStore(this);
// root store
@ -114,12 +106,7 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
return this.subIssuesStateDistribution[issueId] ?? undefined;
};
subIssuesByIssueId = (issueId: string) => {
if (!issueId) return undefined;
return this.subIssues[issueId] ?? undefined;
};
groupedSubIssuesByIssueId = (issueId: string) => this.groupedSubIssuesMap[issueId] ?? undefined;
subIssuesByIssueId = computedFn((issueId: string) => this.subIssues[issueId]);
subIssueHelpersByIssueId = (issueId: string) => ({
preview_loader: this.subIssueHelpers?.[issueId]?.preview_loader || [],
@ -138,20 +125,17 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
};
fetchSubIssues = async (workspaceSlug: string, projectId: string, parentIssueId: string) => {
// get filter params
const filterParams = this.filters.computedFilterParams(parentIssueId);
const response = await this.issueService.subIssues(workspaceSlug, projectId, parentIssueId, filterParams);
this.loader = "init-loader";
const response = await this.issueService.subIssues(workspaceSlug, projectId, parentIssueId);
const subIssuesStateDistribution = response?.state_distribution ?? {};
// process sub issues response
const { issueList, groupedIssues } = this.filters.processSubIssueResponse(response.sub_issues);
// set grouped issues count
set(this.groupedSubIssuesMap, [parentIssueId], groupedIssues);
const issueList = (response.sub_issues ?? []) as TIssue[];
this.rootIssueDetailStore.rootIssueStore.issues.addIssue(issueList);
// fetch other issues states and members when sub-issues are from different project
if (issueList && issueList.length > 0) {
const otherProjectIds = uniq(
issueList.map((issue) => issue.project_id).filter((id) => !!id && id !== projectId)
@ -163,6 +147,7 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
sub_issues_count: issueList.length,
});
}
runInAction(() => {
set(this.subIssuesStateDistribution, parentIssueId, subIssuesStateDistribution);
set(
@ -171,6 +156,8 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
issueList.map((issue) => issue.id)
);
});
this.loader = undefined;
return response;
};

View file

@ -1,202 +1,139 @@
import set from "lodash/set";
import { action, makeObservable, observable } from "mobx";
import { ALL_ISSUES, EIssueFilterType, EIssueGroupByToServerOptions } from "@plane/constants";
import { action, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
import { EIssueFilterType } from "@plane/constants";
import {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
IIssueFilters,
TGroupedIssueCount,
TGroupedIssues,
TIssue,
TIssueParams,
TIssues,
TSubGroupedIssues,
TSubIssueResponse,
} from "@plane/types";
import { IIssueSubIssuesStore } from "./sub_issues.store";
import { getFilteredWorkItems, getGroupedWorkItemIds, updateFilters } from "../helpers/base-issues-utils";
import { IssueSubIssuesStore } from "./sub_issues.store";
export const DEFAULT_DISPLAY_PROPERTIES = {
key: true,
issue_type: true,
assignee: true,
start_date: true,
due_date: true,
labels: true,
priority: true,
state: true,
};
export interface IWorkItemSubIssueFiltersStore {
subIssueFiltersMap: Record<string, Partial<IIssueFilters>>;
subIssueFilters: Record<string, Partial<IIssueFilters>>;
// helpers methods
updateSubIssueFilters: (
workspaceSlug: string,
projectId: string,
updateSubWorkItemFilters: (
filterType: EIssueFilterType,
filters: IIssueDisplayFilterOptions | IIssueDisplayProperties,
parentId: string
) => Promise<void>;
getSubIssueFilters: (parentId: string) => Partial<IIssueFilters>;
computedFilterParams: (parentId: string) => Partial<Record<TIssueParams, string | boolean>>;
processSubIssueResponse: (issueResponse: TSubIssueResponse) => {
issueList: TIssue[];
groupedIssues: TIssues;
groupedIssueCount: TGroupedIssueCount;
};
filters: IIssueDisplayFilterOptions | IIssueDisplayProperties | IIssueFilterOptions,
workItemId: string
) => void;
getGroupedSubWorkItems: (workItemId: string) => TGroupedIssues;
getFilteredSubWorkItems: (workItemId: string, filters: IIssueFilterOptions) => TIssue[];
getSubIssueFilters: (workItemId: string) => Partial<IIssueFilters>;
resetFilters: (workItemId: string) => void;
}
export class WorkItemSubIssueFiltersStore implements IWorkItemSubIssueFiltersStore {
// observables
subIssueFiltersMap: Record<string, Partial<IIssueFilters>> = {};
subIssueFilters: Record<string, Partial<IIssueFilters>> = {};
subIssueStore: IIssueSubIssuesStore;
// root store
subIssueStore: IssueSubIssuesStore;
constructor(subIssueStore: IIssueSubIssuesStore) {
constructor(subIssueStore: IssueSubIssuesStore) {
makeObservable(this, {
subIssueFiltersMap: observable,
updateSubIssueFilters: action,
subIssueFilters: observable,
updateSubWorkItemFilters: action,
getSubIssueFilters: action,
});
// sub issue store
// root store
this.subIssueStore = subIssueStore;
}
/**
* @description This method is used to initialize the sub issue filters
* @param parentId
*/
initSubIssueFilters = (parentId: string) => {
set(this.subIssueFiltersMap, [parentId], {
displayFilters: {},
displayProperties: {
key: true,
issue_type: true,
assignee: true,
start_date: true,
due_date: true,
labels: true,
priority: true,
state: true,
},
});
};
/**
* @description This method is used to process the sub issue response to provide the data to update the store
* @param issueResponse
* @returns issueList, list of issues data
* @returns groupedIssues, grouped issue ids
* @returns groupedIssueCount, object containing issue counts of individual groups
*/
processSubIssueResponse = (
issueResponse: TSubIssueResponse
): {
issueList: TIssue[];
groupedIssues: TIssues;
groupedIssueCount: TGroupedIssueCount;
} => {
const issueResult = issueResponse;
if (!issueResult) {
return {
issueList: [],
groupedIssues: {},
groupedIssueCount: {},
};
}
//if is an array then it's an ungrouped response. return values with groupId as ALL_ISSUES
if (Array.isArray(issueResult)) {
return {
issueList: issueResult,
groupedIssues: {
[ALL_ISSUES]: issueResult.map((issue) => issue.id),
},
groupedIssueCount: {
[ALL_ISSUES]: issueResult.length,
},
};
}
const issueList: TIssue[] = [];
const groupedIssues: TGroupedIssues | TSubGroupedIssues = {};
const groupedIssueCount: TGroupedIssueCount = {};
// update total issue count to ALL_ISSUES
set(groupedIssueCount, [ALL_ISSUES], issueResult.length);
// loop through all the groupIds from issue Result
for (const groupId in issueResult) {
const groupIssueResult = issueResult[groupId];
// if groupIssueResult is undefined then continue the loop
if (!groupIssueResult) continue;
// set grouped Issue count of the current groupId
set(groupedIssueCount, [groupId], groupIssueResult.length);
// add the result to issueList
issueList.push(...groupIssueResult);
// set the issue Ids to the groupId path
set(
groupedIssues,
[groupId],
groupIssueResult.map((issue) => issue.id)
);
}
return { issueList, groupedIssues, groupedIssueCount };
};
/**
* @description This method is used to get the sub issue filters
* @param parentId
* @returns IIssueFilters
* @param workItemId
* @returns
*/
getSubIssueFilters = (parentId: string) => {
if (!this.subIssueFiltersMap[parentId]) {
this.initSubIssueFilters(parentId);
getSubIssueFilters = (workItemId: string) => {
if (!this.subIssueFilters[workItemId]) {
this.initializeFilters(workItemId);
}
return this.subIssueFiltersMap[parentId];
};
computedFilterParams = (parentId: string) => {
const displayFilters = this.getSubIssueFilters(parentId).displayFilters;
const computedFilters: Partial<Record<TIssueParams, undefined | string[] | boolean | string>> = {
order_by: displayFilters?.order_by || undefined,
group_by: displayFilters?.group_by ? EIssueGroupByToServerOptions[displayFilters.group_by] : undefined,
};
const issueFiltersParams: Partial<Record<TIssueParams, boolean | string>> = {};
Object.keys(computedFilters).forEach((key) => {
const _key = key as TIssueParams;
const _value: string | boolean | string[] | undefined = computedFilters[_key];
const nonEmptyArrayValue = Array.isArray(_value) && _value.length === 0 ? undefined : _value;
if (nonEmptyArrayValue != undefined)
issueFiltersParams[_key] = Array.isArray(nonEmptyArrayValue)
? nonEmptyArrayValue.join(",")
: nonEmptyArrayValue;
});
return issueFiltersParams;
return this.subIssueFilters[workItemId];
};
/**
* @description This method is used to update the sub issue filters
* @param projectId
* @description This method is used to initialize the sub issue filters
* @param workItemId
*/
initializeFilters = (workItemId: string) => {
set(this.subIssueFilters, [workItemId, "displayProperties"], DEFAULT_DISPLAY_PROPERTIES);
set(this.subIssueFilters, [workItemId, "filters"], {});
set(this.subIssueFilters, [workItemId, "displayFilters"], {});
};
/**
* @description This method updates filters for sub issues.
* @param filterType
* @param filters
*/
updateSubIssueFilters = async (
workspaceSlug: string,
projectId: string,
updateSubWorkItemFilters = (
filterType: EIssueFilterType,
filters: IIssueDisplayFilterOptions | IIssueDisplayProperties,
parentId: string
filters: IIssueDisplayFilterOptions | IIssueDisplayProperties | IIssueFilterOptions,
workItemId: string
) => {
const _filters = this.getSubIssueFilters(parentId);
switch (filterType) {
case EIssueFilterType.DISPLAY_FILTERS: {
set(this.subIssueFiltersMap, [parentId, "displayFilters"], { ..._filters.displayFilters, ...filters });
this.subIssueStore.fetchSubIssues(workspaceSlug, projectId, parentId);
break;
}
case EIssueFilterType.DISPLAY_PROPERTIES:
set(this.subIssueFiltersMap, [parentId, "displayProperties"], {
..._filters.displayProperties,
...filters,
});
break;
}
runInAction(() => {
updateFilters(this.subIssueFilters, filterType, filters, workItemId);
});
};
/**
* @description This method is used to get the grouped sub work items
* @param parentWorkItemId
* @returns
*/
getGroupedSubWorkItems = computedFn((parentWorkItemId: string) => {
const subIssueFilters = this.getSubIssueFilters(parentWorkItemId);
const filteredWorkItems = this.getFilteredSubWorkItems(parentWorkItemId, subIssueFilters.filters ?? {});
// get group by and order by
const groupByKey = subIssueFilters.displayFilters?.group_by;
const orderByKey = subIssueFilters.displayFilters?.order_by;
const groupedWorkItemIds = getGroupedWorkItemIds(filteredWorkItems, groupByKey, orderByKey);
return groupedWorkItemIds;
});
/**
* @description This method is used to get the filtered sub work items
* @param workItemId
* @returns
*/
getFilteredSubWorkItems = computedFn((workItemId: string, filters: IIssueFilterOptions) => {
const subIssueIds = this.subIssueStore.subIssuesByIssueId(workItemId);
const workItems = this.subIssueStore.rootIssueDetailStore.rootIssueStore.issues.getIssuesByIds(
subIssueIds,
"un-archived"
);
const filteredWorkItems = getFilteredWorkItems(workItems, filters);
return filteredWorkItems;
});
/**
* @description This method is used to reset the filters
* @param workItemId
*/
resetFilters = (workItemId: string) => {
this.initializeFilters(workItemId);
};
}

View file

@ -405,3 +405,73 @@ export const generateDateArray = (startDate: string | Date, endDate: string | Da
return dateArray;
};
/**
* Processes relative date strings like "1_weeks", "2_months" etc and returns a Date
* @param value The relative date string (e.g., "1_weeks", "2_months")
* @returns Date object representing the calculated date
*/
export const processRelativeDate = (value: string): Date => {
const [amountStr, unit] = value.split("_");
const amount = parseInt(amountStr, 10);
if (isNaN(amount)) {
throw new Error(`Invalid relative amount: ${amountStr}`);
}
const date = new Date();
switch (unit) {
case "days":
date.setDate(date.getDate() + amount);
break;
case "weeks":
date.setDate(date.getDate() + amount * 7);
break;
case "months":
date.setMonth(date.getMonth() + amount);
break;
default:
throw new Error(`Unsupported time unit: ${unit}`);
}
return date;
};
/**
* Parses a date filter string and returns the comparison type and date
* @param filterValue The date filter string (e.g., "1_weeks;after;fromnow" or "2024-12-01;after")
* @returns Object containing the comparison type and target date
*/
export const parseDateFilter = (filterValue: string): { type: "after" | "before"; date: Date } => {
const parts = filterValue.split(";");
const dateStr = parts[0];
const type = parts[1] as "after" | "before";
let date: Date;
if (dateStr.includes("_")) {
// Handle relative dates (e.g., "1_weeks;after;fromnow")
date = processRelativeDate(dateStr);
} else {
// Handle absolute dates (e.g., "2024-12-01;after")
date = new Date(dateStr);
}
return { type, date };
};
/**
* Checks if a date meets the filter criteria
* @param dateToCheck The date to check
* @param filterDate The filter date to compare against
* @param type The type of comparison ('after' or 'before')
* @returns boolean indicating if the date meets the criteria
*/
export const checkDateCriteria = (dateToCheck: Date | null, filterDate: Date, type: "after" | "before"): boolean => {
if (!dateToCheck) return false;
const checkDate = new Date(dateToCheck);
const normalizedCheck = new Date(checkDate.setHours(0, 0, 0, 0));
const normalizedFilter = new Date(filterDate.getTime());
normalizedFilter.setHours(0, 0, 0, 0);
return type === "after" ? normalizedCheck >= normalizedFilter : normalizedCheck <= normalizedFilter;
};