Skip to content

Commit

Permalink
[WEB-554] feat: modules filtering, searching and ordering (#3947)
Browse files Browse the repository at this point in the history
* feat: modules filtering, searching and ordering implemented

* fix: modules ordering

* chore: total issues in list endpoint

* fix: modules ordering

* fix: build errors

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
  • Loading branch information
aaryan610 and NarayanBavisetti authored Mar 12, 2024
1 parent 69e110f commit b930d98
Show file tree
Hide file tree
Showing 35 changed files with 1,454 additions and 51 deletions.
1 change: 1 addition & 0 deletions apiserver/plane/app/views/cycle/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ def list(self, request, slug, project_id):
"external_id",
"progress_snapshot",
# meta fields
"total_issues",
"is_favorite",
"cancelled_issues",
"completed_issues",
Expand Down
10 changes: 10 additions & 0 deletions apiserver/plane/app/views/module/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ def get_queryset(self):
),
)
)
.annotate(
total_issues=Count(
"issue_module",
filter=Q(
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
),
)
.annotate(
completed_issues=Count(
"issue_module__issue__state__group",
Expand Down Expand Up @@ -214,6 +223,7 @@ def list(self, request, slug, project_id):
"external_source",
"external_id",
# computed fields
"total_issues",
"is_favorite",
"cancelled_issues",
"completed_issues",
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export * from "./dashboard";
export * from "./project";
export * from "./state";
export * from "./issues";
export * from "./modules";
export * from "./module";
export * from "./views";
export * from "./integration";
export * from "./pages";
Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/module/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./module_filters";
export * from "./modules";
32 changes: 32 additions & 0 deletions packages/types/src/module/module_filters.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export type TModuleOrderByOptions =
| "name"
| "-name"
| "progress"
| "-progress"
| "issues_length"
| "-issues_length"
| "target_date"
| "-target_date"
| "created_at"
| "-created_at";

export type TModuleLayoutOptions = "list" | "board" | "gantt";

export type TModuleDisplayFilters = {
favorites?: boolean;
layout?: TModuleLayoutOptions;
order_by?: TModuleOrderByOptions;
};

export type TModuleFilters = {
lead?: string[] | null;
members?: string[] | null;
start_date?: string[] | null;
status?: string[] | null;
target_date?: string[] | null;
};

export type TModuleStoredFilters = {
display_filters?: TModuleDisplayFilters;
filters?: TModuleFilters;
};
File renamed without changes.
7 changes: 5 additions & 2 deletions web/components/cycles/cycles-view-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => {
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
else setIsSearchOpen(false);
else {
setIsSearchOpen(false);
inputRef.current?.blur();
}
}
};

Expand Down Expand Up @@ -107,7 +110,7 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => {
<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"
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
placeholder="Search"
value={searchQuery}
onChange={(e) => updateSearchQuery(e.target.value)}
Expand Down
170 changes: 149 additions & 21 deletions web/components/headers/modules-list.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,90 @@
import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
// icons
import { GanttChartSquare, LayoutGrid, List, Plus } from "lucide-react";
// ui
import { Breadcrumbs, Button, Tooltip, DiceIcon, CustomMenu } from "@plane/ui";
import { GanttChartSquare, LayoutGrid, List, ListFilter, Plus, Search, X } from "lucide-react";
// hooks
import { useApplication, useEventTracker, useMember, useModuleFilter, useProject, useUser } from "hooks/store";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { BreadcrumbLink } from "components/common";
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
import { ProjectLogo } from "components/project";
import { ModuleFiltersSelection, ModuleOrderByDropdown } from "components/modules";
import { FiltersDropdown } from "components/issues";
// ui
import { Breadcrumbs, Button, Tooltip, DiceIcon, CustomMenu } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
// types
import { TModuleFilters } from "@plane/types";
// constants
import { MODULE_VIEW_LAYOUTS } from "constants/module";
import { EUserProjectRoles } from "constants/project";
// hooks
import { useApplication, useEventTracker, useProject, useUser } from "hooks/store";
import useLocalStorage from "hooks/use-local-storage";
import { ProjectLogo } from "components/project";

export const ModulesListHeader: React.FC = observer(() => {
// states
const [isSearchOpen, setIsSearchOpen] = useState(false);
// refs
const inputRef = useRef<HTMLInputElement>(null);
// router
const router = useRouter();
const { workspaceSlug } = router.query;
const { workspaceSlug, projectId } = router.query;
// store hooks
const { commandPalette: commandPaletteStore } = useApplication();
const { setTrackElement } = useEventTracker();
const {
membership: { currentProjectRole },
} = useUser();
const { currentProjectDetails } = useProject();
const {
workspace: { workspaceMemberIds },
} = useMember();
const {
currentProjectDisplayFilters: displayFilters,
currentProjectFilters: filters,
searchQuery,
updateDisplayFilters,
updateFilters,
updateSearchQuery,
} = useModuleFilter();
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
});

const { storedValue: modulesView, setValue: setModulesView } = useLocalStorage("modules_view", "grid");
const handleFilters = useCallback(
(key: keyof TModuleFilters, value: string | string[]) => {
if (!projectId) return;
const newValues = filters?.[key] ?? [];

if (Array.isArray(value))
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
});
else {
if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}

updateFilters(projectId.toString(), { [key]: newValues });
},
[filters, projectId, updateFilters]
);

const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
else {
setIsSearchOpen(false);
inputRef.current?.blur();
}
}
};

