diff --git a/packages/types/src/importer/index.d.ts b/packages/types/src/importer/index.d.ts index 877c07196..271d685b6 100644 --- a/packages/types/src/importer/index.d.ts +++ b/packages/types/src/importer/index.d.ts @@ -1,7 +1,7 @@ export * from "./github-importer"; export * from "./jira-importer"; -import { IProjectLite } from "../projects"; +import { IProjectLite } from "../project"; // types import { IUserLite } from "../users"; diff --git a/packages/types/src/inbox/inbox-types.d.ts b/packages/types/src/inbox/inbox-types.d.ts index 9db71c3ee..c3ec8461e 100644 --- a/packages/types/src/inbox/inbox-types.d.ts +++ b/packages/types/src/inbox/inbox-types.d.ts @@ -1,5 +1,5 @@ import { TIssue } from "../issues/base"; -import type { IProjectLite } from "../projects"; +import type { IProjectLite } from "../project"; export type TInboxIssueExtended = { completed_at: string | null; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index eeec266b5..6e6244451 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -2,7 +2,7 @@ export * from "./users"; export * from "./workspace"; export * from "./cycle"; export * from "./dashboard"; -export * from "./projects"; +export * from "./project"; export * from "./state"; export * from "./issues"; export * from "./modules"; diff --git a/packages/types/src/project/index.ts b/packages/types/src/project/index.ts new file mode 100644 index 000000000..ef7308bf7 --- /dev/null +++ b/packages/types/src/project/index.ts @@ -0,0 +1,2 @@ +export * from "./project_filters"; +export * from "./projects"; diff --git a/packages/types/src/project/project_filters.d.ts b/packages/types/src/project/project_filters.d.ts new file mode 100644 index 000000000..02ad09ee1 --- /dev/null +++ b/packages/types/src/project/project_filters.d.ts @@ -0,0 +1,25 @@ +export type TProjectOrderByOptions = + | "sort_order" + | "name" + | "-name" + | "created_at" + | "-created_at" + | "members_length" + | "-members_length"; + +export type TProjectDisplayFilters = { + my_projects?: boolean; + order_by?: TProjectOrderByOptions; +}; + +export type TProjectFilters = { + access?: string[] | null; + lead?: string[] | null; + members?: string[] | null; + created_at?: string[] | null; +}; + +export type TProjectStoredFilters = { + display_filters?: TProjectDisplayFilters; + filters?: TProjectFilters; +}; diff --git a/packages/types/src/projects.d.ts b/packages/types/src/project/projects.d.ts similarity index 99% rename from packages/types/src/projects.d.ts rename to packages/types/src/project/projects.d.ts index afae5199f..f310d9c66 100644 --- a/packages/types/src/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -7,7 +7,7 @@ import type { IWorkspace, IWorkspaceLite, TStateGroups, -} from "."; +} from ".."; export type TProjectLogoProps = { in_use: "emoji" | "icon"; diff --git a/web/components/cycles/applied-filters/date.tsx b/web/components/cycles/applied-filters/date.tsx index 0298f12d2..84ca45692 100644 --- a/web/components/cycles/applied-filters/date.tsx +++ b/web/components/cycles/applied-filters/date.tsx @@ -4,7 +4,7 @@ import { X } from "lucide-react"; import { renderFormattedDate } from "helpers/date-time.helper"; import { capitalizeFirstLetter } from "helpers/string.helper"; // constants -import { DATE_FILTER_OPTIONS } from "constants/filters"; +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; type Props = { editable: boolean | undefined; @@ -18,7 +18,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/cycles/dropdowns/filters/end-date.tsx b/web/components/cycles/dropdowns/filters/end-date.tsx index 10a401500..0af92da41 100644 --- a/web/components/cycles/dropdowns/filters/end-date.tsx +++ b/web/components/cycles/dropdowns/filters/end-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 FilterEndDate: 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/cycles/dropdowns/filters/start-date.tsx b/web/components/cycles/dropdowns/filters/start-date.tsx index 87def7e29..3c47eb286 100644 --- a/web/components/cycles/dropdowns/filters/start-date.tsx +++ b/web/components/cycles/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/headers/projects.tsx b/web/components/headers/projects.tsx index 3810860aa..81f77cdbb 100644 --- a/web/components/headers/projects.tsx +++ b/web/components/headers/projects.tsx @@ -1,26 +1,81 @@ +import { useCallback, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; -import { Search, Plus, Briefcase } from "lucide-react"; +import { Search, Plus, Briefcase, X, ListFilter } from "lucide-react"; // hooks -// ui -import { Breadcrumbs, Button } from "@plane/ui"; -// constants +import { useApplication, useEventTracker, useMember, useProject, useProjectFilter, 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"; +// ui +import { Breadcrumbs, Button } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +// constants import { EUserWorkspaceRoles } from "constants/workspace"; -// components -import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; +import { FiltersDropdown } from "components/issues"; +import { ProjectFiltersSelection, ProjectOrderByDropdown } from "components/project"; +import { TProjectFilters } from "@plane/types"; export const ProjectsHeader = observer(() => { + // states + const [isSearchOpen, setIsSearchOpen] = useState(false); + // refs + const inputRef = useRef(null); // store hooks - const { commandPalette: commandPaletteStore } = useApplication(); + const { + commandPalette: commandPaletteStore, + router: { workspaceSlug }, + } = useApplication(); const { setTrackElement } = useEventTracker(); const { membership: { currentWorkspaceRole }, } = useUser(); - const { workspaceProjectIds, searchQuery, setSearchQuery } = useProject(); - + const { workspaceProjectIds } = useProject(); + const { + currentWorkspaceDisplayFilters: displayFilters, + currentWorkspaceFilters: filters, + updateFilters, + updateDisplayFilters, + searchQuery, + updateSearchQuery, + } = useProjectFilter(); + const { + workspace: { workspaceMemberIds }, + } = useMember(); + // outside click detector hook + useOutsideClickDetector(inputRef, () => { + if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); + }); + // auth const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; + const handleFilters = useCallback( + (key: keyof TProjectFilters, value: string | string[]) => { + if (!workspaceSlug) 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(workspaceSlug, { [key]: newValues }); + }, + [filters, updateFilters, workspaceSlug] + ); + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); + else setIsSearchOpen(false); + } + }; + return (
@@ -34,18 +89,74 @@ export const ProjectsHeader = observer(() => {
-
+
{workspaceProjectIds && workspaceProjectIds?.length > 0 && ( -
- - setSearchQuery(e.target.value)} - placeholder="Search" - /> +
+ {!isSearchOpen && ( + + )} +
+ + updateSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + {isSearchOpen && ( + + )} +
)} + { + if (!workspaceSlug || val === displayFilters?.order_by) return; + updateDisplayFilters(workspaceSlug, { + order_by: val, + }); + }} + /> + } title="Filters" placement="bottom-end"> + { + if (!workspaceSlug) return; + updateDisplayFilters(workspaceSlug, val); + }} + memberIds={workspaceMemberIds ?? undefined} + /> + {isAuthorizedUser && ( )}
diff --git a/web/components/issues/issue-layouts/filters/applied-filters/date.tsx b/web/components/issues/issue-layouts/filters/applied-filters/date.tsx index fdaed4b9b..24a197c76 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/date.tsx +++ b/web/components/issues/issue-layouts/filters/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 @@ -18,7 +18,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/issues/issue-layouts/filters/header/filters/start-date.tsx b/web/components/issues/issue-layouts/filters/header/filters/start-date.tsx index 87def7e29..3c47eb286 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/start-date.tsx +++ b/web/components/issues/issue-layouts/filters/header/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/issues/issue-layouts/filters/header/filters/target-date.tsx b/web/components/issues/issue-layouts/filters/header/filters/target-date.tsx index 9e0ce18a7..83a526351 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/target-date.tsx +++ b/web/components/issues/issue-layouts/filters/header/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 ( <> diff --git a/web/components/project/applied-filters/access.tsx b/web/components/project/applied-filters/access.tsx new file mode 100644 index 000000000..bdb6ec053 --- /dev/null +++ b/web/components/project/applied-filters/access.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react-lite"; +import { X } from "lucide-react"; +// constants +import { NETWORK_CHOICES } from "constants/project"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; + editable: boolean | undefined; +}; + +export const AppliedAccessFilters: React.FC = observer((props) => { + const { handleRemove, values, editable } = props; + + return ( + <> + {values.map((status) => { + const accessDetails = NETWORK_CHOICES.find((s) => `${s.key}` === status); + return ( +
+ {accessDetails?.label} + {editable && ( + + )} +
+ ); + })} + + ); +}); diff --git a/web/components/project/applied-filters/date.tsx b/web/components/project/applied-filters/date.tsx new file mode 100644 index 000000000..aab0cf98a --- /dev/null +++ b/web/components/project/applied-filters/date.tsx @@ -0,0 +1,55 @@ +import { observer } from "mobx-react-lite"; +import { X } from "lucide-react"; +// helpers +import { renderFormattedDate } from "helpers/date-time.helper"; +import { capitalizeFirstLetter } from "helpers/string.helper"; +// constants +import { DATE_BEFORE_FILTER_OPTIONS } from "constants/filters"; + +type Props = { + editable: boolean | undefined; + handleRemove: (val: string) => void; + values: string[]; +}; + +export const AppliedDateFilters: React.FC = observer((props) => { + const { editable, handleRemove, values } = props; + + const getDateLabel = (value: string): string => { + let dateLabel = ""; + + const dateDetails = DATE_BEFORE_FILTER_OPTIONS.find((d) => d.value === value); + + if (dateDetails) dateLabel = dateDetails.name; + else { + const dateParts = value.split(";"); + + if (dateParts.length === 2) { + const [date, time] = dateParts; + + dateLabel = `${capitalizeFirstLetter(time)} ${renderFormattedDate(date)}`; + } + } + + return dateLabel; + }; + + return ( + <> + {values.map((date) => ( +
+ {getDateLabel(date)} + {editable && ( + + )} +
+ ))} + + ); +}); diff --git a/web/components/project/applied-filters/index.ts b/web/components/project/applied-filters/index.ts new file mode 100644 index 000000000..818aa6134 --- /dev/null +++ b/web/components/project/applied-filters/index.ts @@ -0,0 +1,4 @@ +export * from "./access"; +export * from "./date"; +export * from "./members"; +export * from "./root"; diff --git a/web/components/project/applied-filters/members.tsx b/web/components/project/applied-filters/members.tsx new file mode 100644 index 000000000..88f18ee0c --- /dev/null +++ b/web/components/project/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/project/applied-filters/root.tsx b/web/components/project/applied-filters/root.tsx new file mode 100644 index 000000000..6e1cbf6a7 --- /dev/null +++ b/web/components/project/applied-filters/root.tsx @@ -0,0 +1,113 @@ +import { X } from "lucide-react"; +// components +import { AppliedAccessFilters, AppliedDateFilters, AppliedMembersFilters } from "components/project"; +// ui +import { Tooltip } from "@plane/ui"; +// helpers +import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; +// types +import { TProjectFilters } from "@plane/types"; + +type Props = { + appliedFilters: TProjectFilters; + handleClearAllFilters: () => void; + handleRemoveFilter: (key: keyof TProjectFilters, value: string | null) => void; + alwaysAllowEditing?: boolean; + filteredProjects: number; + totalProjects: number; +}; + +const MEMBERS_FILTERS = ["lead", "members"]; +const DATE_FILTERS = ["created_at"]; + +export const ProjectAppliedFiltersList: React.FC = (props) => { + const { + appliedFilters, + handleClearAllFilters, + handleRemoveFilter, + alwaysAllowEditing, + filteredProjects, + totalProjects, + } = 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 TProjectFilters; + + if (!value) return; + if (Array.isArray(value) && value.length === 0) return; + + return ( +
+
+ {replaceUnderscoreIfSnakeCase(filterKey)} + {filterKey === "access" && ( + handleRemoveFilter("access", val)} + values={value} + /> + )} + {DATE_FILTERS.includes(filterKey) && ( + handleRemoveFilter(filterKey, val)} + values={value} + /> + )} + {MEMBERS_FILTERS.includes(filterKey) && ( + handleRemoveFilter(filterKey, val)} + values={value} + /> + )} + {isEditingAllowed && ( + + )} +
+
+ ); + })} + {isEditingAllowed && ( + + )} +
+ + {filteredProjects} of{" "} + {totalProjects} projects match the applied filters. +

