[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

@ -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";
// 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 <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 (
<>
{workspaceProjectIds.length > 0 ? (
<div className="h-full w-full overflow-y-auto p-8 vertical-scrollbar scrollbar-lg">
{searchedProjects.length == 0 ? (
<div className="mt-10 w-full text-center text-custom-text-400">No matching projects</div>
) : (
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
{searchedProjects.map((projectId) => {
const projectDetails = getProjectById(projectId);
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);
}}
/>
)}
</>
<div className="h-full w-full overflow-y-auto p-8 vertical-scrollbar scrollbar-lg">
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
{filteredProjectIds.map((projectId) => {
const projectDetails = getProjectById(projectId);
if (!projectDetails) return;
return <ProjectCard key={projectDetails.id} project={projectDetails} />;
})}
</div>
</div>
);
});

View file

@ -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<ProjectCardProps> = observer((props) => {
export const ProjectCard: React.FC<Props> = 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<ProjectCardProps> = 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<ProjectCardProps> = 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 */}
<DeleteProjectModal
project={project}
isOpen={deleteProjectModalOpen}
@ -94,20 +91,22 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => {
{/* Join Project Modal */}
{workspaceSlug && (
<JoinProjectModal
workspaceSlug={workspaceSlug?.toString()}
workspaceSlug={workspaceSlug.toString()}
project={project}
isOpen={joinProjectModalOpen}
handleClose={() => setJoinProjectModal(false)}
/>
)}
{/* Card Information */}
<div
onClick={() => {
if (project.is_member) router.push(`/${workspaceSlug?.toString()}/projects/${project.id}/issues`);
else setJoinProjectModal(true);
<Link
href={`/${workspaceSlug}/projects/${project.id}/issues`}
onClick={(e) => {
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"
>
<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" />
@ -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"
/>
<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 item-center justify-center h-9 w-9 flex-shrink-0 rounded bg-white/90">
<span className="grid place-items-center">
<ProjectLogo logo={project.logo_props} />
</span>
<div className="h-9 w-9 flex-shrink-0 grid place-items-center rounded bg-white/90">
<ProjectLogo logo={project.logo_props} />
</div>
<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
className="flex h-6 w-6 items-center justify-center rounded bg-white/10"
onClick={(e) => {
if (project.is_favorite) {
e.preventDefault();
e.stopPropagation();
handleRemoveFromFavorites();
} else {
e.preventDefault();
e.stopPropagation();
handleAddToFavorites();
}
e.preventDefault();
e.stopPropagation();
if (project.is_favorite) handleRemoveFromFavorites();
else handleAddToFavorites();
}}
>
<Star
@ -172,7 +164,11 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => {
</div>
<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">
<Tooltip
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>
)}
</Tooltip>
{(isOwner || isMember) && (
<Link
className="flex items-center justify-center rounded p-1 text-custom-text-400 hover:bg-custom-background-80 hover:text-custom-text-200"
onClick={(e) => {
e.stopPropagation();
}}
href={`/${workspaceSlug}/projects/${project.id}/settings`}
>
<Pencil className="h-3.5 w-3.5" />
</Link>
)}
{!project.is_member ? (
{project.is_member &&
(isOwner || isMember ? (
<Link
className="flex items-center justify-center rounded p-1 text-custom-text-400 hover:bg-custom-background-80 hover:text-custom-text-200"
onClick={(e) => {
e.stopPropagation();
}}
href={`/${workspaceSlug}/projects/${project.id}/settings`}
>
<Pencil className="h-3.5 w-3.5" />
</Link>
) : (
<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">
<Button
variant="link-primary"
@ -223,10 +224,10 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => {
Join
</Button>
</div>
) : null}
)}
</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 "./settings";
export * from "./card-list";