[WEB-578] feat: projects list filtering and ordering (#3926)

* style: project card UI updated

* dev: initialize project filter store and types

* chore: implemented filtering logic

* chore: implemented ordering

* chore: my projects filter added

* chore: update created at date filter options

* refactor: order by dropdown

* style: revert project card UI

* fix: project card z-index

* fix: members filtering

* fix: build errors
This commit is contained in:
Aaryan Khandelwal 2024-03-12 19:36:40 +05:30 committed by GitHub
parent c3c6ef8830
commit 69e110f4a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1452 additions and 186 deletions

View File

@ -1,7 +1,7 @@
export * from "./github-importer"; export * from "./github-importer";
export * from "./jira-importer"; export * from "./jira-importer";
import { IProjectLite } from "../projects"; import { IProjectLite } from "../project";
// types // types
import { IUserLite } from "../users"; import { IUserLite } from "../users";

View File

@ -1,5 +1,5 @@
import { TIssue } from "../issues/base"; import { TIssue } from "../issues/base";
import type { IProjectLite } from "../projects"; import type { IProjectLite } from "../project";
export type TInboxIssueExtended = { export type TInboxIssueExtended = {
completed_at: string | null; completed_at: string | null;

View File

@ -2,7 +2,7 @@ export * from "./users";
export * from "./workspace"; export * from "./workspace";
export * from "./cycle"; export * from "./cycle";
export * from "./dashboard"; export * from "./dashboard";
export * from "./projects"; export * from "./project";
export * from "./state"; export * from "./state";
export * from "./issues"; export * from "./issues";
export * from "./modules"; export * from "./modules";

View File

@ -0,0 +1,2 @@
export * from "./project_filters";
export * from "./projects";

View File

@ -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;
};

View File

@ -7,7 +7,7 @@ import type {
IWorkspace, IWorkspace,
IWorkspaceLite, IWorkspaceLite,
TStateGroups, TStateGroups,
} from "."; } from "..";
export type TProjectLogoProps = { export type TProjectLogoProps = {
in_use: "emoji" | "icon"; in_use: "emoji" | "icon";

View File

@ -4,7 +4,7 @@ import { X } from "lucide-react";
import { renderFormattedDate } from "helpers/date-time.helper"; import { renderFormattedDate } from "helpers/date-time.helper";
import { capitalizeFirstLetter } from "helpers/string.helper"; import { capitalizeFirstLetter } from "helpers/string.helper";
// constants // constants
import { DATE_FILTER_OPTIONS } from "constants/filters"; import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters";
type Props = { type Props = {
editable: boolean | undefined; editable: boolean | undefined;
@ -18,7 +18,7 @@ export const AppliedDateFilters: React.FC<Props> = observer((props) => {
const getDateLabel = (value: string): string => { const getDateLabel = (value: string): string => {
let dateLabel = ""; 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; if (dateDetails) dateLabel = dateDetails.name;
else { else {

View File

@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite";
import { DateFilterModal } from "components/core"; import { DateFilterModal } from "components/core";
import { FilterHeader, FilterOption } from "components/issues"; import { FilterHeader, FilterOption } from "components/issues";
// constants // constants
import { DATE_FILTER_OPTIONS } from "constants/filters"; import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters";
type Props = { type Props = {
appliedFilters: string[] | null; appliedFilters: string[] | null;
@ -21,7 +21,9 @@ export const FilterEndDate: React.FC<Props> = observer((props) => {
const appliedFiltersCount = appliedFilters?.length ?? 0; 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 ( return (
<> <>

View File

@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite";
import { DateFilterModal } from "components/core"; import { DateFilterModal } from "components/core";
import { FilterHeader, FilterOption } from "components/issues"; import { FilterHeader, FilterOption } from "components/issues";
// constants // constants
import { DATE_FILTER_OPTIONS } from "constants/filters"; import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters";
type Props = { type Props = {
appliedFilters: string[] | null; appliedFilters: string[] | null;
@ -21,7 +21,9 @@ export const FilterStartDate: React.FC<Props> = observer((props) => {
const appliedFiltersCount = appliedFilters?.length ?? 0; 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 ( return (
<> <>

View File

@ -1,26 +1,81 @@
import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Search, Plus, Briefcase } from "lucide-react"; import { Search, Plus, Briefcase, X, ListFilter } from "lucide-react";
// hooks // hooks
// ui import { useApplication, useEventTracker, useMember, useProject, useProjectFilter, useUser } from "hooks/store";
import { Breadcrumbs, Button } from "@plane/ui"; import useOutsideClickDetector from "hooks/use-outside-click-detector";
// constants // components
import { BreadcrumbLink } from "components/common"; import { BreadcrumbLink } from "components/common";
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; 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"; import { EUserWorkspaceRoles } from "constants/workspace";
// components import { FiltersDropdown } from "components/issues";
import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; import { ProjectFiltersSelection, ProjectOrderByDropdown } from "components/project";
import { TProjectFilters } from "@plane/types";
export const ProjectsHeader = observer(() => { export const ProjectsHeader = observer(() => {
// states
const [isSearchOpen, setIsSearchOpen] = useState(false);
// refs
const inputRef = useRef<HTMLInputElement>(null);
// store hooks // store hooks
const { commandPalette: commandPaletteStore } = useApplication(); const {
commandPalette: commandPaletteStore,
router: { workspaceSlug },
} = useApplication();
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { const {
membership: { currentWorkspaceRole }, membership: { currentWorkspaceRole },
} = useUser(); } = 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 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<HTMLInputElement>) => {
if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
else setIsSearchOpen(false);
}
};
return ( return (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4"> <div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
@ -34,18 +89,74 @@ export const ProjectsHeader = observer(() => {
</Breadcrumbs> </Breadcrumbs>
</div> </div>
</div> </div>
<div className="flex w-full justify-end items-center gap-3"> <div className="w-full flex items-center justify-end gap-3">
{workspaceProjectIds && workspaceProjectIds?.length > 0 && ( {workspaceProjectIds && workspaceProjectIds?.length > 0 && (
<div className=" flex items-center justify-start gap-1 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5 text-custom-text-400"> <div className="flex items-center">
<Search className="h-3.5" /> {!isSearchOpen && (
<input <button
className="border-none w-full bg-transparent text-sm focus:outline-none" type="button"
value={searchQuery} className="-mr-1 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onChange={(e) => setSearchQuery(e.target.value)} onClick={() => {
placeholder="Search" setIsSearchOpen(true);
/> inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 focus:outline-none"
placeholder="Search"
value={searchQuery}
onChange={(e) => updateSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
updateSearchQuery("");
setIsSearchOpen(false);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div> </div>
)} )}
<ProjectOrderByDropdown
value={displayFilters?.order_by}
onChange={(val) => {
if (!workspaceSlug || val === displayFilters?.order_by) return;
updateDisplayFilters(workspaceSlug, {
order_by: val,
});
}}
/>
<FiltersDropdown icon={<ListFilter className="h-3 w-3" />} title="Filters" placement="bottom-end">
<ProjectFiltersSelection
displayFilters={displayFilters ?? {}}
filters={filters ?? {}}
handleFiltersUpdate={handleFilters}
handleDisplayFiltersUpdate={(val) => {
if (!workspaceSlug) return;
updateDisplayFilters(workspaceSlug, val);
}}
memberIds={workspaceMemberIds ?? undefined}
/>
</FiltersDropdown>
{isAuthorizedUser && ( {isAuthorizedUser && (
<Button <Button
prependIcon={<Plus />} prependIcon={<Plus />}
@ -54,9 +165,9 @@ export const ProjectsHeader = observer(() => {
setTrackElement("Projects page"); setTrackElement("Projects page");
commandPaletteStore.toggleCreateProjectModal(true); commandPaletteStore.toggleCreateProjectModal(true);
}} }}
className="items-center" className="items-center gap-1"
> >
<div className="hidden sm:block">Add</div> Project <span className="hidden sm:inline-block">Add</span> Project
</Button> </Button>
)} )}
</div> </div>

View File

@ -2,7 +2,7 @@ import { observer } from "mobx-react-lite";
// icons // icons
import { X } from "lucide-react"; import { X } from "lucide-react";
// helpers // helpers
import { DATE_FILTER_OPTIONS } from "constants/filters"; import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters";
import { renderFormattedDate } from "helpers/date-time.helper"; import { renderFormattedDate } from "helpers/date-time.helper";
import { capitalizeFirstLetter } from "helpers/string.helper"; import { capitalizeFirstLetter } from "helpers/string.helper";
// constants // constants
@ -18,7 +18,7 @@ export const AppliedDateFilters: React.FC<Props> = observer((props) => {
const getDateLabel = (value: string): string => { const getDateLabel = (value: string): string => {
let dateLabel = ""; 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; if (dateDetails) dateLabel = dateDetails.name;
else { else {

View File

@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite";
import { DateFilterModal } from "components/core"; import { DateFilterModal } from "components/core";
import { FilterHeader, FilterOption } from "components/issues"; import { FilterHeader, FilterOption } from "components/issues";
// constants // constants
import { DATE_FILTER_OPTIONS } from "constants/filters"; import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters";
type Props = { type Props = {
appliedFilters: string[] | null; appliedFilters: string[] | null;
@ -21,7 +21,9 @@ export const FilterStartDate: React.FC<Props> = observer((props) => {
const appliedFiltersCount = appliedFilters?.length ?? 0; 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 ( return (
<> <>

View File

@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite";
import { DateFilterModal } from "components/core"; import { DateFilterModal } from "components/core";
import { FilterHeader, FilterOption } from "components/issues"; import { FilterHeader, FilterOption } from "components/issues";
// constants // constants
import { DATE_FILTER_OPTIONS } from "constants/filters"; import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters";
type Props = { type Props = {
appliedFilters: string[] | null; appliedFilters: string[] | null;
@ -21,7 +21,9 @@ export const FilterTargetDate: React.FC<Props> = observer((props) => {
const appliedFiltersCount = appliedFilters?.length ?? 0; 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 ( return (
<> <>

View File

@ -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<Props> = observer((props) => {
const { handleRemove, values, editable } = props;
return (
<>
{values.map((status) => {
const accessDetails = NETWORK_CHOICES.find((s) => `${s.key}` === status);
return (
<div key={status} className="flex items-center gap-1 rounded p-1 text-xs bg-custom-background-80">
{accessDetails?.label}
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(status)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@ -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<Props> = 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) => (
<div key={date} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<span className="normal-case">{getDateLabel(date)}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(date)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
))}
</>
);
});

View File

@ -0,0 +1,4 @@
export * from "./access";
export * from "./date";
export * from "./members";
export * from "./root";

View File

@ -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<Props> = 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 (
<div key={memberId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<Avatar name={memberDetails.display_name} src={memberDetails.avatar} showTooltip={false} />
<span className="normal-case">{memberDetails.display_name}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(memberId)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@ -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> = (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 (
<div className="flex items-start justify-between gap-1.5">
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">
{Object.entries(appliedFilters).map(([key, value]) => {
const filterKey = key as keyof TProjectFilters;
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
return (
<div
key={filterKey}
className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize"
>
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
{filterKey === "access" && (
<AppliedAccessFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter("access", val)}
values={value}
/>
)}
{DATE_FILTERS.includes(filterKey) && (
<AppliedDateFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
values={value}
/>
)}
{MEMBERS_FILTERS.includes(filterKey) && (
<AppliedMembersFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
values={value}
/>
)}
{isEditingAllowed && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemoveFilter(filterKey, null)}
>
<X size={12} strokeWidth={2} />
</button>
)}
</div>
</div>
);
})}
{isEditingAllowed && (
<button
type="button"
onClick={handleClearAllFilters}
className="flex items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 text-xs text-custom-text-300 hover:text-custom-text-200"
>
Clear all
<X size={12} strokeWidth={2} />
</button>
)}
</div>
<Tooltip
tooltipContent={
<p>
<span className="font-semibold">{filteredProjects}</span> of{" "}
<span className="font-semibold">{totalProjects}</span> projects match the applied filters.
</p>
}
>
<span className="bg-custom-background-80 rounded-full text-sm font-medium py-1 px-2.5">
{filteredProjects}/{totalProjects}
</span>
</Tooltip>
</div>
);
};

View File

@ -1,10 +1,14 @@
import Image from "next/image";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// hooks // hooks
import { useApplication, useEventTracker, useProject } from "hooks/store"; import { useApplication, useEventTracker, useProject, useProjectFilter } from "hooks/store";
// components // components
import { EmptyState } from "components/empty-state"; import { EmptyState } from "components/empty-state";
import { ProjectCard } from "components/project"; import { ProjectCard } from "components/project";
import { ProjectsLoader } from "components/ui"; 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 // constants
import { EmptyStateType } from "constants/empty-state"; import { EmptyStateType } from "constants/empty-state";
@ -12,38 +16,49 @@ export const ProjectCardList = observer(() => {
// store hooks // store hooks
const { commandPalette: commandPaletteStore } = useApplication(); const { commandPalette: commandPaletteStore } = useApplication();
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { workspaceProjectIds, filteredProjectIds, getProjectById } = useProject();
const { searchQuery } = useProjectFilter();
const { workspaceProjectIds, searchedProjects, getProjectById } = useProject(); if (!filteredProjectIds) return <ProjectsLoader />;
if (!workspaceProjectIds) return <ProjectsLoader />; if (workspaceProjectIds?.length === 0)
return (
<EmptyState
type={EmptyStateType.WORKSPACE_PROJECTS}
primaryButtonOnClick={() => {
setTrackElement("Project empty state");
commandPaletteStore.toggleCreateProjectModal(true);
}}
/>
);
if (filteredProjectIds.length === 0)
return (
<div className="h-full w-full grid place-items-center">
<div className="text-center">
<Image
src={searchQuery.trim() === "" ? AllFiltersImage : NameFilterImage}
className="h-36 sm:h-48 w-36 sm:w-48 mx-auto"
alt="No matching projects"
/>
<h5 className="text-xl font-medium mt-7 mb-1">No matching projects</h5>
<p className="text-custom-text-400 text-base whitespace-pre-line">
{searchQuery.trim() === ""
? "Remove the filters to see all projects"
: "No projects detected with the matching\ncriteria. Create a new project instead"}
</p>
</div>
</div>
);
return ( return (
<> <div className="h-full w-full overflow-y-auto p-8 vertical-scrollbar scrollbar-lg">
{workspaceProjectIds.length > 0 ? ( <div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
<div className="h-full w-full overflow-y-auto p-8 vertical-scrollbar scrollbar-lg"> {filteredProjectIds.map((projectId) => {
{searchedProjects.length == 0 ? ( const projectDetails = getProjectById(projectId);
<div className="mt-10 w-full text-center text-custom-text-400">No matching projects</div> if (!projectDetails) return;
) : ( return <ProjectCard key={projectDetails.id} project={projectDetails} />;
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3"> })}
{searchedProjects.map((projectId) => { </div>
const projectDetails = getProjectById(projectId); </div>
if (!projectDetails) return;
return <ProjectCard key={projectDetails.id} project={projectDetails} />;
})}
</div>
)}
</div>
) : (
<EmptyState
type={EmptyStateType.WORKSPACE_PROJECTS}
primaryButtonOnClick={() => {
setTrackElement("Project empty state");
commandPaletteStore.toggleCreateProjectModal(true);
}}
/>
)}
</>
); );
}); });

View File

@ -2,36 +2,38 @@ import React, { useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { LinkIcon, Lock, Pencil, Star } from "lucide-react"; import { Check, LinkIcon, Lock, Pencil, Star } from "lucide-react";
// ui // ui
import { Avatar, AvatarGroup, Button, Tooltip, TOAST_TYPE, setToast, setPromiseToast } from "@plane/ui"; import { Avatar, AvatarGroup, Button, Tooltip, TOAST_TYPE, setToast, setPromiseToast } from "@plane/ui";
// components // components
import { DeleteProjectModal, JoinProjectModal, ProjectLogo } from "components/project"; import { DeleteProjectModal, JoinProjectModal, ProjectLogo } from "components/project";
// helpers // helpers
import { copyTextToClipboard } from "helpers/string.helper"; import { copyUrlToClipboard } from "helpers/string.helper";
import { renderFormattedDate } from "helpers/date-time.helper";
// hooks // hooks
import { useProject } from "hooks/store"; import { useProject } from "hooks/store";
// types // types
import type { IProject } from "@plane/types"; import type { IProject } from "@plane/types";
import { EUserProjectRoles } from "constants/project";
// constants // constants
import { EUserProjectRoles } from "constants/project";
export type ProjectCardProps = { type Props = {
project: IProject; project: IProject;
}; };
export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => { export const ProjectCard: React.FC<Props> = observer((props) => {
const { project } = props; const { project } = props;
// router
const router = useRouter();
const { workspaceSlug } = router.query;
// states // states
const [deleteProjectModalOpen, setDeleteProjectModal] = useState(false); const [deleteProjectModalOpen, setDeleteProjectModal] = useState(false);
const [joinProjectModalOpen, setJoinProjectModal] = useState(false); const [joinProjectModalOpen, setJoinProjectModal] = useState(false);
// router
const router = useRouter();
const { workspaceSlug } = router.query;
// store hooks // store hooks
const { addProjectToFavorites, removeProjectFromFavorites } = useProject(); const { addProjectToFavorites, removeProjectFromFavorites } = useProject();
// derived values
project.member_role; const projectMembersIds = project.members?.map((member) => member.member_id);
// auth
const isOwner = project.member_role === EUserProjectRoles.ADMIN; const isOwner = project.member_role === EUserProjectRoles.ADMIN;
const isMember = project.member_role === EUserProjectRoles.MEMBER; const isMember = project.member_role === EUserProjectRoles.MEMBER;
@ -53,7 +55,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => {
}; };
const handleRemoveFromFavorites = () => { const handleRemoveFromFavorites = () => {
if (!workspaceSlug || !project) return; if (!workspaceSlug) return;
const removeFromFavoritePromise = removeProjectFromFavorites(workspaceSlug.toString(), project.id); const removeFromFavoritePromise = removeProjectFromFavorites(workspaceSlug.toString(), project.id);
setPromiseToast(removeFromFavoritePromise, { setPromiseToast(removeFromFavoritePromise, {
@ -69,23 +71,18 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => {
}); });
}; };
const handleCopyText = () => { const handleCopyText = () =>
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; copyUrlToClipboard(`${workspaceSlug}/projects/${project.id}/issues`).then(() =>
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${project.id}/issues`).then(() => {
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Link Copied!", title: "Link Copied!",
message: "Project link copied to clipboard.", message: "Project link copied to clipboard.",
}); })
}); );
};
const projectMembersIds = project.members?.map((member) => member.member_id);
return ( return (
<> <>
{/* Delete Project Modal */} {/* Delete Project Modal */}
<DeleteProjectModal <DeleteProjectModal
project={project} project={project}
isOpen={deleteProjectModalOpen} isOpen={deleteProjectModalOpen}
@ -94,20 +91,22 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => {
{/* Join Project Modal */} {/* Join Project Modal */}
{workspaceSlug && ( {workspaceSlug && (
<JoinProjectModal <JoinProjectModal
workspaceSlug={workspaceSlug?.toString()} workspaceSlug={workspaceSlug.toString()}
project={project} project={project}
isOpen={joinProjectModalOpen} isOpen={joinProjectModalOpen}
handleClose={() => setJoinProjectModal(false)} handleClose={() => setJoinProjectModal(false)}
/> />
)} )}
<Link
{/* Card Information */} href={`/${workspaceSlug}/projects/${project.id}/issues`}
<div onClick={(e) => {
onClick={() => { if (!project.is_member) {
if (project.is_member) router.push(`/${workspaceSlug?.toString()}/projects/${project.id}/issues`); e.preventDefault();
else setJoinProjectModal(true); 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"
> >
<div className="relative h-[118px] w-full rounded-t "> <div className="relative h-[118px] w-full rounded-t ">
<div className="absolute inset-0 z-[1] bg-gradient-to-t from-black/60 to-transparent" /> <div className="absolute inset-0 z-[1] bg-gradient-to-t from-black/60 to-transparent" />
@ -121,12 +120,10 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => {
className="absolute left-0 top-0 h-full w-full rounded-t object-cover" className="absolute left-0 top-0 h-full w-full rounded-t object-cover"
/> />
<div className="absolute bottom-4 z-10 flex h-10 w-full items-center justify-between gap-3 px-4"> <div className="absolute bottom-4 z-[1] flex h-10 w-full items-center justify-between gap-3 px-4">
<div className="flex flex-grow items-center gap-2.5 truncate"> <div className="flex flex-grow items-center gap-2.5 truncate">
<div className="flex item-center justify-center h-9 w-9 flex-shrink-0 rounded bg-white/90"> <div className="h-9 w-9 flex-shrink-0 grid place-items-center rounded bg-white/90">
<span className="grid place-items-center"> <ProjectLogo logo={project.logo_props} />
<ProjectLogo logo={project.logo_props} />
</span>
</div> </div>
<div className="flex w-full flex-col justify-between gap-0.5 truncate"> <div className="flex w-full flex-col justify-between gap-0.5 truncate">
@ -152,15 +149,10 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => {
<button <button
className="flex h-6 w-6 items-center justify-center rounded bg-white/10" className="flex h-6 w-6 items-center justify-center rounded bg-white/10"
onClick={(e) => { onClick={(e) => {
if (project.is_favorite) { e.preventDefault();
e.preventDefault(); e.stopPropagation();
e.stopPropagation(); if (project.is_favorite) handleRemoveFromFavorites();
handleRemoveFromFavorites(); else handleAddToFavorites();
} else {
e.preventDefault();
e.stopPropagation();
handleAddToFavorites();
}
}} }}
> >
<Star <Star
@ -172,7 +164,11 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => {
</div> </div>
<div className="flex h-[104px] w-full flex-col justify-between rounded-b p-4"> <div className="flex h-[104px] w-full flex-col justify-between rounded-b p-4">
<p className="line-clamp-2 break-words text-sm text-custom-text-300">{project.description}</p> <p className="line-clamp-2 break-words text-sm text-custom-text-300">
{project.description && project.description.trim() !== ""
? project.description
: `Created on ${renderFormattedDate(project.created_at)}`}
</p>
<div className="item-center flex justify-between"> <div className="item-center flex justify-between">
<Tooltip <Tooltip
tooltipHeading="Members" tooltipHeading="Members"
@ -197,19 +193,24 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => {
<span className="text-sm italic text-custom-text-400">No Member Yet</span> <span className="text-sm italic text-custom-text-400">No Member Yet</span>
)} )}
</Tooltip> </Tooltip>
{(isOwner || isMember) && ( {project.is_member &&
<Link (isOwner || isMember ? (
className="flex items-center justify-center rounded p-1 text-custom-text-400 hover:bg-custom-background-80 hover:text-custom-text-200" <Link
onClick={(e) => { className="flex items-center justify-center rounded p-1 text-custom-text-400 hover:bg-custom-background-80 hover:text-custom-text-200"
e.stopPropagation(); onClick={(e) => {
}} e.stopPropagation();
href={`/${workspaceSlug}/projects/${project.id}/settings`} }}
> href={`/${workspaceSlug}/projects/${project.id}/settings`}
<Pencil className="h-3.5 w-3.5" /> >
</Link> <Pencil className="h-3.5 w-3.5" />
)} </Link>
) : (
{!project.is_member ? ( <span className="flex items-center gap-1 text-custom-text-400 text-sm">
<Check className="h-3.5 w-3.5" />
Joined
</span>
))}
{!project.is_member && (
<div className="flex items-center"> <div className="flex items-center">
<Button <Button
variant="link-primary" variant="link-primary"
@ -223,10 +224,10 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => {
Join Join
</Button> </Button>
</div> </div>
) : null} )}
</div> </div>
</div> </div>
</div> </Link>
</> </>
); );
}); });

View File

@ -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<Props> = 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 (
<>
<FilterHeader
title={`Access${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
filteredOptions.map((access) => (
<FilterOption
key={access.key}
isChecked={appliedFilters?.includes(`${access.key}`) ? true : false}
onClick={() => handleUpdate(`${access.key}`)}
icon={<access.icon className="h-3 w-3" />}
title={access.label}
/>
))
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@ -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<Props> = 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 && (
<DateFilterModal
handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen}
onSelect={(val) => handleUpdate(val)}
title="Created date"
/>
)}
<FilterHeader
title={`Created date${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
<>
{filteredOptions.map((option) => (
<FilterOption
key={option.value}
isChecked={appliedFilters?.includes(option.value) ? true : false}
onClick={() => handleUpdate(option.value)}
title={option.name}
multiple
/>
))}
<FilterOption isChecked={false} onClick={() => setIsDateFilterModalOpen(true)} title="Custom" multiple />
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@ -0,0 +1,5 @@
export * from "./access";
export * from "./created-at";
export * from "./lead";
export * from "./members";
export * from "./root";

View File

@ -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<Props> = 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 (
<>
<FilterHeader
title={`Lead${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions ? (
sortedOptions.length > 0 ? (
<>
{sortedOptions.slice(0, itemsToRender).map((memberId) => {
const member = getUserDetails(memberId);
if (!member) return null;
return (
<FilterOption
key={`lead-${member.id}`}
isChecked={appliedFilters?.includes(member.id) ? true : false}
onClick={() => handleUpdate(member.id)}
icon={<Avatar name={member.display_name} src={member.avatar} showTooltip={false} size="md" />}
title={member.display_name}
/>
);
})}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View File

@ -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<Props> = 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 (
<>
<FilterHeader
title={`Members${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions ? (
sortedOptions.length > 0 ? (
<>
{sortedOptions.slice(0, itemsToRender).map((memberId) => {
const member = getUserDetails(memberId);
if (!member) return null;
return (
<FilterOption
key={`member-${member.id}`}
isChecked={appliedFilters?.includes(member.id) ? true : false}
onClick={() => handleUpdate(member.id)}
icon={<Avatar name={member.display_name} src={member.avatar} showTooltip={false} size="md" />}
title={member.display_name}
/>
);
})}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View File

@ -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<TProjectDisplayFilters>) => void;
memberIds?: string[] | undefined;
};
export const ProjectFiltersSelection: React.FC<Props> = observer((props) => {
const { displayFilters, filters, handleFiltersUpdate, handleDisplayFiltersUpdate, memberIds } = props;
// states
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="bg-custom-background-100 p-2.5 pb-0">
<div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs">
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
<input
type="text"
className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400"
placeholder="Search"
value={filtersSearchQuery}
onChange={(e) => setFiltersSearchQuery(e.target.value)}
autoFocus
/>
{filtersSearchQuery !== "" && (
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
<X className="text-custom-text-300" size={12} strokeWidth={2} />
</button>
)}
</div>
</div>
<div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm">
<div className="py-2">
<FilterOption
isChecked={!!displayFilters.my_projects}
onClick={() =>
handleDisplayFiltersUpdate({
my_projects: !displayFilters.my_projects,
})
}
title="My projects"
/>
</div>
{/* access */}
<div className="py-2">
<FilterAccess
appliedFilters={filters.access ?? null}
handleUpdate={(val) => handleFiltersUpdate("access", val)}
searchQuery={filtersSearchQuery}
/>
</div>
{/* lead */}
<div className="py-2">
<FilterLead
appliedFilters={filters.lead ?? null}
handleUpdate={(val) => handleFiltersUpdate("lead", val)}
searchQuery={filtersSearchQuery}
memberIds={memberIds}
/>
</div>
{/* members */}
<div className="py-2">
<FilterMembers
appliedFilters={filters.members ?? null}
handleUpdate={(val) => handleFiltersUpdate("members", val)}
searchQuery={filtersSearchQuery}
memberIds={memberIds}
/>
</div>
{/* created date */}
<div className="py-2">
<FilterCreatedDate
appliedFilters={filters.created_at ?? null}
handleUpdate={(val) => handleFiltersUpdate("created_at", val)}
searchQuery={filtersSearchQuery}
/>
</div>
</div>
</div>
);
});

View File

@ -0,0 +1,2 @@
export * from "./filters";
export * from "./order-by";

View File

@ -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> = (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 (
<CustomMenu
customButton={
<div className={cn(getButtonStyling("neutral-primary", "sm"), "px-2 text-custom-text-300")}>
<ArrowDownWideNarrow className="h-3 w-3" />
{orderByDetails?.label}
<ChevronDown className="h-3 w-3" strokeWidth={2} />
</div>
}
placement="bottom-end"
closeOnSelect
>
{PROJECT_ORDER_BY_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
className="flex items-center justify-between gap-2"
onClick={() => {
if (isDescending) onChange(`-${option.key}` as TProjectOrderByOptions);
else onChange(option.key);
}}
>
{option.label}
{value?.includes(option.key) && <Check className="h-3 w-3" />}
</CustomMenu.MenuItem>
))}
<hr className="my-2" />
<CustomMenu.MenuItem
className="flex items-center justify-between gap-2"
onClick={() => {
if (isDescending) onChange(value.slice(1) as TProjectOrderByOptions);
}}
disabled={isOrderingDisabled}
>
Ascending
{!isOrderingDisabled && !isDescending && <Check className="h-3 w-3" />}
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
className="flex items-center justify-between gap-2"
onClick={() => {
if (!isDescending) onChange(`-${value}` as TProjectOrderByOptions);
}}
disabled={isOrderingDisabled}
>
Descending
{!isOrderingDisabled && isDescending && <Check className="h-3 w-3" />}
</CustomMenu.MenuItem>
</CustomMenu>
);
};

View File

@ -1,3 +1,5 @@
export * from "./applied-filters";
export * from "./dropdowns";
export * from "./publish-project"; export * from "./publish-project";
export * from "./settings"; export * from "./settings";
export * from "./card-list"; export * from "./card-list";

View File

@ -59,6 +59,7 @@ export const ProjectViewListItem: React.FC<Props> = observer((props) => {
}); });
}; };
// @ts-expect-error key types are not compatible
const totalFilters = calculateTotalFilters(view.filters ?? {}); const totalFilters = calculateTotalFilters(view.filters ?? {});
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;

View File

@ -1,4 +1,4 @@
export const DATE_FILTER_OPTIONS = [ export const DATE_AFTER_FILTER_OPTIONS = [
{ {
name: "1 week from now", name: "1 week from now",
value: "1_weeks;after;fromnow", value: "1_weeks;after;fromnow",
@ -16,3 +16,18 @@ export const DATE_FILTER_OPTIONS = [
value: "2_months;after;fromnow", 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",
},
];

View File

@ -3,6 +3,7 @@ import { Globe2, Lock, LucideIcon } from "lucide-react";
import { SettingIcon } from "components/icons"; import { SettingIcon } from "components/icons";
// types // types
import { Props } from "components/icons/types"; import { Props } from "components/icons/types";
import { TProjectOrderByOptions } from "@plane/types";
export enum EUserProjectRoles { export enum EUserProjectRoles {
GUEST = 5, GUEST = 5,
@ -39,23 +40,6 @@ export const GROUP_CHOICES = {
cancelled: "Cancelled", 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 = [ export const PROJECT_AUTOMATION_MONTHS = [
{ label: "1 month", value: 1 }, { label: "1 month", value: 1 },
{ label: "3 months", value: 3 }, { label: "3 months", value: 3 },
@ -156,3 +140,25 @@ export const PROJECT_SETTINGS_LINKS: {
Icon: SettingIcon, 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",
},
];

View File

@ -1,15 +1,22 @@
import { differenceInCalendarDays } from "date-fns"; import differenceInCalendarDays from "date-fns/differenceInCalendarDays";
// types
import { IIssueFilterOptions } from "@plane/types";
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 filters && Object.keys(filters).length > 0
? Object.keys(filters) ? Object.keys(filters)
.map((key) => .map((key) =>
filters[key as keyof IIssueFilterOptions] !== null filters[key as keyof TFilters] !== null
? isNaN((filters[key as keyof IIssueFilterOptions] as string[]).length) ? isNaN((filters[key as keyof TFilters] as string[]).length)
? 0 ? 0
: (filters[key as keyof IIssueFilterOptions] as string[]).length : (filters[key as keyof TFilters] as string[]).length
: 0 : 0
) )
.reduce((curr, prev) => curr + prev, 0) .reduce((curr, prev) => curr + prev, 0)
@ -30,6 +37,12 @@ export const satisfiesDateFilter = (date: Date, filter: string): boolean => {
} }
if (from === "fromnow") { 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 (operator === "after") {
if (value === "1_weeks") return differenceInCalendarDays(date, new Date()) >= 7; if (value === "1_weeks") return differenceInCalendarDays(date, new Date()) >= 7;
if (value === "2_weeks") return differenceInCalendarDays(date, new Date()) >= 14; if (value === "2_weeks") return differenceInCalendarDays(date, new Date()) >= 14;

View File

@ -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. * Updates the sort order of the project.
@ -46,3 +50,58 @@ export const orderJoinedProjects = (
export const projectIdentifierSanitizer = (identifier: string): string => export const projectIdentifierSanitizer = (identifier: string): string =>
identifier.replace(/[^ÇŞĞIİÖÜA-Za-z0-9]/g, ""); 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;
};

View File

@ -11,6 +11,7 @@ export * from "./use-member";
export * from "./use-mention"; export * from "./use-mention";
export * from "./use-module"; export * from "./use-module";
export * from "./use-page"; export * from "./use-page";
export * from "./use-project-filter";
export * from "./use-project-publish"; export * from "./use-project-publish";
export * from "./use-project-state"; export * from "./use-project-state";
export * from "./use-project-view"; export * from "./use-project-view";

View File

@ -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;
};

View File

@ -1,25 +1,60 @@
import { ReactElement } from "react"; import { ReactElement, useCallback } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// components // components
import { PageHead } from "components/core"; import { PageHead } from "components/core";
import { ProjectsHeader } from "components/headers"; import { ProjectsHeader } from "components/headers";
import { ProjectCardList } from "components/project"; import { ProjectAppliedFiltersList, ProjectCardList } from "components/project";
// layouts // layouts
import { useWorkspace } from "hooks/store"; import { useApplication, useProject, useProjectFilter, useWorkspace } from "hooks/store";
import { AppLayout } from "layouts/app-layout"; import { AppLayout } from "layouts/app-layout";
// type // helpers
import { calculateTotalFilters } from "helpers/filter.helper";
// types
import { NextPageWithLayout } from "lib/types"; import { NextPageWithLayout } from "lib/types";
import { TProjectFilters } from "@plane/types";
const ProjectsPage: NextPageWithLayout = observer(() => { const ProjectsPage: NextPageWithLayout = observer(() => {
// store // store
const {
router: { workspaceSlug },
} = useApplication();
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { workspaceProjectIds, filteredProjectIds } = useProject();
const { currentWorkspaceFilters, clearAllFilters, updateFilters } = useProjectFilter();
// derived values // derived values
const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Projects` : undefined; 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 ( return (
<> <>
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<ProjectCardList /> <div className="h-full w-full flex flex-col">
{calculateTotalFilters(currentWorkspaceFilters ?? {}) !== 0 && (
<div className="border-b border-custom-border-200 px-5 py-3">
<ProjectAppliedFiltersList
appliedFilters={currentWorkspaceFilters ?? {}}
handleClearAllFilters={() => clearAllFilters(`${workspaceSlug}`)}
handleRemoveFilter={handleRemoveFilter}
filteredProjects={filteredProjectIds?.length ?? 0}
totalProjects={workspaceProjectIds?.length ?? 0}
alwaysAllowEditing
/>
</div>
)}
<ProjectCardList />
</div>
</> </>
); );
}); });

View File

@ -0,0 +1,42 @@
<svg width="206" height="217" viewBox="0 0 206 217" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="103" cy="102.5" r="102.5" fill="url(#paint0_linear_8013_111910)"/>
<path d="M153 64.75H53C46.0964 64.75 40.5 70.3464 40.5 77.25V139.75C40.5 146.654 46.0964 152.25 53 152.25H153C159.904 152.25 165.5 146.654 165.5 139.75V77.25C165.5 70.3464 159.904 64.75 153 64.75Z" stroke="#B9BBC6" stroke-width="3.36" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M128 152.25V52.25C128 48.9348 126.683 45.7554 124.339 43.4112C121.995 41.067 118.815 39.75 115.5 39.75H90.5C87.1848 39.75 84.0054 41.067 81.6612 43.4112C79.317 45.7554 78 48.9348 78 52.25V152.25" stroke="#B9BBC6" stroke-width="3.36" stroke-linecap="round" stroke-linejoin="round"/>
<g filter="url(#filter0_ddd_8013_111910)">
<circle cx="104" cy="174" r="31" fill="#3A5BC7"/>
<path d="M89 164H119" stroke="#F9F9FB" stroke-width="3.57143" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M95.667 174H112.334" stroke="#F9F9FB" stroke-width="3.57143" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M100.667 184H107.334" stroke="#F9F9FB" stroke-width="3.57143" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<filter id="filter0_ddd_8013_111910" x="65" y="138" width="78" height="79" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="2" operator="erode" in="SourceAlpha" result="effect1_dropShadow_8013_111910"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0 0 0 0.051 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_8013_111910"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect2_dropShadow_8013_111910"/>
<feOffset dy="3"/>
<feGaussianBlur stdDeviation="6"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.055 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_8013_111910" result="effect2_dropShadow_8013_111910"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="8" operator="erode" in="SourceAlpha" result="effect3_dropShadow_8013_111910"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="8"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.078 0"/>
<feBlend mode="normal" in2="effect2_dropShadow_8013_111910" result="effect3_dropShadow_8013_111910"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect3_dropShadow_8013_111910" result="shape"/>
</filter>
<linearGradient id="paint0_linear_8013_111910" x1="103" y1="0" x2="103" y2="207.52" gradientUnits="userSpaceOnUse">
<stop stop-color="#F7F7F7"/>
<stop offset="1" stop-color="#F8F9FA" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,41 @@
<svg width="206" height="217" viewBox="0 0 206 217" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="103" cy="102.5" r="102.5" fill="url(#paint0_linear_8013_110289)"/>
<path d="M153 64.75H53C46.0964 64.75 40.5 70.3464 40.5 77.25V139.75C40.5 146.654 46.0964 152.25 53 152.25H153C159.904 152.25 165.5 146.654 165.5 139.75V77.25C165.5 70.3464 159.904 64.75 153 64.75Z" stroke="#B9BBC6" stroke-width="3.36" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M128 152.25V52.25C128 48.9348 126.683 45.7554 124.339 43.4112C121.995 41.067 118.815 39.75 115.5 39.75H90.5C87.1848 39.75 84.0054 41.067 81.6612 43.4112C79.317 45.7554 78 48.9348 78 52.25V152.25" stroke="#B9BBC6" stroke-width="3.36" stroke-linecap="round" stroke-linejoin="round"/>
<g filter="url(#filter0_ddd_8013_110289)">
<circle cx="103.754" cy="173.828" r="31" fill="#3A5BC7"/>
<path d="M102.074 185.584C109.493 185.584 115.508 179.57 115.508 172.151C115.508 164.732 109.493 158.717 102.074 158.717C94.6553 158.717 88.641 164.732 88.641 172.151C88.641 179.57 94.6553 185.584 102.074 185.584Z" stroke="white" stroke-width="2.52049" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M118.866 188.942L111.646 181.722" stroke="white" stroke-width="2.52049" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<filter id="filter0_ddd_8013_110289" x="64.7539" y="137.828" width="78" height="79" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="2" operator="erode" in="SourceAlpha" result="effect1_dropShadow_8013_110289"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0 0 0 0.051 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_8013_110289"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect2_dropShadow_8013_110289"/>
<feOffset dy="3"/>
<feGaussianBlur stdDeviation="6"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.055 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_8013_110289" result="effect2_dropShadow_8013_110289"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="8" operator="erode" in="SourceAlpha" result="effect3_dropShadow_8013_110289"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="8"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.078 0"/>
<feBlend mode="normal" in2="effect2_dropShadow_8013_110289" result="effect3_dropShadow_8013_110289"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect3_dropShadow_8013_110289" result="shape"/>
</filter>
<linearGradient id="paint0_linear_8013_110289" x1="103" y1="0" x2="103" y2="207.52" gradientUnits="userSpaceOnUse">
<stop stop-color="#F7F7F7"/>
<stop offset="1" stop-color="#F8F9FA" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -1,18 +1,22 @@
import { RootStore } from "store/root.store"; import { RootStore } from "store/root.store";
import { IProjectPublishStore, ProjectPublishStore } from "./project-publish.store"; import { IProjectPublishStore, ProjectPublishStore } from "./project-publish.store";
import { IProjectStore, ProjectStore } from "./project.store"; import { IProjectStore, ProjectStore } from "./project.store";
import { IProjectFilterStore, ProjectFilterStore } from "./project_filter.store";
export interface IProjectRootStore { export interface IProjectRootStore {
project: IProjectStore; project: IProjectStore;
projectFilter: IProjectFilterStore;
publish: IProjectPublishStore; publish: IProjectPublishStore;
} }
export class ProjectRootStore { export class ProjectRootStore {
project: IProjectStore; project: IProjectStore;
projectFilter: IProjectFilterStore;
publish: IProjectPublishStore; publish: IProjectPublishStore;
constructor(_root: RootStore) { constructor(_root: RootStore) {
this.project = new ProjectStore(_root); this.project = new ProjectStore(_root);
this.projectFilter = new ProjectFilterStore(_root);
this.publish = new ProjectPublishStore(this); this.publish = new ProjectPublishStore(this);
} }
} }

View File

@ -1,4 +1,3 @@
import { cloneDeep, update } from "lodash";
import set from "lodash/set"; import set from "lodash/set";
import sortBy from "lodash/sortBy"; import sortBy from "lodash/sortBy";
import { observable, action, computed, makeObservable, runInAction } from "mobx"; 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 { ProjectService, ProjectStateService } from "services/project";
import { IProject } from "@plane/types"; import { IProject } from "@plane/types";
import { RootStore } from "../root.store"; import { RootStore } from "../root.store";
import { orderProjects, shouldFilterProject } from "helpers/project.helper";
// services // services
export interface IProjectStore { export interface IProjectStore {
// observables // observables
searchQuery: string;
projectMap: { projectMap: {
[projectId: string]: IProject; // projectId: project Info [projectId: string]: IProject; // projectId: project Info
}; };
// computed // computed
searchedProjects: string[]; filteredProjectIds: string[] | undefined;
workspaceProjectIds: string[] | null; workspaceProjectIds: string[] | undefined;
joinedProjectIds: string[]; joinedProjectIds: string[];
favoriteProjectIds: string[]; favoriteProjectIds: string[];
currentProjectDetails: IProject | undefined; currentProjectDetails: IProject | undefined;
// actions // actions
setSearchQuery: (query: string) => void;
getProjectById: (projectId: string) => IProject | null; getProjectById: (projectId: string) => IProject | null;
getProjectIdentifierById: (projectId: string) => string; getProjectIdentifierById: (projectId: string) => string;
// fetch actions // fetch actions
fetchProjects: (workspaceSlug: string) => Promise<IProject[]>; fetchProjects: (workspaceSlug: string) => Promise<IProject[]>;
fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise<any>; fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise<IProject>;
// favorites actions // favorites actions
addProjectToFavorites: (workspaceSlug: string, projectId: string) => Promise<any>; addProjectToFavorites: (workspaceSlug: string, projectId: string) => Promise<any>;
removeProjectFromFavorites: (workspaceSlug: string, projectId: string) => Promise<any>; removeProjectFromFavorites: (workspaceSlug: string, projectId: string) => Promise<any>;
// project-view action // project-view action
updateProjectView: (workspaceSlug: string, projectId: string, viewProps: any) => Promise<any>; updateProjectView: (workspaceSlug: string, projectId: string, viewProps: any) => Promise<any>;
// CRUD actions // CRUD actions
createProject: (workspaceSlug: string, data: any) => Promise<any>; createProject: (workspaceSlug: string, data: Partial<IProject>) => Promise<IProject>;
updateProject: (workspaceSlug: string, projectId: string, data: Partial<IProject>) => Promise<any>; updateProject: (workspaceSlug: string, projectId: string, data: Partial<IProject>) => Promise<IProject>;
deleteProject: (workspaceSlug: string, projectId: string) => Promise<void>; deleteProject: (workspaceSlug: string, projectId: string) => Promise<void>;
} }
export class ProjectStore implements IProjectStore { export class ProjectStore implements IProjectStore {
// observables // observables
searchQuery: string = "";
projectMap: { projectMap: {
[projectId: string]: IProject; // projectId: project Info [projectId: string]: IProject; // projectId: project Info
} = {}; } = {};
@ -56,16 +53,13 @@ export class ProjectStore implements IProjectStore {
constructor(_rootStore: RootStore) { constructor(_rootStore: RootStore) {
makeObservable(this, { makeObservable(this, {
// observables // observables
searchQuery: observable.ref,
projectMap: observable, projectMap: observable,
// computed // computed
searchedProjects: computed, filteredProjectIds: computed,
workspaceProjectIds: computed, workspaceProjectIds: computed,
currentProjectDetails: computed, currentProjectDetails: computed,
joinedProjectIds: computed, joinedProjectIds: computed,
favoriteProjectIds: computed, favoriteProjectIds: computed,
// actions
setSearchQuery: action.bound,
// fetch actions // fetch actions
fetchProjects: action, fetchProjects: action,
fetchProjectDetails: 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; const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace;
if (!workspaceDetails) return []; const {
const workspaceProjects = Object.values(this.projectMap).filter( currentWorkspaceDisplayFilters: displayFilters,
currentWorkspaceFilters: filters,
searchQuery,
} = this.rootStore.projectRoot.projectFilter;
if (!workspaceDetails || !displayFilters || !filters) return;
let workspaceProjects = Object.values(this.projectMap).filter(
(p) => (p) =>
p.workspace === workspaceDetails.id && p.workspace === workspaceDetails.id &&
(p.name.toLowerCase().includes(this.searchQuery.toLowerCase()) || (p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.identifier.toLowerCase().includes(this.searchQuery.toLowerCase())) p.identifier.toLowerCase().includes(searchQuery.toLowerCase())) &&
shouldFilterProject(p, displayFilters, filters)
); );
workspaceProjects = orderProjects(workspaceProjects, displayFilters.order_by);
return workspaceProjects.map((p) => p.id); return workspaceProjects.map((p) => p.id);
} }
@ -107,7 +108,7 @@ export class ProjectStore implements IProjectStore {
*/ */
get workspaceProjectIds() { get workspaceProjectIds() {
const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace; 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 workspaceProjects = Object.values(this.projectMap).filter((p) => p.workspace === workspaceDetails.id);
const projectIds = workspaceProjects.map((p) => p.id); const projectIds = workspaceProjects.map((p) => p.id);
return projectIds ?? null; return projectIds ?? null;
@ -153,14 +154,6 @@ export class ProjectStore implements IProjectStore {
return projectIds; return projectIds;
} }
/**
* Sets search query
* @param query
*/
setSearchQuery = (query: string) => {
this.searchQuery = query;
};
/** /**
* get Workspace projects using workspace slug * get Workspace projects using workspace slug
* @param workspaceSlug * @param workspaceSlug

View File

@ -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<string, TProjectDisplayFilters>;
filters: Record<string, TProjectFilters>;
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<string, TProjectDisplayFilters> = {};
filters: Record<string, TProjectFilters> = {};
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] = {};
});
};
}

View File

@ -62,8 +62,8 @@ export class RootStore {
this.label = new LabelStore(this); this.label = new LabelStore(this);
this.estimate = new EstimateStore(this); this.estimate = new EstimateStore(this);
this.mention = new MentionStore(this); this.mention = new MentionStore(this);
this.projectPages = new ProjectPageStore(this);
this.dashboard = new DashboardStore(this); this.dashboard = new DashboardStore(this);
this.projectPages = new ProjectPageStore(this);
} }
resetOnSignout() { resetOnSignout() {
@ -82,7 +82,7 @@ export class RootStore {
this.label = new LabelStore(this); this.label = new LabelStore(this);
this.estimate = new EstimateStore(this); this.estimate = new EstimateStore(this);
this.mention = new MentionStore(this); this.mention = new MentionStore(this);
this.projectPages = new ProjectPageStore(this);
this.dashboard = new DashboardStore(this); this.dashboard = new DashboardStore(this);
this.projectPages = new ProjectPageStore(this);
} }
} }