// auth
const canUserCreateModule =
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);

return (
<div>
<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">
Expand Down Expand Up @@ -62,26 +116,97 @@ export const ModulesListHeader: React.FC = observer(() => {
</div>
</div>
<div className="flex items-center gap-2">
<div className="items-center gap-1 rounded bg-custom-background-80 p-1 hidden md:flex">
<div className="flex items-center">
{!isSearchOpen && (
<button
type="button"
className="-mr-1 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
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 placeholder:text-custom-text-400 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 className="hidden md:flex items-center gap-1 rounded bg-custom-background-80 p-1">
{MODULE_VIEW_LAYOUTS.map((layout) => (
<Tooltip key={layout.key} tooltipContent={layout.title}>
<button
type="button"
className={`group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100 ${
modulesView == layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : ""
}`}
onClick={() => setModulesView(layout.key)}
className={cn(
"group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100",
{
"bg-custom-background-100 shadow-custom-shadow-2xs": displayFilters?.layout === layout.key,
}
)}
onClick={() => {
if (!projectId) return;
updateDisplayFilters(projectId.toString(), { layout: layout.key });
}}
>
<layout.icon
strokeWidth={2}
className={`h-3.5 w-3.5 ${
modulesView == layout.key ? "text-custom-text-100" : "text-custom-text-200"
}`}
className={cn("h-3.5 w-3.5 text-custom-text-200", {
"text-custom-text-100": displayFilters?.layout === layout.key,
})}
/>
</button>
</Tooltip>
))}
</div>
<ModuleOrderByDropdown
value={displayFilters?.order_by}
onChange={(val) => {
if (!projectId || val === displayFilters?.order_by) return;
updateDisplayFilters(projectId.toString(), {
order_by: val,
});
}}
/>
<FiltersDropdown icon={<ListFilter className="h-3 w-3" />} title="Filters" placement="bottom-end">
<ModuleFiltersSelection
displayFilters={displayFilters ?? {}}
filters={filters ?? {}}
handleDisplayFiltersUpdate={(val) => {
if (!projectId) return;
updateDisplayFilters(projectId.toString(), val);
}}
handleFiltersUpdate={handleFilters}
memberIds={workspaceMemberIds ?? undefined}
/>
</FiltersDropdown>
{canUserCreateModule && (
<Button
variant="primary"
Expand All @@ -104,9 +229,9 @@ export const ModulesListHeader: React.FC = observer(() => {
// placement="bottom-start"
customButton={
<span className="flex items-center gap-2">
{modulesView === "gantt_chart" ? (
{displayFilters?.layout === "gantt" ? (
<GanttChartSquare className="w-3 h-3" />
) : modulesView === "grid" ? (
) : displayFilters?.layout === "board" ? (
<LayoutGrid className="w-3 h-3" />
) : (
<List className="w-3 h-3" />
Expand All @@ -120,7 +245,10 @@ export const ModulesListHeader: React.FC = observer(() => {
{MODULE_VIEW_LAYOUTS.map((layout) => (
<CustomMenu.MenuItem
key={layout.key}
onClick={() => setModulesView(layout.key)}
onClick={() => {
if (!projectId) return;
updateDisplayFilters(projectId.toString(), { layout: layout.key });
}}
className="flex items-center gap-2"
>
<layout.icon className="w-3 h-3" />
Expand Down
56 changes: 56 additions & 0 deletions web/components/modules/applied-filters/date.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { observer } from "mobx-react-lite";
// icons
import { X } from "lucide-react";
// helpers
import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters";
import { renderFormattedDate } from "helpers/date-time.helper";
import { capitalizeFirstLetter } from "helpers/string.helper";
// constants

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_AFTER_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 changes: 4 additions & 0 deletions web/components/modules/applied-filters/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./date";
export * from "./members";
export * from "./root";
export * from "./status";
Loading

0 comments on commit b930d98

Please sign in to comment.