+ } + > + + {filteredProjects}/{totalProjects} + +
+
+ ); +}; diff --git a/web/components/project/card-list.tsx b/web/components/project/card-list.tsx index df63dfb73..3f23ed9a2 100644 --- a/web/components/project/card-list.tsx +++ b/web/components/project/card-list.tsx @@ -1,10 +1,14 @@ +import Image from "next/image"; import { observer } from "mobx-react-lite"; // hooks -import { useApplication, useEventTracker, useProject } from "hooks/store"; +import { useApplication, useEventTracker, useProject, useProjectFilter } from "hooks/store"; // components import { EmptyState } from "components/empty-state"; import { ProjectCard } from "components/project"; import { ProjectsLoader } from "components/ui"; +// assets +import AllFiltersImage from "public/empty-state/project/all-filters.svg"; +import NameFilterImage from "public/empty-state/project/name-filter.svg"; // constants import { EmptyStateType } from "constants/empty-state"; @@ -12,38 +16,49 @@ export const ProjectCardList = observer(() => { // store hooks const { commandPalette: commandPaletteStore } = useApplication(); const { setTrackElement } = useEventTracker(); + const { workspaceProjectIds, filteredProjectIds, getProjectById } = useProject(); + const { searchQuery } = useProjectFilter(); - const { workspaceProjectIds, searchedProjects, getProjectById } = useProject(); + if (!filteredProjectIds) return ; - if (!workspaceProjectIds) return ; + if (workspaceProjectIds?.length === 0) + return ( + { + setTrackElement("Project empty state"); + commandPaletteStore.toggleCreateProjectModal(true); + }} + /> + ); + if (filteredProjectIds.length === 0) + return ( +
+
+ No matching projects +
No matching projects
+

+ {searchQuery.trim() === "" + ? "Remove the filters to see all projects" + : "No projects detected with the matching\ncriteria. Create a new project instead"} +

+
+
+ ); return ( - <> - {workspaceProjectIds.length > 0 ? ( -
- {searchedProjects.length == 0 ? ( -
No matching projects
- ) : ( -
- {searchedProjects.map((projectId) => { - const projectDetails = getProjectById(projectId); - - if (!projectDetails) return; - - return ; - })} -
- )} -
- ) : ( - { - setTrackElement("Project empty state"); - commandPaletteStore.toggleCreateProjectModal(true); - }} - /> - )} - +
+
+ {filteredProjectIds.map((projectId) => { + const projectDetails = getProjectById(projectId); + if (!projectDetails) return; + return ; + })} +
+
); }); diff --git a/web/components/project/card.tsx b/web/components/project/card.tsx index 08aec43fa..d7562a58f 100644 --- a/web/components/project/card.tsx +++ b/web/components/project/card.tsx @@ -2,36 +2,38 @@ import React, { useState } from "react"; import { observer } from "mobx-react-lite"; import Link from "next/link"; import { useRouter } from "next/router"; -import { LinkIcon, Lock, Pencil, Star } from "lucide-react"; +import { Check, LinkIcon, Lock, Pencil, Star } from "lucide-react"; // ui import { Avatar, AvatarGroup, Button, Tooltip, TOAST_TYPE, setToast, setPromiseToast } from "@plane/ui"; // components import { DeleteProjectModal, JoinProjectModal, ProjectLogo } from "components/project"; // helpers -import { copyTextToClipboard } from "helpers/string.helper"; +import { copyUrlToClipboard } from "helpers/string.helper"; +import { renderFormattedDate } from "helpers/date-time.helper"; // hooks import { useProject } from "hooks/store"; // types import type { IProject } from "@plane/types"; -import { EUserProjectRoles } from "constants/project"; // constants +import { EUserProjectRoles } from "constants/project"; -export type ProjectCardProps = { +type Props = { project: IProject; }; -export const ProjectCard: React.FC = observer((props) => { +export const ProjectCard: React.FC = observer((props) => { const { project } = props; - // router - const router = useRouter(); - const { workspaceSlug } = router.query; // states const [deleteProjectModalOpen, setDeleteProjectModal] = useState(false); const [joinProjectModalOpen, setJoinProjectModal] = useState(false); + // router + const router = useRouter(); + const { workspaceSlug } = router.query; // store hooks const { addProjectToFavorites, removeProjectFromFavorites } = useProject(); - - project.member_role; + // derived values + const projectMembersIds = project.members?.map((member) => member.member_id); + // auth const isOwner = project.member_role === EUserProjectRoles.ADMIN; const isMember = project.member_role === EUserProjectRoles.MEMBER; @@ -53,7 +55,7 @@ export const ProjectCard: React.FC = observer((props) => { }; const handleRemoveFromFavorites = () => { - if (!workspaceSlug || !project) return; + if (!workspaceSlug) return; const removeFromFavoritePromise = removeProjectFromFavorites(workspaceSlug.toString(), project.id); setPromiseToast(removeFromFavoritePromise, { @@ -69,23 +71,18 @@ export const ProjectCard: React.FC = observer((props) => { }); }; - const handleCopyText = () => { - const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - - copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${project.id}/issues`).then(() => { + const handleCopyText = () => + copyUrlToClipboard(`${workspaceSlug}/projects/${project.id}/issues`).then(() => setToast({ type: TOAST_TYPE.SUCCESS, title: "Link Copied!", message: "Project link copied to clipboard.", - }); - }); - }; - - const projectMembersIds = project.members?.map((member) => member.member_id); + }) + ); return ( <> - {/* Delete Project Modal */} + {/* Delete Project Modal */} = observer((props) => { {/* Join Project Modal */} {workspaceSlug && ( setJoinProjectModal(false)} /> )} - - {/* Card Information */} -
{ - if (project.is_member) router.push(`/${workspaceSlug?.toString()}/projects/${project.id}/issues`); - else setJoinProjectModal(true); + { + if (!project.is_member) { + e.preventDefault(); + e.stopPropagation(); + setJoinProjectModal(true); + } }} - className="flex cursor-pointer flex-col rounded border border-custom-border-200 bg-custom-background-100" + className="flex flex-col rounded border border-custom-border-200 bg-custom-background-100" >
@@ -121,12 +120,10 @@ export const ProjectCard: React.FC = observer((props) => { className="absolute left-0 top-0 h-full w-full rounded-t object-cover" /> -
+
-
- - - +
+
@@ -152,15 +149,10 @@ export const ProjectCard: React.FC = observer((props) => {
-

{project.description}

+

+ {project.description && project.description.trim() !== "" + ? project.description + : `Created on ${renderFormattedDate(project.created_at)}`} +

= observer((props) => { No Member Yet )} - {(isOwner || isMember) && ( - { - e.stopPropagation(); - }} - href={`/${workspaceSlug}/projects/${project.id}/settings`} - > - - - )} - - {!project.is_member ? ( + {project.is_member && + (isOwner || isMember ? ( + { + e.stopPropagation(); + }} + href={`/${workspaceSlug}/projects/${project.id}/settings`} + > + + + ) : ( + + + Joined + + ))} + {!project.is_member && (
- ) : null} + )}
-
+ ); }); diff --git a/web/components/project/dropdowns/filters/access.tsx b/web/components/project/dropdowns/filters/access.tsx new file mode 100644 index 000000000..63303872b --- /dev/null +++ b/web/components/project/dropdowns/filters/access.tsx @@ -0,0 +1,48 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +// constants +import { NETWORK_CHOICES } from "constants/project"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterAccess: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + // states + const [previewEnabled, setPreviewEnabled] = useState(true); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + const filteredOptions = NETWORK_CHOICES.filter((a) => a.label.includes(searchQuery.toLowerCase())); + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((access) => ( + handleUpdate(`${access.key}`)} + icon={} + title={access.label} + /> + )) + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/project/dropdowns/filters/created-at.tsx b/web/components/project/dropdowns/filters/created-at.tsx new file mode 100644 index 000000000..3867ab148 --- /dev/null +++ b/web/components/project/dropdowns/filters/created-at.tsx @@ -0,0 +1,64 @@ +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_BEFORE_FILTER_OPTIONS } from "constants/filters"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string | string[]) => void; + searchQuery: string; +}; + +export const FilterCreatedDate: 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_BEFORE_FILTER_OPTIONS.filter((d) => + d.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( + <> + {isDateFilterModalOpen && ( + setIsDateFilterModalOpen(false)} + isOpen={isDateFilterModalOpen} + onSelect={(val) => handleUpdate(val)} + title="Created 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/project/dropdowns/filters/index.ts b/web/components/project/dropdowns/filters/index.ts new file mode 100644 index 000000000..c04162e57 --- /dev/null +++ b/web/components/project/dropdowns/filters/index.ts @@ -0,0 +1,5 @@ +export * from "./access"; +export * from "./created-at"; +export * from "./lead"; +export * from "./members"; +export * from "./root"; diff --git a/web/components/project/dropdowns/filters/lead.tsx b/web/components/project/dropdowns/filters/lead.tsx new file mode 100644 index 000000000..02c257b9b --- /dev/null +++ b/web/components/project/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/project/dropdowns/filters/members.tsx b/web/components/project/dropdowns/filters/members.tsx new file mode 100644 index 000000000..0d2737227 --- /dev/null +++ b/web/components/project/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/project/dropdowns/filters/root.tsx b/web/components/project/dropdowns/filters/root.tsx new file mode 100644 index 000000000..e79fc8418 --- /dev/null +++ b/web/components/project/dropdowns/filters/root.tsx @@ -0,0 +1,96 @@ +import { useState } from "react"; +import { observer } from "mobx-react-lite"; +import { Search, X } from "lucide-react"; +// components +import { FilterAccess, FilterCreatedDate, FilterLead, FilterMembers } from "components/project"; +// types +import { TProjectDisplayFilters, TProjectFilters } from "@plane/types"; +import { FilterOption } from "components/issues"; + +type Props = { + displayFilters: TProjectDisplayFilters; + filters: TProjectFilters; + handleFiltersUpdate: (key: keyof TProjectFilters, value: string | string[]) => void; + handleDisplayFiltersUpdate: (updatedDisplayProperties: Partial) => void; + memberIds?: string[] | undefined; +}; + +export const ProjectFiltersSelection: React.FC = observer((props) => { + const { displayFilters, filters, handleFiltersUpdate, handleDisplayFiltersUpdate, memberIds } = props; + // states + const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); + + return ( +
+
+
+ + setFiltersSearchQuery(e.target.value)} + autoFocus + /> + {filtersSearchQuery !== "" && ( + + )} +
+
+
+
+ + handleDisplayFiltersUpdate({ + my_projects: !displayFilters.my_projects, + }) + } + title="My projects" + /> +
+ + {/* access */} +
+ handleFiltersUpdate("access", val)} + searchQuery={filtersSearchQuery} + /> +
+ + {/* lead */} +
+ handleFiltersUpdate("lead", val)} + searchQuery={filtersSearchQuery} + memberIds={memberIds} + /> +
+ + {/* members */} +
+ handleFiltersUpdate("members", val)} + searchQuery={filtersSearchQuery} + memberIds={memberIds} + /> +
+ + {/* created date */} +
+ handleFiltersUpdate("created_at", val)} + searchQuery={filtersSearchQuery} + /> +
+
+
+ ); +}); diff --git a/web/components/project/dropdowns/index.ts b/web/components/project/dropdowns/index.ts new file mode 100644 index 000000000..f6c42552f --- /dev/null +++ b/web/components/project/dropdowns/index.ts @@ -0,0 +1,2 @@ +export * from "./filters"; +export * from "./order-by"; diff --git a/web/components/project/dropdowns/order-by.tsx b/web/components/project/dropdowns/order-by.tsx new file mode 100644 index 000000000..ceb61997e --- /dev/null +++ b/web/components/project/dropdowns/order-by.tsx @@ -0,0 +1,74 @@ +import { ArrowDownWideNarrow, Check, ChevronDown } from "lucide-react"; +// ui +import { CustomMenu, getButtonStyling } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import { TProjectOrderByOptions } from "@plane/types"; +// constants +import { PROJECT_ORDER_BY_OPTIONS } from "constants/project"; + +type Props = { + onChange: (value: TProjectOrderByOptions) => void; + value: TProjectOrderByOptions | undefined; +}; + +const DISABLED_ORDERING_OPTIONS = ["sort_order"]; + +export const ProjectOrderByDropdown: React.FC = (props) => { + const { onChange, value } = props; + + const orderByDetails = PROJECT_ORDER_BY_OPTIONS.find((option) => value?.includes(option.key)); + + const isDescending = value?.[0] === "-"; + const isOrderingDisabled = !!value && DISABLED_ORDERING_OPTIONS.includes(value); + + return ( + + + {orderByDetails?.label} + +
+ } + placement="bottom-end" + closeOnSelect + > + {PROJECT_ORDER_BY_OPTIONS.map((option) => ( + { + if (isDescending) onChange(`-${option.key}` as TProjectOrderByOptions); + else onChange(option.key); + }} + > + {option.label} + {value?.includes(option.key) && } + + ))} +
+ { + if (isDescending) onChange(value.slice(1) as TProjectOrderByOptions); + }} + disabled={isOrderingDisabled} + > + Ascending + {!isOrderingDisabled && !isDescending && } + + { + if (!isDescending) onChange(`-${value}` as TProjectOrderByOptions); + }} + disabled={isOrderingDisabled} + > + Descending + {!isOrderingDisabled && isDescending && } + + + ); +}; diff --git a/web/components/project/index.ts b/web/components/project/index.ts index 6dedb63d4..db51bc284 100644 --- a/web/components/project/index.ts +++ b/web/components/project/index.ts @@ -1,3 +1,5 @@ +export * from "./applied-filters"; +export * from "./dropdowns"; export * from "./publish-project"; export * from "./settings"; export * from "./card-list"; diff --git a/web/components/views/view-list-item.tsx b/web/components/views/view-list-item.tsx index 29d5bac57..959bbe0dd 100644 --- a/web/components/views/view-list-item.tsx +++ b/web/components/views/view-list-item.tsx @@ -59,6 +59,7 @@ export const ProjectViewListItem: React.FC = observer((props) => { }); }; + // @ts-expect-error key types are not compatible const totalFilters = calculateTotalFilters(view.filters ?? {}); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; diff --git a/web/constants/filters.ts b/web/constants/filters.ts index e4131e451..6ac961bb9 100644 --- a/web/constants/filters.ts +++ b/web/constants/filters.ts @@ -1,4 +1,4 @@ -export const DATE_FILTER_OPTIONS = [ +export const DATE_AFTER_FILTER_OPTIONS = [ { name: "1 week from now", value: "1_weeks;after;fromnow", @@ -16,3 +16,18 @@ export const DATE_FILTER_OPTIONS = [ value: "2_months;after;fromnow", }, ]; + +export const DATE_BEFORE_FILTER_OPTIONS = [ + { + name: "1 week ago", + value: "1_weeks;before;fromnow", + }, + { + name: "2 weeks ago", + value: "2_weeks;before;fromnow", + }, + { + name: "1 month ago", + value: "1_months;before;fromnow", + }, +]; diff --git a/web/constants/project.ts b/web/constants/project.ts index 6073e96be..ba6b2c29c 100644 --- a/web/constants/project.ts +++ b/web/constants/project.ts @@ -3,6 +3,7 @@ import { Globe2, Lock, LucideIcon } from "lucide-react"; import { SettingIcon } from "components/icons"; // types import { Props } from "components/icons/types"; +import { TProjectOrderByOptions } from "@plane/types"; export enum EUserProjectRoles { GUEST = 5, @@ -39,23 +40,6 @@ export const GROUP_CHOICES = { cancelled: "Cancelled", }; -export const MONTHS = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", -]; - -export const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - export const PROJECT_AUTOMATION_MONTHS = [ { label: "1 month", value: 1 }, { label: "3 months", value: 3 }, @@ -156,3 +140,25 @@ export const PROJECT_SETTINGS_LINKS: { Icon: SettingIcon, }, ]; + +export const PROJECT_ORDER_BY_OPTIONS: { + key: TProjectOrderByOptions; + label: string; +}[] = [ + { + key: "sort_order", + label: "Manual", + }, + { + key: "name", + label: "Name", + }, + { + key: "created_at", + label: "Created date", + }, + { + key: "members_length", + label: "Number of members", + }, +]; diff --git a/web/helpers/filter.helper.ts b/web/helpers/filter.helper.ts index 3c34fa9da..d8804cabd 100644 --- a/web/helpers/filter.helper.ts +++ b/web/helpers/filter.helper.ts @@ -1,15 +1,22 @@ -import { differenceInCalendarDays } from "date-fns"; -// types -import { IIssueFilterOptions } from "@plane/types"; +import differenceInCalendarDays from "date-fns/differenceInCalendarDays"; -export const calculateTotalFilters = (filters: IIssueFilterOptions): number => +type TFilters = { + [key: string]: string[] | null; +}; + +/** + * @description calculates the total number of filters applied + * @param {TFilters} filters + * @returns {number} + */ +export const calculateTotalFilters = (filters: TFilters): number => filters && Object.keys(filters).length > 0 ? Object.keys(filters) .map((key) => - filters[key as keyof IIssueFilterOptions] !== null - ? isNaN((filters[key as keyof IIssueFilterOptions] as string[]).length) + filters[key as keyof TFilters] !== null + ? isNaN((filters[key as keyof TFilters] as string[]).length) ? 0 - : (filters[key as keyof IIssueFilterOptions] as string[]).length + : (filters[key as keyof TFilters] as string[]).length : 0 ) .reduce((curr, prev) => curr + prev, 0) @@ -30,6 +37,12 @@ export const satisfiesDateFilter = (date: Date, filter: string): boolean => { } if (from === "fromnow") { + if (operator === "before") { + if (value === "1_weeks") return differenceInCalendarDays(date, new Date()) <= -7; + if (value === "2_weeks") return differenceInCalendarDays(date, new Date()) <= -14; + if (value === "1_months") return differenceInCalendarDays(date, new Date()) <= -30; + } + if (operator === "after") { if (value === "1_weeks") return differenceInCalendarDays(date, new Date()) >= 7; if (value === "2_weeks") return differenceInCalendarDays(date, new Date()) >= 14; diff --git a/web/helpers/project.helper.ts b/web/helpers/project.helper.ts index 441c14a42..ba0d52742 100644 --- a/web/helpers/project.helper.ts +++ b/web/helpers/project.helper.ts @@ -1,4 +1,8 @@ -import { IProject } from "@plane/types"; +import sortBy from "lodash/sortBy"; +// helpers +import { satisfiesDateFilter } from "helpers/filter.helper"; +// types +import { IProject, TProjectDisplayFilters, TProjectFilters, TProjectOrderByOptions } from "@plane/types"; /** * Updates the sort order of the project. @@ -46,3 +50,58 @@ export const orderJoinedProjects = ( export const projectIdentifierSanitizer = (identifier: string): string => identifier.replace(/[^ÇŞĞIİÖÜA-Za-z0-9]/g, ""); + +/** + * @description filters projects based on the filter + * @param {IProject} project + * @param {TProjectFilters} filters + * @param {TProjectDisplayFilters} displayFilters + * @returns {boolean} + */ +export const shouldFilterProject = ( + project: IProject, + displayFilters: TProjectDisplayFilters, + filters: TProjectFilters +): boolean => { + let fallsInFilters = true; + Object.keys(filters).forEach((key) => { + const filterKey = key as keyof TProjectFilters; + if (filterKey === "access" && filters.access && filters.access.length > 0) + fallsInFilters = fallsInFilters && filters.access.includes(`${project.network}`); + if (filterKey === "lead" && filters.lead && filters.lead.length > 0) + fallsInFilters = fallsInFilters && filters.lead.includes(`${project.project_lead}`); + if (filterKey === "members" && filters.members && filters.members.length > 0) { + const memberIds = project.members.map((member) => member.member_id); + fallsInFilters = fallsInFilters && filters.members.some((memberId) => memberIds.includes(memberId)); + } + if (filterKey === "created_at" && filters.created_at && filters.created_at.length > 0) { + filters.created_at.forEach((dateFilter) => { + fallsInFilters = fallsInFilters && satisfiesDateFilter(new Date(project.created_at), dateFilter); + }); + } + }); + if (displayFilters.my_projects && !project.is_member) fallsInFilters = false; + + return fallsInFilters; +}; + +/** + * @description orders projects based on the orderByKey + * @param {IProject[]} projects + * @param {TProjectOrderByOptions | undefined} orderByKey + * @returns {IProject[]} + */ +export const orderProjects = (projects: IProject[], orderByKey: TProjectOrderByOptions | undefined): IProject[] => { + let orderedProjects: IProject[] = []; + if (projects.length === 0) return orderedProjects; + + if (orderByKey === "sort_order") orderedProjects = sortBy(projects, [(p) => p.sort_order]); + if (orderByKey === "name") orderedProjects = sortBy(projects, [(p) => p.name.toLowerCase()]); + if (orderByKey === "-name") orderedProjects = sortBy(projects, [(p) => p.name.toLowerCase()]).reverse(); + if (orderByKey === "created_at") orderedProjects = sortBy(projects, [(p) => p.created_at]); + if (orderByKey === "-created_at") orderedProjects = sortBy(projects, [(p) => !p.created_at]); + if (orderByKey === "members_length") orderedProjects = sortBy(projects, [(p) => p.members.length]); + if (orderByKey === "-members_length") orderedProjects = sortBy(projects, [(p) => p.members.length]).reverse(); + + return orderedProjects; +}; diff --git a/web/hooks/store/index.ts b/web/hooks/store/index.ts index 3ec5c97bf..f67e5cb3a 100644 --- a/web/hooks/store/index.ts +++ b/web/hooks/store/index.ts @@ -11,6 +11,7 @@ export * from "./use-member"; export * from "./use-mention"; export * from "./use-module"; export * from "./use-page"; +export * from "./use-project-filter"; export * from "./use-project-publish"; export * from "./use-project-state"; export * from "./use-project-view"; diff --git a/web/hooks/store/use-project-filter.ts b/web/hooks/store/use-project-filter.ts new file mode 100644 index 000000000..9aebe55d9 --- /dev/null +++ b/web/hooks/store/use-project-filter.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// mobx store +import { StoreContext } from "contexts/store-context"; +// types +import { IProjectFilterStore } from "store/project/project_filter.store"; + +export const useProjectFilter = (): IProjectFilterStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useProjectFilter must be used within StoreProvider"); + return context.projectRoot.projectFilter; +}; diff --git a/web/pages/[workspaceSlug]/projects/index.tsx b/web/pages/[workspaceSlug]/projects/index.tsx index 158e6577f..e941bd8cb 100644 --- a/web/pages/[workspaceSlug]/projects/index.tsx +++ b/web/pages/[workspaceSlug]/projects/index.tsx @@ -1,25 +1,60 @@ -import { ReactElement } from "react"; +import { ReactElement, useCallback } from "react"; import { observer } from "mobx-react"; // components import { PageHead } from "components/core"; import { ProjectsHeader } from "components/headers"; -import { ProjectCardList } from "components/project"; +import { ProjectAppliedFiltersList, ProjectCardList } from "components/project"; // layouts -import { useWorkspace } from "hooks/store"; +import { useApplication, useProject, useProjectFilter, useWorkspace } from "hooks/store"; import { AppLayout } from "layouts/app-layout"; -// type +// helpers +import { calculateTotalFilters } from "helpers/filter.helper"; +// types import { NextPageWithLayout } from "lib/types"; +import { TProjectFilters } from "@plane/types"; const ProjectsPage: NextPageWithLayout = observer(() => { // store + const { + router: { workspaceSlug }, + } = useApplication(); const { currentWorkspace } = useWorkspace(); + const { workspaceProjectIds, filteredProjectIds } = useProject(); + const { currentWorkspaceFilters, clearAllFilters, updateFilters } = useProjectFilter(); // derived values const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Projects` : undefined; + const handleRemoveFilter = useCallback( + (key: keyof TProjectFilters, value: string | null) => { + if (!workspaceSlug) return; + let newValues = currentWorkspaceFilters?.[key] ?? []; + + if (!value) newValues = []; + else newValues = newValues.filter((val) => val !== value); + + updateFilters(workspaceSlug.toString(), { [key]: newValues }); + }, + [currentWorkspaceFilters, updateFilters, workspaceSlug] + ); + return ( <> - +
+ {calculateTotalFilters(currentWorkspaceFilters ?? {}) !== 0 && ( +
+ clearAllFilters(`${workspaceSlug}`)} + handleRemoveFilter={handleRemoveFilter} + filteredProjects={filteredProjectIds?.length ?? 0} + totalProjects={workspaceProjectIds?.length ?? 0} + alwaysAllowEditing + /> +
+ )} + +
); }); diff --git a/web/public/empty-state/project/all-filters.svg b/web/public/empty-state/project/all-filters.svg new file mode 100644 index 000000000..0280bcf2d --- /dev/null +++ b/web/public/empty-state/project/all-filters.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/public/empty-state/project/name-filter.svg b/web/public/empty-state/project/name-filter.svg new file mode 100644 index 000000000..a1e89c9a0 --- /dev/null +++ b/web/public/empty-state/project/name-filter.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/store/project/index.ts b/web/store/project/index.ts index dff0db175..87b0dac19 100644 --- a/web/store/project/index.ts +++ b/web/store/project/index.ts @@ -1,18 +1,22 @@ import { RootStore } from "store/root.store"; import { IProjectPublishStore, ProjectPublishStore } from "./project-publish.store"; import { IProjectStore, ProjectStore } from "./project.store"; +import { IProjectFilterStore, ProjectFilterStore } from "./project_filter.store"; export interface IProjectRootStore { project: IProjectStore; + projectFilter: IProjectFilterStore; publish: IProjectPublishStore; } export class ProjectRootStore { project: IProjectStore; + projectFilter: IProjectFilterStore; publish: IProjectPublishStore; constructor(_root: RootStore) { this.project = new ProjectStore(_root); + this.projectFilter = new ProjectFilterStore(_root); this.publish = new ProjectPublishStore(this); } } diff --git a/web/store/project/project.store.ts b/web/store/project/project.store.ts index 1b9220a2d..4f181ec34 100644 --- a/web/store/project/project.store.ts +++ b/web/store/project/project.store.ts @@ -1,4 +1,3 @@ -import { cloneDeep, update } from "lodash"; import set from "lodash/set"; import sortBy from "lodash/sortBy"; import { observable, action, computed, makeObservable, runInAction } from "mobx"; @@ -8,40 +7,38 @@ import { IssueLabelService, IssueService } from "services/issue"; import { ProjectService, ProjectStateService } from "services/project"; import { IProject } from "@plane/types"; import { RootStore } from "../root.store"; +import { orderProjects, shouldFilterProject } from "helpers/project.helper"; // services export interface IProjectStore { // observables - searchQuery: string; projectMap: { [projectId: string]: IProject; // projectId: project Info }; // computed - searchedProjects: string[]; - workspaceProjectIds: string[] | null; + filteredProjectIds: string[] | undefined; + workspaceProjectIds: string[] | undefined; joinedProjectIds: string[]; favoriteProjectIds: string[]; currentProjectDetails: IProject | undefined; // actions - setSearchQuery: (query: string) => void; getProjectById: (projectId: string) => IProject | null; getProjectIdentifierById: (projectId: string) => string; // fetch actions fetchProjects: (workspaceSlug: string) => Promise; - fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise; + fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise; // favorites actions addProjectToFavorites: (workspaceSlug: string, projectId: string) => Promise; removeProjectFromFavorites: (workspaceSlug: string, projectId: string) => Promise; // project-view action updateProjectView: (workspaceSlug: string, projectId: string, viewProps: any) => Promise; // CRUD actions - createProject: (workspaceSlug: string, data: any) => Promise; - updateProject: (workspaceSlug: string, projectId: string, data: Partial) => Promise; + createProject: (workspaceSlug: string, data: Partial) => Promise; + updateProject: (workspaceSlug: string, projectId: string, data: Partial) => Promise; deleteProject: (workspaceSlug: string, projectId: string) => Promise; } export class ProjectStore implements IProjectStore { // observables - searchQuery: string = ""; projectMap: { [projectId: string]: IProject; // projectId: project Info } = {}; @@ -56,16 +53,13 @@ export class ProjectStore implements IProjectStore { constructor(_rootStore: RootStore) { makeObservable(this, { // observables - searchQuery: observable.ref, projectMap: observable, // computed - searchedProjects: computed, + filteredProjectIds: computed, workspaceProjectIds: computed, currentProjectDetails: computed, joinedProjectIds: computed, favoriteProjectIds: computed, - // actions - setSearchQuery: action.bound, // fetch actions fetchProjects: action, fetchProjectDetails: action, @@ -88,17 +82,24 @@ export class ProjectStore implements IProjectStore { } /** - * Returns searched projects based on search query + * @description returns filtered projects based on filters and search query */ - get searchedProjects() { + get filteredProjectIds() { const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace; - if (!workspaceDetails) return []; - const workspaceProjects = Object.values(this.projectMap).filter( + const { + currentWorkspaceDisplayFilters: displayFilters, + currentWorkspaceFilters: filters, + searchQuery, + } = this.rootStore.projectRoot.projectFilter; + if (!workspaceDetails || !displayFilters || !filters) return; + let workspaceProjects = Object.values(this.projectMap).filter( (p) => p.workspace === workspaceDetails.id && - (p.name.toLowerCase().includes(this.searchQuery.toLowerCase()) || - p.identifier.toLowerCase().includes(this.searchQuery.toLowerCase())) + (p.name.toLowerCase().includes(searchQuery.toLowerCase()) || + p.identifier.toLowerCase().includes(searchQuery.toLowerCase())) && + shouldFilterProject(p, displayFilters, filters) ); + workspaceProjects = orderProjects(workspaceProjects, displayFilters.order_by); return workspaceProjects.map((p) => p.id); } @@ -107,7 +108,7 @@ export class ProjectStore implements IProjectStore { */ get workspaceProjectIds() { const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace; - if (!workspaceDetails) return null; + if (!workspaceDetails) return; const workspaceProjects = Object.values(this.projectMap).filter((p) => p.workspace === workspaceDetails.id); const projectIds = workspaceProjects.map((p) => p.id); return projectIds ?? null; @@ -153,14 +154,6 @@ export class ProjectStore implements IProjectStore { return projectIds; } - /** - * Sets search query - * @param query - */ - setSearchQuery = (query: string) => { - this.searchQuery = query; - }; - /** * get Workspace projects using workspace slug * @param workspaceSlug diff --git a/web/store/project/project_filter.store.ts b/web/store/project/project_filter.store.ts new file mode 100644 index 000000000..35eac1f62 --- /dev/null +++ b/web/store/project/project_filter.store.ts @@ -0,0 +1,144 @@ +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 { TProjectDisplayFilters, TProjectFilters } from "@plane/types"; + +export interface IProjectFilterStore { + // observables + displayFilters: Record; + filters: Record; + searchQuery: string; + // computed + currentWorkspaceDisplayFilters: TProjectDisplayFilters | undefined; + currentWorkspaceFilters: TProjectFilters | undefined; + // computed functions + getDisplayFiltersByWorkspaceSlug: (workspaceSlug: string) => TProjectDisplayFilters | undefined; + getFiltersByWorkspaceSlug: (workspaceSlug: string) => TProjectFilters | undefined; + // actions + updateDisplayFilters: (workspaceSlug: string, displayFilters: TProjectDisplayFilters) => void; + updateFilters: (workspaceSlug: string, filters: TProjectFilters) => void; + updateSearchQuery: (query: string) => void; + clearAllFilters: (workspaceSlug: string) => void; +} + +export class ProjectFilterStore implements IProjectFilterStore { + // 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 + currentWorkspaceDisplayFilters: computed, + currentWorkspaceFilters: computed, + // actions + updateDisplayFilters: action, + updateFilters: action, + updateSearchQuery: action, + clearAllFilters: action, + }); + // root store + this.rootStore = _rootStore; + // initialize display filters of the current workspace + autorun(() => { + const workspaceSlug = this.rootStore.app.router.workspaceSlug; + if (!workspaceSlug) return; + this.initWorkspaceFilters(workspaceSlug); + }); + } + + /** + * @description get display filters of the current workspace + */ + get currentWorkspaceDisplayFilters() { + const workspaceSlug = this.rootStore.app.router.workspaceSlug; + if (!workspaceSlug) return; + return this.displayFilters[workspaceSlug]; + } + + /** + * @description get filters of the current workspace + */ + get currentWorkspaceFilters() { + const workspaceSlug = this.rootStore.app.router.workspaceSlug; + if (!workspaceSlug) return; + return this.filters[workspaceSlug]; + } + + /** + * @description get display filters of a workspace by workspaceSlug + * @param {string} workspaceSlug + */ + getDisplayFiltersByWorkspaceSlug = computedFn((workspaceSlug: string) => this.displayFilters[workspaceSlug]); + + /** + * @description get filters of a workspace by workspaceSlug + * @param {string} workspaceSlug + */ + getFiltersByWorkspaceSlug = computedFn((workspaceSlug: string) => this.filters[workspaceSlug]); + + /** + * @description initialize display filters and filters of a workspace + * @param {string} workspaceSlug + */ + initWorkspaceFilters = (workspaceSlug: string) => { + const displayFilters = this.getDisplayFiltersByWorkspaceSlug(workspaceSlug); + runInAction(() => { + this.displayFilters[workspaceSlug] = { + order_by: displayFilters?.order_by || "created_at", + }; + this.filters[workspaceSlug] = {}; + }); + }; + + /** + * @description update display filters of a workspace + * @param {string} workspaceSlug + * @param {TProjectDisplayFilters} displayFilters + */ + updateDisplayFilters = (workspaceSlug: string, displayFilters: TProjectDisplayFilters) => { + runInAction(() => { + Object.keys(displayFilters).forEach((key) => { + set(this.displayFilters, [workspaceSlug, key], displayFilters[key as keyof TProjectDisplayFilters]); + }); + }); + }; + + /** + * @description update filters of a workspace + * @param {string} workspaceSlug + * @param {TProjectFilters} filters + */ + updateFilters = (workspaceSlug: string, filters: TProjectFilters) => { + runInAction(() => { + Object.keys(filters).forEach((key) => { + set(this.filters, [workspaceSlug, key], filters[key as keyof TProjectFilters]); + }); + }); + }; + + /** + * @description update search query + * @param {string} query + */ + updateSearchQuery = (query: string) => (this.searchQuery = query); + + /** + * @description clear all filters of a workspace + * @param {string} workspaceSlug + */ + clearAllFilters = (workspaceSlug: string) => { + runInAction(() => { + this.filters[workspaceSlug] = {}; + }); + }; +} diff --git a/web/store/root.store.ts b/web/store/root.store.ts index 0390d7ce2..930d9877c 100644 --- a/web/store/root.store.ts +++ b/web/store/root.store.ts @@ -62,8 +62,8 @@ export class RootStore { this.label = new LabelStore(this); this.estimate = new EstimateStore(this); this.mention = new MentionStore(this); - this.projectPages = new ProjectPageStore(this); this.dashboard = new DashboardStore(this); + this.projectPages = new ProjectPageStore(this); } resetOnSignout() { @@ -82,7 +82,7 @@ export class RootStore { this.label = new LabelStore(this); this.estimate = new EstimateStore(this); this.mention = new MentionStore(this); - this.projectPages = new ProjectPageStore(this); this.dashboard = new DashboardStore(this); + this.projectPages = new ProjectPageStore(this); } }