[WEB-4951] [WEB-4884] feat: work item filters revamp (#7810)

This commit is contained in:
Prateek Shourya 2025-09-19 18:27:36 +05:30 committed by GitHub
parent e6a7ca4c72
commit 9aef5d4aa9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
160 changed files with 5879 additions and 4881 deletions

View file

@ -1,53 +1,47 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { Layers } from "lucide-react";
// plane constants
// plane imports
import { ETabIndices, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
// i18n
import { useTranslation } from "@plane/i18n";
import { EmojiPicker, EmojiIconPickerTypes } from "@plane/propel/emoji-icon-picker";
// types
import {
EViewAccess,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
IProjectView,
EIssueLayoutTypes,
EIssuesStoreType,
IIssueFilters,
} from "@plane/types";
// ui
import { Button, Input, TextArea } from "@plane/ui";
import { getComputedDisplayFilters, getComputedDisplayProperties, getTabIndex } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
import {
AppliedFiltersList,
DisplayFiltersSelection,
FilterSelection,
FiltersDropdown,
} from "@/components/issues/issue-layouts/filters";
// helpers
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
import { WorkItemFiltersRow } from "@/components/work-item-filters/work-item-filters-row";
// hooks
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web imports
import { AccessController } from "@/plane-web/components/views/access-controller";
// local imports
import { LayoutDropDown } from "../dropdowns/layout";
import { ProjectLevelWorkItemFiltersHOC } from "../work-item-filters/filters-hoc/project-level";
type Props = {
data?: IProjectView | null;
handleClose: () => void;
handleFormSubmit: (values: IProjectView) => Promise<void>;
preLoadedData?: Partial<IProjectView> | null;
projectId: string;
workspaceSlug: string;
};
const defaultValues: Partial<IProjectView> = {
const DEFAULT_VALUES: Partial<IProjectView> = {
name: "",
description: "",
access: EViewAccess.PUBLIC,
@ -56,23 +50,24 @@ const defaultValues: Partial<IProjectView> = {
};
export const ProjectViewForm: React.FC<Props> = observer((props) => {
const { handleFormSubmit, handleClose, data, preLoadedData } = props;
const { handleFormSubmit, handleClose, data, preLoadedData, projectId, workspaceSlug } = props;
// i18n
const { t } = useTranslation();
// state
const [isOpen, setIsOpen] = useState(false);
// store hooks
const { currentProjectDetails } = useProject();
const { projectStates } = useProjectState();
const { projectLabels } = useLabel();
const {
project: { projectMemberIds },
} = useMember();
const { getProjectById } = useProject();
const { isMobile } = usePlatformOS();
// form info
const defaultValues = {
...DEFAULT_VALUES,
...preLoadedData,
...data,
};
const {
control,
formState: { errors, isSubmitting },
getValues,
handleSubmit,
reset,
setValue,
@ -80,53 +75,23 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
} = useForm<IProjectView>({
defaultValues,
});
// derived values
const projectDetails = getProjectById(projectId);
const logoValue = watch("logo_props");
const { getIndex } = getTabIndex(ETabIndices.PROJECT_VIEW, isMobile);
const selectedFilters: IIssueFilterOptions = {};
Object.entries(watch("filters") ?? {}).forEach(([key, value]) => {
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
selectedFilters[key as keyof IIssueFilterOptions] = value;
});
// for removing filters from a key
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
// If value is null then remove all the filters of that key
if (!value) {
setValue("filters", {
...selectedFilters,
[key]: null,
});
return;
}
const newValues = selectedFilters?.[key] ?? [];
if (Array.isArray(value)) {
value.forEach((val) => {
if (newValues.includes(val)) newValues.splice(newValues.indexOf(val), 1);
});
} else {
if (selectedFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
}
setValue("filters", {
...selectedFilters,
[key]: newValues,
});
const workItemFilters: IIssueFilters = {
richFilters: getValues("rich_filters"),
displayFilters: getValues("display_filters"),
displayProperties: getValues("display_properties"),
kanbanFilters: undefined,
};
const { getIndex } = getTabIndex(ETabIndices.PROJECT_VIEW, isMobile);
const handleCreateUpdateView = async (formData: IProjectView) => {
await handleFormSubmit({
name: formData.name,
description: formData.description,
logo_props: formData.logo_props,
filters: formData.filters,
rich_filters: formData.rich_filters,
display_filters: formData.display_filters,
display_properties: formData.display_properties,
access: formData.access,
@ -137,20 +102,6 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
});
};
const clearAllFilters = () => {
if (!selectedFilters) return;
setValue("filters", {});
};
useEffect(() => {
reset({
...defaultValues,
...preLoadedData,
...data,
});
}, [data, preLoadedData, reset]);
return (
<form onSubmit={handleSubmit(handleCreateUpdateView)}>
<div className="space-y-5 p-5">
@ -263,43 +214,6 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
}
value={displayFilters.layout}
/>
{/* filters dropdown */}
<Controller
control={control}
name="filters"
render={({ field: { onChange, value: filters } }) => (
<FiltersDropdown title={t("common.filters")} tabIndex={getIndex("filters")}>
<FilterSelection
filters={filters ?? {}}
handleFiltersUpdate={(key, value) => {
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);
}
onChange({
...filters,
[key]: newValues,
});
}}
layoutDisplayFiltersOptions={ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[displayFilters.layout]}
labels={projectLabels ?? undefined}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</FiltersDropdown>
)}
/>
{/* display filters dropdown */}
<Controller
control={control}
@ -307,7 +221,9 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
render={({ field: { onChange: onDisplayPropertiesChange, value: displayProperties } }) => (
<FiltersDropdown title={t("common.display")}>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[displayFilters.layout]}
layoutDisplayFiltersOptions={
ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[displayFilters.layout]
}
displayFilters={displayFilters ?? {}}
handleDisplayFiltersUpdate={(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
onDisplayFiltersChange({
@ -324,8 +240,8 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
...updatedDisplayProperties,
});
}}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
cycleViewDisabled={!projectDetails?.cycle_view}
moduleViewDisabled={!projectDetails?.module_view}
/>
</FiltersDropdown>
)}
@ -334,17 +250,31 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
)}
/>
</div>
{selectedFilters && Object.keys(selectedFilters).length > 0 && (
<div>
<AppliedFiltersList
appliedFilters={selectedFilters}
handleClearAllFilters={clearAllFilters}
handleRemoveFilter={handleRemoveFilter}
labels={projectLabels ?? []}
states={projectStates}
/>
</div>
)}
<div>
{/* filters dropdown */}
<Controller
control={control}
name="rich_filters"
render={({ field: { onChange: onFiltersChange } }) => (
<ProjectLevelWorkItemFiltersHOC
entityId={data?.id}
entityType={EIssuesStoreType.PROJECT_VIEW}
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.filters}
initialWorkItemFilters={workItemFilters}
isTemporary
updateFilters={(updateFilters) => onFiltersChange(updateFilters)}
projectId={projectId}
workspaceSlug={workspaceSlug}
>
{({ filter: projectViewWorkItemsFilter }) =>
projectViewWorkItemsFilter && (
<WorkItemFiltersRow filter={projectViewWorkItemsFilter} variant="default" />
)
}
</ProjectLevelWorkItemFiltersHOC>
)}
/>
</div>
</div>
</div>
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">

View file

@ -1,6 +1,6 @@
import { EIssueLayoutTypes } from "@plane/types";
import { WorkspaceSpreadsheetRoot } from "@/components/issues/issue-layouts/spreadsheet/roots/workspace-root";
import { WorkspaceAdditionalLayouts } from "@/plane-web/components/views/helper";
import { WorkspaceSpreadsheetRoot } from "../issues/issue-layouts/spreadsheet/roots/workspace-root";
export type TWorkspaceLayoutProps = {
activeLayout: EIssueLayoutTypes | undefined;

View file

@ -4,12 +4,14 @@ import { FC } from "react";
import { observer } from "mobx-react";
// types
import { PROJECT_VIEW_TRACKER_EVENTS } from "@plane/constants";
import { IProjectView } from "@plane/types";
import { EIssuesStoreType, IProjectView } from "@plane/types";
// ui
import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useIssues } from "@/hooks/store/use-issues";
import { useProjectView } from "@/hooks/store/use-project-view";
import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters";
import { useAppRouter } from "@/hooks/use-app-router";
import useKeypress from "@/hooks/use-keypress";
// local imports
@ -30,6 +32,10 @@ export const CreateUpdateProjectViewModal: FC<Props> = observer((props) => {
const router = useAppRouter();
// store hooks
const { createView, updateView } = useProjectView();
const {
issuesFilter: { mutateFilters },
} = useIssues(EIssuesStoreType.PROJECT_VIEW);
const { resetExpression } = useWorkItemFilters();
const handleClose = () => {
onClose();
@ -66,7 +72,9 @@ export const CreateUpdateProjectViewModal: FC<Props> = observer((props) => {
const handleUpdateView = async (payload: IProjectView) => {
await updateView(workspaceSlug, projectId, data?.id as string, payload)
.then(() => {
.then((viewDetails) => {
mutateFilters(workspaceSlug, viewDetails.id, viewDetails);
resetExpression(EIssuesStoreType.PROJECT_VIEW, viewDetails.id, viewDetails.rich_filters);
handleClose();
captureSuccess({
eventName: PROJECT_VIEW_TRACKER_EVENTS.update,
@ -106,6 +114,8 @@ export const CreateUpdateProjectViewModal: FC<Props> = observer((props) => {
handleClose={handleClose}
handleFormSubmit={handleFormSubmit}
preLoadedData={preLoadedData}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
</ModalCore>
);

View file

@ -1,64 +0,0 @@
import { SetStateAction, useEffect, useState } from "react";
import { Button } from "@plane/ui";
type Props = {
isLocked: boolean;
areFiltersEqual: boolean;
isOwner: boolean;
isAuthorizedUser: boolean;
setIsModalOpen: (value: SetStateAction<boolean>) => void;
handleUpdateView: () => void;
lockedTooltipContent?: string;
trackerElement: string;
};
export const UpdateViewComponent = (props: Props) => {
const { isLocked, areFiltersEqual, isOwner, isAuthorizedUser, setIsModalOpen, handleUpdateView, trackerElement } =
props;
const [isUpdating, setIsUpdating] = useState(false);
useEffect(() => {
if (areFiltersEqual) {
setIsUpdating(false);
}
}, [areFiltersEqual]);
// Change state while updating view to have a feedback
const updateButton = isUpdating ? (
<Button variant="primary" size="sm" className="flex-shrink-0">
Updating...
</Button>
) : (
<Button
variant="primary"
size="sm"
className="flex-shrink-0"
onClick={() => {
setIsUpdating(true);
handleUpdateView();
}}
>
Update view
</Button>
);
return (
<div className="flex gap-2 h-fit">
{!isLocked && !areFiltersEqual && isAuthorizedUser && (
<>
<Button
variant="outline-primary"
size="md"
className="flex-shrink-0"
data-ph-element={trackerElement}
onClick={() => setIsModalOpen(true)}
>
Save as
</Button>
{isOwner && <>{updateButton}</>}
</>
)}
</div>
);
};

View file

@ -8,7 +8,7 @@ import { useLocalStorage } from "@plane/hooks";
import { Tooltip } from "@plane/propel/tooltip";
import { EViewAccess, IProjectView } from "@plane/types";
import { FavoriteStar } from "@plane/ui";
import { calculateTotalFilters, getPublishViewLink } from "@plane/utils";
import { getPublishViewLink } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useProjectView } from "@/hooks/store/use-project-view";
@ -52,8 +52,6 @@ export const ViewListItemAction: FC<Props> = observer((props) => {
EUserPermissionsLevel.PROJECT
);
const totalFilters = calculateTotalFilters(view.filters ?? {});
const access = view.access;
const publishLink = getPublishViewLink(view?.anchor);
@ -87,10 +85,6 @@ export const ViewListItemAction: FC<Props> = observer((props) => {
/>
)}
<DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
<p className="hidden rounded bg-custom-background-80 px-2 py-1 text-xs text-custom-text-200 group-hover:block">
{totalFilters} {totalFilters === 1 ? "filter" : "filters"}
</p>
<div className="cursor-default text-custom-text-300">
<Tooltip tooltipContent={access === EViewAccess.PUBLIC ? "Public" : "Private"}>
{access === EViewAccess.PUBLIC ? <Earth className="h-4 w-4" /> : <Lock className="h-4 w-4" />}