[WEB-1255] feat: filters for project views and changes required for advanced views (#4949)

* View with filters and changes required for advanced views

* minor refactoring of views

* minor name change
This commit is contained in:
rahulramesha 2024-06-27 14:40:14 +05:30 committed by GitHub
parent adaf3b15de
commit 82661589fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 922 additions and 146 deletions

View file

@ -6,8 +6,8 @@ import { LogoSpinner } from "@/components/common";
import { WorkspaceLogo } from "@/components/workspace/logo";
// helpers
import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper";
import { WorkspaceService } from "@/plane-web/services";
// services
import { WorkspaceService } from "@/services/workspace.service";
type TAuthHeader = {
workspaceSlug: string | undefined;

View file

@ -30,8 +30,9 @@ import { useAppRouter } from "@/hooks/use-app-router";
import useDebounce from "@/hooks/use-debounce";
import { usePlatformOS } from "@/hooks/use-platform-os";
// services
import { WorkspaceService } from "@/plane-web/services";
import { IssueService } from "@/services/issue";
import { WorkspaceService } from "@/services/workspace.service";
// ui
// components
// types

View file

@ -1,11 +1,11 @@
import { observer } from "mobx-react";
// icons
import { X } from "lucide-react";
// helpers
// constants
import { DATE_BEFORE_FILTER_OPTIONS } from "@/constants/filters";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { capitalizeFirstLetter } from "@/helpers/string.helper";
// constants
type Props = {
editable: boolean | undefined;

View file

@ -0,0 +1,2 @@
export * from "./date";
export * from "./members";

View file

@ -0,0 +1,2 @@
export * from "./created-at";
export * from "./created-by";

View file

@ -11,7 +11,7 @@ import { MarkdownRenderer } from "@/components/ui";
// icons
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { WorkspaceService } from "@/services/workspace.service";
import { WorkspaceService } from "@/plane-web/services";
type Props = {
isOpen: boolean;

View file

@ -6,7 +6,7 @@ import { IGithubRepoCollaborator } from "@plane/types";
// services
import { Avatar, CustomSelect, CustomSearchSelect, Input } from "@plane/ui";
import { WORKSPACE_MEMBERS } from "@/constants/fetch-keys";
import { WorkspaceService } from "@/services/workspace.service";
import { WorkspaceService } from "@/plane-web/services";
// ui
// types
import { IUserDetails } from "./root";

View file

@ -8,7 +8,7 @@ import { IJiraImporterForm } from "@plane/types";
// services
import { Avatar, CustomSelect, CustomSearchSelect, Input, ToggleSwitch } from "@plane/ui";
import { WORKSPACE_MEMBERS } from "@/constants/fetch-keys";
import { WorkspaceService } from "@/services/workspace.service";
import { WorkspaceService } from "@/plane-web/services";
// ui
// types
// fetch keys

View file

@ -15,7 +15,7 @@ import { UpdateViewComponent } from "@/components/views/update-view-component";
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
import { EUserWorkspaceRoles } from "@/constants/workspace";
// hooks
import { useIssues, useLabel, useProjectView, useUser } from "@/hooks/store";
import { useIssues, useLabel, useProjectState, useProjectView, useUser } from "@/hooks/store";
import { getAreFiltersEqual } from "../../../utils";
export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => {
@ -26,6 +26,7 @@ export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.PROJECT_VIEW);
const { projectLabels } = useLabel();
const { projectStates } = useProjectState();
const { viewMap, updateView } = useProjectView();
const {
data,
@ -129,6 +130,7 @@ export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => {
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
labels={projectLabels ?? []}
states={projectStates}
disableEditing={isLocked}
/>
</div>

View file

@ -13,7 +13,7 @@ import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@/constants/workspace";
// hooks
import { useEventTracker, useUserProfile, useUserSettings, useWorkspace } from "@/hooks/store";
// services
import { WorkspaceService } from "@/services/workspace.service";
import { WorkspaceService } from "@/plane-web/services";
type Props = {
stepChange: (steps: Partial<TOnboardingSteps>) => Promise<void>;

View file

@ -16,7 +16,7 @@ import { getUserRole } from "@/helpers/user.helper";
// hooks
import { useEventTracker, useWorkspace } from "@/hooks/store";
// services
import { WorkspaceService } from "@/services/workspace.service";
import { WorkspaceService } from "@/plane-web/services";
type Props = {
handleNextStep: () => Promise<void>;

View file

@ -30,10 +30,10 @@ import { getUserRole } from "@/helpers/user.helper";
import { useEventTracker } from "@/hooks/store";
import useDynamicDropdownPosition from "@/hooks/use-dynamic-dropdown";
// services
import { WorkspaceService } from "@/plane-web/services";
// assets
import InviteMembersDark from "@/public/onboarding/invite-members-dark.svg";
import InviteMembersLight from "@/public/onboarding/invite-members-light.svg";
import { WorkspaceService } from "@/services/workspace.service";
// components
import { OnboardingHeader } from "./header";
import { SwitchAccountDropdown } from "./switch-account-dropdown";

View file

@ -1,3 +1 @@
export * from "./date";
export * from "./members";
export * from "./root";

View file

@ -1,7 +1,7 @@
import { X } from "lucide-react";
import { TPageFilterProps } from "@plane/types";
// components
import { AppliedDateFilters, AppliedMembersFilters } from "@/components/pages";
import { AppliedDateFilters, AppliedMembersFilters } from "@/components/common/applied-filters";
// helpers
import { replaceUnderscoreIfSnakeCase } from "@/helpers/string.helper";
// types

View file

@ -1,3 +1 @@
export * from "./created-at";
export * from "./created-by";
export * from "./root";

View file

@ -3,8 +3,8 @@ import { observer } from "mobx-react";
import { Search, X } from "lucide-react";
import { TPageFilterProps, TPageFilters } from "@plane/types";
// components
import { FilterCreatedBy, FilterCreatedDate } from "@/components/common/filters";
import { FilterOption } from "@/components/issues";
import { FilterCreatedBy, FilterCreatedDate } from "@/components/pages";
type Props = {
filters: TPageFilters;

View file

@ -0,0 +1,81 @@
import { X } from "lucide-react";
import { TViewFilterProps } from "@plane/types";
// components
import { AppliedDateFilters, AppliedMembersFilters } from "@/components/common/applied-filters";
// helpers
import { replaceUnderscoreIfSnakeCase } from "@/helpers/string.helper";
// types
type Props = {
appliedFilters: TViewFilterProps;
handleClearAllFilters: () => void;
handleRemoveFilter: (key: keyof TViewFilterProps, value: string | null) => void;
alwaysAllowEditing?: boolean;
};
const MEMBERS_FILTERS = ["owned_by"];
const DATE_FILTERS = ["created_at"];
export const ViewAppliedFiltersList: 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 TViewFilterProps;
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>
{DATE_FILTERS.includes(filterKey) && (
<AppliedDateFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
values={Array.isArray(value) ? (value as string[]) : []}
/>
)}
{MEMBERS_FILTERS.includes(filterKey) && (
<AppliedMembersFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
values={Array.isArray(value) ? (value as string[]) : []}
/>
)}
{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,101 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { Search, X } from "lucide-react";
import { TViewFilterProps, TViewFilters } from "@plane/types";
// components
import { FilterCreatedBy, FilterCreatedDate } from "@/components/common/filters";
import { FilterOption } from "@/components/issues";
// constants
import { EViewAccess } from "@/constants/views";
type Props = {
filters: TViewFilters;
handleFiltersUpdate: <T extends keyof TViewFilters>(filterKey: T, filterValue: TViewFilters[T]) => void;
memberIds?: string[] | undefined;
};
export const ViewFiltersSelection: React.FC<Props> = observer((props) => {
const { filters, handleFiltersUpdate, memberIds } = props;
// states
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
// handles filter update
const handleFilters = (key: keyof TViewFilterProps, value: boolean | string | EViewAccess | string[]) => {
const currValues = (filters.filters?.[key] ?? []) as (string | EViewAccess)[];
if (typeof currValues === "boolean" && typeof value === "boolean") return;
if (Array.isArray(currValues)) {
if (Array.isArray(value)) {
value.forEach((val) => {
if (!currValues.includes(val)) currValues.push(val);
else currValues.splice(currValues.indexOf(val), 1);
});
} else if (typeof value !== "boolean") {
if (currValues?.includes(value)) currValues.splice(currValues.indexOf(value), 1);
else currValues.push(value);
}
}
handleFiltersUpdate("filters", {
...filters.filters,
[key]: currValues,
});
};
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={!!filters.filters?.favorites}
onClick={() =>
handleFiltersUpdate("filters", {
...filters.filters,
favorites: !filters.filters?.favorites,
})
}
title="Favorites"
/>
</div>
{/* created date */}
<div className="py-2">
<FilterCreatedDate
appliedFilters={filters.filters?.created_at ?? null}
handleUpdate={(val: string | string[]) => handleFilters("created_at", val)}
searchQuery={filtersSearchQuery}
/>
</div>
{/* created by */}
<div className="py-2">
<FilterCreatedBy
appliedFilters={filters.filters?.owned_by ?? null}
handleUpdate={(val) => handleFilters("owned_by", val)}
searchQuery={filtersSearchQuery}
memberIds={memberIds}
/>
</div>
</div>
</div>
);
});

View file

@ -0,0 +1,75 @@
"use client";
import { ArrowDownWideNarrow, Check, ChevronDown } from "lucide-react";
// types
import { TViewFiltersSortBy, TViewFiltersSortKey } from "@plane/types";
// ui
import { CustomMenu, getButtonStyling } from "@plane/ui";
// constants
import { VIEW_SORTING_KEY_OPTIONS } from "@/constants/views";
// helpers
import { cn } from "@/helpers/common.helper";
type Props = {
onChange: (value: { key?: TViewFiltersSortKey; order?: TViewFiltersSortBy }) => void;
sortBy: TViewFiltersSortBy;
sortKey: TViewFiltersSortKey;
};
export const ViewOrderByDropdown: React.FC<Props> = (props) => {
const { onChange, sortBy, sortKey } = props;
const orderByDetails = VIEW_SORTING_KEY_OPTIONS.find((option) => sortKey === option.key);
const isDescending = sortBy === "desc";
const sortByOptions = [
{ key: "asc", label: "Ascending", isSelected: !isDescending },
{ key: "desc", label: "Descending", isSelected: isDescending },
];
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
>
{VIEW_SORTING_KEY_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
className="flex items-center justify-between gap-2"
onClick={() =>
onChange({
key: option.key,
})
}
>
{option.label}
{sortKey === option.key && <Check className="h-3 w-3" />}
</CustomMenu.MenuItem>
))}
<hr className="my-2 border-custom-border-200" />
{sortByOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
className="flex items-center justify-between gap-2"
onClick={() => {
if (!option.isSelected)
onChange({
order: option.key as TViewFiltersSortBy,
});
}}
>
{option.label}
{option.isSelected && <Check className="h-3 w-3" />}
</CustomMenu.MenuItem>
))}
</CustomMenu>
);
};

View file

@ -14,6 +14,7 @@ import { AppliedFiltersList, FilterSelection, FiltersDropdown } from "@/componen
import { EIssueLayoutTypes, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
// helpers
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
import { getComputedDisplayFilters, getComputedDisplayProperties } from "@/helpers/issue.helper";
// hooks
import { useLabel, useMember, useProject, useProjectState } from "@/hooks/store";
import { LayoutDropDown } from "../dropdowns/layout";
@ -28,6 +29,8 @@ type Props = {
const defaultValues: Partial<IProjectView> = {
name: "",
description: "",
display_properties: getComputedDisplayProperties(),
display_filters: getComputedDisplayFilters(),
};
export const ProjectViewForm: React.FC<Props> = observer((props) => {

View file

@ -51,14 +51,14 @@ export const UpdateViewComponent = (props: Props) => {
);
return (
<div className="flex gap-2">
<div className="flex gap-2 h-fit">
{isLocked ? (
<LockedComponent toolTipContent={lockedTooltipContent} />
) : (
!areFiltersEqual &&
isAuthorizedUser && (
<>
<Button variant="outline-primary" size="sm" className="flex-shrink-0" onClick={() => setIsModalOpen(true)}>
<Button variant="outline-primary" size="md" className="flex-shrink-0" onClick={() => setIsModalOpen(true)}>
Save as
</Button>
{isOwner && <>{updateButton}</>}

View file

@ -1,12 +1,15 @@
import React, { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
// icons
import { Search, X } from "lucide-react";
import { ListFilter, Search, X } from "lucide-react";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useProjectView } from "@/hooks/store";
import { useMember, useProjectView } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { FiltersDropdown } from "../issues";
import { ViewFiltersSelection } from "./filters/filter-selection";
import { ViewOrderByDropdown } from "./filters/order-by";
export const ViewListHeader = observer(() => {
// states
@ -14,13 +17,17 @@ export const ViewListHeader = observer(() => {
// refs
const inputRef = useRef<HTMLInputElement>(null);
// store hooks
const { searchQuery, updateSearchQuery } = useProjectView();
const { filters, updateFilters } = useProjectView();
const {
project: { projectMemberIds },
} = useMember();
// handlers
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
else {
if (filters?.searchQuery && filters?.searchQuery.trim() !== "") {
updateFilters("searchQuery", "");
} else {
setIsSearchOpen(false);
inputRef.current?.blur();
}
@ -29,12 +36,12 @@ export const ViewListHeader = observer(() => {
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
if (isSearchOpen && filters?.searchQuery.trim() === "") setIsSearchOpen(false);
});
useEffect(() => {
if (searchQuery.trim() !== "") setIsSearchOpen(true);
}, [searchQuery]);
if (filters?.searchQuery.trim() !== "") setIsSearchOpen(true);
}, [filters?.searchQuery]);
return (
<div className="h-full flex items-center gap-2">
@ -64,8 +71,8 @@ export const ViewListHeader = observer(() => {
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)}
value={filters?.searchQuery}
onChange={(e) => updateFilters("searchQuery", e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
@ -73,7 +80,7 @@ export const ViewListHeader = observer(() => {
type="button"
className="grid place-items-center"
onClick={() => {
updateSearchQuery("");
updateFilters("searchQuery", "");
setIsSearchOpen(false);
}}
>
@ -82,6 +89,26 @@ export const ViewListHeader = observer(() => {
)}
</div>
</div>
<ViewOrderByDropdown
sortBy={filters.sortBy}
sortKey={filters.sortKey}
onChange={(val) => {
if (val.key) updateFilters("sortKey", val.key);
if (val.order) updateFilters("sortBy", val.order);
}}
/>
<FiltersDropdown
icon={<ListFilter className="h-3 w-3" />}
title="Filters"
placement="bottom-end"
isFiltersApplied={false}
>
<ViewFiltersSelection
filters={filters}
handleFiltersUpdate={updateFilters}
memberIds={projectMemberIds ?? undefined}
/>
</FiltersDropdown>
</div>
);
});

View file

@ -1,4 +1,6 @@
import { observer } from "mobx-react";
import Image from "next/image";
import { useParams } from "next/navigation";
// components
import { ListLayout } from "@/components/core/list";
import { EmptyState } from "@/components/empty-state";
@ -8,26 +10,48 @@ import { ProjectViewListItem } from "@/components/views";
import { EmptyStateType } from "@/constants/empty-state";
// hooks
import { useCommandPalette, useProjectView } from "@/hooks/store";
// assets
import AllFiltersImage from "@/public/empty-state/pages/all-filters.svg";
import NameFilterImage from "@/public/empty-state/pages/name-filter.svg";
export const ProjectViewsList = observer(() => {
const { projectId } = useParams();
// store hooks
const { toggleCreateViewModal } = useCommandPalette();
const { projectViewIds, getViewById, loader, searchQuery } = useProjectView();
const { getProjectViews, getFilteredProjectViews, filters, loader } = useProjectView();
if (loader || !projectViewIds) return <ViewListLoader />;
const projectViews = getProjectViews(projectId?.toString());
const filteredProjectViews = getFilteredProjectViews(projectId?.toString());
// derived values
const viewsList = projectViewIds.map((viewId) => getViewById(viewId));
if (loader || !projectViews || !filteredProjectViews) return <ViewListLoader />;
const filteredViewsList = viewsList.filter((v) => v?.name.toLowerCase().includes(searchQuery.toLowerCase()));
if (filteredProjectViews.length === 0 && projectViews) {
return (
<div className="h-full w-full grid place-items-center">
<div className="text-center">
<Image
src={filters.searchQuery.length > 0 ? NameFilterImage : AllFiltersImage}
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 views</h5>
<p className="text-custom-text-400 text-base">
{filters.searchQuery.length > 0
? "Remove the search criteria to see all views"
: "Remove the filters to see all views"}
</p>
</div>
</div>
);
}
return (
<>
{viewsList.length > 0 ? (
{filteredProjectViews.length > 0 ? (
<div className="flex h-full w-full flex-col">
<ListLayout>
{filteredViewsList.length > 0 ? (
filteredViewsList.map((view) => <ProjectViewListItem key={view.id} view={view} />)
{filteredProjectViews.length > 0 ? (
filteredProjectViews.map((view) => <ProjectViewListItem key={view.id} view={view} />)
) : (
<p className="mt-10 text-center text-sm text-custom-text-300">No results found</p>
)}

View file

@ -14,7 +14,7 @@ import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@/constants/workspace";
import { useEventTracker, useWorkspace } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
// services
import { WorkspaceService } from "@/services/workspace.service";
import { WorkspaceService } from "@/plane-web/services";
type Props = {
onSubmit?: (res: IWorkspace) => Promise<void>;

View file

@ -11,6 +11,8 @@ import { Button, Input, TextArea } from "@plane/ui";
import { AppliedFiltersList, FilterSelection, FiltersDropdown } from "@/components/issues";
// constants
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
// helpers
import { getComputedDisplayFilters, getComputedDisplayProperties } from "@/helpers/issue.helper";
// hooks
import { useLabel, useMember } from "@/hooks/store";
@ -24,6 +26,8 @@ type Props = {
const defaultValues: Partial<IWorkspaceView> = {
name: "",
description: "",
display_properties: getComputedDisplayProperties(),
display_filters: getComputedDisplayFilters(),
};
export const WorkspaceViewForm: React.FC<Props> = observer((props) => {