Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WEB-554] feat: modules filtering, searching and ordering #3947

Merged
merged 8 commits into from
Mar 12, 2024
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 "./projects";
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_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_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
Loading