[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";
|
||||
|
||||
|
|
@ -361,3 +367,17 @@ export const SPREADSHEET_PROPERTY_DETAILS: {
|
|||
icon: "LayersIcon",
|
||||
},
|
||||
};
|
||||
|
||||
// Map filter keys to their corresponding issue property keys
|
||||
export const FILTER_TO_ISSUE_MAP: Partial<Record<keyof IIssueFilterOptions, keyof TIssue>> = {
|
||||
assignees: "assignee_ids",
|
||||
created_by: "created_by",
|
||||
labels: "label_ids",
|
||||
priority: "priority",
|
||||
cycle: "cycle_id",
|
||||
module: "module_ids",
|
||||
project: "project_id",
|
||||
state: "state_id",
|
||||
issue_type: "type_id",
|
||||
state_group: "state__group",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
import {
|
||||
ILayoutDisplayFiltersOptions,
|
||||
TIssueActivityComment,
|
||||
} from "@plane/types";
|
||||
import { ILayoutDisplayFiltersOptions, TIssueActivityComment } from "@plane/types";
|
||||
import {
|
||||
TIssueFilterPriorityObject,
|
||||
ISSUE_DISPLAY_PROPERTIES_KEYS,
|
||||
|
|
@ -358,9 +355,10 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
|
|||
sub_work_items: {
|
||||
list: {
|
||||
display_properties: SUB_ISSUES_DISPLAY_PROPERTIES_KEYS,
|
||||
filters: [],
|
||||
filters: ["priority", "state", "project", "issue_type", "assignees", "start_date", "target_date"],
|
||||
display_filters: {
|
||||
order_by: ["-created_at", "-updated_at", "start_date", "-priority"],
|
||||
group_by: ["state", "priority", "assignees", null],
|
||||
},
|
||||
extra_options: {
|
||||
access: true,
|
||||
|
|
@ -370,9 +368,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
|
|||
},
|
||||
};
|
||||
|
||||
export const ISSUE_STORE_TO_FILTERS_MAP: Partial<
|
||||
Record<EIssuesStoreType, TFiltersByLayout>
|
||||
> = {
|
||||
export const ISSUE_STORE_TO_FILTERS_MAP: Partial<Record<EIssuesStoreType, TFiltersByLayout>> = {
|
||||
[EIssuesStoreType.PROJECT]: ISSUE_DISPLAY_FILTERS_BY_PAGE.issues,
|
||||
};
|
||||
|
||||
|
|
@ -383,10 +379,7 @@ export enum EActivityFilterType {
|
|||
|
||||
export type TActivityFilters = EActivityFilterType;
|
||||
|
||||
export const ACTIVITY_FILTER_TYPE_OPTIONS: Record<
|
||||
TActivityFilters,
|
||||
{ labelTranslationKey: string }
|
||||
> = {
|
||||
export const ACTIVITY_FILTER_TYPE_OPTIONS: Record<TActivityFilters, { labelTranslationKey: string }> = {
|
||||
[EActivityFilterType.ACTIVITY]: {
|
||||
labelTranslationKey: "common.updates",
|
||||
},
|
||||
|
|
@ -402,17 +395,12 @@ export type TActivityFilterOption = {
|
|||
onClick: () => void;
|
||||
};
|
||||
|
||||
export const defaultActivityFilters: TActivityFilters[] = [
|
||||
EActivityFilterType.ACTIVITY,
|
||||
EActivityFilterType.COMMENT,
|
||||
];
|
||||
export const defaultActivityFilters: TActivityFilters[] = [EActivityFilterType.ACTIVITY, EActivityFilterType.COMMENT];
|
||||
|
||||
export const filterActivityOnSelectedFilters = (
|
||||
activity: TIssueActivityComment[],
|
||||
filters: TActivityFilters[]
|
||||
): TIssueActivityComment[] =>
|
||||
activity.filter((activity) =>
|
||||
filters.includes(activity.activity_type as TActivityFilters)
|
||||
);
|
||||
activity.filter((activity) => filters.includes(activity.activity_type as TActivityFilters));
|
||||
|
||||
export const ENABLE_ISSUE_DEPENDENCIES = false;
|
||||
|
|
|
|||
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;
|
||||
// sourceIssueId is used to store the original issue id when creating a copy of an issue. Used in cloning property values. It is not a part of the API response.
|
||||
sourceIssueId?: string;
|
||||
state__group?: string | null;
|
||||
};
|
||||
|
||||
export type TIssueMap = {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { FC } from "react";
|
||||
import { cn } from "../../helpers";
|
||||
import { DropdownIcon } from "../icons";
|
||||
import { DropdownIcon, ISvgIcons } from "../icons";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
|
|
@ -10,6 +10,7 @@ type Props = {
|
|||
actionItemElement?: React.ReactNode;
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
ChevronIcon?: React.FC<ISvgIcons>;
|
||||
};
|
||||
|
||||
export const CollapsibleButton: FC<Props> = (props) => {
|
||||
|
|
@ -21,6 +22,7 @@ export const CollapsibleButton: FC<Props> = (props) => {
|
|||
actionItemElement,
|
||||
className = "",
|
||||
titleClassName = "",
|
||||
ChevronIcon = DropdownIcon,
|
||||
} = props;
|
||||
return (
|
||||
<div
|
||||
|
|
@ -32,7 +34,7 @@ export const CollapsibleButton: FC<Props> = (props) => {
|
|||
<div className="flex items-center gap-3.5">
|
||||
<div className="flex items-center gap-3">
|
||||
{!hideChevron && (
|
||||
<DropdownIcon
|
||||
<ChevronIcon
|
||||
className={cn("size-2 text-custom-text-300 hover:text-custom-text-200 duration-300", {
|
||||
"-rotate-90": !isOpen,
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,25 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
actionElement?: React.ReactNode;
|
||||
customClassName?: string;
|
||||
};
|
||||
|
||||
export const SectionEmptyState: FC<Props> = (props) => {
|
||||
const { title, description, icon, actionElement } = props;
|
||||
const { title, description, icon, actionElement, customClassName } = props;
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-center justify-center rounded-md border border-custom-border-200 p-10">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-4 items-center justify-center rounded-md border border-custom-border-200 p-10",
|
||||
customClassName
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex items-center justify-center size-8 bg-custom-background-80 rounded">{icon}</div>
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
import React, { FC, useEffect, useState, useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { EIssueServiceType } from "@plane/constants";
|
||||
import { EIssueServiceType, EIssuesStoreType } from "@plane/constants";
|
||||
import { TIssue, TIssueServiceType } from "@plane/types";
|
||||
// components
|
||||
import { DeleteIssueModal } from "@/components/issues/delete-issue-modal";
|
||||
|
|
@ -117,6 +117,7 @@ export const SubIssuesCollapsibleContent: FC<Props> = observer((props) => {
|
|||
<>
|
||||
{subIssueHelpers.issue_visibility.includes(parentIssueId) && (
|
||||
<SubIssuesListRoot
|
||||
storeType={EIssuesStoreType.PROJECT}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssueId={parentIssueId}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { FC } from "react";
|
||||
import { FC, useMemo } from "react";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { observer } from "mobx-react";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { IIssueDisplayFilterOptions, ILayoutDisplayFiltersOptions, IIssueDisplayProperties } from "@plane/types";
|
||||
import { DisplayPropertiesIcon } from "@plane/ui";
|
||||
import { FilterDisplayProperties, FilterOrderBy, FiltersDropdown } from "@/components/issues";
|
||||
import { cn } from "@plane/utils";
|
||||
import { FilterDisplayProperties, FilterGroupBy, FilterOrderBy, FiltersDropdown } from "@/components/issues";
|
||||
import { isDisplayFiltersApplied } from "@/components/issues/issue-layouts/utils";
|
||||
|
||||
type TSubIssueDisplayFiltersProps = {
|
||||
displayProperties: IIssueDisplayProperties;
|
||||
|
|
@ -24,20 +26,29 @@ export const SubIssueDisplayFilters: FC<TSubIssueDisplayFiltersProps> = observer
|
|||
displayFilters,
|
||||
} = props;
|
||||
|
||||
const isFilterApplied = useMemo(
|
||||
() => isDisplayFiltersApplied({ displayProperties, displayFilters }),
|
||||
[displayProperties, displayFilters]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{layoutDisplayFiltersOptions?.display_filters && layoutDisplayFiltersOptions?.display_properties.length > 0 && (
|
||||
<FiltersDropdown
|
||||
placement="bottom-end"
|
||||
menuButton={<DisplayPropertiesIcon className="h-3.5 w-3.5 text-custom-text-100" />}
|
||||
menuButton={
|
||||
<div
|
||||
className={cn(
|
||||
"p-1 rounded relative transition-all duration-200",
|
||||
isFilterApplied && "bg-custom-primary-60/20"
|
||||
)}
|
||||
>
|
||||
{isFilterApplied && <span className="p-1 rounded-full bg-custom-primary-100 absolute -top-1 -right-1" />}
|
||||
<SlidersHorizontal className="h-3.5 w-3.5 text-custom-text-100" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
className="vertical-scrollbar scrollbar-sm relative h-full w-full divide-y divide-custom-border-200 overflow-hidden overflow-y-auto px-2.5 max-h-[25rem] text-left"
|
||||
>
|
||||
<div className="vertical-scrollbar scrollbar-sm relative h-full w-full divide-y divide-custom-border-200 overflow-hidden overflow-y-auto px-2.5 max-h-[25rem] text-left">
|
||||
{/* display properties */}
|
||||
<div className="py-2">
|
||||
<FilterDisplayProperties
|
||||
|
|
@ -48,6 +59,20 @@ export const SubIssueDisplayFilters: FC<TSubIssueDisplayFiltersProps> = observer
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* group by */}
|
||||
<div className="py-2">
|
||||
<FilterGroupBy
|
||||
displayFilters={displayFilters}
|
||||
groupByOptions={layoutDisplayFiltersOptions?.display_filters.group_by ?? []}
|
||||
handleUpdate={(val) =>
|
||||
handleDisplayFiltersUpdate({
|
||||
group_by: val,
|
||||
})
|
||||
}
|
||||
ignoreGroupedFilters={[]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* order by */}
|
||||
{!isEmpty(layoutDisplayFiltersOptions?.display_filters?.order_by) && (
|
||||
<div className="py-2">
|
||||
|
|
|
|||
|
|
@ -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 { ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } from "lucide-react";
|
||||
// plane imports
|
||||
import { EIssueServiceType } from "@plane/constants";
|
||||
import { EIssueServiceType, EIssuesStoreType } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
|
||||
import { ControlLink, CustomMenu, Tooltip } from "@plane/ui";
|
||||
|
|
@ -37,6 +37,7 @@ type Props = {
|
|||
subIssueOperations: TSubIssueOperations;
|
||||
issueId: string;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
storeType?: EIssuesStoreType;
|
||||
};
|
||||
|
||||
export const SubIssuesListItem: React.FC<Props> = observer((props) => {
|
||||
|
|
@ -51,6 +52,7 @@ export const SubIssuesListItem: React.FC<Props> = observer((props) => {
|
|||
handleIssueCrudState,
|
||||
subIssueOperations,
|
||||
issueServiceType = EIssueServiceType.ISSUES,
|
||||
storeType = EIssuesStoreType.PROJECT,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
|
|
@ -81,7 +83,7 @@ export const SubIssuesListItem: React.FC<Props> = observer((props) => {
|
|||
|
||||
// derived values
|
||||
const subIssueFilters = getSubIssueFilters(parentIssueId);
|
||||
const displayProperties = subIssueFilters.displayProperties ?? {};
|
||||
const displayProperties = subIssueFilters?.displayProperties ?? {};
|
||||
|
||||
//
|
||||
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug, issue, isMobile);
|
||||
|
|
@ -265,6 +267,7 @@ export const SubIssuesListItem: React.FC<Props> = observer((props) => {
|
|||
subIssueCount > 0 &&
|
||||
!isCurrentIssueRoot && (
|
||||
<SubIssuesListRoot
|
||||
storeType={storeType}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
parentIssueId={issue.id}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,47 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
|
|||
maxDate?.setDate(maxDate.getDate());
|
||||
return (
|
||||
<div className="relative flex items-center gap-2">
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state">
|
||||
<div className="h-5 flex-shrink-0">
|
||||
<StateDropdown
|
||||
value={issue.state_id}
|
||||
projectId={issue.project_id ?? undefined}
|
||||
onChange={(val) =>
|
||||
issue.project_id &&
|
||||
updateSubIssue(
|
||||
workspaceSlug,
|
||||
issue.project_id,
|
||||
parentIssueId,
|
||||
issueId,
|
||||
{
|
||||
state_id: val,
|
||||
},
|
||||
{ ...issue }
|
||||
)
|
||||
}
|
||||
disabled={!disabled}
|
||||
buttonVariant="border-with-text"
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="priority">
|
||||
<div className="h-5 flex-shrink-0">
|
||||
<PriorityDropdown
|
||||
value={issue.priority}
|
||||
onChange={(val) =>
|
||||
issue.project_id &&
|
||||
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
|
||||
priority: val,
|
||||
})
|
||||
}
|
||||
disabled={!disabled}
|
||||
buttonVariant="border-without-text"
|
||||
buttonClassName="border"
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="start_date">
|
||||
<div className="h-5 flex-shrink-0" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
<DateDropdown
|
||||
|
|
@ -97,47 +138,6 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
|
|||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state">
|
||||
<div className="h-5 flex-shrink-0">
|
||||
<StateDropdown
|
||||
value={issue.state_id}
|
||||
projectId={issue.project_id ?? undefined}
|
||||
onChange={(val) =>
|
||||
issue.project_id &&
|
||||
updateSubIssue(
|
||||
workspaceSlug,
|
||||
issue.project_id,
|
||||
parentIssueId,
|
||||
issueId,
|
||||
{
|
||||
state_id: val,
|
||||
},
|
||||
{ ...issue }
|
||||
)
|
||||
}
|
||||
disabled={!disabled}
|
||||
buttonVariant="border-with-text"
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="priority">
|
||||
<div className="h-5 flex-shrink-0">
|
||||
<PriorityDropdown
|
||||
value={issue.priority}
|
||||
onChange={(val) =>
|
||||
issue.project_id &&
|
||||
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
|
||||
priority: val,
|
||||
})
|
||||
}
|
||||
disabled={!disabled}
|
||||
buttonVariant="border-without-text"
|
||||
buttonClassName="border"
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
|
||||
<div className="h-5 flex-shrink-0">
|
||||
<MemberDropdown
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EIssueServiceType } from "@plane/constants";
|
||||
import { TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
|
||||
import { ListFilter } from "lucide-react";
|
||||
import { EIssueServiceType, EIssuesStoreType } from "@plane/constants";
|
||||
import { GroupByColumnTypes, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
|
||||
// hooks
|
||||
import { Button } from "@plane/ui";
|
||||
import { SectionEmptyState } from "@/components/empty-state";
|
||||
import { getGroupByColumns, isWorkspaceLevel } from "@/components/issues/issue-layouts/utils";
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
// local imports
|
||||
import { SubIssuesListItem } from "./list-item";
|
||||
|
||||
import { SubIssuesListGroup } from "./list-group";
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
|
|
@ -21,6 +25,7 @@ type Props = {
|
|||
) => void;
|
||||
subIssueOperations: TSubIssueOperations;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
storeType: EIssuesStoreType;
|
||||
};
|
||||
|
||||
export const SubIssuesListRoot: React.FC<Props> = observer((props) => {
|
||||
|
|
@ -29,36 +34,89 @@ export const SubIssuesListRoot: React.FC<Props> = observer((props) => {
|
|||
projectId,
|
||||
parentIssueId,
|
||||
rootIssueId,
|
||||
spacingLeft = 10,
|
||||
disabled,
|
||||
handleIssueCrudState,
|
||||
subIssueOperations,
|
||||
issueServiceType = EIssueServiceType.ISSUES,
|
||||
storeType = EIssuesStoreType.PROJECT,
|
||||
spacingLeft = 0,
|
||||
} = props;
|
||||
// store hooks
|
||||
const {
|
||||
subIssues: { subIssuesByIssueId },
|
||||
subIssues: {
|
||||
subIssuesByIssueId,
|
||||
filters: { getSubIssueFilters, getGroupedSubWorkItems, getFilteredSubWorkItems, resetFilters },
|
||||
},
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
// derived values
|
||||
const subIssueIds = subIssuesByIssueId(parentIssueId);
|
||||
const filters = getSubIssueFilters(parentIssueId);
|
||||
const isRootLevel = useMemo(() => rootIssueId === parentIssueId, [rootIssueId, parentIssueId]);
|
||||
const group_by = isRootLevel ? (filters?.displayFilters?.group_by ?? null) : null;
|
||||
const filteredSubWorkItemsCount = (getFilteredSubWorkItems(rootIssueId, filters.filters ?? {}) ?? []).length;
|
||||
|
||||
const groups = getGroupByColumns({
|
||||
groupBy: group_by as GroupByColumnTypes,
|
||||
includeNone: true,
|
||||
isWorkspaceLevel: isWorkspaceLevel(storeType),
|
||||
isEpic: issueServiceType === EIssueServiceType.EPICS,
|
||||
projectId,
|
||||
});
|
||||
|
||||
const getWorkItemIds = useCallback(
|
||||
(groupId: string) => {
|
||||
if (isRootLevel) {
|
||||
const groupedSubIssues = getGroupedSubWorkItems(parentIssueId);
|
||||
return groupedSubIssues?.[groupId] ?? [];
|
||||
}
|
||||
const subIssueIds = subIssuesByIssueId(parentIssueId);
|
||||
return subIssueIds ?? [];
|
||||
},
|
||||
[isRootLevel, subIssuesByIssueId, parentIssueId, getGroupedSubWorkItems]
|
||||
);
|
||||
|
||||
const isSubWorkItems = issueServiceType === EIssueServiceType.ISSUES;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{subIssueIds?.map((issueId) => (
|
||||
<SubIssuesListItem
|
||||
key={issueId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssueId={parentIssueId}
|
||||
rootIssueId={rootIssueId}
|
||||
issueId={issueId}
|
||||
spacingLeft={spacingLeft}
|
||||
disabled={disabled}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
subIssueOperations={subIssueOperations}
|
||||
issueServiceType={issueServiceType}
|
||||
{filteredSubWorkItemsCount > 0 ? (
|
||||
groups?.map((group) => (
|
||||
<SubIssuesListGroup
|
||||
key={group.id}
|
||||
workItemIds={getWorkItemIds(group.id)}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
group={group}
|
||||
serviceType={issueServiceType}
|
||||
disabled={disabled}
|
||||
parentIssueId={parentIssueId}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
subIssueOperations={subIssueOperations}
|
||||
storeType={storeType}
|
||||
spacingLeft={spacingLeft}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<SectionEmptyState
|
||||
title={
|
||||
!isSubWorkItems
|
||||
? "You don't have work items that match the filters you've applied."
|
||||
: "You don't have sub-work items that match the filters you've applied."
|
||||
}
|
||||
description={
|
||||
!isSubWorkItems
|
||||
? "To see all work items, clear all applied filters."
|
||||
: "To see all sub-work items, clear all applied filters."
|
||||
}
|
||||
icon={<ListFilter />}
|
||||
customClassName={storeType !== EIssuesStoreType.EPIC ? "border-none" : ""}
|
||||
actionElement={
|
||||
<Button variant="neutral-primary" size="sm" onClick={() => resetFilters(rootIssueId)}>
|
||||
Clear filters
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,58 +1,89 @@
|
|||
import { FC, useCallback } from "react";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import { observer } from "mobx-react";
|
||||
import { EIssueFilterType, EIssueServiceType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueServiceType } from "@plane/types";
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
import {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
TIssueServiceType,
|
||||
} from "@plane/types";
|
||||
import { useIssueDetail, useMember, useProjectState } from "@/hooks/store";
|
||||
import { SubIssueDisplayFilters } from "./display-filters";
|
||||
import { SubIssueFilters } from "./filters";
|
||||
import { SubIssuesActionButton } from "./quick-action-button";
|
||||
|
||||
type TSubWorkItemTitleActionsProps = {
|
||||
disabled: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
parentId: string;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const SubWorkItemTitleActions: FC<TSubWorkItemTitleActionsProps> = observer((props) => {
|
||||
const { disabled, issueServiceType = EIssueServiceType.ISSUES, parentId, workspaceSlug, projectId } = props;
|
||||
const { disabled, issueServiceType = EIssueServiceType.ISSUES, parentId, projectId } = props;
|
||||
|
||||
// store hooks
|
||||
const {
|
||||
subIssues: {
|
||||
filters: { getSubIssueFilters, updateSubIssueFilters },
|
||||
filters: { getSubIssueFilters, updateSubWorkItemFilters },
|
||||
},
|
||||
} = useIssueDetail(issueServiceType);
|
||||
const { getProjectStates } = useProjectState();
|
||||
const {
|
||||
project: { getProjectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
// derived values
|
||||
const subIssueFilters = getSubIssueFilters(parentId);
|
||||
const projectStates = getProjectStates(projectId);
|
||||
const projectMemberIds = getProjectMemberIds(projectId, false);
|
||||
|
||||
const layoutDisplayFiltersOptions = ISSUE_DISPLAY_FILTERS_BY_PAGE["sub_work_items"].list;
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateSubIssueFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, parentId);
|
||||
updateSubWorkItemFilters(EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, parentId);
|
||||
},
|
||||
[workspaceSlug, projectId, parentId, updateSubIssueFilters]
|
||||
[updateSubWorkItemFilters, parentId]
|
||||
);
|
||||
|
||||
const handleDisplayPropertiesUpdate = useCallback(
|
||||
(updatedDisplayProperties: Partial<IIssueDisplayProperties>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateSubIssueFilters(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
EIssueFilterType.DISPLAY_PROPERTIES,
|
||||
updatedDisplayProperties,
|
||||
parentId
|
||||
);
|
||||
updateSubWorkItemFilters(EIssueFilterType.DISPLAY_PROPERTIES, updatedDisplayProperties, parentId);
|
||||
},
|
||||
[workspaceSlug, projectId, parentId, updateSubIssueFilters]
|
||||
[updateSubWorkItemFilters, parentId]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
const newValues = cloneDeep(subIssueFilters?.filters?.[key]) ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (subIssueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateSubWorkItemFilters(EIssueFilterType.FILTERS, { [key]: newValues }, parentId);
|
||||
},
|
||||
[subIssueFilters?.filters, updateSubWorkItemFilters, parentId]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
// prevent click everywhere
|
||||
<div
|
||||
className="flex gap-2 items-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<SubIssueDisplayFilters
|
||||
isEpic={issueServiceType === EIssueServiceType.EPICS}
|
||||
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
|
||||
|
|
@ -61,6 +92,13 @@ export const SubWorkItemTitleActions: FC<TSubWorkItemTitleActionsProps> = observ
|
|||
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
/>
|
||||
<SubIssueFilters
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
filters={subIssueFilters?.filters ?? {}}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
|
||||
/>
|
||||
{!disabled && (
|
||||
<SubIssuesActionButton issueId={parentId} disabled={disabled} issueServiceType={issueServiceType} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -33,10 +33,7 @@ export const SubIssuesCollapsibleTitle: FC<Props> = observer((props) => {
|
|||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const {
|
||||
subIssues: {
|
||||
subIssuesByIssueId,
|
||||
stateDistributionByIssueId,
|
||||
},
|
||||
subIssues: { subIssuesByIssueId, stateDistributionByIssueId },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
// derived values
|
||||
const subIssuesDistribution = stateDistributionByIssueId(parentIssueId);
|
||||
|
|
@ -63,7 +60,6 @@ export const SubIssuesCollapsibleTitle: FC<Props> = observer((props) => {
|
|||
}
|
||||
actionItemElement={
|
||||
<SubWorkItemTitleActions
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentId={parentIssueId}
|
||||
disabled={disabled}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { CSSProperties, FC } from "react";
|
||||
import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
|
||||
import { isEmpty } from "lodash";
|
||||
import clone from "lodash/clone";
|
||||
import concat from "lodash/concat";
|
||||
import isEqual from "lodash/isEqual";
|
||||
|
|
@ -25,6 +26,7 @@ import {
|
|||
IProjectView,
|
||||
TGroupedIssues,
|
||||
IWorkspaceView,
|
||||
IIssueDisplayFilterOptions,
|
||||
} from "@plane/types";
|
||||
// plane ui
|
||||
import { Avatar, CycleGroupIcon, DiceIcon, ISvgIcons, PriorityIcon, StateGroupIcon } from "@plane/ui";
|
||||
|
|
@ -39,6 +41,7 @@ import { store } from "@/lib/store-context";
|
|||
import { getTeamProjectColumns, SpreadSheetPropertyIconMap } from "@/plane-web/components/issues/issue-layouts/utils";
|
||||
// store
|
||||
import { ISSUE_FILTER_DEFAULT_DATA } from "@/store/issue/helpers/base-issues.store";
|
||||
import { DEFAULT_DISPLAY_PROPERTIES } from "@/store/issue/issue-details/sub_issues_filter.store";
|
||||
|
||||
export const HIGHLIGHT_CLASS = "highlight";
|
||||
export const HIGHLIGHT_WITH_LINE = "highlight-with-line";
|
||||
|
|
@ -58,6 +61,11 @@ export type IssueUpdates = {
|
|||
};
|
||||
};
|
||||
|
||||
type TGetColumns = {
|
||||
isWorkspaceLevel?: boolean;
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
export const isWorkspaceLevel = (type: EIssuesStoreType) =>
|
||||
[EIssuesStoreType.PROFILE, EIssuesStoreType.GLOBAL].includes(type) ? true : false;
|
||||
|
||||
|
|
@ -66,6 +74,7 @@ type TGetGroupByColumns = {
|
|||
includeNone: boolean;
|
||||
isWorkspaceLevel: boolean;
|
||||
isEpic?: boolean;
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
// NOTE: Type of groupBy is different compared to what's being passed from the components.
|
||||
|
|
@ -76,6 +85,7 @@ export const getGroupByColumns = ({
|
|||
includeNone,
|
||||
isWorkspaceLevel,
|
||||
isEpic = false,
|
||||
projectId,
|
||||
}: TGetGroupByColumns): IGroupByColumn[] | undefined => {
|
||||
// If no groupBy is specified and includeNone is true, return "All Issues" group
|
||||
if (!groupBy && includeNone) {
|
||||
|
|
@ -93,21 +103,24 @@ export const getGroupByColumns = ({
|
|||
if (!groupBy) return undefined;
|
||||
|
||||
// Map of group by options to their corresponding column getter functions
|
||||
const groupByColumnMap: Record<GroupByColumnTypes, () => IGroupByColumn[] | undefined> = {
|
||||
const groupByColumnMap: Record<
|
||||
GroupByColumnTypes,
|
||||
({ isWorkspaceLevel, projectId }: TGetColumns) => IGroupByColumn[] | undefined
|
||||
> = {
|
||||
project: getProjectColumns,
|
||||
cycle: getCycleColumns,
|
||||
module: getModuleColumns,
|
||||
state: getStateColumns,
|
||||
"state_detail.group": getStateGroupColumns,
|
||||
priority: getPriorityColumns,
|
||||
labels: () => getLabelsColumns(isWorkspaceLevel),
|
||||
labels: getLabelsColumns,
|
||||
assignees: getAssigneeColumns,
|
||||
created_by: getCreatedByColumns,
|
||||
team_project: getTeamProjectColumns,
|
||||
};
|
||||
|
||||
// Get and return the columns for the specified group by option
|
||||
return groupByColumnMap[groupBy]?.();
|
||||
return groupByColumnMap[groupBy]?.({ isWorkspaceLevel, projectId });
|
||||
};
|
||||
|
||||
const getProjectColumns = (): IGroupByColumn[] | undefined => {
|
||||
|
|
@ -190,11 +203,12 @@ const getModuleColumns = (): IGroupByColumn[] | undefined => {
|
|||
return modules;
|
||||
};
|
||||
|
||||
const getStateColumns = (): IGroupByColumn[] | undefined => {
|
||||
const { projectStates } = store.state;
|
||||
if (!projectStates) return;
|
||||
const getStateColumns = ({ projectId }: TGetColumns): IGroupByColumn[] | undefined => {
|
||||
const { getProjectStates, projectStates } = store.state;
|
||||
const _states = projectId ? getProjectStates(projectId) : projectStates;
|
||||
if (!_states) return;
|
||||
// map project states to group by columns
|
||||
return projectStates.map((state) => ({
|
||||
return _states.map((state) => ({
|
||||
id: state.id,
|
||||
name: state.name,
|
||||
icon: (
|
||||
|
|
@ -232,7 +246,7 @@ const getPriorityColumns = (): IGroupByColumn[] => {
|
|||
}));
|
||||
};
|
||||
|
||||
const getLabelsColumns = (isWorkspaceLevel: boolean = false): IGroupByColumn[] => {
|
||||
const getLabelsColumns = ({ isWorkspaceLevel }: TGetColumns): IGroupByColumn[] => {
|
||||
const { workspaceLabels, projectLabels } = store.label;
|
||||
// map labels to group by columns
|
||||
const labels = [
|
||||
|
|
@ -250,22 +264,40 @@ const getLabelsColumns = (isWorkspaceLevel: boolean = false): IGroupByColumn[] =
|
|||
}));
|
||||
};
|
||||
|
||||
const getAssigneeColumns = (): IGroupByColumn[] | undefined => {
|
||||
const getAssigneeColumns = ({ isWorkspaceLevel, projectId }: TGetColumns): IGroupByColumn[] | undefined => {
|
||||
const assigneeColumns: IGroupByColumn[] = [];
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
project: { projectMemberIds, getProjectMemberIds },
|
||||
getUserDetails,
|
||||
} = store.memberRoot;
|
||||
if (!projectMemberIds) return;
|
||||
// Map project member ids to group by assignee columns
|
||||
const assigneeColumns: IGroupByColumn[] = projectMemberIds.map((memberId) => {
|
||||
const member = getUserDetails(memberId);
|
||||
return {
|
||||
id: memberId,
|
||||
name: member?.display_name || "",
|
||||
icon: <Avatar name={member?.display_name} src={getFileURL(member?.avatar_url ?? "")} size="md" />,
|
||||
payload: { assignee_ids: [memberId] },
|
||||
};
|
||||
});
|
||||
// if workspace level
|
||||
if (isWorkspaceLevel) {
|
||||
const { workspaceMemberIds } = store.memberRoot.workspace;
|
||||
if (!workspaceMemberIds) return;
|
||||
workspaceMemberIds.forEach((memberId) => {
|
||||
const member = getUserDetails(memberId);
|
||||
assigneeColumns.push({
|
||||
id: memberId,
|
||||
name: member?.display_name || "",
|
||||
icon: <Avatar name={member?.display_name} src={getFileURL(member?.avatar_url ?? "")} size="md" />,
|
||||
payload: { assignee_ids: [memberId] },
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// if project level
|
||||
const _projectMemberIds = projectId ? getProjectMemberIds(projectId, false) : projectMemberIds;
|
||||
if (!_projectMemberIds) return;
|
||||
// Map project member ids to group by assignee columns
|
||||
_projectMemberIds.forEach((memberId) => {
|
||||
const member = getUserDetails(memberId);
|
||||
assigneeColumns.push({
|
||||
id: memberId,
|
||||
name: member?.display_name || "",
|
||||
icon: <Avatar name={member?.display_name} src={getFileURL(member?.avatar_url ?? "")} size="md" />,
|
||||
payload: { assignee_ids: [memberId] },
|
||||
});
|
||||
});
|
||||
}
|
||||
assigneeColumns.push({ id: "None", name: "None", icon: <Avatar size="md" />, payload: {} });
|
||||
return assigneeColumns;
|
||||
};
|
||||
|
|
@ -719,3 +751,37 @@ export const SpreadSheetPropertyIcon: FC<ISvgIcons & { iconKey: string }> = (pro
|
|||
if (!Icon) return null;
|
||||
return <Icon {...props} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* This method returns if the filters are applied
|
||||
* @param filters
|
||||
* @returns
|
||||
*/
|
||||
export const isDisplayFiltersApplied = (filters: Partial<IIssueFilters>): boolean => {
|
||||
const isDisplayPropertiesApplied = Object.keys(DEFAULT_DISPLAY_PROPERTIES).some(
|
||||
(key) => !filters.displayProperties?.[key as keyof IIssueDisplayProperties]
|
||||
);
|
||||
|
||||
const isDisplayFiltersApplied = Object.keys(filters.displayFilters ?? {}).some((key) => {
|
||||
const value = filters.displayFilters?.[key as keyof IIssueDisplayFilterOptions];
|
||||
if (!value) return false;
|
||||
// -create_at is the default order
|
||||
if (key === "order_by") {
|
||||
return value !== "-created_at";
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return isDisplayPropertiesApplied || isDisplayFiltersApplied;
|
||||
};
|
||||
|
||||
/**
|
||||
* This method returns if the filters are applied
|
||||
* @param filters
|
||||
* @returns
|
||||
*/
|
||||
export const isFiltersApplied = (filters: IIssueFilterOptions): boolean =>
|
||||
Object.values(filters).some((value) => {
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
return value !== undefined && value !== null && value !== "";
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,24 @@
|
|||
import cloneDeep from "lodash/cloneDeep";
|
||||
import groupBy from "lodash/groupBy";
|
||||
import indexOf from "lodash/indexOf";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import set from "lodash/set";
|
||||
import uniq from "lodash/uniq";
|
||||
import { ALL_ISSUES } from "@plane/constants";
|
||||
import { TIssue } from "@plane/types";
|
||||
import { EIssueGroupedAction } from "./base-issues.store";
|
||||
import { runInAction } from "mobx";
|
||||
import { ALL_ISSUES, EIssueFilterType, FILTER_TO_ISSUE_MAP, ISSUE_PRIORITIES } from "@plane/constants";
|
||||
import {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
IIssueFilters,
|
||||
TIssue,
|
||||
TIssueGroupByOptions,
|
||||
TIssueOrderByOptions,
|
||||
} from "@plane/types";
|
||||
import { checkDateCriteria, convertToISODateString, parseDateFilter } from "@/helpers/date-time.helper";
|
||||
import { store } from "@/lib/store-context";
|
||||
import { EIssueGroupedAction, ISSUE_GROUP_BY_KEY } from "./base-issues.store";
|
||||
|
||||
/**
|
||||
* returns,
|
||||
|
|
@ -173,3 +189,192 @@ export const getSortOrderToFilterEmptyValues = (key: string, object: any) => {
|
|||
|
||||
// get IssueIds from Issue data List
|
||||
export const getIssueIds = (issues: TIssue[]) => issues.map((issue) => issue?.id);
|
||||
|
||||
/**
|
||||
* Checks if an issue meets the date filter criteria
|
||||
* @param issue The issue to check
|
||||
* @param filterKey The date field to check ('start_date' or 'target_date')
|
||||
* @param dateFilters Array of date filter strings
|
||||
* @returns boolean indicating if the issue meets the date criteria
|
||||
*/
|
||||
export const checkIssueDateFilter = (
|
||||
issue: TIssue,
|
||||
filterKey: "start_date" | "target_date",
|
||||
dateFilters: string[]
|
||||
): boolean => {
|
||||
if (!dateFilters || dateFilters.length === 0) return true;
|
||||
|
||||
const issueDate = issue[filterKey];
|
||||
if (!issueDate) return false;
|
||||
|
||||
// Issue should match all the date filters (AND operation)
|
||||
return dateFilters.every((filterValue) => {
|
||||
const parsed = parseDateFilter(filterValue);
|
||||
if (!parsed?.date || !parsed?.type) {
|
||||
// ignore invalid filter instead of failing the whole evaluation
|
||||
console.warn(`[filters] Ignoring unparsable date filter "${filterValue}"`);
|
||||
return true;
|
||||
}
|
||||
return checkDateCriteria(new Date(issueDate), parsed.date, parsed.type);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper method to get previous issues state
|
||||
* @param issues - The array of issues to get the previous state for.
|
||||
* @returns The previous state of the issues.
|
||||
*/
|
||||
export const getPreviousIssuesState = (issues: TIssue[]) => {
|
||||
const issueIds = issues.map((issue) => issue.id);
|
||||
const issuesPreviousState: Record<string, TIssue> = {};
|
||||
issueIds.forEach((issueId) => {
|
||||
if (store.issue.issues.issuesMap[issueId]) {
|
||||
issuesPreviousState[issueId] = cloneDeep(store.issue.issues.issuesMap[issueId]);
|
||||
}
|
||||
});
|
||||
return issuesPreviousState;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters the given work items based on the provided filters and display filters.
|
||||
* @param work items - The array of work items to be filtered.
|
||||
* @param filters - The filters to be applied to the issues.
|
||||
* @param displayFilters - The display filters to be applied to the issues.
|
||||
* @returns The filtered array of issues.
|
||||
*/
|
||||
export const getFilteredWorkItems = (workItems: TIssue[], filters: IIssueFilterOptions | undefined): TIssue[] => {
|
||||
if (!filters) return workItems;
|
||||
// Get all active filters
|
||||
const activeFilters = Object.entries(filters).filter(([, value]) => value && value.length > 0);
|
||||
// If no active filters, return all issues
|
||||
if (activeFilters.length === 0) {
|
||||
return workItems;
|
||||
}
|
||||
|
||||
return workItems.filter((workItem) =>
|
||||
// Check all filter conditions (AND operation between different filters)
|
||||
activeFilters.every(([filterKey, filterValues]) => {
|
||||
// Handle date filters separately
|
||||
if (filterKey === "start_date" || filterKey === "target_date") {
|
||||
return checkIssueDateFilter(workItem, filterKey as "start_date" | "target_date", filterValues as string[]);
|
||||
}
|
||||
// Handle regular filters
|
||||
const issueKey = FILTER_TO_ISSUE_MAP[filterKey as keyof IIssueFilterOptions];
|
||||
if (!issueKey) return true; // Skip if no mapping exists
|
||||
const issueValue = workItem[issueKey as keyof TIssue];
|
||||
// Handle array-based properties vs single value properties
|
||||
if (Array.isArray(issueValue)) {
|
||||
return filterValues!.some((filterValue: any) => issueValue.includes(filterValue));
|
||||
} else {
|
||||
return filterValues!.includes(issueValue as string);
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Orders the given work items based on the provided order by key.
|
||||
* @param workItems - The array of work items to be ordered.
|
||||
* @param orderByKey - The key to order the issues by.
|
||||
* @returns The ordered array of work items.
|
||||
*/
|
||||
export const getOrderedWorkItems = (workItems: TIssue[], orderByKey: TIssueOrderByOptions): string[] => {
|
||||
switch (orderByKey) {
|
||||
case "-updated_at":
|
||||
return getIssueIds(orderBy(workItems, (item) => convertToISODateString(item["updated_at"]), ["desc"]));
|
||||
|
||||
case "-created_at":
|
||||
return getIssueIds(orderBy(workItems, (item) => convertToISODateString(item["created_at"]), ["desc"]));
|
||||
|
||||
case "-start_date":
|
||||
return getIssueIds(
|
||||
orderBy(
|
||||
workItems,
|
||||
[getSortOrderToFilterEmptyValues.bind(null, "start_date"), "start_date"], //preferring sorting based on empty values to always keep the empty values below
|
||||
["asc", "desc"]
|
||||
)
|
||||
);
|
||||
|
||||
case "-priority": {
|
||||
const sortArray = ISSUE_PRIORITIES.map((i) => i.key);
|
||||
return getIssueIds(
|
||||
orderBy(workItems, (currentIssue: TIssue) => indexOf(sortArray, currentIssue?.priority), ["asc"])
|
||||
);
|
||||
}
|
||||
default:
|
||||
return getIssueIds(workItems);
|
||||
}
|
||||
};
|
||||
|
||||
export const getGroupedWorkItemIds = (
|
||||
workItems: TIssue[],
|
||||
groupByKey?: TIssueGroupByOptions,
|
||||
orderByKey: TIssueOrderByOptions = "-created_at"
|
||||
): Record<string, string[]> => {
|
||||
// If group by is not set set default as ALL ISSUES
|
||||
if (!groupByKey) {
|
||||
return {
|
||||
[ALL_ISSUES]: getOrderedWorkItems(workItems, orderByKey),
|
||||
};
|
||||
}
|
||||
|
||||
// Group work items
|
||||
const groupKey = ISSUE_GROUP_BY_KEY[groupByKey];
|
||||
const groupedWorkItems = groupBy(workItems, (item) => {
|
||||
const value = item[groupKey];
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return "None";
|
||||
// Sort & join to build deterministic set-like key
|
||||
return value.slice().sort().join(",");
|
||||
}
|
||||
return value ?? "None";
|
||||
});
|
||||
|
||||
// Convert to Record type
|
||||
const groupedWorkItemsRecord: Record<string, string[]> = {};
|
||||
Object.entries(groupedWorkItems).forEach(([key, items]) => {
|
||||
groupedWorkItemsRecord[key] = getOrderedWorkItems(items as TIssue[], orderByKey);
|
||||
});
|
||||
|
||||
return groupedWorkItemsRecord;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the filters for a given work item.
|
||||
* @param filtersMap - The map of filters for the work item.
|
||||
* @param filterType - The type of filter to update.
|
||||
* @param filters - The filters to update.
|
||||
* @param workItemId - The ID of the work item to update.
|
||||
*/
|
||||
export const updateFilters = (
|
||||
filtersMap: Record<string, Partial<IIssueFilters>>,
|
||||
filterType: EIssueFilterType,
|
||||
filters: IIssueDisplayFilterOptions | IIssueDisplayProperties | IIssueFilterOptions,
|
||||
workItemId: string
|
||||
) => {
|
||||
const existingFilters = filtersMap[workItemId] ?? {};
|
||||
const _filters = {
|
||||
filters: existingFilters.filters,
|
||||
displayFilters: existingFilters.displayFilters,
|
||||
displayProperties: existingFilters.displayProperties,
|
||||
};
|
||||
|
||||
switch (filterType) {
|
||||
case EIssueFilterType.FILTERS: {
|
||||
const updatedFilters = filters as IIssueFilterOptions;
|
||||
_filters.filters = { ..._filters.filters, ...updatedFilters };
|
||||
set(filtersMap, [workItemId, "filters"], { ..._filters.filters, ...updatedFilters });
|
||||
break;
|
||||
}
|
||||
case EIssueFilterType.DISPLAY_FILTERS: {
|
||||
set(filtersMap, [workItemId, "displayFilters"], { ..._filters.displayFilters, ...filters });
|
||||
break;
|
||||
}
|
||||
case EIssueFilterType.DISPLAY_PROPERTIES:
|
||||
set(filtersMap, [workItemId, "displayProperties"], {
|
||||
..._filters.displayProperties,
|
||||
...filters,
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -118,10 +118,10 @@ export interface IBaseIssuesStore {
|
|||
}
|
||||
|
||||
// This constant maps the group by keys to the respective issue property that the key relies on
|
||||
const ISSUE_GROUP_BY_KEY: Record<TIssueDisplayFilterOptions, keyof TIssue> = {
|
||||
export const ISSUE_GROUP_BY_KEY: Record<TIssueDisplayFilterOptions, keyof TIssue> = {
|
||||
project: "project_id",
|
||||
state: "state_id",
|
||||
"state_detail.group": "state_id" as keyof TIssue, // state_detail.group is only being used for state_group display,
|
||||
"state_detail.group": "state__group" as keyof TIssue, // state_detail.group is only being used for state_group display,
|
||||
priority: "priority",
|
||||
labels: "label_ids",
|
||||
created_by: "created_by",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import set from "lodash/set";
|
|||
import uniq from "lodash/uniq";
|
||||
import update from "lodash/update";
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
import { EIssueServiceType } from "@plane/constants";
|
||||
// types
|
||||
import {
|
||||
|
|
@ -14,8 +15,6 @@ import {
|
|||
TSubIssuesStateDistribution,
|
||||
TIssueServiceType,
|
||||
TLoader,
|
||||
TGroupedIssues,
|
||||
TGroupedIssueCount,
|
||||
} from "@plane/types";
|
||||
// services
|
||||
import { updatePersistentLayer } from "@/local-db/utils/utils";
|
||||
|
|
@ -51,8 +50,6 @@ export interface IIssueSubIssuesStore extends IIssueSubIssuesStoreActions {
|
|||
// observables
|
||||
subIssuesStateDistribution: TIssueSubIssuesStateDistributionMap;
|
||||
subIssues: TIssueSubIssuesIdMap;
|
||||
groupedSubIssuesMap: Record<string, TGroupedIssues>;
|
||||
groupedSubIssuesCount: TGroupedIssueCount;
|
||||
subIssueHelpers: Record<string, TSubIssueHelpers>; // parent_issue_id -> TSubIssueHelpers
|
||||
loader: TLoader;
|
||||
filters: IWorkItemSubIssueFiltersStore;
|
||||
|
|
@ -60,7 +57,6 @@ export interface IIssueSubIssuesStore extends IIssueSubIssuesStoreActions {
|
|||
stateDistributionByIssueId: (issueId: string) => TSubIssuesStateDistribution | undefined;
|
||||
subIssuesByIssueId: (issueId: string) => string[] | undefined;
|
||||
subIssueHelpersByIssueId: (issueId: string) => TSubIssueHelpers;
|
||||
groupedSubIssuesByIssueId: (issueId: string) => TGroupedIssues | undefined;
|
||||
// actions
|
||||
fetchOtherProjectProperties: (workspaceSlug: string, projectIds: string[]) => Promise<void>;
|
||||
setSubIssueHelpers: (parentIssueId: string, key: TSubIssueHelpersKeys, value: string) => void;
|
||||
|
|
@ -70,8 +66,6 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
|
|||
// observables
|
||||
subIssuesStateDistribution: TIssueSubIssuesStateDistributionMap = {};
|
||||
subIssues: TIssueSubIssuesIdMap = {};
|
||||
groupedSubIssuesMap: Record<string, TGroupedIssues> = {};
|
||||
groupedSubIssuesCount: TGroupedIssueCount = {};
|
||||
subIssueHelpers: Record<string, TSubIssueHelpers> = {};
|
||||
loader: TLoader = undefined;
|
||||
|
||||
|
|
@ -88,7 +82,6 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
|
|||
subIssuesStateDistribution: observable,
|
||||
subIssues: observable,
|
||||
subIssueHelpers: observable,
|
||||
groupedSubIssuesMap: observable,
|
||||
loader: observable.ref,
|
||||
// actions
|
||||
setSubIssueHelpers: action,
|
||||
|
|
@ -98,7 +91,6 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
|
|||
removeSubIssue: action,
|
||||
deleteSubIssue: action,
|
||||
fetchOtherProjectProperties: action,
|
||||
groupedSubIssuesByIssueId: action,
|
||||
});
|
||||
this.filters = new WorkItemSubIssueFiltersStore(this);
|
||||
// root store
|
||||
|
|
@ -114,12 +106,7 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
|
|||
return this.subIssuesStateDistribution[issueId] ?? undefined;
|
||||
};
|
||||
|
||||
subIssuesByIssueId = (issueId: string) => {
|
||||
if (!issueId) return undefined;
|
||||
return this.subIssues[issueId] ?? undefined;
|
||||
};
|
||||
|
||||
groupedSubIssuesByIssueId = (issueId: string) => this.groupedSubIssuesMap[issueId] ?? undefined;
|
||||
subIssuesByIssueId = computedFn((issueId: string) => this.subIssues[issueId]);
|
||||
|
||||
subIssueHelpersByIssueId = (issueId: string) => ({
|
||||
preview_loader: this.subIssueHelpers?.[issueId]?.preview_loader || [],
|
||||
|
|
@ -138,20 +125,17 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
|
|||
};
|
||||
|
||||
fetchSubIssues = async (workspaceSlug: string, projectId: string, parentIssueId: string) => {
|
||||
// get filter params
|
||||
const filterParams = this.filters.computedFilterParams(parentIssueId);
|
||||
const response = await this.issueService.subIssues(workspaceSlug, projectId, parentIssueId, filterParams);
|
||||
this.loader = "init-loader";
|
||||
|
||||
const response = await this.issueService.subIssues(workspaceSlug, projectId, parentIssueId);
|
||||
|
||||
const subIssuesStateDistribution = response?.state_distribution ?? {};
|
||||
|
||||
// process sub issues response
|
||||
const { issueList, groupedIssues } = this.filters.processSubIssueResponse(response.sub_issues);
|
||||
|
||||
// set grouped issues count
|
||||
set(this.groupedSubIssuesMap, [parentIssueId], groupedIssues);
|
||||
const issueList = (response.sub_issues ?? []) as TIssue[];
|
||||
|
||||
this.rootIssueDetailStore.rootIssueStore.issues.addIssue(issueList);
|
||||
|
||||
// fetch other issues states and members when sub-issues are from different project
|
||||
if (issueList && issueList.length > 0) {
|
||||
const otherProjectIds = uniq(
|
||||
issueList.map((issue) => issue.project_id).filter((id) => !!id && id !== projectId)
|
||||
|
|
@ -163,6 +147,7 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
|
|||
sub_issues_count: issueList.length,
|
||||
});
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
set(this.subIssuesStateDistribution, parentIssueId, subIssuesStateDistribution);
|
||||
set(
|
||||
|
|
@ -171,6 +156,8 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
|
|||
issueList.map((issue) => issue.id)
|
||||
);
|
||||
});
|
||||
|
||||
this.loader = undefined;
|
||||
return response;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,202 +1,139 @@
|
|||
import set from "lodash/set";
|
||||
import { action, makeObservable, observable } from "mobx";
|
||||
import { ALL_ISSUES, EIssueFilterType, EIssueGroupByToServerOptions } from "@plane/constants";
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
import { EIssueFilterType } from "@plane/constants";
|
||||
import {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
IIssueFilters,
|
||||
TGroupedIssueCount,
|
||||
TGroupedIssues,
|
||||
TIssue,
|
||||
TIssueParams,
|
||||
TIssues,
|
||||
TSubGroupedIssues,
|
||||
TSubIssueResponse,
|
||||
} from "@plane/types";
|
||||
import { IIssueSubIssuesStore } from "./sub_issues.store";
|
||||
import { getFilteredWorkItems, getGroupedWorkItemIds, updateFilters } from "../helpers/base-issues-utils";
|
||||
import { IssueSubIssuesStore } from "./sub_issues.store";
|
||||
|
||||
export const DEFAULT_DISPLAY_PROPERTIES = {
|
||||
key: true,
|
||||
issue_type: true,
|
||||
assignee: true,
|
||||
start_date: true,
|
||||
due_date: true,
|
||||
labels: true,
|
||||
priority: true,
|
||||
state: true,
|
||||
};
|
||||
|
||||
export interface IWorkItemSubIssueFiltersStore {
|
||||
subIssueFiltersMap: Record<string, Partial<IIssueFilters>>;
|
||||
subIssueFilters: Record<string, Partial<IIssueFilters>>;
|
||||
// helpers methods
|
||||
updateSubIssueFilters: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
updateSubWorkItemFilters: (
|
||||
filterType: EIssueFilterType,
|
||||
filters: IIssueDisplayFilterOptions | IIssueDisplayProperties,
|
||||
parentId: string
|
||||
) => Promise<void>;
|
||||
getSubIssueFilters: (parentId: string) => Partial<IIssueFilters>;
|
||||
computedFilterParams: (parentId: string) => Partial<Record<TIssueParams, string | boolean>>;
|
||||
processSubIssueResponse: (issueResponse: TSubIssueResponse) => {
|
||||
issueList: TIssue[];
|
||||
groupedIssues: TIssues;
|
||||
groupedIssueCount: TGroupedIssueCount;
|
||||
};
|
||||
filters: IIssueDisplayFilterOptions | IIssueDisplayProperties | IIssueFilterOptions,
|
||||
workItemId: string
|
||||
) => void;
|
||||
getGroupedSubWorkItems: (workItemId: string) => TGroupedIssues;
|
||||
getFilteredSubWorkItems: (workItemId: string, filters: IIssueFilterOptions) => TIssue[];
|
||||
getSubIssueFilters: (workItemId: string) => Partial<IIssueFilters>;
|
||||
resetFilters: (workItemId: string) => void;
|
||||
}
|
||||
|
||||
export class WorkItemSubIssueFiltersStore implements IWorkItemSubIssueFiltersStore {
|
||||
// observables
|
||||
subIssueFiltersMap: Record<string, Partial<IIssueFilters>> = {};
|
||||
subIssueFilters: Record<string, Partial<IIssueFilters>> = {};
|
||||
|
||||
subIssueStore: IIssueSubIssuesStore;
|
||||
// root store
|
||||
subIssueStore: IssueSubIssuesStore;
|
||||
|
||||
constructor(subIssueStore: IIssueSubIssuesStore) {
|
||||
constructor(subIssueStore: IssueSubIssuesStore) {
|
||||
makeObservable(this, {
|
||||
subIssueFiltersMap: observable,
|
||||
updateSubIssueFilters: action,
|
||||
subIssueFilters: observable,
|
||||
updateSubWorkItemFilters: action,
|
||||
getSubIssueFilters: action,
|
||||
});
|
||||
// sub issue store
|
||||
|
||||
// root store
|
||||
this.subIssueStore = subIssueStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description This method is used to initialize the sub issue filters
|
||||
* @param parentId
|
||||
*/
|
||||
initSubIssueFilters = (parentId: string) => {
|
||||
set(this.subIssueFiltersMap, [parentId], {
|
||||
displayFilters: {},
|
||||
displayProperties: {
|
||||
key: true,
|
||||
issue_type: true,
|
||||
assignee: true,
|
||||
start_date: true,
|
||||
due_date: true,
|
||||
labels: true,
|
||||
priority: true,
|
||||
state: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description This method is used to process the sub issue response to provide the data to update the store
|
||||
* @param issueResponse
|
||||
* @returns issueList, list of issues data
|
||||
* @returns groupedIssues, grouped issue ids
|
||||
* @returns groupedIssueCount, object containing issue counts of individual groups
|
||||
*/
|
||||
processSubIssueResponse = (
|
||||
issueResponse: TSubIssueResponse
|
||||
): {
|
||||
issueList: TIssue[];
|
||||
groupedIssues: TIssues;
|
||||
groupedIssueCount: TGroupedIssueCount;
|
||||
} => {
|
||||
const issueResult = issueResponse;
|
||||
|
||||
if (!issueResult) {
|
||||
return {
|
||||
issueList: [],
|
||||
groupedIssues: {},
|
||||
groupedIssueCount: {},
|
||||
};
|
||||
}
|
||||
|
||||
//if is an array then it's an ungrouped response. return values with groupId as ALL_ISSUES
|
||||
if (Array.isArray(issueResult)) {
|
||||
return {
|
||||
issueList: issueResult,
|
||||
groupedIssues: {
|
||||
[ALL_ISSUES]: issueResult.map((issue) => issue.id),
|
||||
},
|
||||
groupedIssueCount: {
|
||||
[ALL_ISSUES]: issueResult.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const issueList: TIssue[] = [];
|
||||
const groupedIssues: TGroupedIssues | TSubGroupedIssues = {};
|
||||
const groupedIssueCount: TGroupedIssueCount = {};
|
||||
|
||||
// update total issue count to ALL_ISSUES
|
||||
set(groupedIssueCount, [ALL_ISSUES], issueResult.length);
|
||||
|
||||
// loop through all the groupIds from issue Result
|
||||
for (const groupId in issueResult) {
|
||||
const groupIssueResult = issueResult[groupId];
|
||||
|
||||
// if groupIssueResult is undefined then continue the loop
|
||||
if (!groupIssueResult) continue;
|
||||
|
||||
// set grouped Issue count of the current groupId
|
||||
set(groupedIssueCount, [groupId], groupIssueResult.length);
|
||||
|
||||
// add the result to issueList
|
||||
issueList.push(...groupIssueResult);
|
||||
// set the issue Ids to the groupId path
|
||||
set(
|
||||
groupedIssues,
|
||||
[groupId],
|
||||
groupIssueResult.map((issue) => issue.id)
|
||||
);
|
||||
}
|
||||
|
||||
return { issueList, groupedIssues, groupedIssueCount };
|
||||
};
|
||||
|
||||
/**
|
||||
* @description This method is used to get the sub issue filters
|
||||
* @param parentId
|
||||
* @returns IIssueFilters
|
||||
* @param workItemId
|
||||
* @returns
|
||||
*/
|
||||
getSubIssueFilters = (parentId: string) => {
|
||||
if (!this.subIssueFiltersMap[parentId]) {
|
||||
this.initSubIssueFilters(parentId);
|
||||
getSubIssueFilters = (workItemId: string) => {
|
||||
if (!this.subIssueFilters[workItemId]) {
|
||||
this.initializeFilters(workItemId);
|
||||
}
|
||||
return this.subIssueFiltersMap[parentId];
|
||||
};
|
||||
|
||||
computedFilterParams = (parentId: string) => {
|
||||
const displayFilters = this.getSubIssueFilters(parentId).displayFilters;
|
||||
|
||||
const computedFilters: Partial<Record<TIssueParams, undefined | string[] | boolean | string>> = {
|
||||
order_by: displayFilters?.order_by || undefined,
|
||||
group_by: displayFilters?.group_by ? EIssueGroupByToServerOptions[displayFilters.group_by] : undefined,
|
||||
};
|
||||
|
||||
const issueFiltersParams: Partial<Record<TIssueParams, boolean | string>> = {};
|
||||
Object.keys(computedFilters).forEach((key) => {
|
||||
const _key = key as TIssueParams;
|
||||
const _value: string | boolean | string[] | undefined = computedFilters[_key];
|
||||
const nonEmptyArrayValue = Array.isArray(_value) && _value.length === 0 ? undefined : _value;
|
||||
if (nonEmptyArrayValue != undefined)
|
||||
issueFiltersParams[_key] = Array.isArray(nonEmptyArrayValue)
|
||||
? nonEmptyArrayValue.join(",")
|
||||
: nonEmptyArrayValue;
|
||||
});
|
||||
|
||||
return issueFiltersParams;
|
||||
return this.subIssueFilters[workItemId];
|
||||
};
|
||||
|
||||
/**
|
||||
* @description This method is used to update the sub issue filters
|
||||
* @param projectId
|
||||
* @description This method is used to initialize the sub issue filters
|
||||
* @param workItemId
|
||||
*/
|
||||
initializeFilters = (workItemId: string) => {
|
||||
set(this.subIssueFilters, [workItemId, "displayProperties"], DEFAULT_DISPLAY_PROPERTIES);
|
||||
set(this.subIssueFilters, [workItemId, "filters"], {});
|
||||
set(this.subIssueFilters, [workItemId, "displayFilters"], {});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description This method updates filters for sub issues.
|
||||
* @param filterType
|
||||
* @param filters
|
||||
*/
|
||||
updateSubIssueFilters = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
updateSubWorkItemFilters = (
|
||||
filterType: EIssueFilterType,
|
||||
filters: IIssueDisplayFilterOptions | IIssueDisplayProperties,
|
||||
parentId: string
|
||||
filters: IIssueDisplayFilterOptions | IIssueDisplayProperties | IIssueFilterOptions,
|
||||
workItemId: string
|
||||
) => {
|
||||
const _filters = this.getSubIssueFilters(parentId);
|
||||
switch (filterType) {
|
||||
case EIssueFilterType.DISPLAY_FILTERS: {
|
||||
set(this.subIssueFiltersMap, [parentId, "displayFilters"], { ..._filters.displayFilters, ...filters });
|
||||
this.subIssueStore.fetchSubIssues(workspaceSlug, projectId, parentId);
|
||||
break;
|
||||
}
|
||||
case EIssueFilterType.DISPLAY_PROPERTIES:
|
||||
set(this.subIssueFiltersMap, [parentId, "displayProperties"], {
|
||||
..._filters.displayProperties,
|
||||
...filters,
|
||||
});
|
||||
break;
|
||||
}
|
||||
runInAction(() => {
|
||||
updateFilters(this.subIssueFilters, filterType, filters, workItemId);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description This method is used to get the grouped sub work items
|
||||
* @param parentWorkItemId
|
||||
* @returns
|
||||
*/
|
||||
getGroupedSubWorkItems = computedFn((parentWorkItemId: string) => {
|
||||
const subIssueFilters = this.getSubIssueFilters(parentWorkItemId);
|
||||
|
||||
const filteredWorkItems = this.getFilteredSubWorkItems(parentWorkItemId, subIssueFilters.filters ?? {});
|
||||
|
||||
// get group by and order by
|
||||
const groupByKey = subIssueFilters.displayFilters?.group_by;
|
||||
const orderByKey = subIssueFilters.displayFilters?.order_by;
|
||||
|
||||
const groupedWorkItemIds = getGroupedWorkItemIds(filteredWorkItems, groupByKey, orderByKey);
|
||||
|
||||
return groupedWorkItemIds;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description This method is used to get the filtered sub work items
|
||||
* @param workItemId
|
||||
* @returns
|
||||
*/
|
||||
getFilteredSubWorkItems = computedFn((workItemId: string, filters: IIssueFilterOptions) => {
|
||||
const subIssueIds = this.subIssueStore.subIssuesByIssueId(workItemId);
|
||||
const workItems = this.subIssueStore.rootIssueDetailStore.rootIssueStore.issues.getIssuesByIds(
|
||||
subIssueIds,
|
||||
"un-archived"
|
||||
);
|
||||
|
||||
const filteredWorkItems = getFilteredWorkItems(workItems, filters);
|
||||
|
||||
return filteredWorkItems;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description This method is used to reset the filters
|
||||
* @param workItemId
|
||||
*/
|
||||
resetFilters = (workItemId: string) => {
|
||||
this.initializeFilters(workItemId);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -405,3 +405,73 @@ export const generateDateArray = (startDate: string | Date, endDate: string | Da
|
|||
|
||||
return dateArray;
|
||||
};
|
||||
|
||||
/**
|
||||
* Processes relative date strings like "1_weeks", "2_months" etc and returns a Date
|
||||
* @param value The relative date string (e.g., "1_weeks", "2_months")
|
||||
* @returns Date object representing the calculated date
|
||||
*/
|
||||
export const processRelativeDate = (value: string): Date => {
|
||||
const [amountStr, unit] = value.split("_");
|
||||
const amount = parseInt(amountStr, 10);
|
||||
if (isNaN(amount)) {
|
||||
throw new Error(`Invalid relative amount: ${amountStr}`);
|
||||
}
|
||||
const date = new Date();
|
||||
|
||||
switch (unit) {
|
||||
case "days":
|
||||
date.setDate(date.getDate() + amount);
|
||||
break;
|
||||
case "weeks":
|
||||
date.setDate(date.getDate() + amount * 7);
|
||||
break;
|
||||
case "months":
|
||||
date.setMonth(date.getMonth() + amount);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported time unit: ${unit}`);
|
||||
}
|
||||
|
||||
return date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a date filter string and returns the comparison type and date
|
||||
* @param filterValue The date filter string (e.g., "1_weeks;after;fromnow" or "2024-12-01;after")
|
||||
* @returns Object containing the comparison type and target date
|
||||
*/
|
||||
export const parseDateFilter = (filterValue: string): { type: "after" | "before"; date: Date } => {
|
||||
const parts = filterValue.split(";");
|
||||
const dateStr = parts[0];
|
||||
const type = parts[1] as "after" | "before";
|
||||
|
||||
let date: Date;
|
||||
if (dateStr.includes("_")) {
|
||||
// Handle relative dates (e.g., "1_weeks;after;fromnow")
|
||||
date = processRelativeDate(dateStr);
|
||||
} else {
|
||||
// Handle absolute dates (e.g., "2024-12-01;after")
|
||||
date = new Date(dateStr);
|
||||
}
|
||||
|
||||
return { type, date };
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a date meets the filter criteria
|
||||
* @param dateToCheck The date to check
|
||||
* @param filterDate The filter date to compare against
|
||||
* @param type The type of comparison ('after' or 'before')
|
||||
* @returns boolean indicating if the date meets the criteria
|
||||
*/
|
||||
export const checkDateCriteria = (dateToCheck: Date | null, filterDate: Date, type: "after" | "before"): boolean => {
|
||||
if (!dateToCheck) return false;
|
||||
|
||||
const checkDate = new Date(dateToCheck);
|
||||
const normalizedCheck = new Date(checkDate.setHours(0, 0, 0, 0));
|
||||
const normalizedFilter = new Date(filterDate.getTime());
|
||||
normalizedFilter.setHours(0, 0, 0, 0);
|
||||
|
||||
return type === "after" ? normalizedCheck >= normalizedFilter : normalizedCheck <= normalizedFilter;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue