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

* 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>
This commit is contained in:
Aaryan Khandelwal 2024-03-12 20:24:21 +05:30 committed by GitHub
parent 69e110f4a8
commit b930d98665
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1454 additions and 51 deletions

View File

@ -354,6 +354,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"external_id",
"progress_snapshot",
# meta fields
"total_issues",
"is_favorite",
"cancelled_issues",
"completed_issues",

View File

@ -79,6 +79,15 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
),
)
)
.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",
@ -214,6 +223,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"external_source",
"external_id",
# computed fields
"total_issues",
"is_favorite",
"cancelled_issues",
"completed_issues",

View File

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

View File

@ -0,0 +1,2 @@
export * from "./module_filters";
export * from "./modules";

View File

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

View File

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

View File

@ -1,24 +1,34 @@
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();
@ -26,11 +36,55 @@ export const ModulesListHeader: React.FC = observer(() => {
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">
@ -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"
@ -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" />
@ -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" />

View File

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

View File

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

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,88 @@
import { X } from "lucide-react";
// components
import { AppliedDateFilters, AppliedMembersFilters, AppliedStatusFilters } from "components/modules";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// types
import { TModuleFilters } from "@plane/types";
type Props = {
appliedFilters: TModuleFilters;
handleClearAllFilters: () => void;
handleRemoveFilter: (key: keyof TModuleFilters, value: string | null) => void;
alwaysAllowEditing?: boolean;
};
const MEMBERS_FILTERS = ["lead", "members"];
const DATE_FILTERS = ["start_date", "target_date"];
export const ModuleAppliedFiltersList: React.FC<Props> = (props) => {
const { appliedFilters, handleClearAllFilters, handleRemoveFilter, alwaysAllowEditing } = props;
if (!appliedFilters) return null;
if (Object.keys(appliedFilters).length === 0) return null;
const isEditingAllowed = alwaysAllowEditing;
return (
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">
{Object.entries(appliedFilters).map(([key, value]) => {
const filterKey = key as keyof TModuleFilters;
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 === "status" && (
<AppliedStatusFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter("status", 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>
);
};

View File

@ -0,0 +1,41 @@
import { observer } from "mobx-react-lite";
import { X } from "lucide-react";
// ui
import { ModuleStatusIcon } from "@plane/ui";
// constants
import { MODULE_STATUS } from "constants/module";
type Props = {
handleRemove: (val: string) => void;
values: string[];
editable: boolean | undefined;
};
export const AppliedStatusFilters: React.FC<Props> = observer((props) => {
const { handleRemove, values, editable } = props;
return (
<>
{values.map((status) => {
const statusDetails = MODULE_STATUS?.find((s) => s.value === status);
if (!statusDetails) return null;
return (
<div key={status} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<ModuleStatusIcon status={statusDetails.value} height="12px" width="12px" />
{statusDetails.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,6 @@
export * from "./lead";
export * from "./members";
export * from "./root";
export * from "./start-date";
export * from "./status";
export * from "./target-date";

View File

@ -0,0 +1,96 @@
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(),
]);
}, [appliedFilters, getUserDetails, memberIds, , 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,106 @@
import { useState } from "react";
import { observer } from "mobx-react-lite";
import { Search, X } from "lucide-react";
// components
import { FilterLead, FilterMembers, FilterStartDate, FilterStatus, FilterTargetDate } from "components/modules";
import { FilterOption } from "components/issues";
// types
import { TModuleDisplayFilters, TModuleFilters } from "@plane/types";
import { TModuleStatus } from "@plane/ui";
type Props = {
displayFilters: TModuleDisplayFilters;
filters: TModuleFilters;
handleDisplayFiltersUpdate: (updatedDisplayProperties: Partial<TModuleDisplayFilters>) => void;
handleFiltersUpdate: (key: keyof TModuleFilters, value: string | string[]) => void;
memberIds?: string[] | undefined;
};
export const ModuleFiltersSelection: React.FC<Props> = observer((props) => {
const { displayFilters, filters, handleDisplayFiltersUpdate, handleFiltersUpdate, 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.favorites}
onClick={() =>
handleDisplayFiltersUpdate({
favorites: !displayFilters.favorites,
})
}
title="Favorites"
/>
</div>
{/* status */}
<div className="py-2">
<FilterStatus
appliedFilters={(filters.status as TModuleStatus[]) ?? null}
handleUpdate={(val) => handleFiltersUpdate("status", 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>
{/* start date */}
<div className="py-2">
<FilterStartDate
appliedFilters={filters.start_date ?? null}
handleUpdate={(val) => handleFiltersUpdate("start_date", val)}
searchQuery={filtersSearchQuery}
/>
</div>
{/* target date */}
<div className="py-2">
<FilterTargetDate
appliedFilters={filters.target_date ?? null}
handleUpdate={(val) => handleFiltersUpdate("target_date", val)}
searchQuery={filtersSearchQuery}
/>
</div>
</div>
</div>
);
});

View File

@ -0,0 +1,65 @@
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_AFTER_FILTER_OPTIONS } from "constants/filters";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string | string[]) => void;
searchQuery: string;
};
export const FilterStartDate: 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_AFTER_FILTER_OPTIONS.filter((d) =>
d.name.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<>
{isDateFilterModalOpen && (
<DateFilterModal
handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen}
onSelect={(val) => handleUpdate(val)}
title="Start date"
/>
)}
<FilterHeader
title={`Start 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,52 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
// components
import { FilterHeader, FilterOption } from "components/issues";
// ui
import { ModuleStatusIcon } from "@plane/ui";
// types
import { TModuleStatus } from "@plane/types";
// constants
import { MODULE_STATUS } from "constants/module";
type Props = {
appliedFilters: TModuleStatus[] | null;
handleUpdate: (val: string) => void;
searchQuery: string;
};
export const FilterStatus: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery } = props;
// states
const [previewEnabled, setPreviewEnabled] = useState(true);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = MODULE_STATUS.filter((p) => p.value.includes(searchQuery.toLowerCase()));
return (
<>
<FilterHeader
title={`Status${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
filteredOptions.map((status) => (
<FilterOption
key={status.value}
isChecked={appliedFilters?.includes(status.value) ? true : false}
onClick={() => handleUpdate(status.value)}
icon={<ModuleStatusIcon status={status.value} />}
title={status.label}
/>
))
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@ -0,0 +1,65 @@
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_AFTER_FILTER_OPTIONS } from "constants/filters";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string | string[]) => void;
searchQuery: string;
};
export const FilterTargetDate: 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_AFTER_FILTER_OPTIONS.filter((d) =>
d.name.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<>
{isDateFilterModalOpen && (
<DateFilterModal
handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen}
onSelect={(val) => handleUpdate(val)}
title="Due date"
/>
)}
<FilterHeader
title={`Due 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,2 @@
export * from "./filters";
export * from "./order-by";

View File

@ -0,0 +1,70 @@
import { ArrowDownWideNarrow, Check, ChevronDown } from "lucide-react";
// ui
import { CustomMenu, getButtonStyling } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
// types
import { TModuleOrderByOptions } from "@plane/types";
// constants
import { MODULE_ORDER_BY_OPTIONS } from "constants/module";
type Props = {
onChange: (value: TModuleOrderByOptions) => void;
value: TModuleOrderByOptions | undefined;
};
export const ModuleOrderByDropdown: React.FC<Props> = (props) => {
const { onChange, value } = props;
const orderByDetails = MODULE_ORDER_BY_OPTIONS.find((option) => value?.includes(option.key));
const isDescending = value?.[0] === "-";
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"
maxHeight="lg"
closeOnSelect
>
{MODULE_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 TModuleOrderByOptions);
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 TModuleOrderByOptions);
}}
>
Ascending
{!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 TModuleOrderByOptions);
}}
>
Descending
{isDescending && <Check className="h-3 w-3" />}
</CustomMenu.MenuItem>
</CustomMenu>
);
};

View File

@ -11,10 +11,12 @@ import { IModule } from "@plane/types";
export const ModulesListGanttChartView: React.FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug } = router.query;
const { workspaceSlug, projectId } = router.query;
// store
const { currentProjectDetails } = useProject();
const { projectModuleIds, moduleMap, updateModuleDetails } = useModule();
const { getFilteredModuleIds, moduleMap, updateModuleDetails } = useModule();
// derived values
const filteredModuleIds = projectId ? getFilteredModuleIds(projectId.toString()) : undefined;
const handleModuleUpdate = async (module: IModule, data: IBlockUpdateData) => {
if (!workspaceSlug || !module) return;
@ -44,7 +46,7 @@ export const ModulesListGanttChartView: React.FC = observer(() => {
<GanttChartRoot
title="Modules"
loaderTitle="Modules"
blocks={projectModuleIds ? blockFormat(projectModuleIds) : null}
blocks={filteredModuleIds ? blockFormat(filteredModuleIds) : null}
sidebarToRender={(props) => <ModuleGanttSidebar {...props} />}
blockUpdateHandler={(block, payload) => handleModuleUpdate(block, payload)}
blockToRender={(data: IModule) => <ModuleGanttBlock moduleId={data.id} />}

View File

@ -1,3 +1,5 @@
export * from "./applied-filters";
export * from "./dropdowns";
export * from "./select";
export * from "./sidebar-select";
export * from "./delete-module-modal";

View File

@ -1,13 +1,16 @@
import Image from "next/image";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
// hooks
import { useApplication, useEventTracker, useModule } from "hooks/store";
import useLocalStorage from "hooks/use-local-storage";
import { useApplication, useEventTracker, useModule, useModuleFilter } from "hooks/store";
// components
import { ModuleCardItem, ModuleListItem, ModulePeekOverview, ModulesListGanttChartView } from "components/modules";
import { EmptyState } from "components/empty-state";
// ui
import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui";
// assets
import NameFilterImage from "public/empty-state/module/name-filter.svg";
import AllFiltersImage from "public/empty-state/module/all-filters.svg";
// constants
import { EmptyStateType } from "constants/empty-state";
@ -18,29 +21,48 @@ export const ModulesListView: React.FC = observer(() => {
// store hooks
const { commandPalette: commandPaletteStore } = useApplication();
const { setTrackElement } = useEventTracker();
const { getFilteredModuleIds, loader } = useModule();
const { currentProjectDisplayFilters: displayFilters, searchQuery } = useModuleFilter();
// derived values
const filteredModuleIds = projectId ? getFilteredModuleIds(projectId.toString()) : undefined;
const { projectModuleIds, loader } = useModule();
const { storedValue: modulesView } = useLocalStorage("modules_view", "grid");
if (loader || !projectModuleIds)
if (loader || !filteredModuleIds)
return (
<>
{modulesView === "list" && <CycleModuleListLayout />}
{modulesView === "grid" && <CycleModuleBoardLayout />}
{modulesView === "gantt_chart" && <GanttLayoutLoader />}
{displayFilters?.layout === "list" && <CycleModuleListLayout />}
{displayFilters?.layout === "board" && <CycleModuleBoardLayout />}
{displayFilters?.layout === "gantt" && <GanttLayoutLoader />}
</>
);
if (filteredModuleIds.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 modules"
/>
<h5 className="text-xl font-medium mt-7 mb-1">No matching modules</h5>
<p className="text-custom-text-400 text-base">
{searchQuery.trim() === ""
? "Remove the filters to see all modules"
: "Remove the search criteria to see all modules"}
</p>
</div>
</div>
);
return (
<>
{projectModuleIds.length > 0 ? (
{filteredModuleIds.length > 0 ? (
<>
{modulesView === "list" && (
{displayFilters?.layout === "list" && (
<div className="h-full overflow-y-auto">
<div className="flex h-full w-full justify-between">
<div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">
{projectModuleIds.map((moduleId) => (
{filteredModuleIds.map((moduleId) => (
<ModuleListItem key={moduleId} moduleId={moduleId} />
))}
</div>
@ -51,7 +73,7 @@ export const ModulesListView: React.FC = observer(() => {
</div>
</div>
)}
{modulesView === "grid" && (
{displayFilters?.layout === "board" && (
<div className="h-full w-full">
<div className="flex h-full w-full justify-between">
<div
@ -61,7 +83,7 @@ export const ModulesListView: React.FC = observer(() => {
: "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4"
} auto-rows-max transition-all vertical-scrollbar scrollbar-lg`}
>
{projectModuleIds.map((moduleId) => (
{filteredModuleIds.map((moduleId) => (
<ModuleCardItem key={moduleId} moduleId={moduleId} />
))}
</div>
@ -72,7 +94,7 @@ export const ModulesListView: React.FC = observer(() => {
</div>
</div>
)}
{modulesView === "gantt_chart" && <ModulesListGanttChartView />}
{displayFilters?.layout === "gantt" && <ModulesListGanttChartView />}
</>
) : (
<EmptyState

View File

@ -1,6 +1,6 @@
import { GanttChartSquare, LayoutGrid, List } from "lucide-react";
// types
import { TModuleStatus } from "@plane/types";
import { TModuleLayoutOptions, TModuleOrderByOptions, TModuleStatus } from "@plane/types";
export const MODULE_STATUS: {
label: string;
@ -53,20 +53,43 @@ export const MODULE_STATUS: {
},
];
export const MODULE_VIEW_LAYOUTS: { key: "list" | "grid" | "gantt_chart"; icon: any; title: string }[] = [
export const MODULE_VIEW_LAYOUTS: { key: TModuleLayoutOptions; icon: any; title: string }[] = [
{
key: "list",
icon: List,
title: "List layout",
},
{
key: "grid",
key: "board",
icon: LayoutGrid,
title: "Grid layout",
},
{
key: "gantt_chart",
key: "gantt",
icon: GanttChartSquare,
title: "Gantt layout",
},
];
export const MODULE_ORDER_BY_OPTIONS: { key: TModuleOrderByOptions; label: string }[] = [
{
key: "name",
label: "Name",
},
{
key: "progress",
label: "Progress",
},
{
key: "issues_length",
label: "Number of issues",
},
{
key: "target_date",
label: "Due date",
},
{
key: "created_at",
label: "Created date",
},
];

View File

@ -0,0 +1,80 @@
import sortBy from "lodash/sortBy";
// helpers
import { satisfiesDateFilter } from "helpers/filter.helper";
// types
import { IModule, TModuleDisplayFilters, TModuleFilters, TModuleOrderByOptions } from "@plane/types";
/**
* @description orders modules based on their status
* @param {IModule[]} modules
* @param {TModuleOrderByOptions | undefined} orderByKey
* @returns {IModule[]}
*/
export const orderModules = (modules: IModule[], orderByKey: TModuleOrderByOptions | undefined): IModule[] => {
let orderedModules: IModule[] = [];
if (modules.length === 0 || !orderByKey) return [];
if (orderByKey === "name") orderedModules = sortBy(modules, [(m) => m.name.toLowerCase()]);
if (orderByKey === "-name") orderedModules = sortBy(modules, [(m) => m.name.toLowerCase()]).reverse();
if (["progress", "-progress"].includes(orderByKey))
orderedModules = sortBy(modules, [
(m) => {
let progress = (m.completed_issues + m.cancelled_issues) / m.total_issues;
if (isNaN(progress)) progress = 0;
return orderByKey === "progress" ? progress : !progress;
},
"name",
]);
if (["issues_length", "-issues_length"].includes(orderByKey))
orderedModules = sortBy(modules, [
(m) => (orderByKey === "issues_length" ? m.total_issues : !m.total_issues),
"name",
]);
if (orderByKey === "target_date") orderedModules = sortBy(modules, [(m) => m.target_date]);
if (orderByKey === "-target_date") orderedModules = sortBy(modules, [(m) => !m.target_date]);
if (orderByKey === "created_at") orderedModules = sortBy(modules, [(m) => m.created_at]);
if (orderByKey === "-created_at") orderedModules = sortBy(modules, [(m) => !m.created_at]);
return orderedModules;
};
/**
* @description filters modules based on the filters
* @param {IModule} module
* @param {TModuleDisplayFilters} displayFilters
* @param {TModuleFilters} filters
* @returns {boolean}
*/
export const shouldFilterModule = (
module: IModule,
displayFilters: TModuleDisplayFilters,
filters: TModuleFilters
): boolean => {
let fallsInFilters = true;
Object.keys(filters).forEach((key) => {
const filterKey = key as keyof TModuleFilters;
if (filterKey === "status" && filters.status && filters.status.length > 0)
fallsInFilters = fallsInFilters && filters.status.includes(module.status.toLowerCase());
if (filterKey === "lead" && filters.lead && filters.lead.length > 0)
fallsInFilters = fallsInFilters && filters.lead.includes(`${module.lead_id}`);
if (filterKey === "members" && filters.members && filters.members.length > 0) {
const memberIds = module.member_ids;
fallsInFilters = fallsInFilters && filters.members.some((memberId) => memberIds.includes(memberId));
}
if (filterKey === "start_date" && filters.start_date && filters.start_date.length > 0) {
filters.start_date.forEach((dateFilter) => {
fallsInFilters =
fallsInFilters && !!module.start_date && satisfiesDateFilter(new Date(module.start_date), dateFilter);
});
}
if (filterKey === "target_date" && filters.target_date && filters.target_date.length > 0) {
filters.target_date.forEach((dateFilter) => {
fallsInFilters =
fallsInFilters && !!module.target_date && satisfiesDateFilter(new Date(module.target_date), dateFilter);
});
}
});
if (displayFilters.favorites && !module.is_favorite) fallsInFilters = false;
return fallsInFilters;
};

View File

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

View File

@ -0,0 +1,11 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "contexts/store-context";
// types
import { IModuleFilterStore } from "store/module_filter.store";
export const useModuleFilter = (): IModuleFilterStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useModuleFilter must be used within StoreProvider");
return context.moduleFilter;
};

View File

@ -1,30 +1,58 @@
import { ReactElement } from "react";
import { ReactElement, useCallback } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
// layouts
// components
import { PageHead } from "components/core";
import { ModulesListHeader } from "components/headers";
import { ModulesListView } from "components/modules";
import { ModuleAppliedFiltersList, ModulesListView } from "components/modules";
// types
// hooks
import { useProject } from "hooks/store";
import { useModuleFilter, useProject } from "hooks/store";
import { AppLayout } from "layouts/app-layout";
import { NextPageWithLayout } from "lib/types";
import { calculateTotalFilters } from "helpers/filter.helper";
import { TModuleFilters } from "@plane/types";
const ProjectModulesPage: NextPageWithLayout = observer(() => {
const router = useRouter();
const { projectId } = router.query;
// store
const { getProjectById } = useProject();
const { currentProjectFilters, clearAllFilters, updateFilters } = useModuleFilter();
// derived values
const project = projectId ? getProjectById(projectId.toString()) : undefined;
const pageTitle = project?.name ? `${project?.name} - Modules` : undefined;
const handleRemoveFilter = useCallback(
(key: keyof TModuleFilters, value: string | null) => {
if (!projectId) return;
let newValues = currentProjectFilters?.[key] ?? [];
if (!value) newValues = [];
else newValues = newValues.filter((val) => val !== value);
updateFilters(projectId.toString(), { [key]: newValues });
},
[currentProjectFilters, projectId, updateFilters]
);
return (
<>
<PageHead title={pageTitle} />
<ModulesListView />
<div className="h-full w-full flex flex-col">
{calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && (
<div className="border-b border-custom-border-200 px-5 py-3">
<ModuleAppliedFiltersList
appliedFilters={currentProjectFilters ?? {}}
handleClearAllFilters={() => clearAllFilters(`${projectId}`)}
handleRemoveFilter={handleRemoveFilter}
alwaysAllowEditing
/>
</div>
)}
<ModulesListView />
</div>
</>
);
});

View File

@ -0,0 +1,45 @@
<svg width="205" height="217" viewBox="0 0 205 217" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="102.5" cy="102.5" r="102.5" fill="url(#paint0_linear_8097_110478)"/>
<path d="M146.167 47.5H59.8333C53.0218 47.5 47.5 53.0218 47.5 59.8333V146.167C47.5 152.978 53.0218 158.5 59.8333 158.5H146.167C152.978 158.5 158.5 152.978 158.5 146.167V59.8333C158.5 53.0218 152.978 47.5 146.167 47.5Z" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M83.1297 72.167H73.5371C72.7802 72.167 72.1667 72.7805 72.1667 73.5374V83.13C72.1667 83.8868 72.7802 84.5003 73.5371 84.5003H83.1297C83.8865 84.5003 84.5 83.8868 84.5 83.13V73.5374C84.5 72.7805 83.8865 72.167 83.1297 72.167Z" fill="#80838D" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M83.1297 121.5H73.5371C72.7802 121.5 72.1667 122.114 72.1667 122.87V132.463C72.1667 133.22 72.7802 133.833 73.5371 133.833H83.1297C83.8865 133.833 84.5 133.22 84.5 132.463V122.87C84.5 122.114 83.8865 121.5 83.1297 121.5Z" fill="#80838D" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M132.463 72.167H122.87C122.114 72.167 121.5 72.7805 121.5 73.5374V83.13C121.5 83.8868 122.114 84.5003 122.87 84.5003H132.463C133.22 84.5003 133.833 83.8868 133.833 83.13V73.5374C133.833 72.7805 133.22 72.167 132.463 72.167Z" fill="#80838D" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M132.463 121.5H122.87C122.114 121.5 121.5 122.114 121.5 122.87V132.463C121.5 133.22 122.114 133.833 122.87 133.833H132.463C133.22 133.833 133.833 133.22 133.833 132.463V122.87C133.833 122.114 133.22 121.5 132.463 121.5Z" fill="#80838D" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<g filter="url(#filter0_ddd_8097_110478)">
<circle cx="103" cy="173.828" r="31" fill="#3A5BC7"/>
<path d="M91.9961 166.5H114.496" stroke="#F9F9FB" stroke-width="3.57143" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M96.9961 174H109.496" stroke="#F9F9FB" stroke-width="3.57143" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M100.746 181.5H105.746" stroke="#F9F9FB" stroke-width="3.57143" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<filter id="filter0_ddd_8097_110478" x="64" y="137.828" width="78" height="79" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="2" operator="erode" in="SourceAlpha" result="effect1_dropShadow_8097_110478"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0 0 0 0.051 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_8097_110478"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect2_dropShadow_8097_110478"/>
<feOffset dy="3"/>
<feGaussianBlur stdDeviation="6"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.055 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_8097_110478" result="effect2_dropShadow_8097_110478"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="8" operator="erode" in="SourceAlpha" result="effect3_dropShadow_8097_110478"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="8"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.078 0"/>
<feBlend mode="normal" in2="effect2_dropShadow_8097_110478" result="effect3_dropShadow_8097_110478"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect3_dropShadow_8097_110478" result="shape"/>
</filter>
<linearGradient id="paint0_linear_8097_110478" x1="102.5" y1="0" x2="102.5" y2="207.52" gradientUnits="userSpaceOnUse">
<stop stop-color="#F7F7F7"/>
<stop offset="1" stop-color="#F8F9FA" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,44 @@
<svg width="206" height="217" viewBox="0 0 206 217" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="103" cy="102.5" r="102.5" fill="url(#paint0_linear_8097_112031)"/>
<path d="M146.667 47.5H60.3333C53.5218 47.5 48 53.0218 48 59.8333V146.167C48 152.978 53.5218 158.5 60.3333 158.5H146.667C153.478 158.5 159 152.978 159 146.167V59.8333C159 53.0218 153.478 47.5 146.667 47.5Z" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M83.6297 72.167H74.0371C73.2803 72.167 72.6667 72.7805 72.6667 73.5374V83.13C72.6667 83.8868 73.2803 84.5003 74.0371 84.5003H83.6297C84.3865 84.5003 85.0001 83.8868 85.0001 83.13V73.5374C85.0001 72.7805 84.3865 72.167 83.6297 72.167Z" fill="#80838D" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M83.6297 121.5H74.0371C73.2803 121.5 72.6667 122.114 72.6667 122.87V132.463C72.6667 133.22 73.2803 133.833 74.0371 133.833H83.6297C84.3865 133.833 85.0001 133.22 85.0001 132.463V122.87C85.0001 122.114 84.3865 121.5 83.6297 121.5Z" fill="#80838D" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M132.963 72.167H123.37C122.614 72.167 122 72.7805 122 73.5374V83.13C122 83.8868 122.614 84.5003 123.37 84.5003H132.963C133.72 84.5003 134.333 83.8868 134.333 83.13V73.5374C134.333 72.7805 133.72 72.167 132.963 72.167Z" fill="#80838D" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M132.963 121.5H123.37C122.614 121.5 122 122.114 122 122.87V132.463C122 133.22 122.614 133.833 123.37 133.833H132.963C133.72 133.833 134.333 133.22 134.333 132.463V122.87C134.333 122.114 133.72 121.5 132.963 121.5Z" fill="#80838D" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<g filter="url(#filter0_ddd_8097_112031)">
<circle cx="103.5" cy="174" r="31" fill="#3A5BC7"/>
<path d="M101.821 185.756C109.24 185.756 115.254 179.742 115.254 172.323C115.254 164.904 109.24 158.89 101.821 158.89C94.4015 158.89 88.3872 164.904 88.3872 172.323C88.3872 179.742 94.4015 185.756 101.821 185.756Z" stroke="white" stroke-width="2.52049" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M118.612 189.114L111.392 181.894" stroke="white" stroke-width="2.52049" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<filter id="filter0_ddd_8097_112031" x="64.5" y="138" width="78" height="79" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="2" operator="erode" in="SourceAlpha" result="effect1_dropShadow_8097_112031"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0 0 0 0.051 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_8097_112031"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect2_dropShadow_8097_112031"/>
<feOffset dy="3"/>
<feGaussianBlur stdDeviation="6"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.055 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_8097_112031" result="effect2_dropShadow_8097_112031"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="8" operator="erode" in="SourceAlpha" result="effect3_dropShadow_8097_112031"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="8"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.078 0"/>
<feBlend mode="normal" in2="effect2_dropShadow_8097_112031" result="effect3_dropShadow_8097_112031"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect3_dropShadow_8097_112031" result="shape"/>
</filter>
<linearGradient id="paint0_linear_8097_112031" x1="103" y1="0" x2="103" y2="207.52" gradientUnits="userSpaceOnUse">
<stop stop-color="#F7F7F7"/>
<stop offset="1" stop-color="#F8F9FA" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -5,6 +5,8 @@ import { computedFn } from "mobx-utils";
// services
import { ModuleService } from "services/module.service";
import { ProjectService } from "services/project";
// helpers
import { orderModules, shouldFilterModule } from "helpers/module.helper";
// types
import { RootStore } from "store/root.store";
import { IModule, ILinkDetails } from "@plane/types";
@ -18,6 +20,7 @@ export interface IModuleStore {
// computed
projectModuleIds: string[] | null;
// computed actions
getFilteredModuleIds: (projectId: string) => string[] | null;
getModuleById: (moduleId: string) => IModule | null;
getModuleNameById: (moduleId: string) => string;
getProjectModuleIds: (projectId: string) => string[] | null;
@ -108,6 +111,28 @@ export class ModulesStore implements IModuleStore {
return projectModuleIds || null;
}
/**
* @description returns filtered module ids based on display filters and filters
* @param {TModuleDisplayFilters} displayFilters
* @param {TModuleFilters} filters
* @returns {string[] | null}
*/
getFilteredModuleIds = computedFn((projectId: string) => {
const displayFilters = this.rootStore.moduleFilter.getDisplayFiltersByProjectId(projectId);
const filters = this.rootStore.moduleFilter.getFiltersByProjectId(projectId);
const searchQuery = this.rootStore.moduleFilter.searchQuery;
if (!this.fetchedMap[projectId]) return null;
let modules = Object.values(this.moduleMap ?? {}).filter(
(m) =>
m.project_id === projectId &&
m.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
shouldFilterModule(m, displayFilters ?? {}, filters ?? {})
);
modules = orderModules(modules, displayFilters?.order_by);
const moduleIds = modules.map((m) => m.id);
return moduleIds;
});
/**
* @description get module by id
* @param moduleId

View File

@ -0,0 +1,146 @@
import { action, computed, observable, makeObservable, runInAction, autorun } from "mobx";
import { computedFn } from "mobx-utils";
import set from "lodash/set";
// types
import { RootStore } from "store/root.store";
import { TModuleDisplayFilters, TModuleFilters } from "@plane/types";
export interface IModuleFilterStore {
// observables
displayFilters: Record<string, TModuleDisplayFilters>;
filters: Record<string, TModuleFilters>;
searchQuery: string;
// computed
currentProjectDisplayFilters: TModuleDisplayFilters | undefined;
currentProjectFilters: TModuleFilters | undefined;
// computed functions
getDisplayFiltersByProjectId: (projectId: string) => TModuleDisplayFilters | undefined;
getFiltersByProjectId: (projectId: string) => TModuleFilters | undefined;
// actions
updateDisplayFilters: (projectId: string, displayFilters: TModuleDisplayFilters) => void;
updateFilters: (projectId: string, filters: TModuleFilters) => void;
updateSearchQuery: (query: string) => void;
clearAllFilters: (projectId: string) => void;
}
export class ModuleFilterStore implements IModuleFilterStore {
// observables
displayFilters: Record<string, TModuleDisplayFilters> = {};
filters: Record<string, TModuleFilters> = {};
searchQuery: string = "";
// root store
rootStore: RootStore;
constructor(_rootStore: RootStore) {
makeObservable(this, {
// observables
displayFilters: observable,
filters: observable,
searchQuery: observable.ref,
// computed
currentProjectDisplayFilters: computed,
currentProjectFilters: computed,
// actions
updateDisplayFilters: action,
updateFilters: action,
updateSearchQuery: action,
clearAllFilters: action,
});
// root store
this.rootStore = _rootStore;
// initialize display filters of the current project
autorun(() => {
const projectId = this.rootStore.app.router.projectId;
if (!projectId) return;
this.initProjectModuleFilters(projectId);
});
}
/**
* @description get display filters of the current project
*/
get currentProjectDisplayFilters() {
const projectId = this.rootStore.app.router.projectId;
if (!projectId) return;
return this.displayFilters[projectId];
}
/**
* @description get filters of the current project
*/
get currentProjectFilters() {
const projectId = this.rootStore.app.router.projectId;
if (!projectId) return;
return this.filters[projectId];
}
/**
* @description get display filters of a project by projectId
* @param {string} projectId
*/
getDisplayFiltersByProjectId = computedFn((projectId: string) => this.displayFilters[projectId]);
/**
* @description get filters of a project by projectId
* @param {string} projectId
*/
getFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId]);
/**
* @description initialize display filters and filters of a project
* @param {string} projectId
*/
initProjectModuleFilters = (projectId: string) => {
const displayFilters = this.getDisplayFiltersByProjectId(projectId);
runInAction(() => {
this.displayFilters[projectId] = {
favorites: displayFilters?.favorites || false,
layout: displayFilters?.layout || "list",
order_by: displayFilters?.order_by || "name",
};
this.filters[projectId] = {};
});
};
/**
* @description update display filters of a project
* @param {string} projectId
* @param {TModuleDisplayFilters} displayFilters
*/
updateDisplayFilters = (projectId: string, displayFilters: TModuleDisplayFilters) => {
runInAction(() => {
Object.keys(displayFilters).forEach((key) => {
set(this.displayFilters, [projectId, key], displayFilters[key as keyof TModuleDisplayFilters]);
});
});
};
/**
* @description update filters of a project
* @param {string} projectId
* @param {TModuleFilters} filters
*/
updateFilters = (projectId: string, filters: TModuleFilters) => {
runInAction(() => {
Object.keys(filters).forEach((key) => {
set(this.filters, [projectId, key], filters[key as keyof TModuleFilters]);
});
});
};
/**
* @description update search query
* @param {string} query
*/
updateSearchQuery = (query: string) => (this.searchQuery = query);
/**
* @description clear all filters of a project
* @param {string} projectId
*/
clearAllFilters = (projectId: string) => {
runInAction(() => {
this.filters[projectId] = {};
});
};
}

View File

@ -19,6 +19,7 @@ import { IUserRootStore, UserRootStore } from "./user";
import { IWorkspaceRootStore, WorkspaceRootStore } from "./workspace";
import { IProjectPageStore, ProjectPageStore } from "./project-page.store";
import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store";
import { IModuleFilterStore, ModuleFilterStore } from "./module_filter.store";
enableStaticRendering(typeof window === "undefined");
@ -32,6 +33,7 @@ export class RootStore {
cycle: ICycleStore;
cycleFilter: ICycleFilterStore;
module: IModuleStore;
moduleFilter: IModuleFilterStore;
projectView: IProjectViewStore;
globalView: IGlobalViewStore;
issue: IIssueRootStore;
@ -54,6 +56,7 @@ export class RootStore {
this.cycle = new CycleStore(this);
this.cycleFilter = new CycleFilterStore(this);
this.module = new ModulesStore(this);
this.moduleFilter = new ModuleFilterStore(this);
this.projectView = new ProjectViewStore(this);
this.globalView = new GlobalViewStore(this);
this.issue = new IssueRootStore(this);
@ -74,6 +77,7 @@ export class RootStore {
this.cycle = new CycleStore(this);
this.cycleFilter = new CycleFilterStore(this);
this.module = new ModulesStore(this);
this.moduleFilter = new ModuleFilterStore(this);
this.projectView = new ProjectViewStore(this);
this.globalView = new GlobalViewStore(this);
this.issue = new IssueRootStore(this);