From 285a89ade17f7ff8842a10bd93e48327d3b76e67 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Tue, 12 Mar 2024 12:54:37 +0530 Subject: [PATCH 1/5] feat: modules filtering, searching and ordering implemented --- packages/types/src/index.d.ts | 2 +- packages/types/src/module/index.ts | 2 + packages/types/src/module/module_filters.d.ts | 32 ++++ packages/types/src/{ => module}/modules.d.ts | 0 web/components/headers/modules-list.tsx | 157 +++++++++++++++--- .../modules/applied-filters/date.tsx | 56 +++++++ .../modules/applied-filters/index.ts | 4 + .../modules/applied-filters/members.tsx | 46 +++++ .../modules/applied-filters/root.tsx | 88 ++++++++++ .../modules/applied-filters/status.tsx | 41 +++++ .../modules/dropdowns/filters/index.ts | 6 + .../modules/dropdowns/filters/lead.tsx | 97 +++++++++++ .../modules/dropdowns/filters/members.tsx | 97 +++++++++++ .../modules/dropdowns/filters/root.tsx | 91 ++++++++++ .../modules/dropdowns/filters/start-date.tsx | 63 +++++++ .../modules/dropdowns/filters/status.tsx | 52 ++++++ .../modules/dropdowns/filters/target-date.tsx | 63 +++++++ web/components/modules/dropdowns/index.ts | 2 + web/components/modules/dropdowns/order-by.tsx | 70 ++++++++ web/components/modules/index.ts | 2 + web/components/modules/modules-list-view.tsx | 31 ++-- web/constants/module.ts | 31 +++- web/helpers/module.helper.ts | 58 +++++++ web/hooks/store/index.ts | 1 + web/hooks/store/use-module-filter.ts | 11 ++ .../projects/[projectId]/modules/index.tsx | 36 +++- web/store/module.store.ts | 25 +++ web/store/module_filter.store.ts | 146 ++++++++++++++++ web/store/root.store.ts | 4 + 29 files changed, 1268 insertions(+), 46 deletions(-) create mode 100644 packages/types/src/module/index.ts create mode 100644 packages/types/src/module/module_filters.d.ts rename packages/types/src/{ => module}/modules.d.ts (100%) create mode 100644 web/components/modules/applied-filters/date.tsx create mode 100644 web/components/modules/applied-filters/index.ts create mode 100644 web/components/modules/applied-filters/members.tsx create mode 100644 web/components/modules/applied-filters/root.tsx create mode 100644 web/components/modules/applied-filters/status.tsx create mode 100644 web/components/modules/dropdowns/filters/index.ts create mode 100644 web/components/modules/dropdowns/filters/lead.tsx create mode 100644 web/components/modules/dropdowns/filters/members.tsx create mode 100644 web/components/modules/dropdowns/filters/root.tsx create mode 100644 web/components/modules/dropdowns/filters/start-date.tsx create mode 100644 web/components/modules/dropdowns/filters/status.tsx create mode 100644 web/components/modules/dropdowns/filters/target-date.tsx create mode 100644 web/components/modules/dropdowns/index.ts create mode 100644 web/components/modules/dropdowns/order-by.tsx create mode 100644 web/helpers/module.helper.ts create mode 100644 web/hooks/store/use-module-filter.ts create mode 100644 web/store/module_filter.store.ts diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index eeec266b50d..b4f691bb0d3 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -5,7 +5,7 @@ export * from "./dashboard"; export * from "./projects"; export * from "./state"; export * from "./issues"; -export * from "./modules"; +export * from "./module"; export * from "./views"; export * from "./integration"; export * from "./pages"; diff --git a/packages/types/src/module/index.ts b/packages/types/src/module/index.ts new file mode 100644 index 00000000000..783634662b8 --- /dev/null +++ b/packages/types/src/module/index.ts @@ -0,0 +1,2 @@ +export * from "./module_filters"; +export * from "./modules"; diff --git a/packages/types/src/module/module_filters.d.ts b/packages/types/src/module/module_filters.d.ts new file mode 100644 index 00000000000..10d56c32896 --- /dev/null +++ b/packages/types/src/module/module_filters.d.ts @@ -0,0 +1,32 @@ +export type TModuleOrderByOptions = + | "name" + | "-name" + | "progress" + | "-progress" + | "issues_length" + | "-issues_length" + | "target_date" + | "-target_date" + | "created_at" + | "-created_at"; + +export type TModuleLayoutOptions = "list" | "board" | "gantt"; + +export type TModuleDisplayFilters = { + favorites?: boolean; + layout?: TModuleLayoutOptions; + order_by?: TModuleOrderByOptions; +}; + +export type TModuleFilters = { + lead?: string[] | null; + members?: string[] | null; + start_date?: string[] | null; + status?: string[] | null; + target_date?: string[] | null; +}; + +export type TModuleStoredFilters = { + display_filters?: TModuleDisplayFilters; + filters?: TModuleFilters; +}; diff --git a/packages/types/src/modules.d.ts b/packages/types/src/module/modules.d.ts similarity index 100% rename from packages/types/src/modules.d.ts rename to packages/types/src/module/modules.d.ts diff --git a/web/components/headers/modules-list.tsx b/web/components/headers/modules-list.tsx index a1233ae5211..7302731f163 100644 --- a/web/components/headers/modules-list.tsx +++ b/web/components/headers/modules-list.tsx @@ -1,24 +1,33 @@ +import { useCallback, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; -// icons -import { GanttChartSquare, LayoutGrid, List, Plus } from "lucide-react"; -// ui -import { Breadcrumbs, Button, Tooltip, DiceIcon, CustomMenu } from "@plane/ui"; +import { GanttChartSquare, LayoutGrid, List, ListFilter, Plus, Search, X } from "lucide-react"; +// hooks +import { useApplication, useEventTracker, useMember, useModuleFilter, useProject, useUser } from "hooks/store"; // components import { BreadcrumbLink } from "components/common"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +import { ProjectLogo } from "components/project"; +import { ModuleFiltersSelection, ModuleOrderByDropdown } from "components/modules"; +import { FiltersDropdown } from "components/issues"; +// ui +import { Breadcrumbs, Button, Tooltip, DiceIcon, CustomMenu } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import { TModuleFilters } from "@plane/types"; // constants import { MODULE_VIEW_LAYOUTS } from "constants/module"; import { EUserProjectRoles } from "constants/project"; -// hooks -import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; -import useLocalStorage from "hooks/use-local-storage"; -import { ProjectLogo } from "components/project"; export const ModulesListHeader: React.FC = observer(() => { + // states + const [isSearchOpen, setIsSearchOpen] = useState(false); + // refs + const inputRef = useRef(null); // router const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId } = router.query; // store hooks const { commandPalette: commandPaletteStore } = useApplication(); const { setTrackElement } = useEventTracker(); @@ -26,11 +35,48 @@ export const ModulesListHeader: React.FC = observer(() => { membership: { currentProjectRole }, } = useUser(); const { currentProjectDetails } = useProject(); + const { + workspace: { workspaceMemberIds }, + } = useMember(); + const { + currentProjectDisplayFilters: displayFilters, + currentProjectFilters: filters, + searchQuery, + updateDisplayFilters, + updateFilters, + updateSearchQuery, + } = useModuleFilter(); + + const handleFilters = useCallback( + (key: keyof TModuleFilters, value: string | string[]) => { + if (!projectId) return; + const newValues = filters?.[key] ?? []; + + if (Array.isArray(value)) + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + }); + else { + if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(projectId.toString(), { [key]: newValues }); + }, + [filters, projectId, updateFilters] + ); - const { storedValue: modulesView, setValue: setModulesView } = useLocalStorage("modules_view", "grid"); + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); + else setIsSearchOpen(false); + } + }; + // auth const canUserCreateModule = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); + return (
@@ -62,26 +108,92 @@ export const ModulesListHeader: React.FC = observer(() => {
-
+
+ {!isSearchOpen && ( + + )} +
+ + updateSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + {isSearchOpen && ( + + )} +
+
+
{MODULE_VIEW_LAYOUTS.map((layout) => ( ))}
+ { + if (!projectId || val === displayFilters?.order_by) return; + updateDisplayFilters(projectId.toString(), { + order_by: val, + }); + }} + /> + } title="Filters" placement="bottom-end"> + + {canUserCreateModule && ( + )} +
+ ))} + + ); +}); diff --git a/web/components/modules/applied-filters/index.ts b/web/components/modules/applied-filters/index.ts new file mode 100644 index 00000000000..cf34b6e69e4 --- /dev/null +++ b/web/components/modules/applied-filters/index.ts @@ -0,0 +1,4 @@ +export * from "./date"; +export * from "./members"; +export * from "./root"; +export * from "./status"; diff --git a/web/components/modules/applied-filters/members.tsx b/web/components/modules/applied-filters/members.tsx new file mode 100644 index 00000000000..88f18ee0c97 --- /dev/null +++ b/web/components/modules/applied-filters/members.tsx @@ -0,0 +1,46 @@ +import { observer } from "mobx-react-lite"; +import { X } from "lucide-react"; +// ui +import { Avatar } from "@plane/ui"; +// types +import { useMember } from "hooks/store"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; + editable: boolean | undefined; +}; + +export const AppliedMembersFilters: React.FC = observer((props) => { + const { handleRemove, values, editable } = props; + // store hooks + const { + workspace: { getWorkspaceMemberDetails }, + } = useMember(); + + return ( + <> + {values.map((memberId) => { + const memberDetails = getWorkspaceMemberDetails(memberId)?.member; + + if (!memberDetails) return null; + + return ( +
+ + {memberDetails.display_name} + {editable && ( + + )} +
+ ); + })} + + ); +}); diff --git a/web/components/modules/applied-filters/root.tsx b/web/components/modules/applied-filters/root.tsx new file mode 100644 index 00000000000..2969ea71562 --- /dev/null +++ b/web/components/modules/applied-filters/root.tsx @@ -0,0 +1,88 @@ +import { X } from "lucide-react"; +// components +import { AppliedDateFilters, AppliedMembersFilters, AppliedStatusFilters } from "components/modules"; +// helpers +import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; +// types +import { TModuleFilters } from "@plane/types"; + +type Props = { + appliedFilters: TModuleFilters; + handleClearAllFilters: () => void; + handleRemoveFilter: (key: keyof TModuleFilters, value: string | null) => void; + alwaysAllowEditing?: boolean; +}; + +const MEMBERS_FILTERS = ["lead", "members"]; +const DATE_FILTERS = ["start_date", "target_date"]; + +export const ModuleAppliedFiltersList: React.FC = (props) => { + const { appliedFilters, handleClearAllFilters, handleRemoveFilter, alwaysAllowEditing } = props; + + if (!appliedFilters) return null; + if (Object.keys(appliedFilters).length === 0) return null; + + const isEditingAllowed = alwaysAllowEditing; + + return ( +
+ {Object.entries(appliedFilters).map(([key, value]) => { + const filterKey = key as keyof TModuleFilters; + + if (!value) return; + if (Array.isArray(value) && value.length === 0) return; + + return ( +
+
+ {replaceUnderscoreIfSnakeCase(filterKey)} + {filterKey === "status" && ( + handleRemoveFilter("status", val)} + values={value} + /> + )} + {DATE_FILTERS.includes(filterKey) && ( + handleRemoveFilter(filterKey, val)} + values={value} + /> + )} + {MEMBERS_FILTERS.includes(filterKey) && ( + handleRemoveFilter(filterKey, val)} + values={value} + /> + )} + {isEditingAllowed && ( + + )} +
+
+ ); + })} + {isEditingAllowed && ( + + )} +
+ ); +}; diff --git a/web/components/modules/applied-filters/status.tsx b/web/components/modules/applied-filters/status.tsx new file mode 100644 index 00000000000..ed5426cdeb6 --- /dev/null +++ b/web/components/modules/applied-filters/status.tsx @@ -0,0 +1,41 @@ +import { observer } from "mobx-react-lite"; +import { X } from "lucide-react"; +// ui +import { ModuleStatusIcon } from "@plane/ui"; +// constants +import { MODULE_STATUS } from "constants/module"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; + editable: boolean | undefined; +}; + +export const AppliedStatusFilters: React.FC = observer((props) => { + const { handleRemove, values, editable } = props; + + return ( + <> + {values.map((status) => { + const statusDetails = MODULE_STATUS?.find((s) => s.value === status); + if (!statusDetails) return null; + + return ( +
+ + {statusDetails.label} + {editable && ( + + )} +
+ ); + })} + + ); +}); diff --git a/web/components/modules/dropdowns/filters/index.ts b/web/components/modules/dropdowns/filters/index.ts new file mode 100644 index 00000000000..786fc5cec87 --- /dev/null +++ b/web/components/modules/dropdowns/filters/index.ts @@ -0,0 +1,6 @@ +export * from "./lead"; +export * from "./members"; +export * from "./root"; +export * from "./start-date"; +export * from "./status"; +export * from "./target-date"; diff --git a/web/components/modules/dropdowns/filters/lead.tsx b/web/components/modules/dropdowns/filters/lead.tsx new file mode 100644 index 00000000000..02c257b9be7 --- /dev/null +++ b/web/components/modules/dropdowns/filters/lead.tsx @@ -0,0 +1,97 @@ +import { useMemo, useState } from "react"; +import { observer } from "mobx-react-lite"; +import sortBy from "lodash/sortBy"; +// hooks +import { useMember } from "hooks/store"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +// ui +import { Avatar, Loader } from "@plane/ui"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + memberIds: string[] | undefined; + searchQuery: string; +}; + +export const FilterLead: React.FC = observer((props: Props) => { + const { appliedFilters, handleUpdate, memberIds, searchQuery } = props; + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + // store hooks + const { getUserDetails } = useMember(); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const sortedOptions = useMemo(() => { + const filteredOptions = (memberIds || []).filter((memberId) => + getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return sortBy(filteredOptions, [ + (memberId) => !(appliedFilters ?? []).includes(memberId), + (memberId) => getUserDetails(memberId)?.display_name.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]); + + const handleViewToggle = () => { + if (!sortedOptions) return; + + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {sortedOptions ? ( + sortedOptions.length > 0 ? ( + <> + {sortedOptions.slice(0, itemsToRender).map((memberId) => { + const member = getUserDetails(memberId); + + if (!member) return null; + return ( + handleUpdate(member.id)} + icon={} + title={member.display_name} + /> + ); + })} + {sortedOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}); diff --git a/web/components/modules/dropdowns/filters/members.tsx b/web/components/modules/dropdowns/filters/members.tsx new file mode 100644 index 00000000000..0d273722746 --- /dev/null +++ b/web/components/modules/dropdowns/filters/members.tsx @@ -0,0 +1,97 @@ +import { useMemo, useState } from "react"; +import { observer } from "mobx-react-lite"; +import sortBy from "lodash/sortBy"; +// hooks +import { useMember } from "hooks/store"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +// ui +import { Avatar, Loader } from "@plane/ui"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + memberIds: string[] | undefined; + searchQuery: string; +}; + +export const FilterMembers: React.FC = observer((props: Props) => { + const { appliedFilters, handleUpdate, memberIds, searchQuery } = props; + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + // store hooks + const { getUserDetails } = useMember(); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const sortedOptions = useMemo(() => { + const filteredOptions = (memberIds || []).filter((memberId) => + getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return sortBy(filteredOptions, [ + (memberId) => !(appliedFilters ?? []).includes(memberId), + (memberId) => getUserDetails(memberId)?.display_name.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]); + + const handleViewToggle = () => { + if (!sortedOptions) return; + + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {sortedOptions ? ( + sortedOptions.length > 0 ? ( + <> + {sortedOptions.slice(0, itemsToRender).map((memberId) => { + const member = getUserDetails(memberId); + + if (!member) return null; + return ( + handleUpdate(member.id)} + icon={} + title={member.display_name} + /> + ); + })} + {sortedOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}); diff --git a/web/components/modules/dropdowns/filters/root.tsx b/web/components/modules/dropdowns/filters/root.tsx new file mode 100644 index 00000000000..27c82f11cdf --- /dev/null +++ b/web/components/modules/dropdowns/filters/root.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; +import { observer } from "mobx-react-lite"; +import { Search, X } from "lucide-react"; +// components +import { FilterLead, FilterMembers, FilterStartDate, FilterStatus, FilterTargetDate } from "components/modules"; +// types +import { TModuleFilters } from "@plane/types"; +import { TModuleStatus } from "@plane/ui"; + +type Props = { + filters: TModuleFilters; + handleFiltersUpdate: (key: keyof TModuleFilters, value: string | string[]) => void; + memberIds?: string[] | undefined; +}; + +export const ModuleFiltersSelection: React.FC = observer((props) => { + const { filters, handleFiltersUpdate, memberIds } = props; + // states + const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); + + return ( +
+
+
+ + setFiltersSearchQuery(e.target.value)} + autoFocus + /> + {filtersSearchQuery !== "" && ( + + )} +
+
+
+ {/* status */} +
+ handleFiltersUpdate("status", val)} + searchQuery={filtersSearchQuery} + /> +
+ + {/* lead */} +
+ handleFiltersUpdate("lead", val)} + searchQuery={filtersSearchQuery} + memberIds={memberIds} + /> +
+ + {/* members */} +
+ handleFiltersUpdate("members", val)} + searchQuery={filtersSearchQuery} + memberIds={memberIds} + /> +
+ + {/* start date */} +
+ handleFiltersUpdate("start_date", val)} + searchQuery={filtersSearchQuery} + /> +
+ + {/* target date */} +
+ handleFiltersUpdate("target_date", val)} + searchQuery={filtersSearchQuery} + /> +
+
+
+ ); +}); diff --git a/web/components/modules/dropdowns/filters/start-date.tsx b/web/components/modules/dropdowns/filters/start-date.tsx new file mode 100644 index 00000000000..87def7e29f5 --- /dev/null +++ b/web/components/modules/dropdowns/filters/start-date.tsx @@ -0,0 +1,63 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; + +// components +import { DateFilterModal } from "components/core"; +import { FilterHeader, FilterOption } from "components/issues"; +// constants +import { DATE_FILTER_OPTIONS } from "constants/filters"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string | string[]) => void; + searchQuery: string; +}; + +export const FilterStartDate: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + const [previewEnabled, setPreviewEnabled] = useState(true); + const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = DATE_FILTER_OPTIONS.filter((d) => d.name.toLowerCase().includes(searchQuery.toLowerCase())); + + return ( + <> + {isDateFilterModalOpen && ( + setIsDateFilterModalOpen(false)} + isOpen={isDateFilterModalOpen} + onSelect={(val) => handleUpdate(val)} + title="Start date" + /> + )} + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + <> + {filteredOptions.map((option) => ( + handleUpdate(option.value)} + title={option.name} + multiple + /> + ))} + setIsDateFilterModalOpen(true)} title="Custom" multiple /> + + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/modules/dropdowns/filters/status.tsx b/web/components/modules/dropdowns/filters/status.tsx new file mode 100644 index 00000000000..f73db2554f3 --- /dev/null +++ b/web/components/modules/dropdowns/filters/status.tsx @@ -0,0 +1,52 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +// ui +import { ModuleStatusIcon } from "@plane/ui"; +// types +import { TModuleStatus } from "@plane/types"; +// constants +import { MODULE_STATUS } from "constants/module"; + +type Props = { + appliedFilters: TModuleStatus[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterStatus: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + // states + const [previewEnabled, setPreviewEnabled] = useState(true); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + const filteredOptions = MODULE_STATUS.filter((p) => p.value.includes(searchQuery.toLowerCase())); + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((status) => ( + handleUpdate(status.value)} + icon={} + title={status.label} + /> + )) + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/modules/dropdowns/filters/target-date.tsx b/web/components/modules/dropdowns/filters/target-date.tsx new file mode 100644 index 00000000000..e5860ba393f --- /dev/null +++ b/web/components/modules/dropdowns/filters/target-date.tsx @@ -0,0 +1,63 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; + +// components +import { DateFilterModal } from "components/core"; +import { FilterHeader, FilterOption } from "components/issues"; +// constants +import { DATE_FILTER_OPTIONS } from "constants/filters"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string | string[]) => void; + searchQuery: string; +}; + +export const FilterTargetDate: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + const [previewEnabled, setPreviewEnabled] = useState(true); + const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = DATE_FILTER_OPTIONS.filter((d) => d.name.toLowerCase().includes(searchQuery.toLowerCase())); + + return ( + <> + {isDateFilterModalOpen && ( + setIsDateFilterModalOpen(false)} + isOpen={isDateFilterModalOpen} + onSelect={(val) => handleUpdate(val)} + title="Due date" + /> + )} + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + <> + {filteredOptions.map((option) => ( + handleUpdate(option.value)} + title={option.name} + multiple + /> + ))} + setIsDateFilterModalOpen(true)} title="Custom" multiple /> + + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/modules/dropdowns/index.ts b/web/components/modules/dropdowns/index.ts new file mode 100644 index 00000000000..f6c42552f65 --- /dev/null +++ b/web/components/modules/dropdowns/index.ts @@ -0,0 +1,2 @@ +export * from "./filters"; +export * from "./order-by"; diff --git a/web/components/modules/dropdowns/order-by.tsx b/web/components/modules/dropdowns/order-by.tsx new file mode 100644 index 00000000000..a611d1ead74 --- /dev/null +++ b/web/components/modules/dropdowns/order-by.tsx @@ -0,0 +1,70 @@ +import { ArrowDownWideNarrow, Check, ChevronDown } from "lucide-react"; +// ui +import { CustomMenu, getButtonStyling } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import { TModuleOrderByOptions } from "@plane/types"; +// constants +import { MODULE_ORDER_BY_OPTIONS } from "constants/module"; + +type Props = { + onChange: (value: TModuleOrderByOptions) => void; + value: TModuleOrderByOptions | undefined; +}; + +export const ModuleOrderByDropdown: React.FC = (props) => { + const { onChange, value } = props; + + const orderByDetails = MODULE_ORDER_BY_OPTIONS.find((option) => value?.includes(option.key)); + + const isDescending = value?.[0] === "-"; + + return ( + + + {orderByDetails?.label} + +
+ } + placement="bottom-end" + maxHeight="lg" + closeOnSelect + > + {MODULE_ORDER_BY_OPTIONS.map((option) => ( + { + if (isDescending) onChange(`-${option.key}` as TModuleOrderByOptions); + else onChange(option.key); + }} + > + {option.label} + {value?.includes(option.key) && } + + ))} +
+ { + if (isDescending) onChange(value.slice(1) as TModuleOrderByOptions); + }} + > + Ascending + {!isDescending && } + + { + if (!isDescending) onChange(`-${value}` as TModuleOrderByOptions); + }} + > + Descending + {isDescending && } + + + ); +}; diff --git a/web/components/modules/index.ts b/web/components/modules/index.ts index c87ea79d26a..7bda973fa5a 100644 --- a/web/components/modules/index.ts +++ b/web/components/modules/index.ts @@ -1,3 +1,5 @@ +export * from "./applied-filters"; +export * from "./dropdowns"; export * from "./select"; export * from "./sidebar-select"; export * from "./delete-module-modal"; diff --git a/web/components/modules/modules-list-view.tsx b/web/components/modules/modules-list-view.tsx index 78b4a657107..2df7a22de3b 100644 --- a/web/components/modules/modules-list-view.tsx +++ b/web/components/modules/modules-list-view.tsx @@ -1,8 +1,7 @@ import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; // hooks -import { useApplication, useEventTracker, useModule } from "hooks/store"; -import useLocalStorage from "hooks/use-local-storage"; +import { useApplication, useEventTracker, useModule, useModuleFilter } from "hooks/store"; // components import { ModuleCardItem, ModuleListItem, ModulePeekOverview, ModulesListGanttChartView } from "components/modules"; import { EmptyState } from "components/empty-state"; @@ -18,29 +17,29 @@ export const ModulesListView: React.FC = observer(() => { // store hooks const { commandPalette: commandPaletteStore } = useApplication(); const { setTrackElement } = useEventTracker(); + const { getFilteredModuleIds, loader } = useModule(); + const { currentProjectDisplayFilters: displayFilters } = useModuleFilter(); + // derived values + const filteredModuleIds = projectId ? getFilteredModuleIds(projectId.toString()) : undefined; - const { projectModuleIds, loader } = useModule(); - - const { storedValue: modulesView } = useLocalStorage("modules_view", "grid"); - - if (loader || !projectModuleIds) + if (loader || !filteredModuleIds) return ( <> - {modulesView === "list" && } - {modulesView === "grid" && } - {modulesView === "gantt_chart" && } + {displayFilters?.layout === "list" && } + {displayFilters?.layout === "board" && } + {displayFilters?.layout === "gantt" && } ); return ( <> - {projectModuleIds.length > 0 ? ( + {filteredModuleIds.length > 0 ? ( <> - {modulesView === "list" && ( + {displayFilters?.layout === "list" && (
- {projectModuleIds.map((moduleId) => ( + {filteredModuleIds.map((moduleId) => ( ))}
@@ -51,7 +50,7 @@ export const ModulesListView: React.FC = observer(() => {
)} - {modulesView === "grid" && ( + {displayFilters?.layout === "board" && (
{ : "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" } auto-rows-max transition-all vertical-scrollbar scrollbar-lg`} > - {projectModuleIds.map((moduleId) => ( + {filteredModuleIds.map((moduleId) => ( ))}
@@ -72,7 +71,7 @@ export const ModulesListView: React.FC = observer(() => {
)} - {modulesView === "gantt_chart" && } + {displayFilters?.layout === "gantt" && } ) : ( { + let orderedModules: IModule[] = []; + if (modules.length === 0) return []; + + if (orderByKey === "name") orderedModules = sortBy(modules, [(m) => m.name.toLowerCase()]); + if (orderByKey === "-name") orderedModules = sortBy(modules, [(m) => m.name.toLowerCase()]).reverse(); + if (orderByKey === "created_at") orderedModules = sortBy(modules, [(m) => m.created_at]); + if (orderByKey === "-created_at") orderedModules = sortBy(modules, [(m) => m.created_at]); + + return orderedModules; +}; + +/** + * @description filters modules based on the filter + * @param {IModule} module + * @param {TModuleFilters} filter + * @returns {boolean} + */ +export const shouldFilterModule = (module: IModule, filters: TModuleFilters): boolean => { + let fallsInFilters = true; + Object.keys(filters).forEach((key) => { + const filterKey = key as keyof TModuleFilters; + if (filterKey === "status" && filters.status && filters.status.length > 0) + fallsInFilters = fallsInFilters && filters.status.includes(module.status.toLowerCase()); + if (filterKey === "lead" && filters.lead && filters.lead.length > 0) + fallsInFilters = fallsInFilters && filters.lead.includes(`${module.lead_id}`); + if (filterKey === "members" && filters.members && filters.members.length > 0) { + const memberIds = module.member_ids; + fallsInFilters = fallsInFilters && filters.members.some((memberId) => memberIds.includes(memberId)); + } + if (filterKey === "start_date" && filters.start_date && filters.start_date.length > 0) { + filters.start_date.forEach((dateFilter) => { + fallsInFilters = + fallsInFilters && !!module.start_date && satisfiesDateFilter(new Date(module.start_date), dateFilter); + }); + } + if (filterKey === "target_date" && filters.target_date && filters.target_date.length > 0) { + filters.target_date.forEach((dateFilter) => { + fallsInFilters = + fallsInFilters && !!module.target_date && satisfiesDateFilter(new Date(module.target_date), dateFilter); + }); + } + }); + + return fallsInFilters; +}; diff --git a/web/hooks/store/index.ts b/web/hooks/store/index.ts index 3ec5c97bff6..1c2529c3951 100644 --- a/web/hooks/store/index.ts +++ b/web/hooks/store/index.ts @@ -10,6 +10,7 @@ export * from "./use-label"; export * from "./use-member"; export * from "./use-mention"; export * from "./use-module"; +export * from "./use-module-filter"; export * from "./use-page"; export * from "./use-project-publish"; export * from "./use-project-state"; diff --git a/web/hooks/store/use-module-filter.ts b/web/hooks/store/use-module-filter.ts new file mode 100644 index 00000000000..783cea6d68b --- /dev/null +++ b/web/hooks/store/use-module-filter.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// mobx store +import { StoreContext } from "contexts/store-context"; +// types +import { IModuleFilterStore } from "store/module_filter.store"; + +export const useModuleFilter = (): IModuleFilterStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useModuleFilter must be used within StoreProvider"); + return context.moduleFilter; +}; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx index 3648f592253..eb3c920442a 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx @@ -1,30 +1,58 @@ -import { ReactElement } from "react"; +import { ReactElement, useCallback } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; // layouts // components import { PageHead } from "components/core"; import { ModulesListHeader } from "components/headers"; -import { ModulesListView } from "components/modules"; +import { ModuleAppliedFiltersList, ModulesListView } from "components/modules"; // types // hooks -import { useProject } from "hooks/store"; +import { useModuleFilter, useProject } from "hooks/store"; import { AppLayout } from "layouts/app-layout"; import { NextPageWithLayout } from "lib/types"; +import { calculateTotalFilters } from "helpers/filter.helper"; +import { TModuleFilters } from "@plane/types"; const ProjectModulesPage: NextPageWithLayout = observer(() => { const router = useRouter(); const { projectId } = router.query; // store const { getProjectById } = useProject(); + const { currentProjectFilters, clearAllFilters, updateFilters } = useModuleFilter(); // derived values const project = projectId ? getProjectById(projectId.toString()) : undefined; const pageTitle = project?.name ? `${project?.name} - Modules` : undefined; + const handleRemoveFilter = useCallback( + (key: keyof TModuleFilters, value: string | null) => { + if (!projectId) return; + let newValues = currentProjectFilters?.[key] ?? []; + + if (!value) newValues = []; + else newValues = newValues.filter((val) => val !== value); + + updateFilters(projectId.toString(), { [key]: newValues }); + }, + [currentProjectFilters, projectId, updateFilters] + ); + return ( <> - +
+ {calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && ( +
+ clearAllFilters(`${projectId}`)} + handleRemoveFilter={handleRemoveFilter} + alwaysAllowEditing + /> +
+ )} + +
); }); diff --git a/web/store/module.store.ts b/web/store/module.store.ts index c7dcba79c06..717765a8e6b 100644 --- a/web/store/module.store.ts +++ b/web/store/module.store.ts @@ -5,6 +5,8 @@ import { computedFn } from "mobx-utils"; // services import { ModuleService } from "services/module.service"; import { ProjectService } from "services/project"; +// helpers +import { orderModules, shouldFilterModule } from "helpers/module.helper"; // types import { RootStore } from "store/root.store"; import { IModule, ILinkDetails } from "@plane/types"; @@ -18,6 +20,7 @@ export interface IModuleStore { // computed projectModuleIds: string[] | null; // computed actions + getFilteredModuleIds: (projectId: string) => string[] | null; getModuleById: (moduleId: string) => IModule | null; getModuleNameById: (moduleId: string) => string; getProjectModuleIds: (projectId: string) => string[] | null; @@ -108,6 +111,28 @@ export class ModulesStore implements IModuleStore { return projectModuleIds || null; } + /** + * @description returns filtered module ids based on display filters and filters + * @param {TModuleDisplayFilters} displayFilters + * @param {TModuleFilters} filters + * @returns {string[] | null} + */ + getFilteredModuleIds = computedFn((projectId: string) => { + const displayFilters = this.rootStore.moduleFilter.getDisplayFiltersByProjectId(projectId); + const filters = this.rootStore.moduleFilter.getFiltersByProjectId(projectId); + const searchQuery = this.rootStore.moduleFilter.searchQuery; + if (!this.fetchedMap[projectId]) return null; + let modules = Object.values(this.moduleMap ?? {}).filter( + (m) => + m.project_id === projectId && + m.name.toLowerCase().includes(searchQuery.toLowerCase()) && + shouldFilterModule(m, filters ?? {}) + ); + modules = orderModules(modules, displayFilters?.order_by); + const moduleIds = modules.map((m) => m.id); + return moduleIds; + }); + /** * @description get module by id * @param moduleId diff --git a/web/store/module_filter.store.ts b/web/store/module_filter.store.ts new file mode 100644 index 00000000000..52f8f1d4f94 --- /dev/null +++ b/web/store/module_filter.store.ts @@ -0,0 +1,146 @@ +import { action, computed, observable, makeObservable, runInAction, autorun } from "mobx"; +import { computedFn } from "mobx-utils"; +import set from "lodash/set"; +// types +import { RootStore } from "store/root.store"; +import { TModuleDisplayFilters, TModuleFilters } from "@plane/types"; + +export interface IModuleFilterStore { + // observables + displayFilters: Record; + filters: Record; + searchQuery: string; + // computed + currentProjectDisplayFilters: TModuleDisplayFilters | undefined; + currentProjectFilters: TModuleFilters | undefined; + // computed functions + getDisplayFiltersByProjectId: (projectId: string) => TModuleDisplayFilters | undefined; + getFiltersByProjectId: (projectId: string) => TModuleFilters | undefined; + // actions + updateDisplayFilters: (projectId: string, displayFilters: TModuleDisplayFilters) => void; + updateFilters: (projectId: string, filters: TModuleFilters) => void; + updateSearchQuery: (query: string) => void; + clearAllFilters: (projectId: string) => void; +} + +export class ModuleFilterStore implements IModuleFilterStore { + // observables + displayFilters: Record = {}; + filters: Record = {}; + searchQuery: string = ""; + // root store + rootStore: RootStore; + + constructor(_rootStore: RootStore) { + makeObservable(this, { + // observables + displayFilters: observable, + filters: observable, + searchQuery: observable.ref, + // computed + currentProjectDisplayFilters: computed, + currentProjectFilters: computed, + // actions + updateDisplayFilters: action, + updateFilters: action, + updateSearchQuery: action, + clearAllFilters: action, + }); + // root store + this.rootStore = _rootStore; + // initialize display filters of the current project + autorun(() => { + const projectId = this.rootStore.app.router.projectId; + if (!projectId) return; + this.initProjectModuleFilters(projectId); + }); + } + + /** + * @description get display filters of the current project + */ + get currentProjectDisplayFilters() { + const projectId = this.rootStore.app.router.projectId; + if (!projectId) return; + return this.displayFilters[projectId]; + } + + /** + * @description get filters of the current project + */ + get currentProjectFilters() { + const projectId = this.rootStore.app.router.projectId; + if (!projectId) return; + return this.filters[projectId]; + } + + /** + * @description get display filters of a project by projectId + * @param {string} projectId + */ + getDisplayFiltersByProjectId = computedFn((projectId: string) => this.displayFilters[projectId]); + + /** + * @description get filters of a project by projectId + * @param {string} projectId + */ + getFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId]); + + /** + * @description initialize display filters and filters of a project + * @param {string} projectId + */ + initProjectModuleFilters = (projectId: string) => { + const displayFilters = this.getDisplayFiltersByProjectId(projectId); + runInAction(() => { + this.displayFilters[projectId] = { + favorites: displayFilters?.favorites || false, + layout: displayFilters?.layout || "list", + order_by: displayFilters?.order_by || "name", + }; + this.filters[projectId] = {}; + }); + }; + + /** + * @description update display filters of a project + * @param {string} projectId + * @param {TModuleDisplayFilters} displayFilters + */ + updateDisplayFilters = (projectId: string, displayFilters: TModuleDisplayFilters) => { + runInAction(() => { + Object.keys(displayFilters).forEach((key) => { + set(this.displayFilters, [projectId, key], displayFilters[key as keyof TModuleDisplayFilters]); + }); + }); + }; + + /** + * @description update filters of a project + * @param {string} projectId + * @param {TModuleFilters} filters + */ + updateFilters = (projectId: string, filters: TModuleFilters) => { + runInAction(() => { + Object.keys(filters).forEach((key) => { + set(this.filters, [projectId, key], filters[key as keyof TModuleFilters]); + }); + }); + }; + + /** + * @description update search query + * @param {string} query + */ + updateSearchQuery = (query: string) => (this.searchQuery = query); + + /** + * @description clear all filters of a project + * @param {string} projectId + */ + clearAllFilters = (projectId: string) => { + runInAction(() => { + this.filters[projectId] = {}; + }); + }; +} diff --git a/web/store/root.store.ts b/web/store/root.store.ts index 0390d7ce2a6..7161f8939fc 100644 --- a/web/store/root.store.ts +++ b/web/store/root.store.ts @@ -19,6 +19,7 @@ import { IUserRootStore, UserRootStore } from "./user"; import { IWorkspaceRootStore, WorkspaceRootStore } from "./workspace"; import { IProjectPageStore, ProjectPageStore } from "./project-page.store"; import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store"; +import { IModuleFilterStore, ModuleFilterStore } from "./module_filter.store"; enableStaticRendering(typeof window === "undefined"); @@ -32,6 +33,7 @@ export class RootStore { cycle: ICycleStore; cycleFilter: ICycleFilterStore; module: IModuleStore; + moduleFilter: IModuleFilterStore; projectView: IProjectViewStore; globalView: IGlobalViewStore; issue: IIssueRootStore; @@ -54,6 +56,7 @@ export class RootStore { this.cycle = new CycleStore(this); this.cycleFilter = new CycleFilterStore(this); this.module = new ModulesStore(this); + this.moduleFilter = new ModuleFilterStore(this); this.projectView = new ProjectViewStore(this); this.globalView = new GlobalViewStore(this); this.issue = new IssueRootStore(this); @@ -74,6 +77,7 @@ export class RootStore { this.cycle = new CycleStore(this); this.cycleFilter = new CycleFilterStore(this); this.module = new ModulesStore(this); + this.moduleFilter = new ModuleFilterStore(this); this.projectView = new ProjectViewStore(this); this.globalView = new GlobalViewStore(this); this.issue = new IssueRootStore(this); From 0446753ff540e2c7ea2334ac42b33501c3c41e0f Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Tue, 12 Mar 2024 13:10:57 +0530 Subject: [PATCH 2/5] fix: modules ordering --- web/helpers/module.helper.ts | 42 +++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/web/helpers/module.helper.ts b/web/helpers/module.helper.ts index f7ecb73bcd8..03d4b9768dd 100644 --- a/web/helpers/module.helper.ts +++ b/web/helpers/module.helper.ts @@ -16,16 +16,52 @@ export const orderModules = (modules: IModule[], orderByKey: TModuleOrderByOptio if (orderByKey === "name") orderedModules = sortBy(modules, [(m) => m.name.toLowerCase()]); if (orderByKey === "-name") orderedModules = sortBy(modules, [(m) => m.name.toLowerCase()]).reverse(); + if (orderByKey === "progress") + orderedModules = sortBy(modules, [ + (m) => { + const totalIssues = + m.backlog_issues + m.unstarted_issues + m.started_issues + m.completed_issues + m.cancelled_issues; + const progress = (m.unstarted_issues + m.started_issues) / totalIssues; + return progress; + }, + ]); + if (orderByKey === "-progress") + orderedModules = sortBy(modules, [ + (m) => { + const totalIssues = + m.backlog_issues + m.unstarted_issues + m.started_issues + m.completed_issues + m.cancelled_issues; + const progress = (m.unstarted_issues + m.started_issues) / totalIssues; + return !progress; + }, + ]); + if (orderByKey === "issues_length") + orderedModules = sortBy(modules, [ + (m) => { + const totalIssues = + m.backlog_issues + m.unstarted_issues + m.started_issues + m.completed_issues + m.cancelled_issues; + return totalIssues; + }, + ]); + if (orderByKey === "-issues_length") + orderedModules = sortBy(modules, [ + (m) => { + const totalIssues = + m.backlog_issues + m.unstarted_issues + m.started_issues + m.completed_issues + m.cancelled_issues; + return !totalIssues; + }, + ]); + if (orderByKey === "target_date") orderedModules = sortBy(modules, [(m) => m.target_date]); + if (orderByKey === "-target_date") orderedModules = sortBy(modules, [(m) => !m.target_date]); if (orderByKey === "created_at") orderedModules = sortBy(modules, [(m) => m.created_at]); - if (orderByKey === "-created_at") orderedModules = sortBy(modules, [(m) => m.created_at]); + if (orderByKey === "-created_at") orderedModules = sortBy(modules, [(m) => !m.created_at]); return orderedModules; }; /** - * @description filters modules based on the filter + * @description filters modules based on the filters * @param {IModule} module - * @param {TModuleFilters} filter + * @param {TModuleFilters} filters * @returns {boolean} */ export const shouldFilterModule = (module: IModule, filters: TModuleFilters): boolean => { From fdbb9c2e1bfdde5ecc085e20733014ced3850fbf Mon Sep 17 00:00:00 2001 From: NarayanBavisetti Date: Tue, 12 Mar 2024 16:00:02 +0530 Subject: [PATCH 3/5] chore: total issues in list endpoint --- apiserver/plane/app/views/cycle/base.py | 1 + apiserver/plane/app/views/module/base.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 189cdb09693..e777a93a68e 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -354,6 +354,7 @@ def list(self, request, slug, project_id): "external_id", "progress_snapshot", # meta fields + "total_issues", "is_favorite", "cancelled_issues", "completed_issues", diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index ee9718b59c6..881730d6535 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -79,6 +79,15 @@ def get_queryset(self): ), ) ) + .annotate( + total_issues=Count( + "issue_module", + filter=Q( + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ), + ) .annotate( completed_issues=Count( "issue_module__issue__state__group", @@ -214,6 +223,7 @@ def list(self, request, slug, project_id): "external_source", "external_id", # computed fields + "total_issues", "is_favorite", "cancelled_issues", "completed_issues", From a8f3a8bc1a679f49d569dca9e69428def07e89b4 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Tue, 12 Mar 2024 16:37:15 +0530 Subject: [PATCH 4/5] fix: modules ordering --- web/components/cycles/cycles-view-header.tsx | 7 ++- web/components/headers/modules-list.tsx | 17 ++++++- .../modules/dropdowns/filters/root.tsx | 19 +++++++- .../gantt-chart/modules-list-layout.tsx | 8 ++-- web/components/modules/modules-list-view.tsx | 25 +++++++++- web/helpers/module.helper.ts | 48 +++++++------------ web/public/empty-state/module/all-filters.svg | 45 +++++++++++++++++ web/public/empty-state/module/name-filter.svg | 44 +++++++++++++++++ web/store/module.store.ts | 2 +- 9 files changed, 173 insertions(+), 42 deletions(-) create mode 100644 web/public/empty-state/module/all-filters.svg create mode 100644 web/public/empty-state/module/name-filter.svg diff --git a/web/components/cycles/cycles-view-header.tsx b/web/components/cycles/cycles-view-header.tsx index b0feede0e79..0223fe8c323 100644 --- a/web/components/cycles/cycles-view-header.tsx +++ b/web/components/cycles/cycles-view-header.tsx @@ -62,7 +62,10 @@ export const CyclesViewHeader: React.FC = observer((props) => { const handleInputKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); - else setIsSearchOpen(false); + else { + setIsSearchOpen(false); + inputRef.current?.blur(); + } } }; @@ -107,7 +110,7 @@ export const CyclesViewHeader: React.FC = observer((props) => { updateSearchQuery(e.target.value)} diff --git a/web/components/headers/modules-list.tsx b/web/components/headers/modules-list.tsx index 7302731f163..a8b9ef3f69f 100644 --- a/web/components/headers/modules-list.tsx +++ b/web/components/headers/modules-list.tsx @@ -4,6 +4,7 @@ import { useRouter } from "next/router"; import { GanttChartSquare, LayoutGrid, List, ListFilter, Plus, Search, X } from "lucide-react"; // hooks import { useApplication, useEventTracker, useMember, useModuleFilter, useProject, useUser } from "hooks/store"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components import { BreadcrumbLink } from "components/common"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; @@ -46,6 +47,10 @@ export const ModulesListHeader: React.FC = observer(() => { updateFilters, updateSearchQuery, } = useModuleFilter(); + // outside click detector hook + useOutsideClickDetector(inputRef, () => { + if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); + }); const handleFilters = useCallback( (key: keyof TModuleFilters, value: string | string[]) => { @@ -69,7 +74,10 @@ export const ModulesListHeader: React.FC = observer(() => { const handleInputKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); - else setIsSearchOpen(false); + else { + setIsSearchOpen(false); + inputRef.current?.blur(); + } } }; @@ -132,7 +140,7 @@ export const ModulesListHeader: React.FC = observer(() => { updateSearchQuery(e.target.value)} @@ -189,7 +197,12 @@ export const ModulesListHeader: React.FC = observer(() => { /> } title="Filters" placement="bottom-end"> { + if (!projectId) return; + updateDisplayFilters(projectId.toString(), val); + }} handleFiltersUpdate={handleFilters} memberIds={workspaceMemberIds ?? undefined} /> diff --git a/web/components/modules/dropdowns/filters/root.tsx b/web/components/modules/dropdowns/filters/root.tsx index 27c82f11cdf..30841a43a2b 100644 --- a/web/components/modules/dropdowns/filters/root.tsx +++ b/web/components/modules/dropdowns/filters/root.tsx @@ -3,18 +3,21 @@ import { observer } from "mobx-react-lite"; import { Search, X } from "lucide-react"; // components import { FilterLead, FilterMembers, FilterStartDate, FilterStatus, FilterTargetDate } from "components/modules"; +import { FilterOption } from "components/issues"; // types -import { TModuleFilters } from "@plane/types"; +import { TModuleDisplayFilters, TModuleFilters } from "@plane/types"; import { TModuleStatus } from "@plane/ui"; type Props = { + displayFilters: TModuleDisplayFilters; filters: TModuleFilters; + handleDisplayFiltersUpdate: (updatedDisplayProperties: Partial) => void; handleFiltersUpdate: (key: keyof TModuleFilters, value: string | string[]) => void; memberIds?: string[] | undefined; }; export const ModuleFiltersSelection: React.FC = observer((props) => { - const { filters, handleFiltersUpdate, memberIds } = props; + const { displayFilters, filters, handleDisplayFiltersUpdate, handleFiltersUpdate, memberIds } = props; // states const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); @@ -39,6 +42,18 @@ export const ModuleFiltersSelection: React.FC = observer((props) => {
+
+ + handleDisplayFiltersUpdate({ + favorites: !displayFilters.favorites, + }) + } + title="Favorites" + /> +
+ {/* status */}
{ // router const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId } = router.query; // store const { currentProjectDetails } = useProject(); - const { projectModuleIds, moduleMap, updateModuleDetails } = useModule(); + const { getFilteredModuleIds, moduleMap, updateModuleDetails } = useModule(); + // derived values + const filteredModuleIds = projectId ? getFilteredModuleIds(projectId.toString()) : undefined; const handleModuleUpdate = async (module: IModule, data: IBlockUpdateData) => { if (!workspaceSlug || !module) return; @@ -44,7 +46,7 @@ export const ModulesListGanttChartView: React.FC = observer(() => { } blockUpdateHandler={(block, payload) => handleModuleUpdate(block, payload)} blockToRender={(data: IModule) => } diff --git a/web/components/modules/modules-list-view.tsx b/web/components/modules/modules-list-view.tsx index 2df7a22de3b..2998843e1e1 100644 --- a/web/components/modules/modules-list-view.tsx +++ b/web/components/modules/modules-list-view.tsx @@ -1,3 +1,4 @@ +import Image from "next/image"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; // hooks @@ -7,6 +8,9 @@ import { ModuleCardItem, ModuleListItem, ModulePeekOverview, ModulesListGanttCha import { EmptyState } from "components/empty-state"; // ui import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui"; +// assets +import NameFilterImage from "public/empty-state/module/name-filter.svg"; +import AllFiltersImage from "public/empty-state/module/all-filters.svg"; // constants import { EmptyStateType } from "constants/empty-state"; @@ -18,7 +22,7 @@ export const ModulesListView: React.FC = observer(() => { const { commandPalette: commandPaletteStore } = useApplication(); const { setTrackElement } = useEventTracker(); const { getFilteredModuleIds, loader } = useModule(); - const { currentProjectDisplayFilters: displayFilters } = useModuleFilter(); + const { currentProjectDisplayFilters: displayFilters, searchQuery } = useModuleFilter(); // derived values const filteredModuleIds = projectId ? getFilteredModuleIds(projectId.toString()) : undefined; @@ -31,6 +35,25 @@ export const ModulesListView: React.FC = observer(() => { ); + if (filteredModuleIds.length === 0) + return ( +
+
+ No matching modules +
No matching modules
+

+ {searchQuery.trim() === "" + ? "Remove the filters to see all modules" + : "Remove the search criteria to see all modules"} +

+
+
+ ); + return ( <> {filteredModuleIds.length > 0 ? ( diff --git a/web/helpers/module.helper.ts b/web/helpers/module.helper.ts index 03d4b9768dd..7f7a523f680 100644 --- a/web/helpers/module.helper.ts +++ b/web/helpers/module.helper.ts @@ -2,7 +2,7 @@ import sortBy from "lodash/sortBy"; // helpers import { satisfiesDateFilter } from "helpers/filter.helper"; // types -import { IModule, TModuleFilters, TModuleOrderByOptions } from "@plane/types"; +import { IModule, TModuleDisplayFilters, TModuleFilters, TModuleOrderByOptions } from "@plane/types"; /** * @description orders modules based on their status @@ -12,43 +12,23 @@ import { IModule, TModuleFilters, TModuleOrderByOptions } from "@plane/types"; */ export const orderModules = (modules: IModule[], orderByKey: TModuleOrderByOptions | undefined): IModule[] => { let orderedModules: IModule[] = []; - if (modules.length === 0) return []; + if (modules.length === 0 || !orderByKey) return []; if (orderByKey === "name") orderedModules = sortBy(modules, [(m) => m.name.toLowerCase()]); if (orderByKey === "-name") orderedModules = sortBy(modules, [(m) => m.name.toLowerCase()]).reverse(); - if (orderByKey === "progress") + if (["progress", "-progress"].includes(orderByKey)) orderedModules = sortBy(modules, [ (m) => { - const totalIssues = - m.backlog_issues + m.unstarted_issues + m.started_issues + m.completed_issues + m.cancelled_issues; - const progress = (m.unstarted_issues + m.started_issues) / totalIssues; - return progress; + let progress = (m.completed_issues + m.cancelled_issues) / m.total_issues; + if (isNaN(progress)) progress = 0; + return orderByKey === "progress" ? progress : !progress; }, + "name", ]); - if (orderByKey === "-progress") + if (["issues_length", "-issues_length"].includes(orderByKey)) orderedModules = sortBy(modules, [ - (m) => { - const totalIssues = - m.backlog_issues + m.unstarted_issues + m.started_issues + m.completed_issues + m.cancelled_issues; - const progress = (m.unstarted_issues + m.started_issues) / totalIssues; - return !progress; - }, - ]); - if (orderByKey === "issues_length") - orderedModules = sortBy(modules, [ - (m) => { - const totalIssues = - m.backlog_issues + m.unstarted_issues + m.started_issues + m.completed_issues + m.cancelled_issues; - return totalIssues; - }, - ]); - if (orderByKey === "-issues_length") - orderedModules = sortBy(modules, [ - (m) => { - const totalIssues = - m.backlog_issues + m.unstarted_issues + m.started_issues + m.completed_issues + m.cancelled_issues; - return !totalIssues; - }, + (m) => (orderByKey === "issues_length" ? m.total_issues : !m.total_issues), + "name", ]); if (orderByKey === "target_date") orderedModules = sortBy(modules, [(m) => m.target_date]); if (orderByKey === "-target_date") orderedModules = sortBy(modules, [(m) => !m.target_date]); @@ -61,10 +41,15 @@ export const orderModules = (modules: IModule[], orderByKey: TModuleOrderByOptio /** * @description filters modules based on the filters * @param {IModule} module + * @param {TModuleDisplayFilters} displayFilters * @param {TModuleFilters} filters * @returns {boolean} */ -export const shouldFilterModule = (module: IModule, filters: TModuleFilters): boolean => { +export const shouldFilterModule = ( + module: IModule, + displayFilters: TModuleDisplayFilters, + filters: TModuleFilters +): boolean => { let fallsInFilters = true; Object.keys(filters).forEach((key) => { const filterKey = key as keyof TModuleFilters; @@ -89,6 +74,7 @@ export const shouldFilterModule = (module: IModule, filters: TModuleFilters): bo }); } }); + if (displayFilters.favorites && !module.is_favorite) fallsInFilters = false; return fallsInFilters; }; diff --git a/web/public/empty-state/module/all-filters.svg b/web/public/empty-state/module/all-filters.svg new file mode 100644 index 00000000000..6ba0731fe4d --- /dev/null +++ b/web/public/empty-state/module/all-filters.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/public/empty-state/module/name-filter.svg b/web/public/empty-state/module/name-filter.svg new file mode 100644 index 00000000000..0d9655b66b1 --- /dev/null +++ b/web/public/empty-state/module/name-filter.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/store/module.store.ts b/web/store/module.store.ts index 717765a8e6b..8b589a66fad 100644 --- a/web/store/module.store.ts +++ b/web/store/module.store.ts @@ -126,7 +126,7 @@ export class ModulesStore implements IModuleStore { (m) => m.project_id === projectId && m.name.toLowerCase().includes(searchQuery.toLowerCase()) && - shouldFilterModule(m, filters ?? {}) + shouldFilterModule(m, displayFilters ?? {}, filters ?? {}) ); modules = orderModules(modules, displayFilters?.order_by); const moduleIds = modules.map((m) => m.id); From 1b8719bf5a1fca99f978414f86b969ccf036289f Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Tue, 12 Mar 2024 19:41:55 +0530 Subject: [PATCH 5/5] fix: build errors --- web/components/modules/applied-filters/date.tsx | 4 ++-- web/components/modules/dropdowns/filters/lead.tsx | 3 +-- web/components/modules/dropdowns/filters/start-date.tsx | 6 ++++-- web/components/modules/dropdowns/filters/target-date.tsx | 6 ++++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/web/components/modules/applied-filters/date.tsx b/web/components/modules/applied-filters/date.tsx index e494bb46dac..42494bdbdf6 100644 --- a/web/components/modules/applied-filters/date.tsx +++ b/web/components/modules/applied-filters/date.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react-lite"; // icons import { X } from "lucide-react"; // helpers -import { DATE_FILTER_OPTIONS } from "constants/filters"; +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; import { renderFormattedDate } from "helpers/date-time.helper"; import { capitalizeFirstLetter } from "helpers/string.helper"; // constants @@ -19,7 +19,7 @@ export const AppliedDateFilters: React.FC = observer((props) => { const getDateLabel = (value: string): string => { let dateLabel = ""; - const dateDetails = DATE_FILTER_OPTIONS.find((d) => d.value === value); + const dateDetails = DATE_AFTER_FILTER_OPTIONS.find((d) => d.value === value); if (dateDetails) dateLabel = dateDetails.name; else { diff --git a/web/components/modules/dropdowns/filters/lead.tsx b/web/components/modules/dropdowns/filters/lead.tsx index 02c257b9be7..ffd4f8a2ec8 100644 --- a/web/components/modules/dropdowns/filters/lead.tsx +++ b/web/components/modules/dropdowns/filters/lead.tsx @@ -34,8 +34,7 @@ export const FilterLead: React.FC = observer((props: Props) => { (memberId) => !(appliedFilters ?? []).includes(memberId), (memberId) => getUserDetails(memberId)?.display_name.toLowerCase(), ]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchQuery]); + }, [appliedFilters, getUserDetails, memberIds, , searchQuery]); const handleViewToggle = () => { if (!sortedOptions) return; diff --git a/web/components/modules/dropdowns/filters/start-date.tsx b/web/components/modules/dropdowns/filters/start-date.tsx index 87def7e29f5..3c47eb28684 100644 --- a/web/components/modules/dropdowns/filters/start-date.tsx +++ b/web/components/modules/dropdowns/filters/start-date.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite"; import { DateFilterModal } from "components/core"; import { FilterHeader, FilterOption } from "components/issues"; // constants -import { DATE_FILTER_OPTIONS } from "constants/filters"; +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; type Props = { appliedFilters: string[] | null; @@ -21,7 +21,9 @@ export const FilterStartDate: React.FC = observer((props) => { const appliedFiltersCount = appliedFilters?.length ?? 0; - const filteredOptions = DATE_FILTER_OPTIONS.filter((d) => d.name.toLowerCase().includes(searchQuery.toLowerCase())); + const filteredOptions = DATE_AFTER_FILTER_OPTIONS.filter((d) => + d.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); return ( <> diff --git a/web/components/modules/dropdowns/filters/target-date.tsx b/web/components/modules/dropdowns/filters/target-date.tsx index e5860ba393f..d563dbe925c 100644 --- a/web/components/modules/dropdowns/filters/target-date.tsx +++ b/web/components/modules/dropdowns/filters/target-date.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite"; import { DateFilterModal } from "components/core"; import { FilterHeader, FilterOption } from "components/issues"; // constants -import { DATE_FILTER_OPTIONS } from "constants/filters"; +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; type Props = { appliedFilters: string[] | null; @@ -21,7 +21,9 @@ export const FilterTargetDate: React.FC = observer((props) => { const appliedFiltersCount = appliedFilters?.length ?? 0; - const filteredOptions = DATE_FILTER_OPTIONS.filter((d) => d.name.toLowerCase().includes(searchQuery.toLowerCase())); + const filteredOptions = DATE_AFTER_FILTER_OPTIONS.filter((d) => + d.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); return ( <>