[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:
parent
39b5736c83
commit
e401c9d6e4
20 changed files with 1005 additions and 336 deletions
|
|
@ -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";
|
export const ALL_ISSUES = "All Issues";
|
||||||
|
|
||||||
|
|
@ -361,3 +367,17 @@ export const SPREADSHEET_PROPERTY_DETAILS: {
|
||||||
icon: "LayersIcon",
|
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;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
import {
|
import { ILayoutDisplayFiltersOptions, TIssueActivityComment } from "@plane/types";
|
||||||
ILayoutDisplayFiltersOptions,
|
|
||||||
TIssueActivityComment,
|
|
||||||
} from "@plane/types";
|
|
||||||
import {
|
import {
|
||||||
TIssueFilterPriorityObject,
|
TIssueFilterPriorityObject,
|
||||||
ISSUE_DISPLAY_PROPERTIES_KEYS,
|
ISSUE_DISPLAY_PROPERTIES_KEYS,
|
||||||
|
|
@ -358,9 +355,10 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
|
||||||
sub_work_items: {
|
sub_work_items: {
|
||||||
list: {
|
list: {
|
||||||
display_properties: SUB_ISSUES_DISPLAY_PROPERTIES_KEYS,
|
display_properties: SUB_ISSUES_DISPLAY_PROPERTIES_KEYS,
|
||||||
filters: [],
|
filters: ["priority", "state", "project", "issue_type", "assignees", "start_date", "target_date"],
|
||||||
display_filters: {
|
display_filters: {
|
||||||
order_by: ["-created_at", "-updated_at", "start_date", "-priority"],
|
order_by: ["-created_at", "-updated_at", "start_date", "-priority"],
|
||||||
|
group_by: ["state", "priority", "assignees", null],
|
||||||
},
|
},
|
||||||
extra_options: {
|
extra_options: {
|
||||||
access: true,
|
access: true,
|
||||||
|
|
@ -370,9 +368,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ISSUE_STORE_TO_FILTERS_MAP: Partial<
|
export const ISSUE_STORE_TO_FILTERS_MAP: Partial<Record<EIssuesStoreType, TFiltersByLayout>> = {
|
||||||
Record<EIssuesStoreType, TFiltersByLayout>
|
|
||||||
> = {
|
|
||||||
[EIssuesStoreType.PROJECT]: ISSUE_DISPLAY_FILTERS_BY_PAGE.issues,
|
[EIssuesStoreType.PROJECT]: ISSUE_DISPLAY_FILTERS_BY_PAGE.issues,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -383,10 +379,7 @@ export enum EActivityFilterType {
|
||||||
|
|
||||||
export type TActivityFilters = EActivityFilterType;
|
export type TActivityFilters = EActivityFilterType;
|
||||||
|
|
||||||
export const ACTIVITY_FILTER_TYPE_OPTIONS: Record<
|
export const ACTIVITY_FILTER_TYPE_OPTIONS: Record<TActivityFilters, { labelTranslationKey: string }> = {
|
||||||
TActivityFilters,
|
|
||||||
{ labelTranslationKey: string }
|
|
||||||
> = {
|
|
||||||
[EActivityFilterType.ACTIVITY]: {
|
[EActivityFilterType.ACTIVITY]: {
|
||||||
labelTranslationKey: "common.updates",
|
labelTranslationKey: "common.updates",
|
||||||
},
|
},
|
||||||
|
|
@ -402,17 +395,12 @@ export type TActivityFilterOption = {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultActivityFilters: TActivityFilters[] = [
|
export const defaultActivityFilters: TActivityFilters[] = [EActivityFilterType.ACTIVITY, EActivityFilterType.COMMENT];
|
||||||
EActivityFilterType.ACTIVITY,
|
|
||||||
EActivityFilterType.COMMENT,
|
|
||||||
];
|
|
||||||
|
|
||||||
export const filterActivityOnSelectedFilters = (
|
export const filterActivityOnSelectedFilters = (
|
||||||
activity: TIssueActivityComment[],
|
activity: TIssueActivityComment[],
|
||||||
filters: TActivityFilters[]
|
filters: TActivityFilters[]
|
||||||
): TIssueActivityComment[] =>
|
): TIssueActivityComment[] =>
|
||||||
activity.filter((activity) =>
|
activity.filter((activity) => filters.includes(activity.activity_type as TActivityFilters));
|
||||||
filters.includes(activity.activity_type as TActivityFilters)
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ENABLE_ISSUE_DEPENDENCIES = false;
|
export const ENABLE_ISSUE_DEPENDENCIES = false;
|
||||||
|
|
|
||||||
1
packages/types/src/issues/issue.d.ts
vendored
1
packages/types/src/issues/issue.d.ts
vendored
|
|
@ -64,6 +64,7 @@ export type TIssue = TBaseIssue & {
|
||||||
tempId?: string;
|
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 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;
|
sourceIssueId?: string;
|
||||||
|
state__group?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TIssueMap = {
|
export type TIssueMap = {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { FC } from "react";
|
import React, { FC } from "react";
|
||||||
import { cn } from "../../helpers";
|
import { cn } from "../../helpers";
|
||||||
import { DropdownIcon } from "../icons";
|
import { DropdownIcon, ISvgIcons } from "../icons";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -10,6 +10,7 @@ type Props = {
|
||||||
actionItemElement?: React.ReactNode;
|
actionItemElement?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
titleClassName?: string;
|
titleClassName?: string;
|
||||||
|
ChevronIcon?: React.FC<ISvgIcons>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CollapsibleButton: FC<Props> = (props) => {
|
export const CollapsibleButton: FC<Props> = (props) => {
|
||||||
|
|
@ -21,6 +22,7 @@ export const CollapsibleButton: FC<Props> = (props) => {
|
||||||
actionItemElement,
|
actionItemElement,
|
||||||
className = "",
|
className = "",
|
||||||
titleClassName = "",
|
titleClassName = "",
|
||||||
|
ChevronIcon = DropdownIcon,
|
||||||
} = props;
|
} = props;
|
||||||
return (
|
return (
|
||||||
<div
|
<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.5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{!hideChevron && (
|
{!hideChevron && (
|
||||||
<DropdownIcon
|
<ChevronIcon
|
||||||
className={cn("size-2 text-custom-text-300 hover:text-custom-text-200 duration-300", {
|
className={cn("size-2 text-custom-text-300 hover:text-custom-text-200 duration-300", {
|
||||||
"-rotate-90": !isOpen,
|
"-rotate-90": !isOpen,
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,25 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
|
import { cn } from "@plane/utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
actionElement?: React.ReactNode;
|
actionElement?: React.ReactNode;
|
||||||
|
customClassName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SectionEmptyState: FC<Props> = (props) => {
|
export const SectionEmptyState: FC<Props> = (props) => {
|
||||||
const { title, description, icon, actionElement } = props;
|
const { title, description, icon, actionElement, customClassName } = props;
|
||||||
return (
|
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 flex-col items-center gap-2">
|
||||||
<div className="flex items-center justify-center size-8 bg-custom-background-80 rounded">{icon}</div>
|
<div className="flex items-center justify-center size-8 bg-custom-background-80 rounded">{icon}</div>
|
||||||
<span className="text-sm font-medium">{title}</span>
|
<span className="text-sm font-medium">{title}</span>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
import React, { FC, useEffect, useState, useCallback } from "react";
|
import React, { FC, useEffect, useState, useCallback } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { EIssueServiceType } from "@plane/constants";
|
import { EIssueServiceType, EIssuesStoreType } from "@plane/constants";
|
||||||
import { TIssue, TIssueServiceType } from "@plane/types";
|
import { TIssue, TIssueServiceType } from "@plane/types";
|
||||||
// components
|
// components
|
||||||
import { DeleteIssueModal } from "@/components/issues/delete-issue-modal";
|
import { DeleteIssueModal } from "@/components/issues/delete-issue-modal";
|
||||||
|
|
@ -117,6 +117,7 @@ export const SubIssuesCollapsibleContent: FC<Props> = observer((props) => {
|
||||||
<>
|
<>
|
||||||
{subIssueHelpers.issue_visibility.includes(parentIssueId) && (
|
{subIssueHelpers.issue_visibility.includes(parentIssueId) && (
|
||||||
<SubIssuesListRoot
|
<SubIssuesListRoot
|
||||||
|
storeType={EIssuesStoreType.PROJECT}
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
parentIssueId={parentIssueId}
|
parentIssueId={parentIssueId}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { FC } from "react";
|
import { FC, useMemo } from "react";
|
||||||
import isEmpty from "lodash/isEmpty";
|
import isEmpty from "lodash/isEmpty";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
import { SlidersHorizontal } from "lucide-react";
|
||||||
import { IIssueDisplayFilterOptions, ILayoutDisplayFiltersOptions, IIssueDisplayProperties } from "@plane/types";
|
import { IIssueDisplayFilterOptions, ILayoutDisplayFiltersOptions, IIssueDisplayProperties } from "@plane/types";
|
||||||
import { DisplayPropertiesIcon } from "@plane/ui";
|
import { cn } from "@plane/utils";
|
||||||
import { FilterDisplayProperties, FilterOrderBy, FiltersDropdown } from "@/components/issues";
|
import { FilterDisplayProperties, FilterGroupBy, FilterOrderBy, FiltersDropdown } from "@/components/issues";
|
||||||
|
import { isDisplayFiltersApplied } from "@/components/issues/issue-layouts/utils";
|
||||||
|
|
||||||
type TSubIssueDisplayFiltersProps = {
|
type TSubIssueDisplayFiltersProps = {
|
||||||
displayProperties: IIssueDisplayProperties;
|
displayProperties: IIssueDisplayProperties;
|
||||||
|
|
@ -24,20 +26,29 @@ export const SubIssueDisplayFilters: FC<TSubIssueDisplayFiltersProps> = observer
|
||||||
displayFilters,
|
displayFilters,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const isFilterApplied = useMemo(
|
||||||
|
() => isDisplayFiltersApplied({ displayProperties, displayFilters }),
|
||||||
|
[displayProperties, displayFilters]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{layoutDisplayFiltersOptions?.display_filters && layoutDisplayFiltersOptions?.display_properties.length > 0 && (
|
{layoutDisplayFiltersOptions?.display_filters && layoutDisplayFiltersOptions?.display_properties.length > 0 && (
|
||||||
<FiltersDropdown
|
<FiltersDropdown
|
||||||
placement="bottom-end"
|
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
|
<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">
|
||||||
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"
|
|
||||||
>
|
|
||||||
{/* display properties */}
|
{/* display properties */}
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
<FilterDisplayProperties
|
<FilterDisplayProperties
|
||||||
|
|
@ -48,6 +59,20 @@ export const SubIssueDisplayFilters: FC<TSubIssueDisplayFiltersProps> = observer
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* order by */}
|
||||||
{!isEmpty(layoutDisplayFiltersOptions?.display_filters?.order_by) && (
|
{!isEmpty(layoutDisplayFiltersOptions?.display_filters?.order_by) && (
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } from "lucide-react";
|
import { ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } from "lucide-react";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { EIssueServiceType } from "@plane/constants";
|
import { EIssueServiceType, EIssuesStoreType } from "@plane/constants";
|
||||||
import { useTranslation } from "@plane/i18n";
|
import { useTranslation } from "@plane/i18n";
|
||||||
import { TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
|
import { TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
|
||||||
import { ControlLink, CustomMenu, Tooltip } from "@plane/ui";
|
import { ControlLink, CustomMenu, Tooltip } from "@plane/ui";
|
||||||
|
|
@ -37,6 +37,7 @@ type Props = {
|
||||||
subIssueOperations: TSubIssueOperations;
|
subIssueOperations: TSubIssueOperations;
|
||||||
issueId: string;
|
issueId: string;
|
||||||
issueServiceType?: TIssueServiceType;
|
issueServiceType?: TIssueServiceType;
|
||||||
|
storeType?: EIssuesStoreType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SubIssuesListItem: React.FC<Props> = observer((props) => {
|
export const SubIssuesListItem: React.FC<Props> = observer((props) => {
|
||||||
|
|
@ -51,6 +52,7 @@ export const SubIssuesListItem: React.FC<Props> = observer((props) => {
|
||||||
handleIssueCrudState,
|
handleIssueCrudState,
|
||||||
subIssueOperations,
|
subIssueOperations,
|
||||||
issueServiceType = EIssueServiceType.ISSUES,
|
issueServiceType = EIssueServiceType.ISSUES,
|
||||||
|
storeType = EIssuesStoreType.PROJECT,
|
||||||
} = props;
|
} = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
|
|
@ -81,7 +83,7 @@ export const SubIssuesListItem: React.FC<Props> = observer((props) => {
|
||||||
|
|
||||||
// derived values
|
// derived values
|
||||||
const subIssueFilters = getSubIssueFilters(parentIssueId);
|
const subIssueFilters = getSubIssueFilters(parentIssueId);
|
||||||
const displayProperties = subIssueFilters.displayProperties ?? {};
|
const displayProperties = subIssueFilters?.displayProperties ?? {};
|
||||||
|
|
||||||
//
|
//
|
||||||
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug, issue, isMobile);
|
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug, issue, isMobile);
|
||||||
|
|
@ -265,6 +267,7 @@ export const SubIssuesListItem: React.FC<Props> = observer((props) => {
|
||||||
subIssueCount > 0 &&
|
subIssueCount > 0 &&
|
||||||
!isCurrentIssueRoot && (
|
!isCurrentIssueRoot && (
|
||||||
<SubIssuesListRoot
|
<SubIssuesListRoot
|
||||||
|
storeType={storeType}
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={issue.project_id}
|
projectId={issue.project_id}
|
||||||
parentIssueId={issue.id}
|
parentIssueId={issue.id}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,47 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
|
||||||
maxDate?.setDate(maxDate.getDate());
|
maxDate?.setDate(maxDate.getDate());
|
||||||
return (
|
return (
|
||||||
<div className="relative flex items-center gap-2">
|
<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">
|
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="start_date">
|
||||||
<div className="h-5 flex-shrink-0" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
<div className="h-5 flex-shrink-0" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||||
<DateDropdown
|
<DateDropdown
|
||||||
|
|
@ -97,47 +138,6 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
|
||||||
</div>
|
</div>
|
||||||
</WithDisplayPropertiesHOC>
|
</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">
|
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
|
||||||
<div className="h-5 flex-shrink-0">
|
<div className="h-5 flex-shrink-0">
|
||||||
<MemberDropdown
|
<MemberDropdown
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { EIssueServiceType } from "@plane/constants";
|
import { ListFilter } from "lucide-react";
|
||||||
import { TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
|
import { EIssueServiceType, EIssuesStoreType } from "@plane/constants";
|
||||||
|
import { GroupByColumnTypes, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
|
||||||
// hooks
|
// 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";
|
import { useIssueDetail } from "@/hooks/store";
|
||||||
// local imports
|
|
||||||
import { SubIssuesListItem } from "./list-item";
|
|
||||||
|
|
||||||
|
import { SubIssuesListGroup } from "./list-group";
|
||||||
type Props = {
|
type Props = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -21,6 +25,7 @@ type Props = {
|
||||||
) => void;
|
) => void;
|
||||||
subIssueOperations: TSubIssueOperations;
|
subIssueOperations: TSubIssueOperations;
|
||||||
issueServiceType?: TIssueServiceType;
|
issueServiceType?: TIssueServiceType;
|
||||||
|
storeType: EIssuesStoreType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SubIssuesListRoot: React.FC<Props> = observer((props) => {
|
export const SubIssuesListRoot: React.FC<Props> = observer((props) => {
|
||||||
|
|
@ -29,36 +34,89 @@ export const SubIssuesListRoot: React.FC<Props> = observer((props) => {
|
||||||
projectId,
|
projectId,
|
||||||
parentIssueId,
|
parentIssueId,
|
||||||
rootIssueId,
|
rootIssueId,
|
||||||
spacingLeft = 10,
|
|
||||||
disabled,
|
disabled,
|
||||||
handleIssueCrudState,
|
handleIssueCrudState,
|
||||||
subIssueOperations,
|
subIssueOperations,
|
||||||
issueServiceType = EIssueServiceType.ISSUES,
|
issueServiceType = EIssueServiceType.ISSUES,
|
||||||
|
storeType = EIssuesStoreType.PROJECT,
|
||||||
|
spacingLeft = 0,
|
||||||
} = props;
|
} = props;
|
||||||
// store hooks
|
// store hooks
|
||||||
const {
|
const {
|
||||||
subIssues: { subIssuesByIssueId },
|
subIssues: {
|
||||||
|
subIssuesByIssueId,
|
||||||
|
filters: { getSubIssueFilters, getGroupedSubWorkItems, getFilteredSubWorkItems, resetFilters },
|
||||||
|
},
|
||||||
} = useIssueDetail(issueServiceType);
|
} = useIssueDetail(issueServiceType);
|
||||||
|
|
||||||
// derived values
|
// 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 (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{subIssueIds?.map((issueId) => (
|
{filteredSubWorkItemsCount > 0 ? (
|
||||||
<SubIssuesListItem
|
groups?.map((group) => (
|
||||||
key={issueId}
|
<SubIssuesListGroup
|
||||||
workspaceSlug={workspaceSlug}
|
key={group.id}
|
||||||
projectId={projectId}
|
workItemIds={getWorkItemIds(group.id)}
|
||||||
parentIssueId={parentIssueId}
|
projectId={projectId}
|
||||||
rootIssueId={rootIssueId}
|
workspaceSlug={workspaceSlug}
|
||||||
issueId={issueId}
|
group={group}
|
||||||
spacingLeft={spacingLeft}
|
serviceType={issueServiceType}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
handleIssueCrudState={handleIssueCrudState}
|
parentIssueId={parentIssueId}
|
||||||
subIssueOperations={subIssueOperations}
|
handleIssueCrudState={handleIssueCrudState}
|
||||||
issueServiceType={issueServiceType}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,89 @@
|
||||||
import { FC, useCallback } from "react";
|
import { FC, useCallback } from "react";
|
||||||
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { EIssueFilterType, EIssueServiceType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
import { EIssueFilterType, EIssueServiceType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueServiceType } from "@plane/types";
|
import {
|
||||||
import { useIssueDetail } from "@/hooks/store";
|
IIssueDisplayFilterOptions,
|
||||||
|
IIssueDisplayProperties,
|
||||||
|
IIssueFilterOptions,
|
||||||
|
TIssueServiceType,
|
||||||
|
} from "@plane/types";
|
||||||
|
import { useIssueDetail, useMember, useProjectState } from "@/hooks/store";
|
||||||
import { SubIssueDisplayFilters } from "./display-filters";
|
import { SubIssueDisplayFilters } from "./display-filters";
|
||||||
|
import { SubIssueFilters } from "./filters";
|
||||||
import { SubIssuesActionButton } from "./quick-action-button";
|
import { SubIssuesActionButton } from "./quick-action-button";
|
||||||
|
|
||||||
type TSubWorkItemTitleActionsProps = {
|
type TSubWorkItemTitleActionsProps = {
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
issueServiceType?: TIssueServiceType;
|
issueServiceType?: TIssueServiceType;
|
||||||
parentId: string;
|
parentId: string;
|
||||||
workspaceSlug: string;
|
|
||||||
projectId: string;
|
projectId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SubWorkItemTitleActions: FC<TSubWorkItemTitleActionsProps> = observer((props) => {
|
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
|
// store hooks
|
||||||
const {
|
const {
|
||||||
subIssues: {
|
subIssues: {
|
||||||
filters: { getSubIssueFilters, updateSubIssueFilters },
|
filters: { getSubIssueFilters, updateSubWorkItemFilters },
|
||||||
},
|
},
|
||||||
} = useIssueDetail(issueServiceType);
|
} = useIssueDetail(issueServiceType);
|
||||||
|
const { getProjectStates } = useProjectState();
|
||||||
|
const {
|
||||||
|
project: { getProjectMemberIds },
|
||||||
|
} = useMember();
|
||||||
|
|
||||||
// derived values
|
// derived values
|
||||||
const subIssueFilters = getSubIssueFilters(parentId);
|
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 layoutDisplayFiltersOptions = ISSUE_DISPLAY_FILTERS_BY_PAGE["sub_work_items"].list;
|
||||||
|
|
||||||
const handleDisplayFilters = useCallback(
|
const handleDisplayFilters = useCallback(
|
||||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||||
if (!workspaceSlug || !projectId) return;
|
updateSubWorkItemFilters(EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, parentId);
|
||||||
updateSubIssueFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, parentId);
|
|
||||||
},
|
},
|
||||||
[workspaceSlug, projectId, parentId, updateSubIssueFilters]
|
[updateSubWorkItemFilters, parentId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDisplayPropertiesUpdate = useCallback(
|
const handleDisplayPropertiesUpdate = useCallback(
|
||||||
(updatedDisplayProperties: Partial<IIssueDisplayProperties>) => {
|
(updatedDisplayProperties: Partial<IIssueDisplayProperties>) => {
|
||||||
if (!workspaceSlug || !projectId) return;
|
updateSubWorkItemFilters(EIssueFilterType.DISPLAY_PROPERTIES, updatedDisplayProperties, parentId);
|
||||||
updateSubIssueFilters(
|
|
||||||
workspaceSlug,
|
|
||||||
projectId,
|
|
||||||
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 (
|
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
|
<SubIssueDisplayFilters
|
||||||
isEpic={issueServiceType === EIssueServiceType.EPICS}
|
isEpic={issueServiceType === EIssueServiceType.EPICS}
|
||||||
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
|
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
|
||||||
|
|
@ -61,6 +92,13 @@ export const SubWorkItemTitleActions: FC<TSubWorkItemTitleActionsProps> = observ
|
||||||
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
|
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
|
||||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||||
/>
|
/>
|
||||||
|
<SubIssueFilters
|
||||||
|
handleFiltersUpdate={handleFiltersUpdate}
|
||||||
|
filters={subIssueFilters?.filters ?? {}}
|
||||||
|
memberIds={projectMemberIds ?? undefined}
|
||||||
|
states={projectStates}
|
||||||
|
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
|
||||||
|
/>
|
||||||
{!disabled && (
|
{!disabled && (
|
||||||
<SubIssuesActionButton issueId={parentId} disabled={disabled} issueServiceType={issueServiceType} />
|
<SubIssuesActionButton issueId={parentId} disabled={disabled} issueServiceType={issueServiceType} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,7 @@ export const SubIssuesCollapsibleTitle: FC<Props> = observer((props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// store hooks
|
// store hooks
|
||||||
const {
|
const {
|
||||||
subIssues: {
|
subIssues: { subIssuesByIssueId, stateDistributionByIssueId },
|
||||||
subIssuesByIssueId,
|
|
||||||
stateDistributionByIssueId,
|
|
||||||
},
|
|
||||||
} = useIssueDetail(issueServiceType);
|
} = useIssueDetail(issueServiceType);
|
||||||
// derived values
|
// derived values
|
||||||
const subIssuesDistribution = stateDistributionByIssueId(parentIssueId);
|
const subIssuesDistribution = stateDistributionByIssueId(parentIssueId);
|
||||||
|
|
@ -63,7 +60,6 @@ export const SubIssuesCollapsibleTitle: FC<Props> = observer((props) => {
|
||||||
}
|
}
|
||||||
actionItemElement={
|
actionItemElement={
|
||||||
<SubWorkItemTitleActions
|
<SubWorkItemTitleActions
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
parentId={parentIssueId}
|
parentId={parentIssueId}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { CSSProperties, FC } from "react";
|
import { CSSProperties, FC } from "react";
|
||||||
import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
|
import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
|
||||||
|
import { isEmpty } from "lodash";
|
||||||
import clone from "lodash/clone";
|
import clone from "lodash/clone";
|
||||||
import concat from "lodash/concat";
|
import concat from "lodash/concat";
|
||||||
import isEqual from "lodash/isEqual";
|
import isEqual from "lodash/isEqual";
|
||||||
|
|
@ -25,6 +26,7 @@ import {
|
||||||
IProjectView,
|
IProjectView,
|
||||||
TGroupedIssues,
|
TGroupedIssues,
|
||||||
IWorkspaceView,
|
IWorkspaceView,
|
||||||
|
IIssueDisplayFilterOptions,
|
||||||
} from "@plane/types";
|
} from "@plane/types";
|
||||||
// plane ui
|
// plane ui
|
||||||
import { Avatar, CycleGroupIcon, DiceIcon, ISvgIcons, PriorityIcon, StateGroupIcon } from "@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";
|
import { getTeamProjectColumns, SpreadSheetPropertyIconMap } from "@/plane-web/components/issues/issue-layouts/utils";
|
||||||
// store
|
// store
|
||||||
import { ISSUE_FILTER_DEFAULT_DATA } from "@/store/issue/helpers/base-issues.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_CLASS = "highlight";
|
||||||
export const HIGHLIGHT_WITH_LINE = "highlight-with-line";
|
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) =>
|
export const isWorkspaceLevel = (type: EIssuesStoreType) =>
|
||||||
[EIssuesStoreType.PROFILE, EIssuesStoreType.GLOBAL].includes(type) ? true : false;
|
[EIssuesStoreType.PROFILE, EIssuesStoreType.GLOBAL].includes(type) ? true : false;
|
||||||
|
|
||||||
|
|
@ -66,6 +74,7 @@ type TGetGroupByColumns = {
|
||||||
includeNone: boolean;
|
includeNone: boolean;
|
||||||
isWorkspaceLevel: boolean;
|
isWorkspaceLevel: boolean;
|
||||||
isEpic?: boolean;
|
isEpic?: boolean;
|
||||||
|
projectId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// NOTE: Type of groupBy is different compared to what's being passed from the components.
|
// NOTE: Type of groupBy is different compared to what's being passed from the components.
|
||||||
|
|
@ -76,6 +85,7 @@ export const getGroupByColumns = ({
|
||||||
includeNone,
|
includeNone,
|
||||||
isWorkspaceLevel,
|
isWorkspaceLevel,
|
||||||
isEpic = false,
|
isEpic = false,
|
||||||
|
projectId,
|
||||||
}: TGetGroupByColumns): IGroupByColumn[] | undefined => {
|
}: TGetGroupByColumns): IGroupByColumn[] | undefined => {
|
||||||
// If no groupBy is specified and includeNone is true, return "All Issues" group
|
// If no groupBy is specified and includeNone is true, return "All Issues" group
|
||||||
if (!groupBy && includeNone) {
|
if (!groupBy && includeNone) {
|
||||||
|
|
@ -93,21 +103,24 @@ export const getGroupByColumns = ({
|
||||||
if (!groupBy) return undefined;
|
if (!groupBy) return undefined;
|
||||||
|
|
||||||
// Map of group by options to their corresponding column getter functions
|
// 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,
|
project: getProjectColumns,
|
||||||
cycle: getCycleColumns,
|
cycle: getCycleColumns,
|
||||||
module: getModuleColumns,
|
module: getModuleColumns,
|
||||||
state: getStateColumns,
|
state: getStateColumns,
|
||||||
"state_detail.group": getStateGroupColumns,
|
"state_detail.group": getStateGroupColumns,
|
||||||
priority: getPriorityColumns,
|
priority: getPriorityColumns,
|
||||||
labels: () => getLabelsColumns(isWorkspaceLevel),
|
labels: getLabelsColumns,
|
||||||
assignees: getAssigneeColumns,
|
assignees: getAssigneeColumns,
|
||||||
created_by: getCreatedByColumns,
|
created_by: getCreatedByColumns,
|
||||||
team_project: getTeamProjectColumns,
|
team_project: getTeamProjectColumns,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get and return the columns for the specified group by option
|
// Get and return the columns for the specified group by option
|
||||||
return groupByColumnMap[groupBy]?.();
|
return groupByColumnMap[groupBy]?.({ isWorkspaceLevel, projectId });
|
||||||
};
|
};
|
||||||
|
|
||||||
const getProjectColumns = (): IGroupByColumn[] | undefined => {
|
const getProjectColumns = (): IGroupByColumn[] | undefined => {
|
||||||
|
|
@ -190,11 +203,12 @@ const getModuleColumns = (): IGroupByColumn[] | undefined => {
|
||||||
return modules;
|
return modules;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStateColumns = (): IGroupByColumn[] | undefined => {
|
const getStateColumns = ({ projectId }: TGetColumns): IGroupByColumn[] | undefined => {
|
||||||
const { projectStates } = store.state;
|
const { getProjectStates, projectStates } = store.state;
|
||||||
if (!projectStates) return;
|
const _states = projectId ? getProjectStates(projectId) : projectStates;
|
||||||
|
if (!_states) return;
|
||||||
// map project states to group by columns
|
// map project states to group by columns
|
||||||
return projectStates.map((state) => ({
|
return _states.map((state) => ({
|
||||||
id: state.id,
|
id: state.id,
|
||||||
name: state.name,
|
name: state.name,
|
||||||
icon: (
|
icon: (
|
||||||
|
|
@ -232,7 +246,7 @@ const getPriorityColumns = (): IGroupByColumn[] => {
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLabelsColumns = (isWorkspaceLevel: boolean = false): IGroupByColumn[] => {
|
const getLabelsColumns = ({ isWorkspaceLevel }: TGetColumns): IGroupByColumn[] => {
|
||||||
const { workspaceLabels, projectLabels } = store.label;
|
const { workspaceLabels, projectLabels } = store.label;
|
||||||
// map labels to group by columns
|
// map labels to group by columns
|
||||||
const labels = [
|
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 {
|
const {
|
||||||
project: { projectMemberIds },
|
project: { projectMemberIds, getProjectMemberIds },
|
||||||
getUserDetails,
|
getUserDetails,
|
||||||
} = store.memberRoot;
|
} = store.memberRoot;
|
||||||
if (!projectMemberIds) return;
|
// if workspace level
|
||||||
// Map project member ids to group by assignee columns
|
if (isWorkspaceLevel) {
|
||||||
const assigneeColumns: IGroupByColumn[] = projectMemberIds.map((memberId) => {
|
const { workspaceMemberIds } = store.memberRoot.workspace;
|
||||||
const member = getUserDetails(memberId);
|
if (!workspaceMemberIds) return;
|
||||||
return {
|
workspaceMemberIds.forEach((memberId) => {
|
||||||
id: memberId,
|
const member = getUserDetails(memberId);
|
||||||
name: member?.display_name || "",
|
assigneeColumns.push({
|
||||||
icon: <Avatar name={member?.display_name} src={getFileURL(member?.avatar_url ?? "")} size="md" />,
|
id: memberId,
|
||||||
payload: { assignee_ids: [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: {} });
|
assigneeColumns.push({ id: "None", name: "None", icon: <Avatar size="md" />, payload: {} });
|
||||||
return assigneeColumns;
|
return assigneeColumns;
|
||||||
};
|
};
|
||||||
|
|
@ -719,3 +751,37 @@ export const SpreadSheetPropertyIcon: FC<ISvgIcons & { iconKey: string }> = (pro
|
||||||
if (!Icon) return null;
|
if (!Icon) return null;
|
||||||
return <Icon {...props} />;
|
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 !== "";
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 isEmpty from "lodash/isEmpty";
|
||||||
|
import orderBy from "lodash/orderBy";
|
||||||
|
import set from "lodash/set";
|
||||||
import uniq from "lodash/uniq";
|
import uniq from "lodash/uniq";
|
||||||
import { ALL_ISSUES } from "@plane/constants";
|
import { runInAction } from "mobx";
|
||||||
import { TIssue } from "@plane/types";
|
import { ALL_ISSUES, EIssueFilterType, FILTER_TO_ISSUE_MAP, ISSUE_PRIORITIES } from "@plane/constants";
|
||||||
import { EIssueGroupedAction } from "./base-issues.store";
|
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,
|
* returns,
|
||||||
|
|
@ -173,3 +189,192 @@ export const getSortOrderToFilterEmptyValues = (key: string, object: any) => {
|
||||||
|
|
||||||
// get IssueIds from Issue data List
|
// get IssueIds from Issue data List
|
||||||
export const getIssueIds = (issues: TIssue[]) => issues.map((issue) => issue?.id);
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -118,10 +118,10 @@ export interface IBaseIssuesStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
// This constant maps the group by keys to the respective issue property that the key relies on
|
// 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",
|
project: "project_id",
|
||||||
state: "state_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",
|
priority: "priority",
|
||||||
labels: "label_ids",
|
labels: "label_ids",
|
||||||
created_by: "created_by",
|
created_by: "created_by",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import set from "lodash/set";
|
||||||
import uniq from "lodash/uniq";
|
import uniq from "lodash/uniq";
|
||||||
import update from "lodash/update";
|
import update from "lodash/update";
|
||||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||||
|
import { computedFn } from "mobx-utils";
|
||||||
import { EIssueServiceType } from "@plane/constants";
|
import { EIssueServiceType } from "@plane/constants";
|
||||||
// types
|
// types
|
||||||
import {
|
import {
|
||||||
|
|
@ -14,8 +15,6 @@ import {
|
||||||
TSubIssuesStateDistribution,
|
TSubIssuesStateDistribution,
|
||||||
TIssueServiceType,
|
TIssueServiceType,
|
||||||
TLoader,
|
TLoader,
|
||||||
TGroupedIssues,
|
|
||||||
TGroupedIssueCount,
|
|
||||||
} from "@plane/types";
|
} from "@plane/types";
|
||||||
// services
|
// services
|
||||||
import { updatePersistentLayer } from "@/local-db/utils/utils";
|
import { updatePersistentLayer } from "@/local-db/utils/utils";
|
||||||
|
|
@ -51,8 +50,6 @@ export interface IIssueSubIssuesStore extends IIssueSubIssuesStoreActions {
|
||||||
// observables
|
// observables
|
||||||
subIssuesStateDistribution: TIssueSubIssuesStateDistributionMap;
|
subIssuesStateDistribution: TIssueSubIssuesStateDistributionMap;
|
||||||
subIssues: TIssueSubIssuesIdMap;
|
subIssues: TIssueSubIssuesIdMap;
|
||||||
groupedSubIssuesMap: Record<string, TGroupedIssues>;
|
|
||||||
groupedSubIssuesCount: TGroupedIssueCount;
|
|
||||||
subIssueHelpers: Record<string, TSubIssueHelpers>; // parent_issue_id -> TSubIssueHelpers
|
subIssueHelpers: Record<string, TSubIssueHelpers>; // parent_issue_id -> TSubIssueHelpers
|
||||||
loader: TLoader;
|
loader: TLoader;
|
||||||
filters: IWorkItemSubIssueFiltersStore;
|
filters: IWorkItemSubIssueFiltersStore;
|
||||||
|
|
@ -60,7 +57,6 @@ export interface IIssueSubIssuesStore extends IIssueSubIssuesStoreActions {
|
||||||
stateDistributionByIssueId: (issueId: string) => TSubIssuesStateDistribution | undefined;
|
stateDistributionByIssueId: (issueId: string) => TSubIssuesStateDistribution | undefined;
|
||||||
subIssuesByIssueId: (issueId: string) => string[] | undefined;
|
subIssuesByIssueId: (issueId: string) => string[] | undefined;
|
||||||
subIssueHelpersByIssueId: (issueId: string) => TSubIssueHelpers;
|
subIssueHelpersByIssueId: (issueId: string) => TSubIssueHelpers;
|
||||||
groupedSubIssuesByIssueId: (issueId: string) => TGroupedIssues | undefined;
|
|
||||||
// actions
|
// actions
|
||||||
fetchOtherProjectProperties: (workspaceSlug: string, projectIds: string[]) => Promise<void>;
|
fetchOtherProjectProperties: (workspaceSlug: string, projectIds: string[]) => Promise<void>;
|
||||||
setSubIssueHelpers: (parentIssueId: string, key: TSubIssueHelpersKeys, value: string) => void;
|
setSubIssueHelpers: (parentIssueId: string, key: TSubIssueHelpersKeys, value: string) => void;
|
||||||
|
|
@ -70,8 +66,6 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
|
||||||
// observables
|
// observables
|
||||||
subIssuesStateDistribution: TIssueSubIssuesStateDistributionMap = {};
|
subIssuesStateDistribution: TIssueSubIssuesStateDistributionMap = {};
|
||||||
subIssues: TIssueSubIssuesIdMap = {};
|
subIssues: TIssueSubIssuesIdMap = {};
|
||||||
groupedSubIssuesMap: Record<string, TGroupedIssues> = {};
|
|
||||||
groupedSubIssuesCount: TGroupedIssueCount = {};
|
|
||||||
subIssueHelpers: Record<string, TSubIssueHelpers> = {};
|
subIssueHelpers: Record<string, TSubIssueHelpers> = {};
|
||||||
loader: TLoader = undefined;
|
loader: TLoader = undefined;
|
||||||
|
|
||||||
|
|
@ -88,7 +82,6 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
|
||||||
subIssuesStateDistribution: observable,
|
subIssuesStateDistribution: observable,
|
||||||
subIssues: observable,
|
subIssues: observable,
|
||||||
subIssueHelpers: observable,
|
subIssueHelpers: observable,
|
||||||
groupedSubIssuesMap: observable,
|
|
||||||
loader: observable.ref,
|
loader: observable.ref,
|
||||||
// actions
|
// actions
|
||||||
setSubIssueHelpers: action,
|
setSubIssueHelpers: action,
|
||||||
|
|
@ -98,7 +91,6 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
|
||||||
removeSubIssue: action,
|
removeSubIssue: action,
|
||||||
deleteSubIssue: action,
|
deleteSubIssue: action,
|
||||||
fetchOtherProjectProperties: action,
|
fetchOtherProjectProperties: action,
|
||||||
groupedSubIssuesByIssueId: action,
|
|
||||||
});
|
});
|
||||||
this.filters = new WorkItemSubIssueFiltersStore(this);
|
this.filters = new WorkItemSubIssueFiltersStore(this);
|
||||||
// root store
|
// root store
|
||||||
|
|
@ -114,12 +106,7 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
|
||||||
return this.subIssuesStateDistribution[issueId] ?? undefined;
|
return this.subIssuesStateDistribution[issueId] ?? undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
subIssuesByIssueId = (issueId: string) => {
|
subIssuesByIssueId = computedFn((issueId: string) => this.subIssues[issueId]);
|
||||||
if (!issueId) return undefined;
|
|
||||||
return this.subIssues[issueId] ?? undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
groupedSubIssuesByIssueId = (issueId: string) => this.groupedSubIssuesMap[issueId] ?? undefined;
|
|
||||||
|
|
||||||
subIssueHelpersByIssueId = (issueId: string) => ({
|
subIssueHelpersByIssueId = (issueId: string) => ({
|
||||||
preview_loader: this.subIssueHelpers?.[issueId]?.preview_loader || [],
|
preview_loader: this.subIssueHelpers?.[issueId]?.preview_loader || [],
|
||||||
|
|
@ -138,20 +125,17 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchSubIssues = async (workspaceSlug: string, projectId: string, parentIssueId: string) => {
|
fetchSubIssues = async (workspaceSlug: string, projectId: string, parentIssueId: string) => {
|
||||||
// get filter params
|
this.loader = "init-loader";
|
||||||
const filterParams = this.filters.computedFilterParams(parentIssueId);
|
|
||||||
const response = await this.issueService.subIssues(workspaceSlug, projectId, parentIssueId, filterParams);
|
const response = await this.issueService.subIssues(workspaceSlug, projectId, parentIssueId);
|
||||||
|
|
||||||
const subIssuesStateDistribution = response?.state_distribution ?? {};
|
const subIssuesStateDistribution = response?.state_distribution ?? {};
|
||||||
|
|
||||||
// process sub issues response
|
const issueList = (response.sub_issues ?? []) as TIssue[];
|
||||||
const { issueList, groupedIssues } = this.filters.processSubIssueResponse(response.sub_issues);
|
|
||||||
|
|
||||||
// set grouped issues count
|
|
||||||
set(this.groupedSubIssuesMap, [parentIssueId], groupedIssues);
|
|
||||||
|
|
||||||
this.rootIssueDetailStore.rootIssueStore.issues.addIssue(issueList);
|
this.rootIssueDetailStore.rootIssueStore.issues.addIssue(issueList);
|
||||||
|
|
||||||
|
// fetch other issues states and members when sub-issues are from different project
|
||||||
if (issueList && issueList.length > 0) {
|
if (issueList && issueList.length > 0) {
|
||||||
const otherProjectIds = uniq(
|
const otherProjectIds = uniq(
|
||||||
issueList.map((issue) => issue.project_id).filter((id) => !!id && id !== projectId)
|
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,
|
sub_issues_count: issueList.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
set(this.subIssuesStateDistribution, parentIssueId, subIssuesStateDistribution);
|
set(this.subIssuesStateDistribution, parentIssueId, subIssuesStateDistribution);
|
||||||
set(
|
set(
|
||||||
|
|
@ -171,6 +156,8 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
|
||||||
issueList.map((issue) => issue.id)
|
issueList.map((issue) => issue.id)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.loader = undefined;
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,202 +1,139 @@
|
||||||
import set from "lodash/set";
|
import set from "lodash/set";
|
||||||
import { action, makeObservable, observable } from "mobx";
|
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||||
import { ALL_ISSUES, EIssueFilterType, EIssueGroupByToServerOptions } from "@plane/constants";
|
import { computedFn } from "mobx-utils";
|
||||||
|
import { EIssueFilterType } from "@plane/constants";
|
||||||
import {
|
import {
|
||||||
IIssueDisplayFilterOptions,
|
IIssueDisplayFilterOptions,
|
||||||
IIssueDisplayProperties,
|
IIssueDisplayProperties,
|
||||||
|
IIssueFilterOptions,
|
||||||
IIssueFilters,
|
IIssueFilters,
|
||||||
TGroupedIssueCount,
|
|
||||||
TGroupedIssues,
|
TGroupedIssues,
|
||||||
TIssue,
|
TIssue,
|
||||||
TIssueParams,
|
|
||||||
TIssues,
|
|
||||||
TSubGroupedIssues,
|
|
||||||
TSubIssueResponse,
|
|
||||||
} from "@plane/types";
|
} 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 {
|
export interface IWorkItemSubIssueFiltersStore {
|
||||||
subIssueFiltersMap: Record<string, Partial<IIssueFilters>>;
|
subIssueFilters: Record<string, Partial<IIssueFilters>>;
|
||||||
// helpers methods
|
// helpers methods
|
||||||
updateSubIssueFilters: (
|
updateSubWorkItemFilters: (
|
||||||
workspaceSlug: string,
|
|
||||||
projectId: string,
|
|
||||||
filterType: EIssueFilterType,
|
filterType: EIssueFilterType,
|
||||||
filters: IIssueDisplayFilterOptions | IIssueDisplayProperties,
|
filters: IIssueDisplayFilterOptions | IIssueDisplayProperties | IIssueFilterOptions,
|
||||||
parentId: string
|
workItemId: string
|
||||||
) => Promise<void>;
|
) => void;
|
||||||
getSubIssueFilters: (parentId: string) => Partial<IIssueFilters>;
|
getGroupedSubWorkItems: (workItemId: string) => TGroupedIssues;
|
||||||
computedFilterParams: (parentId: string) => Partial<Record<TIssueParams, string | boolean>>;
|
getFilteredSubWorkItems: (workItemId: string, filters: IIssueFilterOptions) => TIssue[];
|
||||||
processSubIssueResponse: (issueResponse: TSubIssueResponse) => {
|
getSubIssueFilters: (workItemId: string) => Partial<IIssueFilters>;
|
||||||
issueList: TIssue[];
|
resetFilters: (workItemId: string) => void;
|
||||||
groupedIssues: TIssues;
|
|
||||||
groupedIssueCount: TGroupedIssueCount;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WorkItemSubIssueFiltersStore implements IWorkItemSubIssueFiltersStore {
|
export class WorkItemSubIssueFiltersStore implements IWorkItemSubIssueFiltersStore {
|
||||||
// observables
|
// 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, {
|
makeObservable(this, {
|
||||||
subIssueFiltersMap: observable,
|
subIssueFilters: observable,
|
||||||
updateSubIssueFilters: action,
|
updateSubWorkItemFilters: action,
|
||||||
getSubIssueFilters: action,
|
getSubIssueFilters: action,
|
||||||
});
|
});
|
||||||
// sub issue store
|
|
||||||
|
// root store
|
||||||
this.subIssueStore = subIssueStore;
|
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
|
* @description This method is used to get the sub issue filters
|
||||||
* @param parentId
|
* @param workItemId
|
||||||
* @returns IIssueFilters
|
* @returns
|
||||||
*/
|
*/
|
||||||
getSubIssueFilters = (parentId: string) => {
|
getSubIssueFilters = (workItemId: string) => {
|
||||||
if (!this.subIssueFiltersMap[parentId]) {
|
if (!this.subIssueFilters[workItemId]) {
|
||||||
this.initSubIssueFilters(parentId);
|
this.initializeFilters(workItemId);
|
||||||
}
|
}
|
||||||
return this.subIssueFiltersMap[parentId];
|
return this.subIssueFilters[workItemId];
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description This method is used to update the sub issue filters
|
* @description This method is used to initialize the sub issue filters
|
||||||
* @param projectId
|
* @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 filterType
|
||||||
* @param filters
|
* @param filters
|
||||||
*/
|
*/
|
||||||
updateSubIssueFilters = async (
|
updateSubWorkItemFilters = (
|
||||||
workspaceSlug: string,
|
|
||||||
projectId: string,
|
|
||||||
filterType: EIssueFilterType,
|
filterType: EIssueFilterType,
|
||||||
filters: IIssueDisplayFilterOptions | IIssueDisplayProperties,
|
filters: IIssueDisplayFilterOptions | IIssueDisplayProperties | IIssueFilterOptions,
|
||||||
parentId: string
|
workItemId: string
|
||||||
) => {
|
) => {
|
||||||
const _filters = this.getSubIssueFilters(parentId);
|
runInAction(() => {
|
||||||
switch (filterType) {
|
updateFilters(this.subIssueFilters, filterType, filters, workItemId);
|
||||||
case EIssueFilterType.DISPLAY_FILTERS: {
|
});
|
||||||
set(this.subIssueFiltersMap, [parentId, "displayFilters"], { ..._filters.displayFilters, ...filters });
|
};
|
||||||
this.subIssueStore.fetchSubIssues(workspaceSlug, projectId, parentId);
|
|
||||||
break;
|
/**
|
||||||
}
|
* @description This method is used to get the grouped sub work items
|
||||||
case EIssueFilterType.DISPLAY_PROPERTIES:
|
* @param parentWorkItemId
|
||||||
set(this.subIssueFiltersMap, [parentId, "displayProperties"], {
|
* @returns
|
||||||
..._filters.displayProperties,
|
*/
|
||||||
...filters,
|
getGroupedSubWorkItems = computedFn((parentWorkItemId: string) => {
|
||||||
});
|
const subIssueFilters = this.getSubIssueFilters(parentWorkItemId);
|
||||||
break;
|
|
||||||
}
|
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);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -405,3 +405,73 @@ export const generateDateArray = (startDate: string | Date, endDate: string | Da
|
||||||
|
|
||||||
return dateArray;
|
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;
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue