From 3c39e67340f3999e7e8c7b19a15827fdc6431aab Mon Sep 17 00:00:00 2001 From: vamsikrishnamathala Date: Tue, 29 Apr 2025 17:26:27 +0530 Subject: [PATCH 1/8] feat: added filters for sub issues --- apiserver/plane/app/views/issue/sub_issue.py | 2 +- packages/constants/src/issue/filter.ts | 24 +--- .../sub-issues/display-filters.tsx | 20 ++- .../sub-issues/filters.tsx | 122 ++++++++++++++++++ .../sub-issues/issues-list/list-group.tsx | 16 +++ .../sub-issues/title-actions.tsx | 51 +++++++- .../issue-details/sub_issues_filter.store.ts | 24 +++- 7 files changed, 231 insertions(+), 28 deletions(-) create mode 100644 web/core/components/issues/issue-detail-widgets/sub-issues/filters.tsx create mode 100644 web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-group.tsx diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index 5791281f0ed..da5b5386d51 100644 --- a/apiserver/plane/app/views/issue/sub_issue.py +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -153,7 +153,7 @@ def get(self, request, slug, project_id, issue_id): result_dict = defaultdict(list) for issue in sub_issues: - if group_by == "assignees__ids": + if group_by == "assignees__id": if issue["assignee_ids"]: assignee_ids = issue["assignee_ids"] for assignee_id in assignee_ids: diff --git a/packages/constants/src/issue/filter.ts b/packages/constants/src/issue/filter.ts index 15952132a07..bc7f6cced65 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, @@ -361,6 +358,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = { filters: [], 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/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx index 595bed0bcfa..3dbe4b306d2 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx @@ -1,9 +1,9 @@ import { FC } 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 { FilterDisplayProperties, FilterGroupBy, FilterOrderBy, FiltersDropdown } from "@/components/issues"; type TSubIssueDisplayFiltersProps = { displayProperties: IIssueDisplayProperties; @@ -29,7 +29,7 @@ export const SubIssueDisplayFilters: FC = observer {layoutDisplayFiltersOptions?.display_filters && layoutDisplayFiltersOptions?.display_properties.length > 0 && ( } + menuButton={} >
{ @@ -48,6 +48,20 @@ export const SubIssueDisplayFilters: FC = 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..b929486ffdd --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/filters.tsx @@ -0,0 +1,122 @@ +import { FC, useState } from "react"; +import { observer } from "mobx-react"; +import { ListFilter, Search, X } from "lucide-react"; +import { useTranslation } from "@plane/i18n"; +import { IIssueFilterOptions, IState } from "@plane/types"; +import { + FilterAssignees, + FilterDueDate, + FilterPriority, + FilterProjects, + FiltersDropdown, + FilterStartDate, + FilterState, +} from "@/components/issues"; +import { FilterIssueTypes } from "@/plane-web/components/issues/filters/issue-types"; + +type TSubIssueFiltersProps = { + handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; + filters: IIssueFilterOptions; + projectMemberIds: string[] | undefined; + projectStates?: IState[]; +}; + +export const SubIssueFilters: FC = observer((props) => { + const { handleFiltersUpdate, filters, projectMemberIds, projectStates } = props; + + // states + const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); + + // hooks + const { t } = useTranslation(); + + return ( + <> + }> +
{ + e.stopPropagation(); + e.preventDefault(); + }} + > +
+
+ + setFiltersSearchQuery(e.target.value)} + /> + {filtersSearchQuery !== "" && ( + + )} +
+
+
+ {/* Priority */} +
+ handleFiltersUpdate("priority", val)} + searchQuery={filtersSearchQuery} + /> +
+ {/* State */} +
+ handleFiltersUpdate("state", val)} + searchQuery={filtersSearchQuery} + states={projectStates} + /> +
+ {/* Projects */} +
+ handleFiltersUpdate("project", val)} + searchQuery={filtersSearchQuery} + /> +
+ {/* work item types */} + handleFiltersUpdate("issue_type", val)} + searchQuery={filtersSearchQuery} + /> + {/* Assignees */} +
+ handleFiltersUpdate("assignees", val)} + memberIds={projectMemberIds} + searchQuery={filtersSearchQuery} + /> +
+ {/* Start Date */} +
+ handleFiltersUpdate("start_date", val)} + searchQuery={filtersSearchQuery} + /> +
+ {/* 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..411786f43b4 --- /dev/null +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-group.tsx @@ -0,0 +1,16 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { IGroupByColumn, TIssueGroupByOptions, TIssueMap, TIssueOrderByOptions } from "@plane/types"; + +interface TSubIssuesListGroupProps { + groupIssueIds: string[] | undefined; + group: IGroupByColumn; + issuesMap: TIssueMap; + group_by: TIssueGroupByOptions | null; + orderBy: TIssueOrderByOptions | undefined; +} + +export const SubIssuesListGroup: FC = observer((props) => { + const { groupIssueIds, group, issuesMap, group_by, orderBy } = props; + return
SubIssuesListGroup
; +}); 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..e37a9b48414 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,9 +1,15 @@ import { FC, useCallback } from "react"; 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 = { @@ -23,9 +29,15 @@ export const SubWorkItemTitleActions: FC = observ filters: { getSubIssueFilters, updateSubIssueFilters }, }, } = 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; @@ -51,8 +63,35 @@ export const SubWorkItemTitleActions: FC = observ [workspaceSlug, projectId, parentId, updateSubIssueFilters] ); + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!workspaceSlug || !projectId) return; + const newValues = 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); + } + + updateSubIssueFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.FILTERS, + { [key]: newValues }, + parentId + ); + }, + [workspaceSlug, projectId, subIssueFilters?.filters, updateSubIssueFilters, parentId] + ); + return ( -
+
= observ handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate} handleDisplayFiltersUpdate={handleDisplayFilters} /> + {!disabled && ( )} 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..1df6cbcc551 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 @@ -4,6 +4,7 @@ import { ALL_ISSUES, EIssueFilterType, EIssueGroupByToServerOptions } from "@pla import { IIssueDisplayFilterOptions, IIssueDisplayProperties, + IIssueFilterOptions, IIssueFilters, TGroupedIssueCount, TGroupedIssues, @@ -22,7 +23,7 @@ export interface IWorkItemSubIssueFiltersStore { workspaceSlug: string, projectId: string, filterType: EIssueFilterType, - filters: IIssueDisplayFilterOptions | IIssueDisplayProperties, + filters: IIssueDisplayFilterOptions | IIssueDisplayProperties | IIssueFilterOptions, parentId: string ) => Promise; getSubIssueFilters: (parentId: string) => Partial; @@ -56,6 +57,7 @@ export class WorkItemSubIssueFiltersStore implements IWorkItemSubIssueFiltersSto */ initSubIssueFilters = (parentId: string) => { set(this.subIssueFiltersMap, [parentId], { + filters: {}, displayFilters: {}, displayProperties: { key: true, @@ -150,9 +152,20 @@ export class WorkItemSubIssueFiltersStore implements IWorkItemSubIssueFiltersSto }; computedFilterParams = (parentId: string) => { - const displayFilters = this.getSubIssueFilters(parentId).displayFilters; + const filters = this.getSubIssueFilters(parentId); + + const displayFilters = filters.displayFilters; + const filterOptions = filters.filters; const computedFilters: Partial> = { + // issue filters + priority: filterOptions?.priority || undefined, + state: filterOptions?.state || undefined, + assignees: filterOptions?.assignees || undefined, + start_date: filterOptions?.start_date || undefined, + target_date: filterOptions?.target_date || undefined, + project: filterOptions?.project || undefined, + issue_type: filterOptions?.issue_type || undefined, order_by: displayFilters?.order_by || undefined, group_by: displayFilters?.group_by ? EIssueGroupByToServerOptions[displayFilters.group_by] : undefined, }; @@ -181,11 +194,16 @@ export class WorkItemSubIssueFiltersStore implements IWorkItemSubIssueFiltersSto workspaceSlug: string, projectId: string, filterType: EIssueFilterType, - filters: IIssueDisplayFilterOptions | IIssueDisplayProperties, + filters: IIssueDisplayFilterOptions | IIssueDisplayProperties | IIssueFilterOptions, parentId: string ) => { const _filters = this.getSubIssueFilters(parentId); switch (filterType) { + case EIssueFilterType.FILTERS: { + set(this.subIssueFiltersMap, [parentId, "filters"], { ..._filters.filters, ...filters }); + this.subIssueStore.fetchSubIssues(workspaceSlug, projectId, parentId); + break; + } case EIssueFilterType.DISPLAY_FILTERS: { set(this.subIssueFiltersMap, [parentId, "displayFilters"], { ..._filters.displayFilters, ...filters }); this.subIssueStore.fetchSubIssues(workspaceSlug, projectId, parentId); From fed5cd623cb6435d1938f3778848091a04fca639 Mon Sep 17 00:00:00 2001 From: vamsikrishnamathala Date: Wed, 30 Apr 2025 13:55:20 +0530 Subject: [PATCH 2/8] feat: added list groups for sub issues --- .../ui/src/collapsible/collapsible-button.tsx | 6 +- .../sub-issues/content.tsx | 3 +- .../sub-issues/display-filters.tsx | 8 +- .../sub-issues/filters.tsx | 8 +- .../sub-issues/issues-list/list-group.tsx | 96 +++++++++++++++++-- .../sub-issues/issues-list/list-item.tsx | 5 +- .../sub-issues/issues-list/properties.tsx | 44 ++++----- .../sub-issues/issues-list/root.tsx | 62 ++++++++---- .../sub-issues/title-actions.tsx | 9 +- .../components/issues/issue-layouts/utils.tsx | 24 +++-- .../issue/issue-details/sub_issues.store.ts | 21 ++-- .../issue-details/sub_issues_filter.store.ts | 21 +++- 12 files changed, 214 insertions(+), 93 deletions(-) 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 && ( - = observer((props) => { <> {subIssueHelpers.issue_visibility.includes(parentIssueId) && ( = observer placement="bottom-end" menuButton={} > -
{ - 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((props) => { return ( <> }> -
{ - e.stopPropagation(); - e.preventDefault(); - }} - > +
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 index 411786f43b4..3b84cd73206 100644 --- 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 @@ -1,16 +1,96 @@ -import { FC } from "react"; +import { FC, useState } from "react"; import { observer } from "mobx-react"; -import { IGroupByColumn, TIssueGroupByOptions, TIssueMap, TIssueOrderByOptions } from "@plane/types"; +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 { - groupIssueIds: string[] | undefined; + workItemIds: string[]; + projectId: string; + workspaceSlug: string; group: IGroupByColumn; - issuesMap: TIssueMap; - group_by: TIssueGroupByOptions | null; - orderBy: TIssueOrderByOptions | undefined; + 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 { groupIssueIds, group, issuesMap, group_by, orderBy } = props; - return
SubIssuesListGroup
; + 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..6f75085d05c 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 { @@ -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,21 @@ 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( @@ -82,26 +79,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,13 +106,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" />
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..58a23369c42 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,13 @@ import { observer } from "mobx-react"; // plane imports -import { EIssueServiceType } from "@plane/constants"; -import { TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types"; +import { EIssueServiceType, EIssuesStoreType } from "@plane/constants"; +import { GroupByColumnTypes, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types"; // hooks +import { Loader } from "@plane/ui"; +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 +22,7 @@ type Props = { ) => void; subIssueOperations: TSubIssueOperations; issueServiceType?: TIssueServiceType; + storeType: EIssuesStoreType; }; export const SubIssuesListRoot: React.FC = observer((props) => { @@ -28,35 +30,61 @@ export const SubIssuesListRoot: React.FC = observer((props) => { workspaceSlug, projectId, parentIssueId, - rootIssueId, - spacingLeft = 10, disabled, handleIssueCrudState, subIssueOperations, issueServiceType = EIssueServiceType.ISSUES, + storeType = EIssuesStoreType.PROJECT, + spacingLeft = 0, } = props; // store hooks const { - subIssues: { subIssuesByIssueId }, + subIssues: { + loader, + getGroupedSubIssuesByIssueId, + filters: { getSubIssueFilters }, + }, } = useIssueDetail(issueServiceType); + + if (loader === "init-loader") { + return ( + + {[...Array(4)].map((_, index) => ( + + ))} + + ); + } + // derived values - const subIssueIds = subIssuesByIssueId(parentIssueId); + const groupedSubIssues = getGroupedSubIssuesByIssueId(parentIssueId); + const filters = getSubIssueFilters(parentIssueId); + const group_by = filters?.displayFilters?.group_by ?? null; + + const groups = getGroupByColumns({ + groupBy: group_by as GroupByColumnTypes, + includeNone: true, + isWorkspaceLevel: isWorkspaceLevel(storeType), + isEpic: issueServiceType === EIssueServiceType.EPICS, + projectId, + }); return (
- {subIssueIds?.map((issueId) => ( - ( + ))}
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 e37a9b48414..344fd004817 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 @@ -91,7 +91,14 @@ export const SubWorkItemTitleActions: FC = observ ); return ( -
+ // prevent click everywhere +
{ + e.stopPropagation(); + e.preventDefault(); + }} + > { // If no groupBy is specified and includeNone is true, return "All Issues" group if (!groupBy && includeNone) { @@ -93,7 +95,7 @@ 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 IGroupByColumn[] | undefined> = { project: getProjectColumns, cycle: getCycleColumns, module: getModuleColumns, @@ -107,7 +109,7 @@ export const getGroupByColumns = ({ }; // Get and return the columns for the specified group by option - return groupByColumnMap[groupBy]?.(); + return groupByColumnMap[groupBy]?.(projectId); }; const getProjectColumns = (): IGroupByColumn[] | undefined => { @@ -190,11 +192,12 @@ const getModuleColumns = (): IGroupByColumn[] | undefined => { return modules; }; -const getStateColumns = (): IGroupByColumn[] | undefined => { - const { projectStates } = store.state; - if (!projectStates) return; +const getStateColumns = (projectId?: string): 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: ( @@ -250,14 +253,15 @@ const getLabelsColumns = (isWorkspaceLevel: boolean = false): IGroupByColumn[] = })); }; -const getAssigneeColumns = (): IGroupByColumn[] | undefined => { +const getAssigneeColumns = (projectId?: string): IGroupByColumn[] | undefined => { const { - project: { projectMemberIds }, + project: { projectMemberIds, getProjectMemberIds }, getUserDetails, } = store.memberRoot; - if (!projectMemberIds) return; + const _projectMemberIds = projectId ? getProjectMemberIds(projectId, false) : projectMemberIds; + if (!_projectMemberIds) return; // Map project member ids to group by assignee columns - const assigneeColumns: IGroupByColumn[] = projectMemberIds.map((memberId) => { + const assigneeColumns: IGroupByColumn[] = _projectMemberIds.map((memberId) => { const member = getUserDetails(memberId); return { id: memberId, 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..15133a12946 100644 --- a/web/core/store/issue/issue-details/sub_issues.store.ts +++ b/web/core/store/issue/issue-details/sub_issues.store.ts @@ -15,7 +15,6 @@ import { TIssueServiceType, TLoader, TGroupedIssues, - TGroupedIssueCount, } from "@plane/types"; // services import { updatePersistentLayer } from "@/local-db/utils/utils"; @@ -52,7 +51,6 @@ export interface IIssueSubIssuesStore extends IIssueSubIssuesStoreActions { subIssuesStateDistribution: TIssueSubIssuesStateDistributionMap; subIssues: TIssueSubIssuesIdMap; groupedSubIssuesMap: Record; - groupedSubIssuesCount: TGroupedIssueCount; subIssueHelpers: Record; // parent_issue_id -> TSubIssueHelpers loader: TLoader; filters: IWorkItemSubIssueFiltersStore; @@ -60,7 +58,7 @@ export interface IIssueSubIssuesStore extends IIssueSubIssuesStoreActions { stateDistributionByIssueId: (issueId: string) => TSubIssuesStateDistribution | undefined; subIssuesByIssueId: (issueId: string) => string[] | undefined; subIssueHelpersByIssueId: (issueId: string) => TSubIssueHelpers; - groupedSubIssuesByIssueId: (issueId: string) => TGroupedIssues | undefined; + getGroupedSubIssuesByIssueId: (issueId: string) => TGroupedIssues | undefined; // actions fetchOtherProjectProperties: (workspaceSlug: string, projectIds: string[]) => Promise; setSubIssueHelpers: (parentIssueId: string, key: TSubIssueHelpersKeys, value: string) => void; @@ -71,7 +69,6 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { subIssuesStateDistribution: TIssueSubIssuesStateDistributionMap = {}; subIssues: TIssueSubIssuesIdMap = {}; groupedSubIssuesMap: Record = {}; - groupedSubIssuesCount: TGroupedIssueCount = {}; subIssueHelpers: Record = {}; loader: TLoader = undefined; @@ -98,7 +95,7 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { removeSubIssue: action, deleteSubIssue: action, fetchOtherProjectProperties: action, - groupedSubIssuesByIssueId: action, + getGroupedSubIssuesByIssueId: action, }); this.filters = new WorkItemSubIssueFiltersStore(this); // root store @@ -114,12 +111,9 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { return this.subIssuesStateDistribution[issueId] ?? undefined; }; - subIssuesByIssueId = (issueId: string) => { - if (!issueId) return undefined; - return this.subIssues[issueId] ?? undefined; - }; + subIssuesByIssueId = (issueId: string) => this.subIssues[issueId]; - groupedSubIssuesByIssueId = (issueId: string) => this.groupedSubIssuesMap[issueId] ?? undefined; + getGroupedSubIssuesByIssueId = (issueId: string) => this.groupedSubIssuesMap[issueId] ?? undefined; subIssueHelpersByIssueId = (issueId: string) => ({ preview_loader: this.subIssueHelpers?.[issueId]?.preview_loader || [], @@ -147,9 +141,6 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { // process sub issues response const { issueList, groupedIssues } = this.filters.processSubIssueResponse(response.sub_issues); - // set grouped issues count - set(this.groupedSubIssuesMap, [parentIssueId], groupedIssues); - this.rootIssueDetailStore.rootIssueStore.issues.addIssue(issueList); if (issueList && issueList.length > 0) { @@ -163,7 +154,11 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { sub_issues_count: issueList.length, }); } + runInAction(() => { + // set grouped issues + set(this.groupedSubIssuesMap, [parentIssueId], groupedIssues); + set(this.subIssuesStateDistribution, parentIssueId, subIssuesStateDistribution); set( this.subIssues, 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 1df6cbcc551..46a43abca1a 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,3 +1,4 @@ +import isEqual from "lodash/isEqual"; import set from "lodash/set"; import { action, makeObservable, observable } from "mobx"; import { ALL_ISSUES, EIssueFilterType, EIssueGroupByToServerOptions } from "@plane/constants"; @@ -200,13 +201,25 @@ export class WorkItemSubIssueFiltersStore implements IWorkItemSubIssueFiltersSto const _filters = this.getSubIssueFilters(parentId); switch (filterType) { case EIssueFilterType.FILTERS: { - set(this.subIssueFiltersMap, [parentId, "filters"], { ..._filters.filters, ...filters }); - this.subIssueStore.fetchSubIssues(workspaceSlug, projectId, parentId); + // check if filters are new + const isNewFilters = !isEqual(_filters.filters, filters); + if (isNewFilters) { + set(this.subIssueFiltersMap, [parentId, "filters"], { ..._filters.filters, ...filters }); + this.subIssueStore.loader = "init-loader"; + await this.subIssueStore.fetchSubIssues(workspaceSlug, projectId, parentId); + this.subIssueStore.loader = undefined; + } break; } case EIssueFilterType.DISPLAY_FILTERS: { - set(this.subIssueFiltersMap, [parentId, "displayFilters"], { ..._filters.displayFilters, ...filters }); - this.subIssueStore.fetchSubIssues(workspaceSlug, projectId, parentId); + // check if display filters are new + const isNewDisplayFilters = !isEqual(_filters.displayFilters, filters); + if (isNewDisplayFilters) { + set(this.subIssueFiltersMap, [parentId, "displayFilters"], { ..._filters.displayFilters, ...filters }); + this.subIssueStore.loader = "init-loader"; + await this.subIssueStore.fetchSubIssues(workspaceSlug, projectId, parentId); + this.subIssueStore.loader = undefined; + } break; } case EIssueFilterType.DISPLAY_PROPERTIES: From e06ea0e2fe37c3831fb1c7f152e414ecda435d56 Mon Sep 17 00:00:00 2001 From: vamsikrishnamathala Date: Wed, 30 Apr 2025 15:38:26 +0530 Subject: [PATCH 3/8] chore: updated order for sub work item properties --- .../sub-issues/issues-list/properties.tsx | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx index c279c76bf60..7f972d7a9ba 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx @@ -67,6 +67,23 @@ export const SubIssuesListItemProperties: React.FC = observer((props) =>
+ +
+ + issue.project_id && + updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, { + priority: val, + }) + } + disabled={!disabled} + buttonVariant="border-without-text" + buttonClassName="border" + /> +
+
+
= observer((props) =>
- -
- - issue.project_id && - updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, { - priority: val, - }) - } - disabled={!disabled} - buttonVariant="border-without-text" - buttonClassName="border" - /> -
-
-
Date: Wed, 30 Apr 2025 17:02:00 +0530 Subject: [PATCH 4/8] feat: filters for sub work items --- apiserver/plane/app/views/issue/sub_issue.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index da5b5386d51..254eecd5e31 100644 --- a/apiserver/plane/app/views/issue/sub_issue.py +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -24,6 +24,8 @@ from collections import defaultdict from plane.utils.host import base_host from plane.utils.order_queryset import order_issue_queryset +from plane.utils.issue_filters import issue_filters + class SubIssuesEndpoint(BaseAPIView): permission_classes = [ProjectEntityPermission] @@ -112,6 +114,10 @@ def get(self, request, slug, project_id, issue_id): sub_issues, order_by_param ) + # Filtering + filters = issue_filters(request.query_params, "GET") + sub_issues = sub_issues.filter(**filters) + # create's a dict with state group name with their respective issue id's result = defaultdict(list) for sub_issue in sub_issues: @@ -148,6 +154,7 @@ def get(self, request, slug, project_id, issue_id): sub_issues = user_timezone_converter( sub_issues, datetime_fields, request.user.user_timezone ) + # Grouping if group_by: result_dict = defaultdict(list) @@ -168,6 +175,7 @@ def get(self, request, slug, project_id, issue_id): {"sub_issues": result_dict, "state_distribution": result}, status=status.HTTP_200_OK, ) + return Response( {"sub_issues": sub_issues, "state_distribution": result}, status=status.HTTP_200_OK, From 1157336c3fbe161f4d63ddb5c9054ea91b829755 Mon Sep 17 00:00:00 2001 From: vamsikrishnamathala Date: Sat, 3 May 2025 13:21:28 +0530 Subject: [PATCH 5/8] feat: added filtering and ordering at frontend --- apiserver/plane/app/views/issue/sub_issue.py | 6 +- packages/constants/src/issue/common.ts | 21 +- .../sub-issues/filters.tsx | 10 +- .../sub-issues/issues-list/root.tsx | 35 +-- .../sub-issues/title-actions.tsx | 30 +-- .../store/issue/helpers/base-issues-utils.ts | 207 ++++++++++++++- .../store/issue/helpers/base-issues.store.ts | 2 +- .../issue/issue-details/sub_issues.store.ts | 26 +- .../issue-details/sub_issues_filter.store.ts | 244 +++++------------- web/helpers/date-time.helper.ts | 63 +++++ 10 files changed, 393 insertions(+), 251 deletions(-) diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index 254eecd5e31..1eb4bd76f54 100644 --- a/apiserver/plane/app/views/issue/sub_issue.py +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -114,10 +114,6 @@ def get(self, request, slug, project_id, issue_id): sub_issues, order_by_param ) - # Filtering - filters = issue_filters(request.query_params, "GET") - sub_issues = sub_issues.filter(**filters) - # create's a dict with state group name with their respective issue id's result = defaultdict(list) for sub_issue in sub_issues: @@ -160,7 +156,7 @@ def get(self, request, slug, project_id, issue_id): result_dict = defaultdict(list) for issue in sub_issues: - if group_by == "assignees__id": + if group_by == "assignees__ids": if issue["assignee_ids"]: assignee_ids = issue["assignee_ids"] for assignee_id in assignee_ids: diff --git a/packages/constants/src/issue/common.ts b/packages/constants/src/issue/common.ts index 39c67505c4c..c9d18473df6 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,16 @@ 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", +} as const; 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 index 6908ed525cf..5efa4d153bb 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/filters.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/filters.tsx @@ -17,12 +17,12 @@ import { FilterIssueTypes } from "@/plane-web/components/issues/filters/issue-ty type TSubIssueFiltersProps = { handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; filters: IIssueFilterOptions; - projectMemberIds: string[] | undefined; - projectStates?: IState[]; + memberIds: string[] | undefined; + states?: IState[]; }; export const SubIssueFilters: FC = observer((props) => { - const { handleFiltersUpdate, filters, projectMemberIds, projectStates } = props; + const { handleFiltersUpdate, filters, memberIds, states } = props; // states const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); @@ -66,7 +66,7 @@ export const SubIssueFilters: FC = observer((props) => { appliedFilters={filters.state ?? null} handleUpdate={(val) => handleFiltersUpdate("state", val)} searchQuery={filtersSearchQuery} - states={projectStates} + states={states} />
{/* Projects */} @@ -88,7 +88,7 @@ export const SubIssueFilters: FC = observer((props) => { handleFiltersUpdate("assignees", val)} - memberIds={projectMemberIds} + memberIds={memberIds} searchQuery={filtersSearchQuery} />
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 58a23369c42..0fffa3b22db 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,9 +1,9 @@ +import { useCallback, useMemo } from "react"; import { observer } from "mobx-react"; // plane imports import { EIssueServiceType, EIssuesStoreType } from "@plane/constants"; import { GroupByColumnTypes, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types"; // hooks -import { Loader } from "@plane/ui"; import { getGroupByColumns, isWorkspaceLevel } from "@/components/issues/issue-layouts/utils"; import { useIssueDetail } from "@/hooks/store"; @@ -30,6 +30,7 @@ export const SubIssuesListRoot: React.FC = observer((props) => { workspaceSlug, projectId, parentIssueId, + rootIssueId, disabled, handleIssueCrudState, subIssueOperations, @@ -41,25 +42,15 @@ export const SubIssuesListRoot: React.FC = observer((props) => { const { subIssues: { loader, - getGroupedSubIssuesByIssueId, - filters: { getSubIssueFilters }, + subIssuesByIssueId, + filters: { getSubIssueFilters, getGroupedSubWorkItems }, }, } = useIssueDetail(issueServiceType); - if (loader === "init-loader") { - return ( - - {[...Array(4)].map((_, index) => ( - - ))} - - ); - } - // derived values - const groupedSubIssues = getGroupedSubIssuesByIssueId(parentIssueId); const filters = getSubIssueFilters(parentIssueId); - const group_by = filters?.displayFilters?.group_by ?? null; + const isRootLevel = useMemo(() => rootIssueId === parentIssueId, [rootIssueId, parentIssueId]); + const group_by = isRootLevel ? (filters?.displayFilters?.group_by ?? null) : null; const groups = getGroupByColumns({ groupBy: group_by as GroupByColumnTypes, @@ -69,12 +60,24 @@ export const SubIssuesListRoot: React.FC = observer((props) => { projectId, }); + const workItemIds = useCallback( + (groupId: string) => { + if (isRootLevel) { + const groupedSubIssues = getGroupedSubWorkItems(rootIssueId); + return groupedSubIssues?.[groupId] ?? []; + } + return subIssuesByIssueId(parentIssueId) ?? []; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [isRootLevel, parentIssueId, rootIssueId, loader] + ); + return (
{groups?.map((group) => ( = observ // store hooks const { subIssues: { - filters: { getSubIssueFilters, updateSubIssueFilters }, + filters: { getSubIssueFilters, updateSubWorkItemFilters }, }, } = useIssueDetail(issueServiceType); const { getProjectStates } = useProjectState(); @@ -44,23 +44,17 @@ export const SubWorkItemTitleActions: FC = observ 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] + [workspaceSlug, projectId, parentId, updateSubWorkItemFilters] ); const handleDisplayPropertiesUpdate = useCallback( (updatedDisplayProperties: Partial) => { if (!workspaceSlug || !projectId) return; - updateSubIssueFilters( - workspaceSlug, - projectId, - EIssueFilterType.DISPLAY_PROPERTIES, - updatedDisplayProperties, - parentId - ); + updateSubWorkItemFilters(EIssueFilterType.DISPLAY_PROPERTIES, updatedDisplayProperties, parentId); }, - [workspaceSlug, projectId, parentId, updateSubIssueFilters] + [workspaceSlug, projectId, parentId, updateSubWorkItemFilters] ); const handleFiltersUpdate = useCallback( @@ -79,15 +73,9 @@ export const SubWorkItemTitleActions: FC = observ else newValues.push(value); } - updateSubIssueFilters( - workspaceSlug.toString(), - projectId.toString(), - EIssueFilterType.FILTERS, - { [key]: newValues }, - parentId - ); + updateSubWorkItemFilters(EIssueFilterType.FILTERS, { [key]: newValues }, parentId); }, - [workspaceSlug, projectId, subIssueFilters?.filters, updateSubIssueFilters, parentId] + [workspaceSlug, projectId, subIssueFilters?.filters, updateSubWorkItemFilters, parentId] ); return ( @@ -110,8 +98,8 @@ export const SubWorkItemTitleActions: FC = observ {!disabled && ( diff --git a/web/core/store/issue/helpers/base-issues-utils.ts b/web/core/store/issue/helpers/base-issues-utils.ts index a9fc639b4d7..84102af39e7 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,188 @@ 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 { type, date } = parseDateFilter(filterValue); + return checkDateCriteria(new Date(issueDate), date, 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"; + return value; + } + 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 _filters = { + filters: filtersMap[workItemId].filters, + displayFilters: filtersMap[workItemId].displayFilters, + displayProperties: filtersMap[workItemId].displayProperties, + }; + + switch (filterType) { + case EIssueFilterType.FILTERS: { + const updatedFilters = filters as IIssueFilterOptions; + _filters.filters = { ..._filters.filters, ...updatedFilters }; + runInAction(() => { + Object.keys(updatedFilters).forEach((_key) => { + set(filtersMap, [workItemId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]); + }); + }); + } + 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..2ee9d57c249 100644 --- a/web/core/store/issue/helpers/base-issues.store.ts +++ b/web/core/store/issue/helpers/base-issues.store.ts @@ -118,7 +118,7 @@ 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, 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 15133a12946..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,7 +15,6 @@ import { TSubIssuesStateDistribution, TIssueServiceType, TLoader, - TGroupedIssues, } from "@plane/types"; // services import { updatePersistentLayer } from "@/local-db/utils/utils"; @@ -50,7 +50,6 @@ export interface IIssueSubIssuesStore extends IIssueSubIssuesStoreActions { // observables subIssuesStateDistribution: TIssueSubIssuesStateDistributionMap; subIssues: TIssueSubIssuesIdMap; - groupedSubIssuesMap: Record; subIssueHelpers: Record; // parent_issue_id -> TSubIssueHelpers loader: TLoader; filters: IWorkItemSubIssueFiltersStore; @@ -58,7 +57,6 @@ export interface IIssueSubIssuesStore extends IIssueSubIssuesStoreActions { stateDistributionByIssueId: (issueId: string) => TSubIssuesStateDistribution | undefined; subIssuesByIssueId: (issueId: string) => string[] | undefined; subIssueHelpersByIssueId: (issueId: string) => TSubIssueHelpers; - getGroupedSubIssuesByIssueId: (issueId: string) => TGroupedIssues | undefined; // actions fetchOtherProjectProperties: (workspaceSlug: string, projectIds: string[]) => Promise; setSubIssueHelpers: (parentIssueId: string, key: TSubIssueHelpersKeys, value: string) => void; @@ -68,7 +66,6 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { // observables subIssuesStateDistribution: TIssueSubIssuesStateDistributionMap = {}; subIssues: TIssueSubIssuesIdMap = {}; - groupedSubIssuesMap: Record = {}; subIssueHelpers: Record = {}; loader: TLoader = undefined; @@ -85,7 +82,6 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { subIssuesStateDistribution: observable, subIssues: observable, subIssueHelpers: observable, - groupedSubIssuesMap: observable, loader: observable.ref, // actions setSubIssueHelpers: action, @@ -95,7 +91,6 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { removeSubIssue: action, deleteSubIssue: action, fetchOtherProjectProperties: action, - getGroupedSubIssuesByIssueId: action, }); this.filters = new WorkItemSubIssueFiltersStore(this); // root store @@ -111,9 +106,7 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { return this.subIssuesStateDistribution[issueId] ?? undefined; }; - subIssuesByIssueId = (issueId: string) => this.subIssues[issueId]; - - getGroupedSubIssuesByIssueId = (issueId: string) => this.groupedSubIssuesMap[issueId] ?? undefined; + subIssuesByIssueId = computedFn((issueId: string) => this.subIssues[issueId]); subIssueHelpersByIssueId = (issueId: string) => ({ preview_loader: this.subIssueHelpers?.[issueId]?.preview_loader || [], @@ -132,17 +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); + 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) @@ -156,9 +149,6 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { } runInAction(() => { - // set grouped issues - set(this.groupedSubIssuesMap, [parentIssueId], groupedIssues); - set(this.subIssuesStateDistribution, parentIssueId, subIssuesStateDistribution); set( this.subIssues, @@ -166,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 46a43abca1a..7798774ec0b 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,65 +1,66 @@ -import isEqual from "lodash/isEqual"; 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 { IIssueSubIssuesStore, IssueSubIssuesStore } from "./sub_issues.store"; export interface IWorkItemSubIssueFiltersStore { - subIssueFiltersMap: Record>; + subIssueFilters: Record>; // helpers methods - updateSubIssueFilters: ( - workspaceSlug: string, - projectId: string, + updateSubWorkItemFilters: ( filterType: EIssueFilterType, filters: IIssueDisplayFilterOptions | IIssueDisplayProperties | IIssueFilterOptions, - parentId: string - ) => Promise; - getSubIssueFilters: (parentId: string) => Partial; - computedFilterParams: (parentId: string) => Partial>; - processSubIssueResponse: (issueResponse: TSubIssueResponse) => { - issueList: TIssue[]; - groupedIssues: TIssues; - groupedIssueCount: TGroupedIssueCount; - }; + workItemId: string + ) => void; + getGroupedSubWorkItems: (workItemId: string) => TGroupedIssues; + getSubIssueFilters: (workItemId: string) => Partial; } 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, + getGroupedSubWorkItems: action, getSubIssueFilters: action, }); - // sub issue store + + // root store this.subIssueStore = subIssueStore; } + /** + * @description This method is used to get the sub issue filters + * @param workItemId + * @returns + */ + getSubIssueFilters = (workItemId: string) => { + if (!this.subIssueFilters[workItemId]) { + this.initSubIssueFilters(workItemId); + } + return this.subIssueFilters[workItemId]; + }; + /** * @description This method is used to initialize the sub issue filters - * @param parentId + * @param workItemId */ - initSubIssueFilters = (parentId: string) => { - set(this.subIssueFiltersMap, [parentId], { - filters: {}, - displayFilters: {}, + initSubIssueFilters = (workItemId: string) => { + set(this.subIssueFilters, [workItemId], { displayProperties: { key: true, issue_type: true, @@ -68,166 +69,45 @@ export class WorkItemSubIssueFiltersStore implements IWorkItemSubIssueFiltersSto 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 + * @description This method updates filters for sub issues. + * @param filterType + * @param filters */ - 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 }; + updateSubWorkItemFilters = ( + filterType: EIssueFilterType, + filters: IIssueDisplayFilterOptions | IIssueDisplayProperties | IIssueFilterOptions, + workItemId: string + ) => { + runInAction(() => { + updateFilters(this.subIssueFilters, filterType, filters, workItemId); + }); }; /** - * @description This method is used to get the sub issue filters - * @param parentId - * @returns IIssueFilters + * @description This method is used to get the grouped sub work items + * @param parentWorkItemId + * @returns */ - getSubIssueFilters = (parentId: string) => { - if (!this.subIssueFiltersMap[parentId]) { - this.initSubIssueFilters(parentId); - } - return this.subIssueFiltersMap[parentId]; - }; + getGroupedSubWorkItems = computedFn((parentWorkItemId: string) => { + const subIssueIds = this.subIssueStore.subIssuesByIssueId(parentWorkItemId); + const workItems = this.subIssueStore.rootIssueDetailStore.rootIssueStore.issues.getIssuesByIds( + subIssueIds, + "un-archived" + ); - computedFilterParams = (parentId: string) => { - const filters = this.getSubIssueFilters(parentId); + const subIssueFilters = this.getSubIssueFilters(parentWorkItemId); + const orderByKey = subIssueFilters.displayFilters?.order_by; + const groupByKey = subIssueFilters.displayFilters?.group_by; - const displayFilters = filters.displayFilters; - const filterOptions = filters.filters; + const filteredWorkItems = getFilteredWorkItems(workItems, subIssueFilters.filters); - const computedFilters: Partial> = { - // issue filters - priority: filterOptions?.priority || undefined, - state: filterOptions?.state || undefined, - assignees: filterOptions?.assignees || undefined, - start_date: filterOptions?.start_date || undefined, - target_date: filterOptions?.target_date || undefined, - project: filterOptions?.project || undefined, - issue_type: filterOptions?.issue_type || undefined, - order_by: displayFilters?.order_by || undefined, - group_by: displayFilters?.group_by ? EIssueGroupByToServerOptions[displayFilters.group_by] : undefined, - }; + const groupedWorkItemIds = getGroupedWorkItemIds(filteredWorkItems, groupByKey, orderByKey); - 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; - }); - - return issueFiltersParams; - }; - - /** - * @description This method is used to update the sub issue filters - * @param projectId - * @param filterType - * @param filters - */ - updateSubIssueFilters = async ( - workspaceSlug: string, - projectId: string, - filterType: EIssueFilterType, - filters: IIssueDisplayFilterOptions | IIssueDisplayProperties | IIssueFilterOptions, - parentId: string - ) => { - const _filters = this.getSubIssueFilters(parentId); - switch (filterType) { - case EIssueFilterType.FILTERS: { - // check if filters are new - const isNewFilters = !isEqual(_filters.filters, filters); - if (isNewFilters) { - set(this.subIssueFiltersMap, [parentId, "filters"], { ..._filters.filters, ...filters }); - this.subIssueStore.loader = "init-loader"; - await this.subIssueStore.fetchSubIssues(workspaceSlug, projectId, parentId); - this.subIssueStore.loader = undefined; - } - break; - } - case EIssueFilterType.DISPLAY_FILTERS: { - // check if display filters are new - const isNewDisplayFilters = !isEqual(_filters.displayFilters, filters); - if (isNewDisplayFilters) { - set(this.subIssueFiltersMap, [parentId, "displayFilters"], { ..._filters.displayFilters, ...filters }); - this.subIssueStore.loader = "init-loader"; - await this.subIssueStore.fetchSubIssues(workspaceSlug, projectId, parentId); - this.subIssueStore.loader = undefined; - } - break; - } - case EIssueFilterType.DISPLAY_PROPERTIES: - set(this.subIssueFiltersMap, [parentId, "displayProperties"], { - ..._filters.displayProperties, - ...filters, - }); - break; - } - }; + return groupedWorkItemIds; + }); } diff --git a/web/helpers/date-time.helper.ts b/web/helpers/date-time.helper.ts index c26addd7ca7..bd2ae49e029 100644 --- a/web/helpers/date-time.helper.ts +++ b/web/helpers/date-time.helper.ts @@ -405,3 +405,66 @@ 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 [amount, unit] = value.split("_"); + const date = new Date(); + + switch (unit) { + case "weeks": + date.setDate(date.getDate() + parseInt(amount) * 7); + break; + case "months": + date.setMonth(date.getMonth() + parseInt(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); + // Reset time components for date-only comparison + checkDate.setHours(0, 0, 0, 0); + filterDate.setHours(0, 0, 0, 0); + + return type === "after" ? checkDate >= filterDate : checkDate <= filterDate; +}; From 711421e3404edd40542f788388c73f708ec483e9 Mon Sep 17 00:00:00 2001 From: vamsikrishnamathala Date: Sat, 3 May 2025 13:24:20 +0530 Subject: [PATCH 6/8] chore: reverted backend filters --- apiserver/plane/app/views/issue/sub_issue.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index 1eb4bd76f54..5791281f0ed 100644 --- a/apiserver/plane/app/views/issue/sub_issue.py +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -24,8 +24,6 @@ from collections import defaultdict from plane.utils.host import base_host from plane.utils.order_queryset import order_issue_queryset -from plane.utils.issue_filters import issue_filters - class SubIssuesEndpoint(BaseAPIView): permission_classes = [ProjectEntityPermission] @@ -150,7 +148,6 @@ def get(self, request, slug, project_id, issue_id): sub_issues = user_timezone_converter( sub_issues, datetime_fields, request.user.user_timezone ) - # Grouping if group_by: result_dict = defaultdict(list) @@ -171,7 +168,6 @@ def get(self, request, slug, project_id, issue_id): {"sub_issues": result_dict, "state_distribution": result}, status=status.HTTP_200_OK, ) - return Response( {"sub_issues": sub_issues, "state_distribution": result}, status=status.HTTP_200_OK, From 695f1ee3fa8dc8aebc25b6851b9e3ff297092790 Mon Sep 17 00:00:00 2001 From: vamsikrishnamathala Date: Tue, 6 May 2025 17:53:33 +0530 Subject: [PATCH 7/8] feat: added empty states --- packages/constants/src/issue/common.ts | 1 + packages/constants/src/issue/filter.ts | 2 +- packages/types/src/issues/issue.d.ts | 1 + .../empty-state/section-empty-state-root.tsx | 11 +- .../sub-issues/display-filters.tsx | 21 ++- .../sub-issues/filters.tsx | 158 ++++++++++++------ .../sub-issues/issues-list/list-item.tsx | 2 +- .../sub-issues/issues-list/root.tsx | 69 +++++--- .../sub-issues/title-actions.tsx | 15 +- .../issue-detail-widgets/sub-issues/title.tsx | 6 +- .../components/issues/issue-layouts/utils.tsx | 103 ++++++++++-- .../store/issue/helpers/base-issues-utils.ts | 7 +- .../store/issue/helpers/base-issues.store.ts | 2 +- .../issue-details/sub_issues_filter.store.ts | 73 +++++--- 14 files changed, 330 insertions(+), 141 deletions(-) diff --git a/packages/constants/src/issue/common.ts b/packages/constants/src/issue/common.ts index c9d18473df6..c182cb17e04 100644 --- a/packages/constants/src/issue/common.ts +++ b/packages/constants/src/issue/common.ts @@ -379,4 +379,5 @@ export const FILTER_TO_ISSUE_MAP: Partial = (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/display-filters.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx index 3f8b0d8e796..46d1f8d2293 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx @@ -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 { 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,12 +26,27 @@ export const SubIssueDisplayFilters: FC = observer displayFilters, } = props; + const isFilterApplied = useMemo( + () => isDisplayFiltersApplied({ displayProperties, displayFilters }), + [displayProperties, displayFilters] + ); + return ( <> {layoutDisplayFiltersOptions?.display_filters && layoutDisplayFiltersOptions?.display_properties.length > 0 && ( } + menuButton={ +
+ {isFilterApplied && } + +
+ } >
{/* display properties */} 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 index 5efa4d153bb..43be9378b86 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/filters.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/filters.tsx @@ -1,8 +1,9 @@ -import { FC, useState } from "react"; +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, IState } from "@plane/types"; +import { IIssueFilterOptions, ILayoutDisplayFiltersOptions, IState } from "@plane/types"; +import { cn } from "@plane/utils"; import { FilterAssignees, FilterDueDate, @@ -11,7 +12,9 @@ import { 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 = { @@ -19,20 +22,36 @@ type TSubIssueFiltersProps = { filters: IIssueFilterOptions; memberIds: string[] | undefined; states?: IState[]; + layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined; }; export const SubIssueFilters: FC = observer((props) => { - const { handleFiltersUpdate, filters, memberIds, states } = 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 && } + +
+ } + >
@@ -53,61 +72,94 @@ export const SubIssueFilters: FC = observer((props) => {
{/* Priority */} -
- handleFiltersUpdate("priority", val)} - searchQuery={filtersSearchQuery} - /> -
+ {isFilterEnabled("priority") && ( +
+ handleFiltersUpdate("priority", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + + {/* state group */} + {isFilterEnabled("state_group") && ( +
+ handleFiltersUpdate("state_group", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + {/* State */} -
- handleFiltersUpdate("state", val)} - searchQuery={filtersSearchQuery} - states={states} - /> -
+ {isFilterEnabled("state") && ( +
+ handleFiltersUpdate("state", val)} + searchQuery={filtersSearchQuery} + states={states} + /> +
+ )} + {/* Projects */} -
- handleFiltersUpdate("project", val)} - searchQuery={filtersSearchQuery} - /> -
+ {isFilterEnabled("project") && ( +
+ handleFiltersUpdate("project", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + {/* work item types */} - handleFiltersUpdate("issue_type", val)} - searchQuery={filtersSearchQuery} - /> + {isFilterEnabled("issue_type") && ( +
+ handleFiltersUpdate("issue_type", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + {/* Assignees */} -
- handleFiltersUpdate("assignees", val)} - memberIds={memberIds} - searchQuery={filtersSearchQuery} - /> -
+ {isFilterEnabled("assignees") && ( +
+ handleFiltersUpdate("assignees", val)} + memberIds={memberIds} + searchQuery={filtersSearchQuery} + /> +
+ )} + {/* Start Date */} -
- handleFiltersUpdate("start_date", val)} - searchQuery={filtersSearchQuery} - /> -
+ {isFilterEnabled("start_date") && ( +
+ handleFiltersUpdate("start_date", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + {/* Target Date */} -
- handleFiltersUpdate("target_date", val)} - searchQuery={filtersSearchQuery} - /> -
+ {isFilterEnabled("target_date") && ( +
+ handleFiltersUpdate("target_date", val)} + searchQuery={filtersSearchQuery} + /> +
+ )}
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 6f75085d05c..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 @@ -83,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); 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 0fffa3b22db..c7fac0f1e52 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,9 +1,12 @@ import { useCallback, useMemo } from "react"; import { observer } from "mobx-react"; // plane imports +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"; @@ -41,9 +44,8 @@ export const SubIssuesListRoot: React.FC = observer((props) => { // store hooks const { subIssues: { - loader, subIssuesByIssueId, - filters: { getSubIssueFilters, getGroupedSubWorkItems }, + filters: { getSubIssueFilters, getGroupedSubWorkItems, getFilteredSubWorkItems, resetFilters }, }, } = useIssueDetail(issueServiceType); @@ -51,6 +53,7 @@ export const SubIssuesListRoot: React.FC = observer((props) => { 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, @@ -63,33 +66,57 @@ export const SubIssuesListRoot: React.FC = observer((props) => { const workItemIds = useCallback( (groupId: string) => { if (isRootLevel) { - const groupedSubIssues = getGroupedSubWorkItems(rootIssueId); + const groupedSubIssues = getGroupedSubWorkItems(parentIssueId); return groupedSubIssues?.[groupId] ?? []; } - return subIssuesByIssueId(parentIssueId) ?? []; + const subIssueIds = subIssuesByIssueId(parentIssueId); + return subIssueIds ?? []; }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [isRootLevel, parentIssueId, rootIssueId, loader] + [isRootLevel, subIssuesByIssueId, parentIssueId, getGroupedSubWorkItems] ); + const isSubWorkItems = issueServiceType === EIssueServiceType.ISSUES; + return (
- {groups?.map((group) => ( - 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 90b0a8da48e..3026e85a9b8 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 @@ -16,12 +16,11 @@ 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 { @@ -43,23 +42,20 @@ export const SubWorkItemTitleActions: FC = observ const handleDisplayFilters = useCallback( (updatedDisplayFilter: Partial) => { - if (!workspaceSlug || !projectId) return; updateSubWorkItemFilters(EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, parentId); }, - [workspaceSlug, projectId, parentId, updateSubWorkItemFilters] + [updateSubWorkItemFilters, parentId] ); const handleDisplayPropertiesUpdate = useCallback( (updatedDisplayProperties: Partial) => { - if (!workspaceSlug || !projectId) return; updateSubWorkItemFilters(EIssueFilterType.DISPLAY_PROPERTIES, updatedDisplayProperties, parentId); }, - [workspaceSlug, projectId, parentId, updateSubWorkItemFilters] + [updateSubWorkItemFilters, parentId] ); const handleFiltersUpdate = useCallback( (key: keyof IIssueFilterOptions, value: string | string[]) => { - if (!workspaceSlug || !projectId) return; const newValues = subIssueFilters?.filters?.[key] ?? []; if (Array.isArray(value)) { @@ -75,13 +71,13 @@ export const SubWorkItemTitleActions: FC = observ updateSubWorkItemFilters(EIssueFilterType.FILTERS, { [key]: newValues }, parentId); }, - [workspaceSlug, projectId, subIssueFilters?.filters, updateSubWorkItemFilters, parentId] + [subIssueFilters?.filters, updateSubWorkItemFilters, parentId] ); return ( // prevent click everywhere
{ e.stopPropagation(); e.preventDefault(); @@ -100,6 +96,7 @@ export const SubWorkItemTitleActions: FC = observ filters={subIssueFilters?.filters ?? {}} memberIds={projectMemberIds ?? undefined} states={projectStates} + layoutDisplayFiltersOptions={layoutDisplayFiltersOptions} /> {!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; @@ -95,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]?.(projectId); + return groupByColumnMap[groupBy]?.({ isWorkspaceLevel, projectId }); }; const getProjectColumns = (): IGroupByColumn[] | undefined => { @@ -192,7 +203,7 @@ const getModuleColumns = (): IGroupByColumn[] | undefined => { return modules; }; -const getStateColumns = (projectId?: string): IGroupByColumn[] | undefined => { +const getStateColumns = ({ projectId }: TGetColumns): IGroupByColumn[] | undefined => { const { getProjectStates, projectStates } = store.state; const _states = projectId ? getProjectStates(projectId) : projectStates; if (!_states) return; @@ -235,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 = [ @@ -253,23 +264,40 @@ const getLabelsColumns = (isWorkspaceLevel: boolean = false): IGroupByColumn[] = })); }; -const getAssigneeColumns = (projectId?: string): IGroupByColumn[] | undefined => { +const getAssigneeColumns = ({ isWorkspaceLevel, projectId }: TGetColumns): IGroupByColumn[] | undefined => { + const assigneeColumns: IGroupByColumn[] = []; const { project: { projectMemberIds, getProjectMemberIds }, getUserDetails, } = store.memberRoot; - const _projectMemberIds = projectId ? getProjectMemberIds(projectId, false) : projectMemberIds; - 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; }; @@ -723,3 +751,42 @@ 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.keys(filters).some((key) => { + if (filters[key as keyof IIssueFilterOptions]) { + if (Array.isArray(filters[key as keyof IIssueFilterOptions])) { + return !isEmpty(filters[key as keyof IIssueFilterOptions]); + } + return true; + } + return true; + }); diff --git a/web/core/store/issue/helpers/base-issues-utils.ts b/web/core/store/issue/helpers/base-issues-utils.ts index 84102af39e7..fd280c85ada 100644 --- a/web/core/store/issue/helpers/base-issues-utils.ts +++ b/web/core/store/issue/helpers/base-issues-utils.ts @@ -356,11 +356,8 @@ export const updateFilters = ( case EIssueFilterType.FILTERS: { const updatedFilters = filters as IIssueFilterOptions; _filters.filters = { ..._filters.filters, ...updatedFilters }; - runInAction(() => { - Object.keys(updatedFilters).forEach((_key) => { - set(filtersMap, [workItemId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]); - }); - }); + set(filtersMap, [workItemId, "filters"], { ..._filters.filters, ...updatedFilters }); + break; } case EIssueFilterType.DISPLAY_FILTERS: { set(filtersMap, [workItemId, "displayFilters"], { ..._filters.displayFilters, ...filters }); diff --git a/web/core/store/issue/helpers/base-issues.store.ts b/web/core/store/issue/helpers/base-issues.store.ts index 2ee9d57c249..b28ff13faad 100644 --- a/web/core/store/issue/helpers/base-issues.store.ts +++ b/web/core/store/issue/helpers/base-issues.store.ts @@ -121,7 +121,7 @@ export interface IBaseIssuesStore { 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_filter.store.ts b/web/core/store/issue/issue-details/sub_issues_filter.store.ts index 7798774ec0b..a5f5968c1c7 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 @@ -8,9 +8,21 @@ import { IIssueFilterOptions, IIssueFilters, TGroupedIssues, + TIssue, } from "@plane/types"; import { getFilteredWorkItems, getGroupedWorkItemIds, updateFilters } from "../helpers/base-issues-utils"; -import { IIssueSubIssuesStore, IssueSubIssuesStore } from "./sub_issues.store"; +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 { subIssueFilters: Record>; @@ -21,7 +33,9 @@ export interface IWorkItemSubIssueFiltersStore { 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 { @@ -50,7 +64,7 @@ export class WorkItemSubIssueFiltersStore implements IWorkItemSubIssueFiltersSto */ getSubIssueFilters = (workItemId: string) => { if (!this.subIssueFilters[workItemId]) { - this.initSubIssueFilters(workItemId); + this.initializeFilters(workItemId); } return this.subIssueFilters[workItemId]; }; @@ -59,18 +73,10 @@ export class WorkItemSubIssueFiltersStore implements IWorkItemSubIssueFiltersSto * @description This method is used to initialize the sub issue filters * @param workItemId */ - initSubIssueFilters = (workItemId: string) => { - set(this.subIssueFilters, [workItemId], { - displayProperties: { - key: true, - issue_type: true, - assignee: true, - start_date: true, - due_date: true, - labels: true, - priority: true, - }, - }); + initializeFilters = (workItemId: string) => { + set(this.subIssueFilters, [workItemId, "displayProperties"], DEFAULT_DISPLAY_PROPERTIES); + set(this.subIssueFilters, [workItemId, "filters"], {}); + set(this.subIssueFilters, [workItemId, "displayFilters"], {}); }; /** @@ -94,20 +100,41 @@ export class WorkItemSubIssueFiltersStore implements IWorkItemSubIssueFiltersSto * @returns */ getGroupedSubWorkItems = computedFn((parentWorkItemId: string) => { - const subIssueIds = this.subIssueStore.subIssuesByIssueId(parentWorkItemId); - const workItems = this.subIssueStore.rootIssueDetailStore.rootIssueStore.issues.getIssuesByIds( - subIssueIds, - "un-archived" - ); - const subIssueFilters = this.getSubIssueFilters(parentWorkItemId); - const orderByKey = subIssueFilters.displayFilters?.order_by; - const groupByKey = subIssueFilters.displayFilters?.group_by; - const filteredWorkItems = getFilteredWorkItems(workItems, subIssueFilters.filters); + 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); + }; } From 42f50bc4f49353595a850124999bd143fc65c8c1 Mon Sep 17 00:00:00 2001 From: vamsikrishnamathala Date: Tue, 6 May 2025 19:19:45 +0530 Subject: [PATCH 8/8] chore: code improvemnt --- .../sub-issues/filters.tsx | 3 ++- .../sub-issues/issues-list/root.tsx | 4 ++-- .../sub-issues/title-actions.tsx | 3 ++- .../components/issues/issue-layouts/utils.tsx | 11 +++------- .../store/issue/helpers/base-issues-utils.ts | 19 +++++++++++------ .../issue-details/sub_issues_filter.store.ts | 1 - web/helpers/date-time.helper.ts | 21 ++++++++++++------- 7 files changed, 36 insertions(+), 26 deletions(-) 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 index 43be9378b86..8ae6195ea8e 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/filters.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/filters.tsx @@ -31,7 +31,8 @@ export const SubIssueFilters: FC = observer((props) => { // states const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); - const isFilterEnabled = (filter: keyof IIssueFilterOptions) => layoutDisplayFiltersOptions?.filters.includes(filter); + const isFilterEnabled = (filter: keyof IIssueFilterOptions) => + !!layoutDisplayFiltersOptions?.filters.includes(filter); const isFilterApplied = useMemo(() => isFiltersApplied(filters), [filters]); // hooks const { t } = useTranslation(); 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 c7fac0f1e52..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 @@ -63,7 +63,7 @@ export const SubIssuesListRoot: React.FC = observer((props) => { projectId, }); - const workItemIds = useCallback( + const getWorkItemIds = useCallback( (groupId: string) => { if (isRootLevel) { const groupedSubIssues = getGroupedSubWorkItems(parentIssueId); @@ -83,7 +83,7 @@ export const SubIssuesListRoot: React.FC = observer((props) => { groups?.map((group) => ( = observ const handleFiltersUpdate = useCallback( (key: keyof IIssueFilterOptions, value: string | string[]) => { - const newValues = subIssueFilters?.filters?.[key] ?? []; + const newValues = cloneDeep(subIssueFilters?.filters?.[key]) ?? []; if (Array.isArray(value)) { // this validation is majorly for the filter start_date, target_date custom diff --git a/web/core/components/issues/issue-layouts/utils.tsx b/web/core/components/issues/issue-layouts/utils.tsx index 16080adc378..c0e9dc6585a 100644 --- a/web/core/components/issues/issue-layouts/utils.tsx +++ b/web/core/components/issues/issue-layouts/utils.tsx @@ -781,12 +781,7 @@ export const isDisplayFiltersApplied = (filters: Partial): boolea * @returns */ export const isFiltersApplied = (filters: IIssueFilterOptions): boolean => - Object.keys(filters).some((key) => { - if (filters[key as keyof IIssueFilterOptions]) { - if (Array.isArray(filters[key as keyof IIssueFilterOptions])) { - return !isEmpty(filters[key as keyof IIssueFilterOptions]); - } - return true; - } - return true; + 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 fd280c85ada..dcb4e86adb4 100644 --- a/web/core/store/issue/helpers/base-issues-utils.ts +++ b/web/core/store/issue/helpers/base-issues-utils.ts @@ -209,8 +209,13 @@ export const checkIssueDateFilter = ( // Issue should match all the date filters (AND operation) return dateFilters.every((filterValue) => { - const { type, date } = parseDateFilter(filterValue); - return checkDateCriteria(new Date(issueDate), date, type); + 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); }); }; @@ -319,7 +324,8 @@ export const getGroupedWorkItemIds = ( const value = item[groupKey]; if (Array.isArray(value)) { if (value.length === 0) return "None"; - return value; + // Sort & join to build deterministic set-like key + return value.slice().sort().join(","); } return value ?? "None"; }); @@ -346,10 +352,11 @@ export const updateFilters = ( filters: IIssueDisplayFilterOptions | IIssueDisplayProperties | IIssueFilterOptions, workItemId: string ) => { + const existingFilters = filtersMap[workItemId] ?? {}; const _filters = { - filters: filtersMap[workItemId].filters, - displayFilters: filtersMap[workItemId].displayFilters, - displayProperties: filtersMap[workItemId].displayProperties, + filters: existingFilters.filters, + displayFilters: existingFilters.displayFilters, + displayProperties: existingFilters.displayProperties, }; switch (filterType) { 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 a5f5968c1c7..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 @@ -49,7 +49,6 @@ export class WorkItemSubIssueFiltersStore implements IWorkItemSubIssueFiltersSto makeObservable(this, { subIssueFilters: observable, updateSubWorkItemFilters: action, - getGroupedSubWorkItems: action, getSubIssueFilters: action, }); diff --git a/web/helpers/date-time.helper.ts b/web/helpers/date-time.helper.ts index bd2ae49e029..cff0da2c2df 100644 --- a/web/helpers/date-time.helper.ts +++ b/web/helpers/date-time.helper.ts @@ -412,15 +412,22 @@ export const generateDateArray = (startDate: string | Date, endDate: string | Da * @returns Date object representing the calculated date */ export const processRelativeDate = (value: string): Date => { - const [amount, unit] = value.split("_"); + 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() + parseInt(amount) * 7); + date.setDate(date.getDate() + amount * 7); break; case "months": - date.setMonth(date.getMonth() + parseInt(amount)); + date.setMonth(date.getMonth() + amount); break; default: throw new Error(`Unsupported time unit: ${unit}`); @@ -462,9 +469,9 @@ export const checkDateCriteria = (dateToCheck: Date | null, filterDate: Date, ty if (!dateToCheck) return false; const checkDate = new Date(dateToCheck); - // Reset time components for date-only comparison - checkDate.setHours(0, 0, 0, 0); - filterDate.setHours(0, 0, 0, 0); + 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" ? checkDate >= filterDate : checkDate <= filterDate; + return type === "after" ? normalizedCheck >= normalizedFilter : normalizedCheck <= normalizedFilter; };