[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:
parent
c3c6ef8830
commit
69e110f4a8
43 changed files with 1452 additions and 186 deletions
36
web/components/project/applied-filters/access.tsx
Normal file
36
web/components/project/applied-filters/access.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
55
web/components/project/applied-filters/date.tsx
Normal file
55
web/components/project/applied-filters/date.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
4
web/components/project/applied-filters/index.ts
Normal file
4
web/components/project/applied-filters/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./access";
|
||||
export * from "./date";
|
||||
export * from "./members";
|
||||
export * from "./root";
|
||||
46
web/components/project/applied-filters/members.tsx
Normal file
46
web/components/project/applied-filters/members.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
113
web/components/project/applied-filters/root.tsx
Normal file
113
web/components/project/applied-filters/root.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
48
web/components/project/dropdowns/filters/access.tsx
Normal file
48
web/components/project/dropdowns/filters/access.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
64
web/components/project/dropdowns/filters/created-at.tsx
Normal file
64
web/components/project/dropdowns/filters/created-at.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
5
web/components/project/dropdowns/filters/index.ts
Normal file
5
web/components/project/dropdowns/filters/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from "./access";
|
||||
export * from "./created-at";
|
||||
export * from "./lead";
|
||||
export * from "./members";
|
||||
export * from "./root";
|
||||
97
web/components/project/dropdowns/filters/lead.tsx
Normal file
97
web/components/project/dropdowns/filters/lead.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
97
web/components/project/dropdowns/filters/members.tsx
Normal file
97
web/components/project/dropdowns/filters/members.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
96
web/components/project/dropdowns/filters/root.tsx
Normal file
96
web/components/project/dropdowns/filters/root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
2
web/components/project/dropdowns/index.ts
Normal file
2
web/components/project/dropdowns/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./filters";
|
||||
export * from "./order-by";
|
||||
74
web/components/project/dropdowns/order-by.tsx
Normal file
74
web/components/project/dropdowns/order-by.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
export * from "./applied-filters";
|
||||
export * from "./dropdowns";
|
||||
export * from "./publish-project";
|
||||
export * from "./settings";
|
||||
export * from "./card-list";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue