diff --git a/packages/constants/src/issue/common.ts b/packages/constants/src/issue/common.ts index 39c67505c4c..c182cb17e04 100644 --- a/packages/constants/src/issue/common.ts +++ b/packages/constants/src/issue/common.ts @@ -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> = { + 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; diff --git a/packages/constants/src/issue/filter.ts b/packages/constants/src/issue/filter.ts index 15952132a07..fd9b5c2b371 100644 --- a/packages/constants/src/issue/filter.ts +++ b/packages/constants/src/issue/filter.ts @@ -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 -> = { +export const ISSUE_STORE_TO_FILTERS_MAP: Partial> = { [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 = { [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; diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index 18a150c4921..e51bfa36d03 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -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 = { diff --git a/packages/ui/src/collapsible/collapsible-button.tsx b/packages/ui/src/collapsible/collapsible-button.tsx index b6198fa6cc6..48f0d0c703a 100644 --- a/packages/ui/src/collapsible/collapsible-button.tsx +++ b/packages/ui/src/collapsible/collapsible-button.tsx @@ -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; }; export const CollapsibleButton: FC = (props) => { @@ -21,6 +22,7 @@ export const CollapsibleButton: FC = (props) => { actionItemElement, className = "", titleClassName = "", + ChevronIcon = DropdownIcon, } = props; return (
= (props) => {
{!hideChevron && ( - = (props) => { - const { title, description, icon, actionElement } = props; + const { title, description, icon, actionElement, customClassName } = props; return ( -
+
{icon}
{title} diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/content.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/content.tsx index c8c69217aa8..6000959f22d 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/content.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/content.tsx @@ -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 = observer((props) => { <> {subIssueHelpers.issue_visibility.includes(parentIssueId) && ( = observer displayFilters, } = props; + const isFilterApplied = useMemo( + () => isDisplayFiltersApplied({ displayProperties, displayFilters }), + [displayProperties, displayFilters] + ); + return ( <> {layoutDisplayFiltersOptions?.display_filters && layoutDisplayFiltersOptions?.display_properties.length > 0 && ( } + menuButton={ +
+ {isFilterApplied && } + +
+ } > -
{ - 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 */}
= observer />
+ {/* group by */} +
+ + handleDisplayFiltersUpdate({ + group_by: val, + }) + } + ignoreGroupedFilters={[]} + /> +
+ {/* order by */} {!isEmpty(layoutDisplayFiltersOptions?.display_filters?.order_by) && (
diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/filters.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/filters.tsx new file mode 100644 index 00000000000..8ae6195ea8e --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/filters.tsx @@ -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 = 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 ( + <> + + {isFilterApplied && } + +
+ } + > +
+
+
+ + setFiltersSearchQuery(e.target.value)} + /> + {filtersSearchQuery !== "" && ( + + )} +
+
+
+ {/* Priority */} + {isFilterEnabled("priority") && ( +
+ handleFiltersUpdate("priority", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + + {/* state group */} + {isFilterEnabled("state_group") && ( +
+ handleFiltersUpdate("state_group", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + + {/* State */} + {isFilterEnabled("state") && ( +
+ handleFiltersUpdate("state", val)} + searchQuery={filtersSearchQuery} + states={states} + /> +
+ )} + + {/* Projects */} + {isFilterEnabled("project") && ( +
+ handleFiltersUpdate("project", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + + {/* work item types */} + {isFilterEnabled("issue_type") && ( +
+ handleFiltersUpdate("issue_type", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + + {/* Assignees */} + {isFilterEnabled("assignees") && ( +
+ handleFiltersUpdate("assignees", val)} + memberIds={memberIds} + searchQuery={filtersSearchQuery} + /> +
+ )} + + {/* Start Date */} + {isFilterEnabled("start_date") && ( +
+ handleFiltersUpdate("start_date", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + + {/* Target Date */} + {isFilterEnabled("target_date") && ( +
+ handleFiltersUpdate("target_date", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} +
+
+ + + ); +}); diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-group.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-group.tsx new file mode 100644 index 00000000000..3b84cd73206 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-group.tsx @@ -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 = 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 ( + <> + setIsCollapsibleOpen(!isCollapsibleOpen)} + title={ + !isAllIssues && ( +
+ +
+ {group.icon ?? } +
+ {group.name} + {workItemIds.length} +
+ ) + } + buttonClassName={cn("hidden", !isAllIssues && "block")} + > + {/* Work items list */} +
+ {workItemIds?.map((workItemId) => ( + + ))} +
+
+ + ); +}); diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx index abb2aaca05b..1d581929c31 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx @@ -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 = observer((props) => { @@ -51,6 +52,7 @@ export const SubIssuesListItem: React.FC = observer((props) => { handleIssueCrudState, subIssueOperations, issueServiceType = EIssueServiceType.ISSUES, + storeType = EIssuesStoreType.PROJECT, } = props; const { t } = useTranslation(); const { @@ -81,7 +83,7 @@ export const SubIssuesListItem: React.FC = 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 = observer((props) => { subIssueCount > 0 && !isCurrentIssueRoot && ( = observer((props) => maxDate?.setDate(maxDate.getDate()); return (
- -
- +
+ issue.project_id && updateSubIssue( @@ -55,25 +56,38 @@ export const SubIssuesListItemProperties: React.FC = observer((props) => parentIssueId, issueId, { - start_date: val ? renderFormattedPayloadDate(val) : null, + state_id: val, }, { ...issue } ) } - maxDate={maxDate} - placeholder={t("common.order_by.start_date")} - icon={} - buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"} - optionsClassName="z-30" disabled={!disabled} + buttonVariant="border-with-text" />
- + +
+ + issue.project_id && + updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, { + priority: val, + }) + } + disabled={!disabled} + buttonVariant="border-without-text" + buttonClassName="border" + /> +
+
+ +
issue.project_id && updateSubIssue( @@ -82,26 +96,25 @@ export const SubIssuesListItemProperties: React.FC = observer((props) => parentIssueId, issueId, { - target_date: val ? renderFormattedPayloadDate(val) : null, + start_date: val ? renderFormattedPayloadDate(val) : null, }, { ...issue } ) } maxDate={maxDate} - placeholder={t("common.order_by.due_date")} + placeholder={t("common.order_by.start_date")} icon={} - buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"} + buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"} optionsClassName="z-30" disabled={!disabled} />
- -
- +
+ issue.project_id && updateSubIssue( @@ -110,30 +123,17 @@ export const SubIssuesListItemProperties: React.FC = observer((props) => parentIssueId, issueId, { - state_id: val, + target_date: val ? renderFormattedPayloadDate(val) : null, }, { ...issue } ) } + maxDate={maxDate} + placeholder={t("common.order_by.due_date")} + icon={} + buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"} + optionsClassName="z-30" disabled={!disabled} - buttonVariant="border-with-text" - /> -
- - - -
- - issue.project_id && - updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, { - priority: val, - }) - } - disabled={!disabled} - buttonVariant="border-without-text" - buttonClassName="border" />
diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/root.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/root.tsx index 45a45ed91d0..f867651674b 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/root.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/root.tsx @@ -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 = observer((props) => { @@ -29,36 +34,89 @@ export const SubIssuesListRoot: React.FC = 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 (
- {subIssueIds?.map((issueId) => ( - 0 ? ( + groups?.map((group) => ( + + )) + ) : ( + } + customClassName={storeType !== EIssuesStoreType.EPIC ? "border-none" : ""} + actionElement={ + + } /> - ))} + )}
); }); diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/title-actions.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/title-actions.tsx index 80f9af6fe50..e9e4393ed74 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/title-actions.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/title-actions.tsx @@ -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 = 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) => { - 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) => { - if (!workspaceSlug || !projectId) return; - updateSubIssueFilters( - workspaceSlug, - projectId, - EIssueFilterType.DISPLAY_PROPERTIES, - updatedDisplayProperties, - parentId - ); + updateSubWorkItemFilters(EIssueFilterType.DISPLAY_PROPERTIES, updatedDisplayProperties, parentId); + }, + [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); }, - [workspaceSlug, projectId, parentId, updateSubIssueFilters] + [subIssueFilters?.filters, updateSubWorkItemFilters, parentId] ); return ( -
+ // prevent click everywhere +
{ + e.stopPropagation(); + e.preventDefault(); + }} + > = observ handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate} handleDisplayFiltersUpdate={handleDisplayFilters} /> + {!disabled && ( )} diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/title.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/title.tsx index ceccf95dd37..041f0ca234b 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/title.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/title.tsx @@ -33,10 +33,7 @@ export const SubIssuesCollapsibleTitle: FC = 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 = observer((props) => { } actionItemElement={ [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 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: , - 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: , + 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: , + payload: { assignee_ids: [memberId] }, + }); + }); + } assigneeColumns.push({ id: "None", name: "None", icon: , payload: {} }); return assigneeColumns; }; @@ -719,3 +751,37 @@ export const SpreadSheetPropertyIcon: FC = (pro if (!Icon) return null; return ; }; + +/** + * This method returns if the filters are applied + * @param filters + * @returns + */ +export const isDisplayFiltersApplied = (filters: Partial): 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 !== ""; + }); diff --git a/web/core/store/issue/helpers/base-issues-utils.ts b/web/core/store/issue/helpers/base-issues-utils.ts index a9fc639b4d7..dcb4e86adb4 100644 --- a/web/core/store/issue/helpers/base-issues-utils.ts +++ b/web/core/store/issue/helpers/base-issues-utils.ts @@ -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 = {}; + 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 => { + // 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 = {}; + 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>, + 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; + } +}; diff --git a/web/core/store/issue/helpers/base-issues.store.ts b/web/core/store/issue/helpers/base-issues.store.ts index d8a7dfa185c..b28ff13faad 100644 --- a/web/core/store/issue/helpers/base-issues.store.ts +++ b/web/core/store/issue/helpers/base-issues.store.ts @@ -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 = { +export const ISSUE_GROUP_BY_KEY: Record = { 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", diff --git a/web/core/store/issue/issue-details/sub_issues.store.ts b/web/core/store/issue/issue-details/sub_issues.store.ts index d77c42d30eb..a8be50f886e 100644 --- a/web/core/store/issue/issue-details/sub_issues.store.ts +++ b/web/core/store/issue/issue-details/sub_issues.store.ts @@ -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; - groupedSubIssuesCount: TGroupedIssueCount; subIssueHelpers: Record; // 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; setSubIssueHelpers: (parentIssueId: string, key: TSubIssueHelpersKeys, value: string) => void; @@ -70,8 +66,6 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { // observables subIssuesStateDistribution: TIssueSubIssuesStateDistributionMap = {}; subIssues: TIssueSubIssuesIdMap = {}; - groupedSubIssuesMap: Record = {}; - groupedSubIssuesCount: TGroupedIssueCount = {}; subIssueHelpers: Record = {}; 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 subIssuesStateDistribution = response?.state_distribution ?? {}; + const response = await this.issueService.subIssues(workspaceSlug, projectId, parentIssueId); - // process sub issues response - const { issueList, groupedIssues } = this.filters.processSubIssueResponse(response.sub_issues); + const subIssuesStateDistribution = response?.state_distribution ?? {}; - // 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; }; diff --git a/web/core/store/issue/issue-details/sub_issues_filter.store.ts b/web/core/store/issue/issue-details/sub_issues_filter.store.ts index 47edf767c50..d68583b6831 100644 --- a/web/core/store/issue/issue-details/sub_issues_filter.store.ts +++ b/web/core/store/issue/issue-details/sub_issues_filter.store.ts @@ -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>; + subIssueFilters: Record>; // helpers methods - updateSubIssueFilters: ( - workspaceSlug: string, - projectId: string, + updateSubWorkItemFilters: ( filterType: EIssueFilterType, - filters: IIssueDisplayFilterOptions | IIssueDisplayProperties, - parentId: string - ) => Promise; - getSubIssueFilters: (parentId: string) => Partial; - computedFilterParams: (parentId: string) => Partial>; - 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; + resetFilters: (workItemId: string) => void; } export class WorkItemSubIssueFiltersStore implements IWorkItemSubIssueFiltersStore { // observables - subIssueFiltersMap: Record> = {}; + subIssueFilters: Record> = {}; - 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 + * @description This method is used to get the sub issue filters + * @param workItemId + * @returns */ - 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, - }, - }); + getSubIssueFilters = (workItemId: string) => { + if (!this.subIssueFilters[workItemId]) { + this.initializeFilters(workItemId); + } + return this.subIssueFilters[workItemId]; }; /** - * @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 + * @description This method is used to initialize the sub issue filters + * @param workItemId */ - 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 = {}; + initializeFilters = (workItemId: string) => { + set(this.subIssueFilters, [workItemId, "displayProperties"], DEFAULT_DISPLAY_PROPERTIES); + set(this.subIssueFilters, [workItemId, "filters"], {}); + set(this.subIssueFilters, [workItemId, "displayFilters"], {}); + }; - // update total issue count to ALL_ISSUES - set(groupedIssueCount, [ALL_ISSUES], issueResult.length); + /** + * @description This method updates filters for sub issues. + * @param filterType + * @param filters + */ + updateSubWorkItemFilters = ( + filterType: EIssueFilterType, + filters: IIssueDisplayFilterOptions | IIssueDisplayProperties | IIssueFilterOptions, + workItemId: string + ) => { + runInAction(() => { + updateFilters(this.subIssueFilters, filterType, filters, workItemId); + }); + }; - // loop through all the groupIds from issue Result - for (const groupId in issueResult) { - const groupIssueResult = issueResult[groupId]; + /** + * @description This method is used to get the grouped sub work items + * @param parentWorkItemId + * @returns + */ + getGroupedSubWorkItems = computedFn((parentWorkItemId: string) => { + const subIssueFilters = this.getSubIssueFilters(parentWorkItemId); - // if groupIssueResult is undefined then continue the loop - if (!groupIssueResult) continue; + const filteredWorkItems = this.getFilteredSubWorkItems(parentWorkItemId, subIssueFilters.filters ?? {}); - // set grouped Issue count of the current groupId - set(groupedIssueCount, [groupId], groupIssueResult.length); + // get group by and order by + const groupByKey = subIssueFilters.displayFilters?.group_by; + const orderByKey = subIssueFilters.displayFilters?.order_by; - // add the result to issueList - issueList.push(...groupIssueResult); - // set the issue Ids to the groupId path - set( - groupedIssues, - [groupId], - groupIssueResult.map((issue) => issue.id) - ); - } + const groupedWorkItemIds = getGroupedWorkItemIds(filteredWorkItems, groupByKey, orderByKey); - return { issueList, groupedIssues, groupedIssueCount }; - }; + return groupedWorkItemIds; + }); /** - * @description This method is used to get the sub issue filters - * @param parentId - * @returns IIssueFilters + * @description This method is used to get the filtered sub work items + * @param workItemId + * @returns */ - getSubIssueFilters = (parentId: string) => { - if (!this.subIssueFiltersMap[parentId]) { - this.initSubIssueFilters(parentId); - } - return this.subIssueFiltersMap[parentId]; - }; + getFilteredSubWorkItems = computedFn((workItemId: string, filters: IIssueFilterOptions) => { + const subIssueIds = this.subIssueStore.subIssuesByIssueId(workItemId); + const workItems = this.subIssueStore.rootIssueDetailStore.rootIssueStore.issues.getIssuesByIds( + subIssueIds, + "un-archived" + ); - computedFilterParams = (parentId: string) => { - const displayFilters = this.getSubIssueFilters(parentId).displayFilters; - - const computedFilters: Partial> = { - order_by: displayFilters?.order_by || undefined, - group_by: displayFilters?.group_by ? EIssueGroupByToServerOptions[displayFilters.group_by] : undefined, - }; - - const issueFiltersParams: Partial> = {}; - 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; - }); + const filteredWorkItems = getFilteredWorkItems(workItems, filters); - return issueFiltersParams; - }; + return filteredWorkItems; + }); /** - * @description This method is used to update the sub issue filters - * @param projectId - * @param filterType - * @param filters + * @description This method is used to reset the filters + * @param workItemId */ - updateSubIssueFilters = async ( - workspaceSlug: string, - projectId: string, - filterType: EIssueFilterType, - filters: IIssueDisplayFilterOptions | IIssueDisplayProperties, - parentId: 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; - } + resetFilters = (workItemId: string) => { + this.initializeFilters(workItemId); }; } diff --git a/web/helpers/date-time.helper.ts b/web/helpers/date-time.helper.ts index c26addd7ca7..cff0da2c2df 100644 --- a/web/helpers/date-time.helper.ts +++ b/web/helpers/date-time.helper.ts @@ -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; +};