forked from github/plane
[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:
parent
69e110f4a8
commit
b930d98665
@ -354,6 +354,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
"external_id",
|
||||
"progress_snapshot",
|
||||
# meta fields
|
||||
"total_issues",
|
||||
"is_favorite",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
|
@ -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",
|
||||
|
2
packages/types/src/index.d.ts
vendored
2
packages/types/src/index.d.ts
vendored
@ -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";
|
||||
|
2
packages/types/src/module/index.ts
Normal file
2
packages/types/src/module/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./module_filters";
|
||||
export * from "./modules";
|
32
packages/types/src/module/module_filters.d.ts
vendored
Normal file
32
packages/types/src/module/module_filters.d.ts
vendored
Normal 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;
|
||||
};
|
@ -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)}
|
||||
|
@ -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" />
|
||||
|
56
web/components/modules/applied-filters/date.tsx
Normal file
56
web/components/modules/applied-filters/date.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
4
web/components/modules/applied-filters/index.ts
Normal file
4
web/components/modules/applied-filters/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./date";
|
||||
export * from "./members";
|
||||
export * from "./root";
|
||||
export * from "./status";
|
46
web/components/modules/applied-filters/members.tsx
Normal file
46
web/components/modules/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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
88
web/components/modules/applied-filters/root.tsx
Normal file
88
web/components/modules/applied-filters/root.tsx
Normal 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>
|
||||
);
|
||||
};
|
41
web/components/modules/applied-filters/status.tsx
Normal file
41
web/components/modules/applied-filters/status.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
6
web/components/modules/dropdowns/filters/index.ts
Normal file
6
web/components/modules/dropdowns/filters/index.ts
Normal 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";
|
96
web/components/modules/dropdowns/filters/lead.tsx
Normal file
96
web/components/modules/dropdowns/filters/lead.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
97
web/components/modules/dropdowns/filters/members.tsx
Normal file
97
web/components/modules/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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
106
web/components/modules/dropdowns/filters/root.tsx
Normal file
106
web/components/modules/dropdowns/filters/root.tsx
Normal 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>
|
||||
);
|
||||
});
|
65
web/components/modules/dropdowns/filters/start-date.tsx
Normal file
65
web/components/modules/dropdowns/filters/start-date.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
52
web/components/modules/dropdowns/filters/status.tsx
Normal file
52
web/components/modules/dropdowns/filters/status.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
65
web/components/modules/dropdowns/filters/target-date.tsx
Normal file
65
web/components/modules/dropdowns/filters/target-date.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
2
web/components/modules/dropdowns/index.ts
Normal file
2
web/components/modules/dropdowns/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./filters";
|
||||
export * from "./order-by";
|
70
web/components/modules/dropdowns/order-by.tsx
Normal file
70
web/components/modules/dropdowns/order-by.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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} />}
|
||||
|
@ -1,3 +1,5 @@
|
||||
export * from "./applied-filters";
|
||||
export * from "./dropdowns";
|
||||
export * from "./select";
|
||||
export * from "./sidebar-select";
|
||||
export * from "./delete-module-modal";
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
},
|
||||
];
|
||||
|
80
web/helpers/module.helper.ts
Normal file
80
web/helpers/module.helper.ts
Normal 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;
|
||||
};
|
@ -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";
|
||||
|
11
web/hooks/store/use-module-filter.ts
Normal file
11
web/hooks/store/use-module-filter.ts
Normal 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;
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
45
web/public/empty-state/module/all-filters.svg
Normal file
45
web/public/empty-state/module/all-filters.svg
Normal 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 |
44
web/public/empty-state/module/name-filter.svg
Normal file
44
web/public/empty-state/module/name-filter.svg
Normal 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 |
@ -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
|
||||
|
146
web/store/module_filter.store.ts
Normal file
146
web/store/module_filter.store.ts
Normal 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] = {};
|
||||
});
|
||||
};
|
||||
}
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user