[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

@ -48,7 +48,7 @@ export const ArchiveTabsList: FC = observer(() => {
tab.shouldRender(projectDetails) && (
<Link key={tab.key} href={`/${workspaceSlug}/projects/${projectId}/archives/${tab.key}`}>
<span
className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 py-3 px-4 text-sm font-medium outline-none ${
className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 py-4 px-4 text-sm font-medium outline-none ${
pathname.includes(tab.key)
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent hover:border-custom-border-200 text-custom-text-300 hover:text-custom-text-400"

View file

@ -0,0 +1,79 @@
import { observer } from "mobx-react";
import Image from "next/image";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Avatar } from "@plane/ui";
import { getFileURL } from "@plane/utils";
// components
import { SingleProgressStats } from "@/components/core/sidebar/single-progress-stats";
// public
import emptyMembers from "@/public/empty-state/empty_members.svg";
export type TAssigneeData = {
id: string | undefined;
title: string | undefined;
avatar_url: string | undefined;
completed: number;
total: number;
}[];
type TAssigneeStatComponent = {
selectedAssigneeIds: string[];
handleAssigneeFiltersUpdate: (assigneeId: string | undefined) => void;
distribution: TAssigneeData;
isEditable?: boolean;
};
export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => {
const { distribution, isEditable, selectedAssigneeIds, handleAssigneeFiltersUpdate } = props;
const { t } = useTranslation();
return (
<div>
{distribution && distribution.length > 0 ? (
distribution.map((assignee, index) => {
if (assignee?.id)
return (
<SingleProgressStats
key={assignee?.id}
title={
<div className="flex items-center gap-2">
<Avatar name={assignee?.title ?? undefined} src={getFileURL(assignee?.avatar_url ?? "")} />
<span>{assignee?.title ?? ""}</span>
</div>
}
completed={assignee?.completed ?? 0}
total={assignee?.total ?? 0}
{...(isEditable && {
onClick: () => handleAssigneeFiltersUpdate(assignee.id),
selected: assignee.id ? selectedAssigneeIds.includes(assignee.id) : false,
})}
/>
);
else
return (
<SingleProgressStats
key={`unassigned-${index}`}
title={
<div className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
</div>
<span>{t("no_assignee")}</span>
</div>
}
completed={assignee?.completed ?? 0}
total={assignee?.total ?? 0}
/>
);
})
) : (
<div className="flex h-full flex-col items-center justify-center gap-2">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
<Image src={emptyMembers} className="h-12 w-12" alt="empty members" />
</div>
<h6 className="text-base text-custom-text-300">{t("no_assignee")}</h6>
</div>
)}
</div>
);
});

View file

@ -0,0 +1,86 @@
import { observer } from "mobx-react";
import Image from "next/image";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { SingleProgressStats } from "@/components/core/sidebar/single-progress-stats";
// public
import emptyLabel from "@/public/empty-state/empty_label.svg";
export type TLabelData = {
id: string | undefined;
title: string | undefined;
color: string | undefined;
completed: number;
total: number;
}[];
type TLabelStatComponent = {
selectedLabelIds: string[];
handleLabelFiltersUpdate: (labelId: string | undefined) => void;
distribution: TLabelData;
isEditable?: boolean;
};
export const LabelStatComponent = observer((props: TLabelStatComponent) => {
const { distribution, isEditable, selectedLabelIds, handleLabelFiltersUpdate } = props;
const { t } = useTranslation();
return (
<div>
{distribution && distribution.length > 0 ? (
distribution.map((label, index) => {
if (label.id) {
return (
<SingleProgressStats
key={label.id}
title={
<div className="flex items-center gap-2 truncate">
<span
className="block h-3 w-3 rounded-full flex-shrink-0"
style={{
backgroundColor: label.color ?? "transparent",
}}
/>
<span className="text-xs text-ellipsis truncate">{label.title ?? t("no_labels_yet")}</span>
</div>
}
completed={label.completed}
total={label.total}
{...(isEditable && {
onClick: () => handleLabelFiltersUpdate(label.id),
selected: label.id ? selectedLabelIds.includes(label.id) : false,
})}
/>
);
} else {
return (
<SingleProgressStats
key={`no-label-${index}`}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: label.color ?? "transparent",
}}
/>
<span className="text-xs">{label.title ?? t("no_labels_yet")}</span>
</div>
}
completed={label.completed}
total={label.total}
/>
);
}
})
) : (
<div className="flex h-full flex-col items-center justify-center gap-2">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
<Image src={emptyLabel} className="h-12 w-12" alt="empty label" />
</div>
<h6 className="text-base text-custom-text-300">{t("no_labels_yet")}</h6>
</div>
)}
</div>
);
});

View file

@ -0,0 +1,45 @@
import { TWorkItemFilterCondition } from "@plane/shared-state";
import { TFilterConditionNodeForDisplay, TFilterValue, TWorkItemFilterProperty } from "@plane/types";
export const PROGRESS_STATS = [
{
key: "stat-states",
i18n_title: "common.states",
},
{
key: "stat-assignees",
i18n_title: "common.assignees",
},
{
key: "stat-labels",
i18n_title: "common.labels",
},
];
type TSelectedFilterProgressStatsType = TFilterConditionNodeForDisplay<TWorkItemFilterProperty, TFilterValue>;
export type TSelectedFilterProgressStats = {
assignees: TSelectedFilterProgressStatsType | undefined;
labels: TSelectedFilterProgressStatsType | undefined;
stateGroups: TSelectedFilterProgressStatsType | undefined;
};
export const createFilterUpdateHandler =
<T extends string>(
property: TWorkItemFilterProperty,
selectedValues: T[],
handleFiltersUpdate: (condition: TWorkItemFilterCondition) => void
) =>
(value: T | undefined) => {
const updatedValues = value ? [...selectedValues] : [];
if (value) {
if (updatedValues.includes(value)) {
updatedValues.splice(updatedValues.indexOf(value), 1);
} else {
updatedValues.push(value);
}
}
handleFiltersUpdate({ property, operator: "in", value: updatedValues });
};

View file

@ -0,0 +1,46 @@
import { observer } from "mobx-react";
// plane imports
import { StateGroupIcon } from "@plane/propel/icons";
import { TStateGroups } from "@plane/types";
// components
import { SingleProgressStats } from "@/components/core/sidebar/single-progress-stats";
export type TStateGroupData = {
state: string | undefined;
completed: number;
total: number;
}[];
type TStateGroupStatComponent = {
selectedStateGroups: string[];
handleStateGroupFiltersUpdate: (stateGroup: string | undefined) => void;
distribution: TStateGroupData;
totalIssuesCount: number;
isEditable?: boolean;
};
export const StateGroupStatComponent = observer((props: TStateGroupStatComponent) => {
const { distribution, isEditable, totalIssuesCount, selectedStateGroups, handleStateGroupFiltersUpdate } = props;
return (
<div>
{distribution.map((group, index) => (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<StateGroupIcon stateGroup={group.state as TStateGroups} />
<span className="text-xs capitalize">{group.state}</span>
</div>
}
completed={group.completed}
total={totalIssuesCount}
{...(isEditable && {
onClick: () => group.state && handleStateGroupFiltersUpdate(group.state),
selected: group.state ? selectedStateGroups.includes(group.state) : false,
})}
/>
))}
</div>
);
});

View file

@ -18,7 +18,7 @@ export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
<div
className={`flex w-full items-center justify-between gap-4 rounded-sm p-1 text-xs ${
onClick ? "cursor-pointer hover:bg-custom-background-90" : ""
} ${selected ? "bg-custom-background-90" : ""}`}
} ${selected ? "bg-custom-background-80" : ""}`}
onClick={onClick}
>
<div className="w-4/6">{title}</div>

View file

@ -10,7 +10,8 @@ import { Tab } from "@headlessui/react";
import { useTranslation } from "@plane/i18n";
import { PriorityIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { EIssuesStoreType, ICycle, IIssueFilterOptions } from "@plane/types";
import { TWorkItemFilterCondition } from "@plane/shared-state";
import { EIssuesStoreType, ICycle } from "@plane/types";
// ui
import { Loader, Avatar } from "@plane/ui";
import { cn, renderFormattedDate, renderFormattedDateWithoutYear, getFileURL } from "@plane/utils";
@ -35,7 +36,7 @@ export type ActiveCycleStatsProps = {
projectId: string;
cycle: ICycle | null;
cycleId?: string | null;
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => void;
handleFiltersUpdate: (conditions: TWorkItemFilterCondition[]) => void;
cycleIssueDetails: ActiveCycleIssueDetails;
};
@ -185,7 +186,9 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
issueId: issue.id,
isArchived: !!issue.archived_at,
});
handleFiltersUpdate("priority", ["urgent", "high"], true);
handleFiltersUpdate([
{ property: "priority", operator: "in", value: ["urgent", "high"] },
]);
}
}}
>
@ -275,7 +278,9 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
total={assignee.total_issues}
onClick={() => {
if (assignee.assignee_id) {
handleFiltersUpdate("assignees", [assignee.assignee_id], true);
handleFiltersUpdate([
{ property: "assignee_id", operator: "in", value: [assignee.assignee_id] },
]);
}
}}
/>
@ -332,11 +337,15 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
}
completed={label.completed_issues}
total={label.total_issues}
onClick={() => {
if (label.label_id) {
handleFiltersUpdate("labels", [label.label_id], true);
}
}}
onClick={
label.label_id
? () => {
if (label.label_id) {
handleFiltersUpdate([{ property: "label_id", operator: "in", value: [label.label_id] }]);
}
}
: undefined
}
/>
))
) : (

View file

@ -2,30 +2,28 @@
import { FC } from "react";
import { observer } from "mobx-react";
// plane package imports
// plane imports
import { PROGRESS_STATE_GROUPS_DETAILS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { ICycle, IIssueFilterOptions } from "@plane/types";
import { TWorkItemFilterCondition } from "@plane/shared-state";
import { ICycle } from "@plane/types";
import { LinearProgressIndicator, Loader } from "@plane/ui";
// components
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
// hooks
import { useProjectState } from "@/hooks/store/use-project-state";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
export type ActiveCycleProgressProps = {
cycle: ICycle | null;
workspaceSlug: string;
projectId: string;
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => void;
handleFiltersUpdate: (conditions: TWorkItemFilterCondition[]) => void;
};
export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = observer((props) => {
const { handleFiltersUpdate, cycle } = props;
// plane hooks
const { t } = useTranslation();
// store hooks
const { groupedProjectStates } = useProjectState();
// derived values
const progressIndicatorData = PROGRESS_STATE_GROUPS_DETAILS.map((group, index) => ({
id: index,
@ -68,10 +66,7 @@ export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = observer((props
<div
className="flex items-center justify-between gap-2 text-sm cursor-pointer"
onClick={() => {
if (groupedProjectStates) {
const states = groupedProjectStates[group].map((state) => state.id);
handleFiltersUpdate("state", states, true);
}
handleFiltersUpdate([{ property: "state_group", operator: "in", value: [group] }]);
}}
>
<div className="flex items-center gap-1.5">

View file

@ -1,12 +1,15 @@
import { useCallback } from "react";
import isEqual from "lodash/isEqual";
import { useRouter } from "next/navigation";
import useSWR from "swr";
import { EIssueFilterType } from "@plane/constants";
import { EIssuesStoreType, IIssueFilterOptions } from "@plane/types";
// plane imports
import { TWorkItemFilterCondition } from "@plane/shared-state";
import { EIssuesStoreType } from "@plane/types";
// constants
import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useIssues } from "@/hooks/store/use-issues";
import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters";
interface IActiveCycleDetails {
workspaceSlug: string;
@ -21,9 +24,10 @@ const useCyclesDetails = (props: IActiveCycleDetails) => {
const router = useRouter();
// store hooks
const {
issuesFilter: { issueFilters, updateFilters },
issuesFilter: { updateFilterExpression },
issues: { getActiveCycleById: getActiveCycleByIdFromIssue, fetchActiveCycleIssues },
} = useIssues(EIssuesStoreType.CYCLE);
const { updateFilterExpressionFromConditions } = useWorkItemFilters();
const { fetchActiveCycleProgress, getCycleById, fetchActiveCycleAnalytics } = useCycle();
// derived values
@ -62,29 +66,19 @@ const useCyclesDetails = (props: IActiveCycleDetails) => {
const cycleIssueDetails = cycle?.id ? getActiveCycleByIdFromIssue(cycle?.id) : { nextPageResults: false };
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => {
async (conditions: TWorkItemFilterCondition[]) => {
if (!workspaceSlug || !projectId || !cycleId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(issueFilters?.filters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = [];
});
let newValues: string[] = [];
if (isEqual(newValues, value)) newValues = [];
else newValues = value;
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{ ...newFilters, [key]: newValues },
cycleId.toString()
await updateFilterExpressionFromConditions(
EIssuesStoreType.CYCLE,
cycleId,
conditions,
updateFilterExpression.bind(updateFilterExpression, workspaceSlug, projectId, cycleId)
);
if (redirect) router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`);
router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`);
},
[workspaceSlug, projectId, cycleId, issueFilters, updateFilters, router]
[workspaceSlug, projectId, cycleId, updateFilterExpressionFromConditions, updateFilterExpression, router]
);
return {
cycle,

View file

@ -1,21 +1,19 @@
"use client";
import { FC, useCallback, useMemo } from "react";
import { FC, useMemo } from "react";
import isEmpty from "lodash/isEmpty";
import isEqual from "lodash/isEqual";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import { ChevronUp, ChevronDown } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// plane imports
import { EIssueFilterType } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EIssuesStoreType, ICycle, IIssueFilterOptions, TCyclePlotType, TProgressSnapshot } from "@plane/types";
import { EIssuesStoreType, ICycle, TCyclePlotType, TProgressSnapshot } from "@plane/types";
import { getDate } from "@plane/utils";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useIssues } from "@/hooks/store/use-issues";
// plane web components
import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters";
import { SidebarChartRoot } from "@/plane-web/components/cycles";
// local imports
import { CycleProgressStats } from "./progress-stats";
@ -60,23 +58,23 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
// router
const searchParams = useSearchParams();
const peekCycle = searchParams.get("peekCycle") || undefined;
const { getPlotTypeByCycleId, getEstimateTypeByCycleId, getCycleById } = useCycle();
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.CYCLE);
// plane hooks
const { t } = useTranslation();
// store hooks
const { getPlotTypeByCycleId, getEstimateTypeByCycleId, getCycleById } = useCycle();
const { getFilter, updateFilterValueFromSidebar } = useWorkItemFilters();
// derived values
const cycleFilter = getFilter(EIssuesStoreType.CYCLE, cycleId);
const selectedAssignees = cycleFilter?.findFirstConditionByPropertyAndOperator("assignee_id", "in");
const selectedLabels = cycleFilter?.findFirstConditionByPropertyAndOperator("label_id", "in");
const selectedStateGroups = cycleFilter?.findFirstConditionByPropertyAndOperator("state_group", "in");
const cycleDetails = validateCycleSnapshot(getCycleById(cycleId));
const plotType: TCyclePlotType = getPlotTypeByCycleId(cycleId);
const estimateType = getEstimateTypeByCycleId(cycleId);
const totalIssues = cycleDetails?.total_issues || 0;
const totalEstimatePoints = cycleDetails?.total_estimate_points || 0;
const chartDistributionData =
estimateType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined;
const groupedIssues = useMemo(
() => ({
backlog:
@ -92,44 +90,12 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
}),
[estimateType, cycleDetails]
);
const cycleStartDate = getDate(cycleDetails?.start_date);
const cycleEndDate = getDate(cycleDetails?.end_date);
const isCycleStartDateValid = cycleStartDate && cycleStartDate <= new Date();
const isCycleEndDateValid = cycleStartDate && cycleEndDate && cycleEndDate >= cycleStartDate;
const isCycleDateValid = isCycleStartDateValid && isCycleEndDateValid;
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !projectId) return;
let newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) {
if (key === "state") {
if (isEqual(newValues, value)) newValues = [];
else newValues = value;
} else {
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
}
} else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{ [key]: newValues },
cycleId
);
},
[workspaceSlug, projectId, cycleId, issueFilters, updateFilters]
);
if (!cycleDetails) return <></>;
return (
<div className="border-t border-custom-border-200 space-y-4 py-5">
@ -159,7 +125,6 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
</div>
</div>
)}
<Transition show={open}>
<Disclosure.Panel className="flex flex-col divide-y divide-custom-border-200">
{cycleStartDate && cycleEndDate ? (
@ -172,16 +137,24 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
<div className="w-full py-4">
<CycleProgressStats
cycleId={cycleId}
plotType={plotType}
distribution={chartDistributionData}
groupedIssues={groupedIssues}
totalIssuesCount={estimateType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
isEditable={Boolean(!peekCycle)}
size="xs"
roundedTab={false}
handleFiltersUpdate={updateFilterValueFromSidebar.bind(
updateFilterValueFromSidebar,
EIssuesStoreType.CYCLE,
cycleId
)}
isEditable={Boolean(!peekCycle) && cycleFilter !== undefined}
noBackground={false}
filters={issueFilters}
handleFiltersUpdate={handleFiltersUpdate}
plotType={plotType}
roundedTab={false}
selectedFilters={{
assignees: selectedAssignees,
labels: selectedLabels,
stateGroups: selectedStateGroups,
}}
size="xs"
totalIssuesCount={estimateType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
/>
</div>
)}

View file

@ -2,280 +2,67 @@
import { FC } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import { Tab } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { StateGroupIcon } from "@plane/propel/icons";
import {
IIssueFilterOptions,
IIssueFilters,
TCycleDistribution,
TCycleEstimateDistribution,
TCyclePlotType,
TStateGroups,
} from "@plane/types";
import { Avatar } from "@plane/ui";
import { cn, getFileURL } from "@plane/utils";
import { TWorkItemFilterCondition } from "@plane/shared-state";
import { TCycleDistribution, TCycleEstimateDistribution, TCyclePlotType } from "@plane/types";
import { cn, toFilterArray } from "@plane/utils";
// components
import { SingleProgressStats } from "@/components/core/sidebar/single-progress-stats";
import { AssigneeStatComponent, TAssigneeData } from "@/components/core/sidebar/progress-stats/assignee";
import { LabelStatComponent, TLabelData } from "@/components/core/sidebar/progress-stats/label";
import {
createFilterUpdateHandler,
PROGRESS_STATS,
TSelectedFilterProgressStats,
} from "@/components/core/sidebar/progress-stats/shared";
import { StateGroupStatComponent, TStateGroupData } from "@/components/core/sidebar/progress-stats/state_group";
// helpers
// hooks
import { useProjectState } from "@/hooks/store/use-project-state";
import useLocalStorage from "@/hooks/use-local-storage";
// public
import emptyLabel from "@/public/empty-state/empty_label.svg";
import emptyMembers from "@/public/empty-state/empty_members.svg";
// assignee types
type TAssigneeData = {
id: string | undefined;
title: string | undefined;
avatar_url: string | undefined;
completed: number;
total: number;
}[];
type TAssigneeStatComponent = {
distribution: TAssigneeData;
isEditable?: boolean;
filters?: IIssueFilters | undefined;
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
};
// labelTypes
type TLabelData = {
id: string | undefined;
title: string | undefined;
color: string | undefined;
completed: number;
total: number;
}[];
type TLabelStatComponent = {
distribution: TLabelData;
isEditable?: boolean;
filters?: IIssueFilters | undefined;
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
};
// stateTypes
type TStateData = {
state: string | undefined;
completed: number;
total: number;
}[];
type TStateStatComponent = {
distribution: TStateData;
totalIssuesCount: number;
isEditable?: boolean;
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
};
export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => {
const { distribution, isEditable, filters, handleFiltersUpdate } = props;
const { t } = useTranslation();
return (
<div>
{distribution && distribution.length > 0 ? (
distribution.map((assignee, index) => {
if (assignee?.id)
return (
<SingleProgressStats
key={assignee?.id}
title={
<div className="flex items-center gap-2">
<Avatar name={assignee?.title ?? undefined} src={getFileURL(assignee?.avatar_url ?? "")} />
<span>{assignee?.title ?? ""}</span>
</div>
}
completed={assignee?.completed ?? 0}
total={assignee?.total ?? 0}
{...(isEditable && {
onClick: () => handleFiltersUpdate("assignees", assignee.id ?? ""),
selected: filters?.filters?.assignees?.includes(assignee.id ?? ""),
})}
/>
);
else
return (
<SingleProgressStats
key={`unassigned-${index}`}
title={
<div className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
</div>
<span>{t("no_assignee")}</span>
</div>
}
completed={assignee?.completed ?? 0}
total={assignee?.total ?? 0}
/>
);
})
) : (
<div className="flex h-full flex-col items-center justify-center gap-2">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
<Image src={emptyMembers} className="h-12 w-12" alt="empty members" />
</div>
<h6 className="text-base text-custom-text-300">{t("no_assignee")}</h6>
</div>
)}
</div>
);
});
export const LabelStatComponent = observer((props: TLabelStatComponent) => {
const { distribution, isEditable, filters, handleFiltersUpdate } = props;
const { t } = useTranslation();
return (
<div>
{distribution && distribution.length > 0 ? (
distribution.map((label, index) => {
if (label.id) {
return (
<SingleProgressStats
key={label.id}
title={
<div className="flex items-center gap-2 truncate">
<span
className="block h-3 w-3 rounded-full flex-shrink-0"
style={{
backgroundColor: label.color ?? "transparent",
}}
/>
<span className="text-xs text-ellipsis truncate">{label.title ?? t("no_labels_yet")}</span>
</div>
}
completed={label.completed}
total={label.total}
{...(isEditable && {
onClick: () => handleFiltersUpdate("labels", label.id ?? ""),
selected: filters?.filters?.labels?.includes(label.id ?? `no-label-${index}`),
})}
/>
);
} else {
return (
<SingleProgressStats
key={`no-label-${index}`}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: label.color ?? "transparent",
}}
/>
<span className="text-xs">{label.title ?? t("no_labels_yet")}</span>
</div>
}
completed={label.completed}
total={label.total}
/>
);
}
})
) : (
<div className="flex h-full flex-col items-center justify-center gap-2">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
<Image src={emptyLabel} className="h-12 w-12" alt="empty label" />
</div>
<h6 className="text-base text-custom-text-300">{t("no_labels_yet")}</h6>
</div>
)}
</div>
);
});
export const StateStatComponent = observer((props: TStateStatComponent) => {
const { distribution, isEditable, totalIssuesCount, handleFiltersUpdate } = props;
// hooks
const { groupedProjectStates } = useProjectState();
// derived values
const getStateGroupState = (stateGroup: string) => {
const stateGroupStates = groupedProjectStates?.[stateGroup];
const stateGroupStatesId = stateGroupStates?.map((state) => state.id);
return stateGroupStatesId;
};
return (
<div>
{distribution.map((group, index) => (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<StateGroupIcon stateGroup={group.state as TStateGroups} />
<span className="text-xs capitalize">{group.state}</span>
</div>
}
completed={group.completed}
total={totalIssuesCount}
{...(isEditable && {
onClick: () => group.state && handleFiltersUpdate("state", getStateGroupState(group.state) ?? []),
})}
/>
))}
</div>
);
});
const progressStats = [
{
key: "stat-states",
i18n_title: "common.states",
},
{
key: "stat-assignees",
i18n_title: "common.assignees",
},
{
key: "stat-labels",
i18n_title: "common.labels",
},
];
type TCycleProgressStats = {
cycleId: string;
plotType: TCyclePlotType;
distribution: TCycleDistribution | TCycleEstimateDistribution | undefined;
groupedIssues: Record<string, number>;
totalIssuesCount: number;
handleFiltersUpdate: (condition: TWorkItemFilterCondition) => void;
isEditable?: boolean;
filters?: IIssueFilters | undefined;
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
size?: "xs" | "sm";
roundedTab?: boolean;
noBackground?: boolean;
plotType: TCyclePlotType;
roundedTab?: boolean;
selectedFilters: TSelectedFilterProgressStats;
size?: "xs" | "sm";
totalIssuesCount: number;
};
export const CycleProgressStats: FC<TCycleProgressStats> = observer((props) => {
const {
cycleId,
plotType,
distribution,
groupedIssues,
totalIssuesCount,
isEditable = false,
filters,
handleFiltersUpdate,
size = "sm",
roundedTab = false,
isEditable = false,
noBackground = false,
plotType,
roundedTab = false,
selectedFilters,
size = "sm",
totalIssuesCount,
} = props;
// hooks
// plane imports
const { t } = useTranslation();
// store imports
const { storedValue: currentTab, setValue: setCycleTab } = useLocalStorage(
`cycle-analytics-tab-${cycleId}`,
"stat-assignees"
);
const { t } = useTranslation();
// derived values
const currentTabIndex = (tab: string): number => progressStats.findIndex((stat) => stat.key === tab);
const currentTabIndex = (tab: string): number => PROGRESS_STATS.findIndex((stat) => stat.key === tab);
const currentDistribution = distribution as TCycleDistribution;
const currentEstimateDistribution = distribution as TCycleEstimateDistribution;
const selectedAssigneeIds = toFilterArray(selectedFilters?.assignees?.value || []) as string[];
const selectedLabelIds = toFilterArray(selectedFilters?.labels?.value || []) as string[];
const selectedStateGroups = toFilterArray(selectedFilters?.stateGroups?.value || []) as string[];
const distributionAssigneeData: TAssigneeData =
plotType === "burndown"
@ -311,12 +98,24 @@ export const CycleProgressStats: FC<TCycleProgressStats> = observer((props) => {
total: label.total_estimates,
}));
const distributionStateData: TStateData = Object.keys(groupedIssues || {}).map((state) => ({
const distributionStateData: TStateGroupData = Object.keys(groupedIssues || {}).map((state) => ({
state: state,
completed: groupedIssues?.[state] || 0,
total: totalIssuesCount || 0,
}));
const handleAssigneeFiltersUpdate = createFilterUpdateHandler(
"assignee_id",
selectedAssigneeIds,
handleFiltersUpdate
);
const handleLabelFiltersUpdate = createFilterUpdateHandler("label_id", selectedLabelIds, handleFiltersUpdate);
const handleStateGroupFiltersUpdate = createFilterUpdateHandler(
"state_group",
selectedStateGroups,
handleFiltersUpdate
);
return (
<div>
<Tab.Group defaultIndex={currentTabIndex(currentTab ? currentTab : "stat-assignees")}>
@ -329,7 +128,7 @@ export const CycleProgressStats: FC<TCycleProgressStats> = observer((props) => {
size === "xs" ? `text-xs` : `text-sm`
)}
>
{progressStats.map((stat) => (
{PROGRESS_STATS.map((stat) => (
<Tab
className={cn(
`p-1 w-full text-custom-text-100 outline-none focus:outline-none cursor-pointer transition-all`,
@ -347,27 +146,28 @@ export const CycleProgressStats: FC<TCycleProgressStats> = observer((props) => {
</Tab.List>
<Tab.Panels className="py-3 text-custom-text-200">
<Tab.Panel key={"stat-states"}>
<StateStatComponent
<StateGroupStatComponent
distribution={distributionStateData}
totalIssuesCount={totalIssuesCount}
handleStateGroupFiltersUpdate={handleStateGroupFiltersUpdate}
isEditable={isEditable}
handleFiltersUpdate={handleFiltersUpdate}
selectedStateGroups={selectedStateGroups}
totalIssuesCount={totalIssuesCount}
/>
</Tab.Panel>
<Tab.Panel key={"stat-assignees"}>
<AssigneeStatComponent
distribution={distributionAssigneeData}
handleAssigneeFiltersUpdate={handleAssigneeFiltersUpdate}
isEditable={isEditable}
filters={filters}
handleFiltersUpdate={handleFiltersUpdate}
selectedAssigneeIds={selectedAssigneeIds}
/>
</Tab.Panel>
<Tab.Panel key={"stat-labels"}>
<LabelStatComponent
distribution={distributionLabelData}
handleLabelFiltersUpdate={handleLabelFiltersUpdate}
isEditable={isEditable}
filters={filters}
handleFiltersUpdate={handleFiltersUpdate}
selectedLabelIds={selectedLabelIds}
/>
</Tab.Panel>
</Tab.Panels>

View file

@ -1,28 +1,17 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane constants
// plane imports
import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
// i18n
import { useTranslation } from "@plane/i18n";
// types
import {
EIssuesStoreType,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
} from "@plane/types";
import { EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
import { EHeaderVariant, Header } from "@plane/ui";
// components
import { isIssueFilterActive } from "@plane/utils";
import { ArchiveTabsList } from "@/components/archives";
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
// helpers
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
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";
export const ArchivedIssuesHeader: FC = observer(() => {
// router
@ -32,34 +21,10 @@ export const ArchivedIssuesHeader: FC = observer(() => {
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.ARCHIVED);
const { projectStates } = useProjectState();
const { projectLabels } = useLabel();
const {
project: { projectMemberIds },
} = useMember();
// i18n
const { t } = useTranslation();
// for archived issues list layout is the only option
const activeLayout = "list";
// hooks
const handleFiltersUpdate = (key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !projectId) return;
const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) {
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
});
} else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
[key]: newValues,
});
};
const handleDisplayFiltersUpdate = (updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId) return;
@ -77,42 +42,25 @@ export const ArchivedIssuesHeader: FC = observer(() => {
};
return (
<div className="group relative flex border-b border-custom-border-200">
<div className="flex w-full items-center overflow-x-auto px-4 gap-2 horizontal-scrollbar scrollbar-sm">
<Header variant={EHeaderVariant.SECONDARY}>
<Header.LeftItem>
<ArchiveTabsList />
</div>
{/* filter options */}
<div className="flex items-center gap-2 px-8">
<FiltersDropdown
title={t("common.filters")}
placement="bottom-end"
isFiltersApplied={isIssueFilterActive(issueFilters)}
>
<FilterSelection
filters={issueFilters?.filters || {}}
handleFiltersUpdate={handleFiltersUpdate}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFiltersUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.archived_issues[activeLayout] : undefined
}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
/>
</FiltersDropdown>
</Header.LeftItem>
<Header.RightItem className="items-center">
<FiltersDropdown title={t("common.display")} placement="bottom-end">
<DisplayFiltersSelection
displayFilters={issueFilters?.displayFilters || {}}
displayProperties={issueFilters?.displayProperties || {}}
handleDisplayFiltersUpdate={handleDisplayFiltersUpdate}
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
layoutDisplayFiltersOptions={activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.archived_issues.layoutOptions[activeLayout] : undefined
}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</FiltersDropdown>
</div>
</div>
</Header.RightItem>
</Header>
);
});

View file

@ -2,35 +2,21 @@
import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import { ChartNoAxesColumn, ListFilter, SlidersHorizontal } from "lucide-react";
// plane constants
import { ChartNoAxesColumn, SlidersHorizontal } from "lucide-react";
// plane imports
import { EIssueFilterType, ISSUE_STORE_TO_FILTERS_MAP } from "@plane/constants";
// i18n
import { useTranslation } from "@plane/i18n";
// types
import {
EIssuesStoreType,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
EIssueLayoutTypes,
} from "@plane/types";
import { EIssueLayoutTypes, EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
import { Button } from "@plane/ui";
// components
import { isIssueFilterActive } from "@plane/utils";
// helpers
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useProjectState } from "@/hooks/store/use-project-state";
// plane web types
// plane web imports
import { TProject } from "@/plane-web/types";
// local imports
import { WorkItemsModal } from "../analytics/work-items/modal";
import {
DisplayFiltersSelection,
FiltersDropdown,
FilterSelection,
LayoutSelection,
MobileLayoutSelection,
} from "./issue-layouts/filters";
@ -63,38 +49,13 @@ export const HeaderFilters = observer((props: Props) => {
// states
const [analyticsModal, setAnalyticsModal] = useState(false);
// store hooks
const {
project: { projectMemberIds },
} = useMember();
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(storeType);
const { projectStates } = useProjectState();
const { projectLabels } = useLabel();
// derived values
const activeLayout = issueFilters?.displayFilters?.layout;
const layoutDisplayFiltersOptions = ISSUE_STORE_TO_FILTERS_MAP[storeType]?.[activeLayout];
const layoutDisplayFiltersOptions = ISSUE_STORE_TO_FILTERS_MAP[storeType]?.layoutOptions[activeLayout];
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !projectId) return;
const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
} else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues });
},
[workspaceSlug, projectId, issueFilters, updateFilters]
);
const handleLayoutChange = useCallback(
(layout: EIssueLayoutTypes) => {
if (!workspaceSlug || !projectId) return;
@ -141,27 +102,6 @@ export const HeaderFilters = observer((props: Props) => {
activeLayout={activeLayout}
/>
</div>
<FiltersDropdown
title={t("common.filters")}
placement="bottom-end"
isFiltersApplied={isIssueFilterActive(issueFilters)}
miniIcon={<ListFilter className="size-3.5" />}
>
<FilterSelection
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
projectId={projectId}
states={projectStates}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
isEpic={storeType === EIssuesStoreType.EPIC}
/>
</FiltersDropdown>
<FiltersDropdown
miniIcon={<SlidersHorizontal className="size-3.5" />}
title={t("common.display")}

View file

@ -13,7 +13,6 @@ import {
FiltersDropdown,
} from "@/components/issues/issue-layouts/filters";
import { isDisplayFiltersApplied } from "@/components/issues/issue-layouts/utils";
type TSubIssueDisplayFiltersProps = {
displayProperties: IIssueDisplayProperties;
displayFilters: IIssueDisplayFilterOptions;

View file

@ -2,7 +2,7 @@ import { FC, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { ListFilter, Search, X } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { IIssueFilterOptions, ILayoutDisplayFiltersOptions, IState } from "@plane/types";
import { IIssueFilterOptions, IState } from "@plane/types";
import { cn } from "@plane/utils";
import {
FilterAssignees,
@ -16,26 +16,24 @@ import {
} from "@/components/issues/issue-layouts/filters";
import { isFiltersApplied } from "@/components/issues/issue-layouts/utils";
import { FilterIssueTypes } from "@/plane-web/components/issues/filters/issue-types";
type TSubIssueFiltersProps = {
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
filters: IIssueFilterOptions;
memberIds: string[] | undefined;
states?: IState[];
layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined;
availableFilters: (keyof IIssueFilterOptions)[];
};
export const SubIssueFilters: FC<TSubIssueFiltersProps> = observer((props) => {
const { handleFiltersUpdate, filters, memberIds, states, layoutDisplayFiltersOptions } = props;
const { handleFiltersUpdate, filters, memberIds, states, availableFilters } = props;
// plane hooks
const { t } = useTranslation();
// states
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
const isFilterEnabled = (filter: keyof IIssueFilterOptions) =>
!!layoutDisplayFiltersOptions?.filters.includes(filter);
const isFilterEnabled = (filter: keyof IIssueFilterOptions) => !!availableFilters.includes(filter);
const isFilterApplied = useMemo(() => isFiltersApplied(filters), [filters]);
// hooks
const { t } = useTranslation();
return (
<>

View file

@ -3,3 +3,4 @@ export * from "./title";
export * from "./root";
export * from "./quick-action-button";
export * from "./display-filters";
export * from "./content";

View file

@ -1,7 +1,11 @@
import { FC, useCallback } from "react";
import cloneDeep from "lodash/cloneDeep";
import { observer } from "mobx-react";
import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import {
EIssueFilterType,
ISSUE_DISPLAY_FILTERS_BY_PAGE,
SUB_WORK_ITEM_AVAILABLE_FILTERS_FOR_WORK_ITEM_PAGE,
} from "@plane/constants";
import {
EIssueServiceType,
IIssueDisplayFilterOptions,
@ -38,11 +42,10 @@ export const SubWorkItemTitleActions: FC<TSubWorkItemTitleActionsProps> = observ
} = useMember();
// derived values
const subIssueFilters = getSubIssueFilters(parentId);
const projectStates = getProjectStates(projectId);
const projectMemberIds = getProjectMemberIds(projectId, false);
const layoutDisplayFiltersOptions = ISSUE_DISPLAY_FILTERS_BY_PAGE["sub_work_items"].list;
const subIssueFilters = getSubIssueFilters(parentId);
const layoutDisplayFiltersOptions = ISSUE_DISPLAY_FILTERS_BY_PAGE["sub_work_items"].layoutOptions.list;
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
@ -72,7 +75,6 @@ export const SubWorkItemTitleActions: FC<TSubWorkItemTitleActionsProps> = observ
if (subIssueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateSubWorkItemFilters(EIssueFilterType.FILTERS, { [key]: newValues }, parentId);
},
[subIssueFilters?.filters, updateSubWorkItemFilters, parentId]
@ -100,7 +102,7 @@ export const SubWorkItemTitleActions: FC<TSubWorkItemTitleActionsProps> = observ
filters={subIssueFilters?.filters ?? {}}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
availableFilters={SUB_WORK_ITEM_AVAILABLE_FILTERS_FOR_WORK_ITEM_PAGE}
/>
{!disabled && (
<SubIssuesActionButton issueId={parentId} disabled={disabled} issueServiceType={issueServiceType} />

View file

@ -5,20 +5,17 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { observer } from "mobx-react";
// plane constants
import { EIssueFilterType } from "@plane/constants";
import { TSupportedFilterTypeForUpdate } from "@plane/constants";
// types
import {
EIssuesStoreType,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
TGroupedIssues,
TIssue,
TIssueKanbanFilters,
TIssueMap,
TPaginationData,
ICalendarWeek,
EIssueLayoutTypes,
TSupportedFilterForUpdate,
} from "@plane/types";
// ui
import { Spinner } from "@plane/ui";
@ -71,8 +68,8 @@ type Props = {
readOnly?: boolean;
updateFilters?: (
projectId: string,
filterType: EIssueFilterType,
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters
filterType: TSupportedFilterTypeForUpdate,
filters: TSupportedFilterForUpdate
) => Promise<void>;
canEditProperties: (projectId: string | undefined) => boolean;
isEpic?: boolean;

View file

@ -9,15 +9,9 @@ import { Popover, Transition } from "@headlessui/react";
// hooks
// ui
// icons
import { EIssueFilterType } from "@plane/constants";
import { EIssueFilterType, TSupportedFilterTypeForUpdate } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
TCalendarLayouts,
TIssueKanbanFilters,
} from "@plane/types";
import { TCalendarLayouts, TSupportedFilterForUpdate } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
// types
// constants
@ -39,8 +33,8 @@ interface ICalendarHeader {
| IProjectEpicsFilter;
updateFilters?: (
projectId: string,
filterType: EIssueFilterType,
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters
filterType: TSupportedFilterTypeForUpdate,
filters: TSupportedFilterForUpdate
) => Promise<void>;
}

View file

@ -2,14 +2,9 @@ import { observer } from "mobx-react";
// components
import { ChevronLeft, ChevronRight } from "lucide-react";
import { EIssueFilterType } from "@plane/constants";
import { TSupportedFilterTypeForUpdate } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
TIssueKanbanFilters,
} from "@plane/types";
import { TSupportedFilterForUpdate } from "@plane/types";
import { Row } from "@plane/ui";
// icons
import { useCalendarView } from "@/hooks/store/use-calendar-view";
@ -29,8 +24,8 @@ interface ICalendarHeader {
| IProjectEpicsFilter;
updateFilters?: (
projectId: string,
filterType: EIssueFilterType,
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters
filterType: TSupportedFilterTypeForUpdate,
filters: TSupportedFilterForUpdate
) => Promise<void>;
setSelectedDate: (date: Date) => void;
}

View file

@ -1,36 +1,35 @@
import size from "lodash/size";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EIssueFilterType, EUserPermissionsLevel } from "@plane/constants";
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EIssuesStoreType, EUserProjectRoles, IIssueFilterOptions } from "@plane/types";
import { EIssuesStoreType, EUserProjectRoles } from "@plane/types";
// components
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useUserPermissions } from "@/hooks/store/user";
import { useWorkItemFilterInstance } from "@/hooks/store/work-item-filters/use-work-item-filter-instance";
import { useAppRouter } from "@/hooks/use-app-router";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
export const ProjectArchivedEmptyState: React.FC = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = useParams();
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams();
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
// plane hooks
const { t } = useTranslation();
// store hooks
const { issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED);
const { allowPermissions } = useUserPermissions();
// derived values
const userFilters = issuesFilter?.issueFilters?.filters;
const archivedWorkItemFilter = projectId
? useWorkItemFilterInstance(EIssuesStoreType.ARCHIVED, projectId)
: undefined;
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
const issueFilterCount = size(
Object.fromEntries(
Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0)
)
);
const additionalPath = issueFilterCount > 0 ? (activeLayout ?? "list") : undefined;
const additionalPath = archivedWorkItemFilter?.hasActiveFilters ? (activeLayout ?? "list") : undefined;
const canPerformEmptyStateActions = allowPermissions(
[EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER],
EUserPermissionsLevel.PROJECT
@ -43,27 +42,16 @@ export const ProjectArchivedEmptyState: React.FC = observer(() => {
basePath: "/empty-state/archived/empty-issues",
});
const handleClearAllFilters = () => {
if (!workspaceSlug || !projectId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(userFilters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = [];
});
issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
...newFilters,
});
};
return (
<div className="relative h-full w-full overflow-y-auto">
{issueFilterCount > 0 ? (
{archivedWorkItemFilter?.hasActiveFilters ? (
<DetailedEmptyState
title={t("project_issues.empty_state.issues_empty_filter.title")}
assetPath={emptyFilterResolvedPath}
secondaryButton={{
text: t("project_issues.empty_state.issues_empty_filter.secondary_button.text"),
onClick: handleClearAllFilters,
disabled: !canPerformEmptyStateActions,
onClick: archivedWorkItemFilter?.clearFilters,
disabled: !canPerformEmptyStateActions || !archivedWorkItemFilter,
}}
/>
) : (

View file

@ -2,13 +2,12 @@
import { useState } from "react";
import isEmpty from "lodash/isEmpty";
import size from "lodash/size";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EIssueFilterType, EUserPermissionsLevel, WORK_ITEM_TRACKER_ELEMENTS } from "@plane/constants";
import { EUserPermissionsLevel, WORK_ITEM_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EIssuesStoreType, EUserProjectRoles, IIssueFilterOptions, ISearchIssueResponse } from "@plane/types";
import { EIssuesStoreType, EUserProjectRoles, ISearchIssueResponse } from "@plane/types";
import { TOAST_TYPE, setToast } from "@plane/ui";
// components
import { ExistingIssuesListModal } from "@/components/core/modals/existing-issues-list-modal";
@ -18,11 +17,15 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useCycle } from "@/hooks/store/use-cycle";
import { useIssues } from "@/hooks/store/use-issues";
import { useUserPermissions } from "@/hooks/store/user";
import { useWorkItemFilterInstance } from "@/hooks/store/work-item-filters/use-work-item-filter-instance";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
export const CycleEmptyState: React.FC = observer(() => {
// router
const { workspaceSlug, projectId, cycleId } = useParams();
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId, cycleId: routerCycleId } = useParams();
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
const cycleId = routerCycleId ? routerCycleId.toString() : undefined;
// states
const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false);
// plane hooks
@ -33,16 +36,10 @@ export const CycleEmptyState: React.FC = observer(() => {
const { toggleCreateIssueModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
// derived values
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
const userFilters = issuesFilter?.issueFilters?.filters;
const cycleWorkItemFilter = cycleId ? useWorkItemFilterInstance(EIssuesStoreType.CYCLE, cycleId) : undefined;
const cycleDetails = cycleId ? getCycleById(cycleId) : undefined;
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
const issueFilterCount = size(
Object.fromEntries(
Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0)
)
);
const isCompletedCycleSnapshotAvailable = !isEmpty(cycleDetails?.progress_snapshot ?? {});
const isEmptyFilters = issueFilterCount > 0;
const isCompletedAndEmpty = isCompletedCycleSnapshotAvailable || cycleDetails?.status?.toLowerCase() === "completed";
const additionalPath = activeLayout ?? "list";
const canPerformEmptyStateActions = allowPermissions(
@ -84,23 +81,6 @@ export const CycleEmptyState: React.FC = observer(() => {
);
};
const handleClearAllFilters = () => {
if (!workspaceSlug || !projectId || !cycleId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(userFilters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = null;
});
issuesFilter.updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{
...newFilters,
},
cycleId.toString()
);
};
return (
<div className="relative h-full w-full overflow-y-auto">
<ExistingIssuesListModal
@ -118,14 +98,14 @@ export const CycleEmptyState: React.FC = observer(() => {
description={t("project_cycles.empty_state.completed_no_issues.description")}
assetPath={completedNoIssuesResolvedPath}
/>
) : isEmptyFilters ? (
) : cycleWorkItemFilter?.hasActiveFilters ? (
<DetailedEmptyState
title={t("project_issues.empty_state.issues_empty_filter.title")}
assetPath={emptyFilterResolvedPath}
secondaryButton={{
text: t("project_issues.empty_state.issues_empty_filter.secondary_button.text"),
onClick: handleClearAllFilters,
disabled: !canPerformEmptyStateActions,
onClick: cycleWorkItemFilter?.clearFilters,
disabled: !canPerformEmptyStateActions || !cycleWorkItemFilter,
}}
/>
) : (

View file

@ -1,7 +1,8 @@
// plane web components
import { EIssuesStoreType } from "@plane/types";
import { TeamEmptyState, TeamViewEmptyState } from "@/plane-web/components/issues/issue-layouts/empty-states";
import { TeamEmptyState } from "@/plane-web/components/issues/issue-layouts/empty-states/team-issues";
import { TeamProjectWorkItemEmptyState } from "@/plane-web/components/issues/issue-layouts/empty-states/team-project";
import { TeamViewEmptyState } from "@/plane-web/components/issues/issue-layouts/empty-states/team-view-issues";
// components
import { ProjectArchivedEmptyState } from "./archived-issues";
import { CycleEmptyState } from "./cycle";

View file

@ -1,13 +1,12 @@
"use client";
import { useState } from "react";
import size from "lodash/size";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EIssueFilterType, EUserPermissionsLevel, WORK_ITEM_TRACKER_ELEMENTS } from "@plane/constants";
import { EUserPermissionsLevel, WORK_ITEM_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EIssuesStoreType, EUserProjectRoles, IIssueFilterOptions, ISearchIssueResponse } from "@plane/types";
import { EIssuesStoreType, EUserProjectRoles, ISearchIssueResponse } from "@plane/types";
import { TOAST_TYPE, setToast } from "@plane/ui";
// components
import { ExistingIssuesListModal } from "@/components/core/modals/existing-issues-list-modal";
@ -17,11 +16,15 @@ import { captureClick } from "@/helpers/event-tracker.helper";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useIssues } from "@/hooks/store/use-issues";
import { useUserPermissions } from "@/hooks/store/user";
import { useWorkItemFilterInstance } from "@/hooks/store/work-item-filters/use-work-item-filter-instance";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
export const ModuleEmptyState: React.FC = observer(() => {
// router
const { workspaceSlug, projectId, moduleId } = useParams();
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId, moduleId: routerModuleId } = useParams();
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
const moduleId = routerModuleId ? routerModuleId.toString() : undefined;
// states
const [moduleIssuesListModal, setModuleIssuesListModal] = useState(false);
// plane hooks
@ -31,14 +34,8 @@ export const ModuleEmptyState: React.FC = observer(() => {
const { toggleCreateIssueModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
// derived values
const userFilters = issuesFilter?.issueFilters?.filters;
const moduleWorkItemFilter = moduleId ? useWorkItemFilterInstance(EIssuesStoreType.MODULE, moduleId) : undefined;
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
const issueFilterCount = size(
Object.fromEntries(
Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0)
)
);
const isEmptyFilters = issueFilterCount > 0;
const additionalPath = activeLayout ?? "list";
const canPerformEmptyStateActions = allowPermissions(
[EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER],
@ -75,23 +72,6 @@ export const ModuleEmptyState: React.FC = observer(() => {
);
};
const handleClearAllFilters = () => {
if (!workspaceSlug || !projectId || !moduleId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(userFilters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = null;
});
issuesFilter.updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{
...newFilters,
},
moduleId.toString()
);
};
return (
<div className="relative h-full w-full overflow-y-auto">
<ExistingIssuesListModal
@ -103,14 +83,14 @@ export const ModuleEmptyState: React.FC = observer(() => {
handleOnSubmit={handleAddIssuesToModule}
/>
<div className="grid h-full w-full place-items-center">
{isEmptyFilters ? (
{moduleWorkItemFilter?.hasActiveFilters ? (
<DetailedEmptyState
title={t("project_issues.empty_state.issues_empty_filter.title")}
assetPath={emptyFilterResolvedPath}
secondaryButton={{
text: t("project_issues.empty_state.issues_empty_filter.secondary_button.text"),
onClick: handleClearAllFilters,
disabled: !canPerformEmptyStateActions,
onClick: moduleWorkItemFilter?.clearFilters,
disabled: !canPerformEmptyStateActions || !moduleWorkItemFilter,
}}
/>
) : (

View file

@ -1,10 +1,9 @@
import size from "lodash/size";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EIssueFilterType, EUserPermissionsLevel, WORK_ITEM_TRACKER_ELEMENTS } from "@plane/constants";
import { EUserPermissionsLevel, WORK_ITEM_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EIssuesStoreType, EUserProjectRoles, IIssueFilterOptions } from "@plane/types";
import { EIssuesStoreType, EUserProjectRoles } from "@plane/types";
// components
import { ComicBoxButton } from "@/components/empty-state/comic-box-button";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
@ -13,11 +12,13 @@ import { captureClick } from "@/helpers/event-tracker.helper";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useIssues } from "@/hooks/store/use-issues";
import { useUserPermissions } from "@/hooks/store/user";
import { useWorkItemFilterInstance } from "@/hooks/store/work-item-filters/use-work-item-filter-instance";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
export const ProjectEmptyState: React.FC = observer(() => {
// router
const { workspaceSlug, projectId } = useParams();
const { projectId: routerProjectId } = useParams();
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
// plane imports
const { t } = useTranslation();
// store hooks
@ -25,14 +26,9 @@ export const ProjectEmptyState: React.FC = observer(() => {
const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT);
const { allowPermissions } = useUserPermissions();
// derived values
const userFilters = issuesFilter?.issueFilters?.filters;
const projectWorkItemFilter = projectId ? useWorkItemFilterInstance(EIssuesStoreType.PROJECT, projectId) : undefined;
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
const issueFilterCount = size(
Object.fromEntries(
Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0)
)
);
const additionalPath = issueFilterCount > 0 ? (activeLayout ?? "list") : undefined;
const additionalPath = projectWorkItemFilter?.hasActiveFilters ? (activeLayout ?? "list") : undefined;
const canPerformEmptyStateActions = allowPermissions(
[EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER],
EUserPermissionsLevel.PROJECT
@ -45,27 +41,16 @@ export const ProjectEmptyState: React.FC = observer(() => {
basePath: "/empty-state/onboarding/issues",
});
const handleClearAllFilters = () => {
if (!workspaceSlug || !projectId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(userFilters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = [];
});
issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
...newFilters,
});
};
return (
<div className="relative h-full w-full overflow-y-auto">
{issueFilterCount > 0 ? (
{projectWorkItemFilter?.hasActiveFilters ? (
<DetailedEmptyState
title={t("project_issues.empty_state.issues_empty_filter.title")}
assetPath={emptyFilterResolvedPath}
secondaryButton={{
text: t("project_issues.empty_state.issues_empty_filter.secondary_button.text"),
onClick: handleClearAllFilters,
disabled: !canPerformEmptyStateActions,
onClick: projectWorkItemFilter?.clearFilters,
disabled: !canPerformEmptyStateActions || !projectWorkItemFilter,
}}
/>
) : (

View file

@ -1,166 +0,0 @@
import { observer } from "mobx-react";
import { X } from "lucide-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IIssueFilterOptions, IIssueLabel, IState } from "@plane/types";
import { Tag } from "@plane/ui";
import { replaceUnderscoreIfSnakeCase } from "@plane/utils";
// components
import {
AppliedCycleFilters,
AppliedDateFilters,
AppliedLabelsFilters,
AppliedMembersFilters,
AppliedModuleFilters,
AppliedPriorityFilters,
AppliedProjectFilters,
AppliedStateFilters,
AppliedStateGroupFilters,
} from "@/components/issues/issue-layouts/filters";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
// plane web components
import { AppliedIssueTypeFilters } from "@/plane-web/components/issues/filters/applied-filters/issue-types";
type Props = {
appliedFilters: IIssueFilterOptions;
handleClearAllFilters: () => void;
handleRemoveFilter: (key: keyof IIssueFilterOptions, value: string | null) => void;
labels?: IIssueLabel[] | undefined;
states?: IState[] | undefined;
alwaysAllowEditing?: boolean;
disableEditing?: boolean;
};
const membersFilters = ["assignees", "mentions", "created_by", "subscriber"];
const dateFilters = ["start_date", "target_date"];
export const AppliedFiltersList: React.FC<Props> = observer((props) => {
const {
appliedFilters,
handleClearAllFilters,
handleRemoveFilter,
labels,
states,
alwaysAllowEditing,
disableEditing = false,
} = props;
// store hooks
const { allowPermissions } = useUserPermissions();
const { t } = useTranslation();
if (!appliedFilters) return null;
if (Object.keys(appliedFilters).length === 0) return null;
const isEditingAllowed =
!disableEditing &&
(alwaysAllowEditing ||
allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT));
return (
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100 truncate my-auto">
{Object.entries(appliedFilters).map(([key, value]) => {
const filterKey = key as keyof IIssueFilterOptions;
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
return (
<Tag key={filterKey}>
<span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
{membersFilters.includes(filterKey) && (
<AppliedMembersFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
values={value}
/>
)}
{dateFilters.includes(filterKey) && (
<AppliedDateFilters handleRemove={(val) => handleRemoveFilter(filterKey, val)} values={value} />
)}
{filterKey === "labels" && (
<AppliedLabelsFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter("labels", val)}
labels={labels}
values={value}
/>
)}
{filterKey === "priority" && (
<AppliedPriorityFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter("priority", val)}
values={value}
/>
)}
{filterKey === "state" && states && (
<AppliedStateFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter("state", val)}
states={states}
values={value}
/>
)}
{filterKey === "state_group" && (
<AppliedStateGroupFilters handleRemove={(val) => handleRemoveFilter("state_group", val)} values={value} />
)}
{filterKey === "project" && (
<AppliedProjectFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter("project", val)}
values={value}
/>
)}
{filterKey === "cycle" && (
<AppliedCycleFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter("cycle", val)}
values={value}
/>
)}
{filterKey === "module" && (
<AppliedModuleFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter("module", val)}
values={value}
/>
)}
{filterKey === "issue_type" && (
<AppliedIssueTypeFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter("issue_type", val)}
values={value}
/>
)}
{filterKey === "team_project" && (
<AppliedProjectFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter("team_project", 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>
)}
</Tag>
);
})}
{isEditingAllowed && (
<button type="button" onClick={handleClearAllFilters}>
<Tag>
{t("common.clear_all")}
<X size={12} strokeWidth={2} />
</Tag>
</button>
)}
</div>
);
});

View file

@ -1,6 +1,4 @@
export * from "./roots";
export * from "./date";
export * from "./filters-list";
export * from "./label";
export * from "./members";
export * from "./priority";

View file

@ -1,82 +0,0 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EIssueFilterType } from "@plane/constants";
import { EIssuesStoreType, IIssueFilterOptions } from "@plane/types";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useLabel } from "@/hooks/store/use-label";
import { useProjectState } from "@/hooks/store/use-project-state";
// local imports
import { AppliedFiltersList } from "../filters-list";
export const ArchivedIssueAppliedFiltersRoot: React.FC = observer(() => {
// router
const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string };
// store hooks
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.ARCHIVED);
const { projectLabels } = useLabel();
const { projectStates } = useProjectState();
// derived values
const userFilters = issueFilters?.filters;
// filters whose value not null or empty array
const appliedFilters: IIssueFilterOptions = {};
Object.entries(userFilters ?? {}).forEach(([key, value]) => {
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
appliedFilters[key as keyof IIssueFilterOptions] = value;
});
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
if (!workspaceSlug || !projectId) return;
// remove all values of the key if value is null
if (!value) {
updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
[key]: null,
});
return;
}
// remove the passed value from the key
let newValues = issueFilters?.filters?.[key] ?? [];
newValues = newValues.filter((val) => val !== value);
updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
[key]: newValues,
});
};
const handleClearAllFilters = () => {
if (!workspaceSlug || !projectId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(userFilters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = [];
});
updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
...newFilters,
});
};
// return if no filters are applied
if (Object.keys(appliedFilters).length === 0) return null;
return (
<div className="flex justify-between p-4 gap-2.5">
<AppliedFiltersList
appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
labels={projectLabels ?? []}
states={projectStates}
/>
</div>
);
});

View file

@ -1,105 +0,0 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EIssueFilterType, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { EIssuesStoreType, IIssueFilterOptions } from "@plane/types";
import { Header, EHeaderVariant } from "@plane/ui";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useLabel } from "@/hooks/store/use-label";
import { useProjectState } from "@/hooks/store/use-project-state";
// local imports
import { SaveFilterView } from "../../../save-filter-view";
import { AppliedFiltersList } from "../filters-list";
export const CycleAppliedFiltersRoot: React.FC = observer(() => {
// router
const { workspaceSlug, projectId, cycleId } = useParams();
// store hooks
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.CYCLE);
const { projectLabels } = useLabel();
const { projectStates } = useProjectState();
// derived values
const userFilters = issueFilters?.filters;
// filters whose value not null or empty array
const appliedFilters: IIssueFilterOptions = {};
Object.entries(userFilters ?? {}).forEach(([key, value]) => {
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
appliedFilters[key as keyof IIssueFilterOptions] = value;
});
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
if (!workspaceSlug || !projectId || !cycleId) return;
if (!value) {
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{
[key]: null,
},
cycleId.toString()
);
return;
}
let newValues = issueFilters?.filters?.[key] ?? [];
newValues = newValues.filter((val) => val !== value);
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{
[key]: newValues,
},
cycleId.toString()
);
};
const handleClearAllFilters = () => {
if (!workspaceSlug || !projectId || !cycleId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(userFilters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = [];
});
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{ ...newFilters },
cycleId.toString()
);
};
// return if no filters are applied
if (Object.keys(appliedFilters).length === 0 || !workspaceSlug || !projectId || !cycleId) return null;
return (
<Header variant={EHeaderVariant.TERNARY}>
<Header.LeftItem>
<AppliedFiltersList
appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
labels={projectLabels ?? []}
states={projectStates}
/>
</Header.LeftItem>
<SaveFilterView
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
filterParams={{
filters: { ...appliedFilters, cycle: [cycleId?.toString()] },
display_filters: issueFilters?.displayFilters,
display_properties: issueFilters?.displayProperties,
}}
trackerElement={PROJECT_VIEW_TRACKER_ELEMENTS.CYCLE_HEADER_SAVE_AS_VIEW_BUTTON}
/>
</Header>
);
});

View file

@ -1,203 +0,0 @@
"use client";
import { useState } from "react";
import cloneDeep from "lodash/cloneDeep";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// Plane imports
import {
DEFAULT_GLOBAL_VIEWS_LIST,
EIssueFilterType,
EUserPermissions,
EUserPermissionsLevel,
GLOBAL_VIEW_TRACKER_ELEMENTS,
GLOBAL_VIEW_TRACKER_EVENTS,
} from "@plane/constants";
import { EIssuesStoreType, EViewAccess, IIssueFilterOptions, TStaticViewTypes } from "@plane/types";
import { Header, EHeaderVariant, Loader } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { UpdateViewComponent } from "@/components/views/update-view-component";
import { CreateUpdateWorkspaceViewModal } from "@/components/workspace/views/modal";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useGlobalView } from "@/hooks/store/use-global-view";
import { useIssues } from "@/hooks/store/use-issues";
import { useLabel } from "@/hooks/store/use-label";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { getAreFiltersEqual } from "../../../utils";
import { AppliedFiltersList } from "../filters-list";
type Props = {
globalViewId: string;
isLoading?: boolean;
};
export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
const { globalViewId, isLoading = false } = props;
// router
const { workspaceSlug } = useParams();
// store hooks
const {
issuesFilter: { filters, updateFilters },
} = useIssues(EIssuesStoreType.GLOBAL);
const { workspaceLabels } = useLabel();
const { globalViewMap, updateGlobalView } = useGlobalView();
const { data } = useUser();
const { allowPermissions } = useUserPermissions();
const [isModalOpen, setIsModalOpen] = useState(false);
// derived values
const issueFilters = filters?.[globalViewId];
const userFilters = issueFilters?.filters;
const viewDetails = globalViewMap[globalViewId];
// filters whose value not null or empty array
let appliedFilters: IIssueFilterOptions | undefined = undefined;
Object.entries(userFilters ?? {}).forEach(([key, value]) => {
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
if (!appliedFilters) appliedFilters = {};
appliedFilters[key as keyof IIssueFilterOptions] = value;
});
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
if (!workspaceSlug || !globalViewId) return;
if (!value) {
updateFilters(
workspaceSlug.toString(),
undefined,
EIssueFilterType.FILTERS,
{ [key]: null },
globalViewId.toString()
);
return;
}
let newValues = userFilters?.[key] ?? [];
newValues = newValues.filter((val) => val !== value);
updateFilters(
workspaceSlug.toString(),
undefined,
EIssueFilterType.FILTERS,
{ [key]: newValues },
globalViewId.toString()
);
};
const handleClearAllFilters = () => {
if (!workspaceSlug || !globalViewId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(userFilters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = [];
});
updateFilters(
workspaceSlug.toString(),
undefined,
EIssueFilterType.FILTERS,
{ ...newFilters },
globalViewId.toString()
);
};
const viewFilters = {
filters: cloneDeep(appliedFilters ?? {}),
display_filters: cloneDeep(issueFilters?.displayFilters),
display_properties: cloneDeep(issueFilters?.displayProperties),
};
const handleUpdateView = () => {
if (!workspaceSlug || !globalViewId) return;
updateGlobalView(workspaceSlug.toString(), globalViewId.toString(), viewFilters)
.then((res) => {
if (res)
captureSuccess({
eventName: GLOBAL_VIEW_TRACKER_EVENTS.update,
payload: {
view_id: globalViewId,
},
});
})
.catch((error) => {
captureError({
eventName: GLOBAL_VIEW_TRACKER_EVENTS.update,
payload: {
view_id: globalViewId,
},
error: error,
});
});
};
// add a placeholder object instead of appliedFilters if it is undefined
const areFiltersEqual = getAreFiltersEqual(appliedFilters ?? {}, issueFilters, viewDetails);
const isAuthorizedUser = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
const isDefaultView = DEFAULT_GLOBAL_VIEWS_LIST.map((view) => view.key).includes(globalViewId as TStaticViewTypes);
const isLocked = viewDetails?.is_locked;
const isOwner = viewDetails?.owned_by === data?.id;
const areAppliedFiltersEmpty = isEmpty(appliedFilters);
// return if no filters are applied
if (areAppliedFiltersEmpty && areFiltersEqual) return null;
return (
<Header
variant={EHeaderVariant.TERNARY}
className={cn({
"justify-end": areAppliedFiltersEmpty,
})}
>
<CreateUpdateWorkspaceViewModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
preLoadedData={{
name: `${viewDetails?.name} 2`,
description: viewDetails?.description,
access: viewDetails?.access ?? EViewAccess.PUBLIC,
...viewFilters,
}}
/>
{isLoading ? (
<Loader className="flex flex-wrap items-stretch gap-2 bg-custom-background-100 truncate my-auto">
<Loader.Item height="36px" width="150px" />
<Loader.Item height="36px" width="100px" />
<Loader.Item height="36px" width="300px" />
</Loader>
) : (
<AppliedFiltersList
labels={workspaceLabels ?? undefined}
appliedFilters={appliedFilters ?? {}}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
disableEditing={isLocked}
alwaysAllowEditing
/>
)}
{!isDefaultView ? (
<UpdateViewComponent
isLocked={isLocked}
areFiltersEqual={!!areFiltersEqual}
isOwner={isOwner}
isAuthorizedUser={isAuthorizedUser}
setIsModalOpen={setIsModalOpen}
handleUpdateView={handleUpdateView}
trackerElement={GLOBAL_VIEW_TRACKER_ELEMENTS.HEADER_SAVE_VIEW_BUTTON}
/>
) : (
<></>
)}
</Header>
);
});

View file

@ -1,7 +0,0 @@
export * from "./cycle-root";
export * from "./global-view-root";
export * from "./module-root";
export * from "./project-view-root";
export * from "./project-root";
export * from "./archived-issue";
export * from "./profile-issues-root";

View file

@ -1,103 +0,0 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EIssueFilterType, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { EIssuesStoreType, IIssueFilterOptions } from "@plane/types";
import { Header, EHeaderVariant } from "@plane/ui";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useLabel } from "@/hooks/store/use-label";
import { useProjectState } from "@/hooks/store/use-project-state";
import { SaveFilterView } from "../../../save-filter-view";
import { AppliedFiltersList } from "../filters-list";
export const ModuleAppliedFiltersRoot: React.FC = observer(() => {
// router
const { workspaceSlug, projectId, moduleId } = useParams();
// store hooks
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.MODULE);
const { projectLabels } = useLabel();
const { projectStates } = useProjectState();
// derived values
const userFilters = issueFilters?.filters;
// filters whose value not null or empty array
const appliedFilters: IIssueFilterOptions = {};
Object.entries(userFilters ?? {}).forEach(([key, value]) => {
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
appliedFilters[key as keyof IIssueFilterOptions] = value;
});
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
if (!workspaceSlug || !projectId || !moduleId) return;
if (!value) {
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{
[key]: null,
},
moduleId.toString()
);
return;
}
let newValues = issueFilters?.filters?.[key] ?? [];
newValues = newValues.filter((val) => val !== value);
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{
[key]: newValues,
},
moduleId.toString()
);
};
const handleClearAllFilters = () => {
if (!workspaceSlug || !projectId || !moduleId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(userFilters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = [];
});
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{ ...newFilters },
moduleId.toString()
);
};
// return if no filters are applied
if (!workspaceSlug || !projectId || !moduleId || Object.keys(appliedFilters).length === 0) return null;
return (
<Header variant={EHeaderVariant.TERNARY}>
<Header.LeftItem>
<AppliedFiltersList
appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
labels={projectLabels ?? []}
states={projectStates}
/>
</Header.LeftItem>
<SaveFilterView
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
filterParams={{
filters: { ...appliedFilters, module: [moduleId.toString()] },
display_filters: issueFilters?.displayFilters,
display_properties: issueFilters?.displayProperties,
}}
trackerElement={PROJECT_VIEW_TRACKER_ELEMENTS.MODULE_HEADER_SAVE_AS_VIEW_BUTTON}
/>
</Header>
);
});

View file

@ -1,78 +0,0 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EIssueFilterType } from "@plane/constants";
import { EIssuesStoreType, IIssueFilterOptions } from "@plane/types";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useLabel } from "@/hooks/store/use-label";
import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties";
import { AppliedFiltersList } from "../filters-list";
export const ProfileIssuesAppliedFiltersRoot: React.FC = observer(() => {
// router
const { workspaceSlug, userId } = useParams();
//swr hook for fetching issue properties
useWorkspaceIssueProperties(workspaceSlug);
// store hooks
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.PROFILE);
const { workspaceLabels } = useLabel();
// derived values
const userFilters = issueFilters?.filters;
// filters whose value not null or empty array
const appliedFilters: IIssueFilterOptions = {};
Object.entries(userFilters ?? {}).forEach(([key, value]) => {
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
appliedFilters[key as keyof IIssueFilterOptions] = value;
});
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
if (!workspaceSlug || !userId) return;
if (!value) {
updateFilters(workspaceSlug.toString(), undefined, EIssueFilterType.FILTERS, { [key]: null }, userId.toString());
return;
}
let newValues = issueFilters?.filters?.[key] ?? [];
newValues = newValues.filter((val) => val !== value);
updateFilters(
workspaceSlug.toString(),
undefined,
EIssueFilterType.FILTERS,
{
[key]: newValues,
},
userId.toString()
);
};
const handleClearAllFilters = () => {
if (!workspaceSlug || !userId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(userFilters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = [];
});
updateFilters(workspaceSlug.toString(), undefined, EIssueFilterType.FILTERS, { ...newFilters }, userId.toString());
};
// return if no filters are applied
if (Object.keys(appliedFilters).length === 0) return null;
return (
<div className="p-4 flex-shrink-0">
<AppliedFiltersList
appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
labels={workspaceLabels ?? []}
states={[]}
/>
</div>
);
});

View file

@ -1,108 +0,0 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import {
EIssueFilterType,
EUserPermissions,
EUserPermissionsLevel,
PROJECT_VIEW_TRACKER_ELEMENTS,
} from "@plane/constants";
import { EIssuesStoreType, IIssueFilterOptions } from "@plane/types";
import { Header, EHeaderVariant } from "@plane/ui";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useLabel } from "@/hooks/store/use-label";
import { useProjectState } from "@/hooks/store/use-project-state";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { SaveFilterView } from "../../../save-filter-view";
import { AppliedFiltersList } from "../filters-list";
type TProjectAppliedFiltersRootProps = {
storeType?: EIssuesStoreType.PROJECT | EIssuesStoreType.EPIC;
};
export const ProjectAppliedFiltersRoot: React.FC<TProjectAppliedFiltersRootProps> = observer((props) => {
const { storeType = EIssuesStoreType.PROJECT } = props;
// router
const { workspaceSlug, projectId } = useParams();
// store hooks
const { projectLabels } = useLabel();
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(storeType);
const { allowPermissions } = useUserPermissions();
const { projectStates } = useProjectState();
// derived values
const isEditingAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
const userFilters = issueFilters?.filters;
// filters whose value not null or empty array
const appliedFilters: IIssueFilterOptions = {};
Object.entries(userFilters ?? {}).forEach(([key, value]) => {
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
appliedFilters[key as keyof IIssueFilterOptions] = value;
});
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
if (!workspaceSlug || !projectId) return;
if (!value) {
updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
[key]: null,
});
return;
}
let newValues = issueFilters?.filters?.[key] ?? [];
newValues = newValues.filter((val) => val !== value);
updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
[key]: newValues,
});
};
const handleClearAllFilters = () => {
if (!workspaceSlug || !projectId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(userFilters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = [];
});
updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { ...newFilters });
};
// return if no filters are applied
if (Object.keys(appliedFilters).length === 0) return null;
return (
<Header variant={EHeaderVariant.TERNARY}>
<Header.LeftItem>
<AppliedFiltersList
appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
labels={projectLabels ?? []}
states={projectStates}
alwaysAllowEditing
/>
</Header.LeftItem>
<Header.RightItem>
{isEditingAllowed && storeType === EIssuesStoreType.PROJECT && (
<SaveFilterView
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
filterParams={{
filters: appliedFilters,
display_filters: issueFilters?.displayFilters,
display_properties: issueFilters?.displayProperties,
}}
trackerElement={PROJECT_VIEW_TRACKER_ELEMENTS.PROJECT_HEADER_SAVE_AS_VIEW_BUTTON}
/>
)}
</Header.RightItem>
</Header>
);
});

View file

@ -1,162 +0,0 @@
"use client";
import { useState } from "react";
import cloneDeep from "lodash/cloneDeep";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// types
import {
EIssueFilterType,
EUserPermissions,
EUserPermissionsLevel,
PROJECT_VIEW_TRACKER_ELEMENTS,
} from "@plane/constants";
import { EIssuesStoreType, EViewAccess, IIssueFilterOptions } from "@plane/types";
// components
import { Header, EHeaderVariant } from "@plane/ui";
import { CreateUpdateProjectViewModal } from "@/components/views/modal";
import { UpdateViewComponent } from "@/components/views/update-view-component";
// constants
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useLabel } from "@/hooks/store/use-label";
import { useProjectState } from "@/hooks/store/use-project-state";
import { useProjectView } from "@/hooks/store/use-project-view";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { getAreFiltersEqual } from "../../../utils";
import { AppliedFiltersList } from "../filters-list";
export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => {
// router
const { workspaceSlug, projectId, viewId } = useParams();
// store hooks
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.PROJECT_VIEW);
const { projectLabels } = useLabel();
const { projectStates } = useProjectState();
const { viewMap, updateView } = useProjectView();
const { data } = useUser();
const { allowPermissions } = useUserPermissions();
const [isModalOpen, setIsModalOpen] = useState(false);
// derived values
const viewDetails = viewId ? viewMap[viewId.toString()] : null;
const userFilters = issueFilters?.filters;
// filters whose value not null or empty array
let appliedFilters: IIssueFilterOptions | undefined = undefined;
Object.entries(userFilters ?? {}).forEach(([key, value]) => {
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
if (!appliedFilters) appliedFilters = {};
appliedFilters[key as keyof IIssueFilterOptions] = value;
});
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
if (!workspaceSlug || !projectId || !viewId) return;
if (!value) {
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{
[key]: null,
},
viewId.toString()
);
return;
}
let newValues = issueFilters?.filters?.[key] ?? [];
newValues = newValues.filter((val) => val !== value);
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{
[key]: newValues,
},
viewId.toString()
);
};
const handleClearAllFilters = () => {
if (!workspaceSlug || !projectId || !viewId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(userFilters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = [];
});
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{ ...newFilters },
viewId.toString()
);
};
// add a placeholder object instead of appliedFilters if it is undefined
const areFiltersEqual = getAreFiltersEqual(appliedFilters ?? {}, issueFilters, viewDetails);
const viewFilters = {
filters: cloneDeep(appliedFilters ?? {}),
display_filters: cloneDeep(issueFilters?.displayFilters),
display_properties: cloneDeep(issueFilters?.displayProperties),
};
// return if no filters are applied
if (isEmpty(appliedFilters) && areFiltersEqual) return null;
const handleUpdateView = () => {
if (!workspaceSlug || !projectId || !viewId || !viewDetails) return;
updateView(workspaceSlug.toString(), projectId.toString(), viewId.toString(), viewFilters);
};
const isAuthorizedUser = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
const isLocked = !!viewDetails?.is_locked;
const isOwner = viewDetails?.owned_by === data?.id;
return (
<Header variant={EHeaderVariant.TERNARY}>
<CreateUpdateProjectViewModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
preLoadedData={{
name: `${viewDetails?.name} 2`,
description: viewDetails?.description,
logo_props: viewDetails?.logo_props,
access: viewDetails?.access ?? EViewAccess.PUBLIC,
...viewFilters,
}}
/>
<Header.LeftItem className="w-[70%]">
<AppliedFiltersList
appliedFilters={appliedFilters ?? {}}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
labels={projectLabels ?? []}
states={projectStates}
disableEditing={isLocked}
/>
</Header.LeftItem>
<Header.RightItem>
<UpdateViewComponent
isLocked={isLocked}
areFiltersEqual={!!areFiltersEqual}
isOwner={isOwner}
isAuthorizedUser={isAuthorizedUser}
setIsModalOpen={setIsModalOpen}
handleUpdateView={handleUpdateView}
trackerElement={PROJECT_VIEW_TRACKER_ELEMENTS.HEADER_SAVE_VIEW_BUTTON}
/>
</Header.RightItem>
</Header>
);
});

View file

@ -1,13 +1,12 @@
"use client";
import { observer } from "mobx-react";
// icons
import { X } from "lucide-react";
// plane imports
import { EIconSize } from "@plane/constants";
import { StateGroupIcon } from "@plane/propel/icons";
import { IState } from "@plane/types";
// types
type Props = {
handleRemove: (val: string) => void;

View file

@ -2,6 +2,5 @@ export * from "./display-filters-selection";
export * from "./display-properties";
export * from "./extra-options";
export * from "./group-by";
export * from "./issue-grouping";
export * from "./order-by";
export * from "./sub-group-by";

View file

@ -1,52 +0,0 @@
import React from "react";
import { observer } from "mobx-react";
import { TIssueGroupingFilters } from "@plane/types";
// components
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// constants
type Props = {
selectedIssueType: TIssueGroupingFilters | undefined;
handleUpdate: (val: TIssueGroupingFilters) => void;
isEpic?: boolean;
};
const ISSUE_FILTER_OPTIONS: {
key: TIssueGroupingFilters;
title: string;
}[] = [
{ key: null, title: "All" },
{ key: "active", title: "Active" },
{ key: "backlog", title: "Backlog" },
];
export const FilterIssueGrouping: React.FC<Props> = observer((props) => {
const { selectedIssueType, handleUpdate, isEpic = false } = props;
const [previewEnabled, setPreviewEnabled] = React.useState(true);
const activeIssueType = selectedIssueType ?? null;
return (
<>
<FilterHeader
title={`${isEpic ? "Epic" : "Work item"} Grouping`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{ISSUE_FILTER_OPTIONS.map((issueType) => (
<FilterOption
key={issueType?.key}
isChecked={activeIssueType === issueType?.key ? true : false}
onClick={() => handleUpdate(issueType?.key)}
title={`${issueType.title} ${isEpic ? "Epics" : "Work items"}`}
multiple={false}
/>
))}
</div>
)}
</>
);
});

View file

@ -1,289 +0,0 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Search, X } from "lucide-react";
// plane imports
import { EUserPermissions } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import {
IIssueDisplayFilterOptions,
IIssueFilterOptions,
IIssueLabel,
ILayoutDisplayFiltersOptions,
IState,
} from "@plane/types";
// components
import {
FilterAssignees,
FilterMentions,
FilterCreatedBy,
FilterDueDate,
FilterLabels,
FilterPriority,
FilterProjects,
FilterStartDate,
FilterState,
FilterStateGroup,
FilterCycle,
FilterModule,
FilterIssueGrouping,
} from "@/components/issues/issue-layouts/filters";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web imports
import { FilterIssueTypes } from "@/plane-web/components/issues/filters/issue-types";
import { FilterTeamProjects } from "@/plane-web/components/issues/filters/team-project";
type Props = {
filters: IIssueFilterOptions;
displayFilters?: IIssueDisplayFilterOptions | undefined;
handleDisplayFiltersUpdate?: (updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => void;
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined;
projectId?: string;
labels?: IIssueLabel[] | undefined;
memberIds?: string[] | undefined;
states?: IState[] | undefined;
cycleViewDisabled?: boolean;
moduleViewDisabled?: boolean;
isEpic?: boolean;
};
export const FilterSelection: React.FC<Props> = observer((props) => {
const {
filters,
displayFilters,
handleDisplayFiltersUpdate,
handleFiltersUpdate,
layoutDisplayFiltersOptions,
projectId,
labels,
memberIds,
states,
cycleViewDisabled = false,
moduleViewDisabled = false,
isEpic = false,
} = props;
// i18n
const { t } = useTranslation();
// hooks
const { isMobile } = usePlatformOS();
const { moduleId, cycleId } = useParams();
const {
project: { getProjectMemberDetails },
} = useMember();
// states
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
// filter guests from assignees
const assigneeIds = memberIds?.filter((id) => {
if (projectId) {
const memeberDetails = getProjectMemberDetails(id, projectId);
const isGuest = (memeberDetails?.role || EUserPermissions.GUEST) === EUserPermissions.GUEST;
if (isGuest && memeberDetails) return false;
}
return true;
});
const isFilterEnabled = (filter: keyof IIssueFilterOptions) => layoutDisplayFiltersOptions?.filters.includes(filter);
const isDisplayFilterEnabled = (displayFilter: keyof IIssueDisplayFilterOptions) =>
Object.keys(layoutDisplayFiltersOptions?.display_filters ?? {}).includes(displayFilter);
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={t("common.search.label")}
value={filtersSearchQuery}
onChange={(e) => setFiltersSearchQuery(e.target.value)}
autoFocus={!isMobile}
/>
{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="vertical-scrollbar scrollbar-sm h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5">
{/* priority */}
{isFilterEnabled("priority") && (
<div className="py-2">
<FilterPriority
appliedFilters={filters.priority ?? null}
handleUpdate={(val) => handleFiltersUpdate("priority", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* state group */}
{isFilterEnabled("state_group") && (
<div className="py-2">
<FilterStateGroup
appliedFilters={filters.state_group ?? null}
handleUpdate={(val) => handleFiltersUpdate("state_group", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* state */}
{isFilterEnabled("state") && (
<div className="py-2">
<FilterState
appliedFilters={filters.state ?? null}
handleUpdate={(val) => handleFiltersUpdate("state", val)}
searchQuery={filtersSearchQuery}
states={states}
/>
</div>
)}
{/* issue type */}
{isFilterEnabled("issue_type") && (
<FilterIssueTypes
appliedFilters={filters.issue_type ?? null}
handleUpdate={(val) => handleFiltersUpdate("issue_type", val)}
searchQuery={filtersSearchQuery}
/>
)}
{/* assignees */}
{isFilterEnabled("assignees") && (
<div className="py-2">
<FilterAssignees
appliedFilters={filters.assignees ?? null}
handleUpdate={(val) => handleFiltersUpdate("assignees", val)}
memberIds={assigneeIds}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* cycle */}
{isFilterEnabled("cycle") && !cycleId && !cycleViewDisabled && (
<div className="py-2">
<FilterCycle
appliedFilters={filters.cycle ?? null}
handleUpdate={(val) => handleFiltersUpdate("cycle", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* module */}
{isFilterEnabled("module") && !moduleId && !moduleViewDisabled && (
<div className="py-2">
<FilterModule
appliedFilters={filters.module ?? null}
handleUpdate={(val) => handleFiltersUpdate("module", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* assignees */}
{isFilterEnabled("mentions") && (
<div className="py-2">
<FilterMentions
appliedFilters={filters.mentions ?? null}
handleUpdate={(val) => handleFiltersUpdate("mentions", val)}
memberIds={memberIds}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* created_by */}
{isFilterEnabled("created_by") && (
<div className="py-2">
<FilterCreatedBy
appliedFilters={filters.created_by ?? null}
handleUpdate={(val) => handleFiltersUpdate("created_by", val)}
memberIds={memberIds}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* labels */}
{isFilterEnabled("labels") && (
<div className="py-2">
<FilterLabels
appliedFilters={filters.labels ?? null}
handleUpdate={(val) => handleFiltersUpdate("labels", val)}
labels={labels}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* project */}
{isFilterEnabled("project") && (
<div className="py-2">
<FilterProjects
appliedFilters={filters.project ?? null}
handleUpdate={(val) => handleFiltersUpdate("project", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* team project */}
{isFilterEnabled("team_project") && (
<div className="py-2">
<FilterTeamProjects
appliedFilters={filters.team_project ?? null}
handleUpdate={(val) => handleFiltersUpdate("team_project", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* issue type */}
{isDisplayFilterEnabled("type") && displayFilters && handleDisplayFiltersUpdate && (
<div className="py-2">
<FilterIssueGrouping
selectedIssueType={displayFilters.type}
handleUpdate={(val) =>
handleDisplayFiltersUpdate({
type: val,
})
}
isEpic={isEpic}
/>
</div>
)}
{/* start_date */}
{isFilterEnabled("start_date") && (
<div className="py-2">
<FilterStartDate
appliedFilters={filters.start_date ?? null}
handleUpdate={(val) => handleFiltersUpdate("start_date", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* target_date */}
{isFilterEnabled("target_date") && (
<div className="py-2">
<FilterDueDate
appliedFilters={filters.target_date ?? null}
handleUpdate={(val) => handleFiltersUpdate("target_date", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
</div>
</div>
);
});

View file

@ -2,7 +2,6 @@ export * from "./assignee";
export * from "./mentions";
export * from "./created-by";
export * from "./due-date";
export * from "./filters-selection";
export * from "./labels";
export * from "./priority";
export * from "./project";

View file

@ -116,7 +116,9 @@ export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => {
>
<div className="inline-block line-clamp-1 truncate font-medium text-custom-text-100">{title}</div>
<div className="pl-2 text-sm font-medium text-custom-text-300">{count || 0}</div>
<WorkFlowGroupTree groupBy={groupBy} groupId={groupID} />
<div className="px-2.5">
<WorkFlowGroupTree groupBy={groupBy} groupId={groupID} />
</div>
</div>
{!disableIssueCreation &&

View file

@ -21,7 +21,7 @@ import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
import { useUserPermissions } from "@/hooks/store/user";
// plane-web components
import { DuplicateWorkItemModal } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns";
import { DuplicateWorkItemModal } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal";
// helper
import { ArchiveIssueModal } from "../../archive-issue-modal";
import { DeleteIssueModal } from "../../delete-issue-modal";

View file

@ -21,7 +21,7 @@ import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
import { useUserPermissions } from "@/hooks/store/user";
// plane-web components
import { DuplicateWorkItemModal } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns";
import { DuplicateWorkItemModal } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal";
// helper
import { ArchiveIssueModal } from "../../archive-issue-modal";
import { DeleteIssueModal } from "../../delete-issue-modal";

View file

@ -20,8 +20,8 @@ import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
import { useUserPermissions } from "@/hooks/store/user";
// plane-web components
import { DuplicateWorkItemModal } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns";
// plane-web imports
import { DuplicateWorkItemModal } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal";
// helper
import { ArchiveIssueModal } from "../../archive-issue-modal";
import { DeleteIssueModal } from "../../delete-issue-modal";

View file

@ -1,19 +1,23 @@
import React, { useCallback } from "react";
import { isEmpty } from "lodash";
import { observer } from "mobx-react";
import { useParams, useSearchParams } from "next/navigation";
import useSWR from "swr";
// plane imports
import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { GLOBAL_VIEW_TRACKER_ELEMENTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
// components
import { EmptyState } from "@/components/common/empty-state";
import { IssuePeekOverview } from "@/components/issues/peek-overview";
import { WorkspaceActiveLayout } from "@/components/views/helper";
import { WorkspaceLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/workspace-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/work-item-filters-row";
// hooks
import { useGlobalView } from "@/hooks/store/use-global-view";
import { useIssues } from "@/hooks/store/use-issues";
import { useAppRouter } from "@/hooks/use-app-router";
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties";
// store
// public imports
import emptyView from "@/public/empty-state/view.svg";
type Props = {
@ -24,61 +28,44 @@ type Props = {
export const AllIssueLayoutRoot: React.FC<Props> = observer((props: Props) => {
const { isDefaultView, isLoading = false, toggleLoading } = props;
// Router hooks
// router
const router = useAppRouter();
const { workspaceSlug, globalViewId } = useParams();
const { workspaceSlug: routerWorkspaceSlug, globalViewId: routerGlobalViewId } = useParams();
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
const globalViewId = routerGlobalViewId ? routerGlobalViewId.toString() : undefined;
// search params
const searchParams = useSearchParams();
// Store hooks
// store hooks
const {
issuesFilter: { fetchFilters, updateFilters },
issuesFilter: { filters, fetchFilters, updateFilterExpression },
issues: { clear, groupedIssueIds, fetchIssues, fetchNextIssues },
} = useIssues(EIssuesStoreType.GLOBAL);
const { fetchAllGlobalViews, getViewDetailsById } = useGlobalView();
// Derived values
const viewDetails = globalViewId ? getViewDetailsById(globalViewId) : undefined;
const workItemFilters = globalViewId ? filters?.[globalViewId] : undefined;
const activeLayout: EIssueLayoutTypes | undefined = workItemFilters?.displayFilters?.layout;
const initialWorkItemFilters = viewDetails
? {
displayFilters: workItemFilters?.displayFilters,
displayProperties: workItemFilters?.displayProperties,
kanbanFilters: workItemFilters?.kanbanFilters,
richFilters: viewDetails?.rich_filters ?? {},
}
: undefined;
// Custom hooks
useWorkspaceIssueProperties(workspaceSlug);
// Derived values
const viewDetails = getViewDetailsById(globalViewId?.toString());
const activeLayout: EIssueLayoutTypes | undefined = EIssueLayoutTypes.SPREADSHEET;
// Route filters
const routeFilters: { [key: string]: string } = {};
searchParams.forEach((value: string, key: string) => {
routeFilters[key] = value;
});
// Apply route filters to store
const routerFilterParams = () => {
if (
workspaceSlug &&
globalViewId &&
["all-issues", "assigned", "created", "subscribed"].includes(globalViewId.toString())
) {
let issueFilters: any = {};
Object.keys(routeFilters).forEach((key) => {
const filterKey: any = key;
const filterValue = routeFilters[key]?.toString() || undefined;
if (ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues.spreadsheet.filters.includes(filterKey) && filterKey && filterValue)
issueFilters = { ...issueFilters, [filterKey]: filterValue.split(",") };
});
if (!isEmpty(routeFilters))
updateFilters(
workspaceSlug.toString(),
undefined,
EIssueFilterType.FILTERS,
issueFilters,
globalViewId.toString()
);
}
};
// Fetch next pages callback
const fetchNextPages = useCallback(() => {
if (workspaceSlug && globalViewId) fetchNextIssues(workspaceSlug.toString(), globalViewId.toString());
if (workspaceSlug && globalViewId) fetchNextIssues(workspaceSlug, globalViewId);
}, [fetchNextIssues, workspaceSlug, globalViewId]);
// Fetch global views
@ -86,7 +73,7 @@ export const AllIssueLayoutRoot: React.FC<Props> = observer((props: Props) => {
workspaceSlug ? `WORKSPACE_GLOBAL_VIEWS_${workspaceSlug}` : null,
async () => {
if (workspaceSlug) {
await fetchAllGlobalViews(workspaceSlug.toString());
await fetchAllGlobalViews(workspaceSlug);
}
},
{ revalidateIfStale: false, revalidateOnFocus: false }
@ -99,17 +86,11 @@ export const AllIssueLayoutRoot: React.FC<Props> = observer((props: Props) => {
if (workspaceSlug && globalViewId) {
clear();
toggleLoading(true);
await fetchFilters(workspaceSlug.toString(), globalViewId.toString());
await fetchIssues(
workspaceSlug.toString(),
globalViewId.toString(),
groupedIssueIds ? "mutation" : "init-loader",
{
canGroup: false,
perPageCount: 100,
}
);
routerFilterParams();
await fetchFilters(workspaceSlug, globalViewId);
await fetchIssues(workspaceSlug, globalViewId, groupedIssueIds ? "mutation" : "init-loader", {
canGroup: false,
perPageCount: 100,
});
toggleLoading(false);
}
},
@ -131,18 +112,51 @@ export const AllIssueLayoutRoot: React.FC<Props> = observer((props: Props) => {
);
}
if (!workspaceSlug || !globalViewId) return null;
return (
<WorkspaceActiveLayout
activeLayout={activeLayout}
isDefaultView={isDefaultView}
isLoading={isLoading}
toggleLoading={toggleLoading}
workspaceSlug={workspaceSlug?.toString()}
globalViewId={globalViewId?.toString()}
routeFilters={routeFilters}
fetchNextPages={fetchNextPages}
globalViewsLoading={globalViewsLoading}
issuesLoading={issuesLoading}
/>
<IssuesStoreContext.Provider value={EIssuesStoreType.GLOBAL}>
<WorkspaceLevelWorkItemFiltersHOC
enableSaveView
saveViewOptions={{
label: "Save as",
}}
enableUpdateView
entityId={globalViewId}
entityType={EIssuesStoreType.GLOBAL}
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues.filters}
initialWorkItemFilters={initialWorkItemFilters}
updateFilters={updateFilterExpression.bind(updateFilterExpression, workspaceSlug, globalViewId)}
workspaceSlug={workspaceSlug}
>
{({ filter: globalWorkItemsFilter }) => (
<div className="h-full overflow-hidden bg-custom-background-100">
<div className="flex h-full w-full flex-col border-b border-custom-border-300">
{globalWorkItemsFilter && (
<WorkItemFiltersRow
filter={globalWorkItemsFilter}
trackerElements={{
saveView: GLOBAL_VIEW_TRACKER_ELEMENTS.HEADER_SAVE_VIEW_BUTTON,
}}
/>
)}
<WorkspaceActiveLayout
activeLayout={activeLayout}
isDefaultView={isDefaultView}
isLoading={isLoading}
toggleLoading={toggleLoading}
workspaceSlug={workspaceSlug}
globalViewId={globalViewId}
routeFilters={routeFilters}
fetchNextPages={fetchNextPages}
globalViewsLoading={globalViewsLoading}
issuesLoading={issuesLoading}
/>
</div>
{/* peek overview */}
<IssuePeekOverview />
</div>
)}
</WorkspaceLevelWorkItemFiltersHOC>
</IssuesStoreContext.Provider>
);
});

View file

@ -1,23 +1,30 @@
import React, { Fragment } from "react";
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// plane imports
import { ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { EIssuesStoreType } from "@plane/types";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
// hooks
import { WorkItemFiltersRow } from "@/components/work-item-filters/work-item-filters-row";
import { useIssues } from "@/hooks/store/use-issues";
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
// local imports
import { IssuePeekOverview } from "../../peek-overview";
import { ArchivedIssueAppliedFiltersRoot } from "../filters";
import { ArchivedIssueListLayout } from "../list/roots/archived-issue-root";
export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
// router
const { workspaceSlug, projectId } = useParams();
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams();
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
// hooks
const { issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED);
// derived values
const workItemFilters = projectId ? issuesFilter?.getIssueFilters(projectId) : undefined;
const { isLoading } = useSWR(
workspaceSlug && projectId ? `ARCHIVED_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}` : null,
@ -29,11 +36,9 @@ export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
{ revalidateIfStale: false, revalidateOnFocus: false }
);
const issueFilters = issuesFilter?.getIssueFilters(projectId?.toString());
if (!workspaceSlug || !projectId) return <></>;
if (isLoading && !issueFilters)
if (isLoading && !workItemFilters)
return (
<div className="h-full w-full flex items-center justify-center">
<LogoSpinner />
@ -42,13 +47,25 @@ export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.ARCHIVED}>
<ArchivedIssueAppliedFiltersRoot />
<Fragment>
<div className="relative h-full w-full overflow-auto">
<ArchivedIssueListLayout />
</div>
<IssuePeekOverview />
</Fragment>
<ProjectLevelWorkItemFiltersHOC
entityType={EIssuesStoreType.ARCHIVED}
entityId={projectId?.toString()}
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.archived_issues.filters}
initialWorkItemFilters={workItemFilters}
updateFilters={issuesFilter?.updateFilterExpression.bind(issuesFilter, workspaceSlug, projectId)}
projectId={projectId}
workspaceSlug={workspaceSlug}
>
{({ filter: archivedWorkItemsFilter }) => (
<>
{archivedWorkItemsFilter && <WorkItemFiltersRow filter={archivedWorkItemsFilter} />}
<div className="relative h-full w-full overflow-auto">
<ArchivedIssueListLayout />
</div>
<IssuePeekOverview />
</>
)}
</ProjectLevelWorkItemFiltersHOC>
</IssuesStoreContext.Provider>
);
});

View file

@ -4,19 +4,21 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// plane constants
import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { TransferIssues } from "@/components/cycles/transfer-issues";
import { TransferIssuesModal } from "@/components/cycles/transfer-issues-modal";
// hooks
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/work-item-filters-row";
import { useCycle } from "@/hooks/store/use-cycle";
import { useIssues } from "@/hooks/store/use-issues";
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
// local imports
import { IssuePeekOverview } from "../../peek-overview";
import { CycleCalendarLayout } from "../calendar/roots/cycle-root";
import { CycleAppliedFiltersRoot } from "../filters";
import { BaseGanttRoot } from "../gantt";
import { CycleKanBanLayout } from "../kanban/roots/cycle-root";
import { CycleListLayout } from "../list/roots/cycle-root";
@ -44,29 +46,30 @@ const CycleIssueLayout = (props: {
};
export const CycleLayoutRoot: React.FC = observer(() => {
const { workspaceSlug, projectId, cycleId } = useParams();
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId, cycleId: routerCycleId } = useParams();
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
const cycleId = routerCycleId ? routerCycleId.toString() : undefined;
// store hooks
const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE);
const { getCycleById } = useCycle();
// state
const [transferIssuesModal, setTransferIssuesModal] = useState(false);
// derived values
const workItemFilters = cycleId ? issuesFilter?.getIssueFilters(cycleId) : undefined;
const activeLayout = workItemFilters?.displayFilters?.layout;
const { isLoading } = useSWR(
workspaceSlug && projectId && cycleId
? `CYCLE_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}_${cycleId.toString()}`
: null,
workspaceSlug && projectId && cycleId ? `CYCLE_ISSUES_${workspaceSlug}_${projectId}_${cycleId}` : null,
async () => {
if (workspaceSlug && projectId && cycleId) {
await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString(), cycleId.toString());
await issuesFilter?.fetchFilters(workspaceSlug, projectId, cycleId);
}
},
{ revalidateIfStale: false, revalidateOnFocus: false }
);
const issueFilters = issuesFilter?.getIssueFilters(cycleId?.toString());
const activeLayout = issueFilters?.displayFilters?.layout;
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
const cycleDetails = cycleId ? getCycleById(cycleId) : undefined;
const cycleStatus = cycleDetails?.status?.toLocaleLowerCase() ?? "draft";
const isCompletedCycle = cycleStatus === "completed";
const isProgressSnapshotEmpty = isEmpty(cycleDetails?.progress_snapshot);
@ -77,7 +80,7 @@ export const CycleLayoutRoot: React.FC = observer(() => {
if (!workspaceSlug || !projectId || !cycleId) return <></>;
if (isLoading && !issueFilters)
if (isLoading && !workItemFilters)
return (
<div className="h-full w-full flex items-center justify-center">
<LogoSpinner />
@ -86,31 +89,48 @@ export const CycleLayoutRoot: React.FC = observer(() => {
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.CYCLE}>
<TransferIssuesModal
handleClose={() => setTransferIssuesModal(false)}
cycleId={cycleId.toString()}
isOpen={transferIssuesModal}
/>
<div className="relative flex h-full w-full flex-col overflow-hidden">
{cycleStatus === "completed" && (
<TransferIssues
handleClick={() => setTransferIssuesModal(true)}
canTransferIssues={canTransferIssues}
disabled={!isEmpty(cycleDetails?.progress_snapshot)}
/>
<ProjectLevelWorkItemFiltersHOC
enableSaveView
entityType={EIssuesStoreType.CYCLE}
entityId={cycleId}
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.filters}
initialWorkItemFilters={workItemFilters}
updateFilters={issuesFilter?.updateFilterExpression.bind(issuesFilter, workspaceSlug, projectId, cycleId)}
projectId={projectId}
workspaceSlug={workspaceSlug}
>
{({ filter: cycleWorkItemsFilter }) => (
<>
<TransferIssuesModal
handleClose={() => setTransferIssuesModal(false)}
cycleId={cycleId}
isOpen={transferIssuesModal}
/>
<div className="relative flex h-full w-full flex-col overflow-hidden">
{cycleStatus === "completed" && (
<TransferIssues
handleClick={() => setTransferIssuesModal(true)}
canTransferIssues={canTransferIssues}
disabled={!isEmpty(cycleDetails?.progress_snapshot)}
/>
)}
{cycleWorkItemsFilter && (
<WorkItemFiltersRow
filter={cycleWorkItemsFilter}
trackerElements={{
saveView: PROJECT_VIEW_TRACKER_ELEMENTS.CYCLE_HEADER_SAVE_AS_VIEW_BUTTON,
}}
/>
)}
<div className="h-full w-full overflow-auto">
<CycleIssueLayout activeLayout={activeLayout} cycleId={cycleId} isCompletedCycle={isCompletedCycle} />
</div>
{/* peek overview */}
<IssuePeekOverview />
</div>
</>
)}
<CycleAppliedFiltersRoot />
<div className="h-full w-full overflow-auto">
<CycleIssueLayout
activeLayout={activeLayout}
cycleId={cycleId?.toString()}
isCompletedCycle={isCompletedCycle}
/>
</div>
{/* peek overview */}
<IssuePeekOverview />
</div>
</ProjectLevelWorkItemFiltersHOC>
</IssuesStoreContext.Provider>
);
});

View file

@ -3,17 +3,19 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// plane imports
import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
import { Row, ERowVariant } from "@plane/ui";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
// hooks
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/work-item-filters-row";
import { useIssues } from "@/hooks/store/use-issues";
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
// local imports
import { IssuePeekOverview } from "../../peek-overview";
import { ModuleCalendarLayout } from "../calendar/roots/module-root";
import { ModuleAppliedFiltersRoot } from "../filters";
import { BaseGanttRoot } from "../gantt";
import { ModuleKanBanLayout } from "../kanban/roots/module-root";
import { ModuleListLayout } from "../list/roots/module-root";
@ -38,9 +40,15 @@ const ModuleIssueLayout = (props: { activeLayout: EIssueLayoutTypes | undefined;
export const ModuleLayoutRoot: React.FC = observer(() => {
// router
const { workspaceSlug, projectId, moduleId } = useParams();
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId, moduleId: routerModuleId } = useParams();
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
const moduleId = routerModuleId ? routerModuleId.toString() : undefined;
// hooks
const { issuesFilter } = useIssues(EIssuesStoreType.MODULE);
// derived values
const workItemFilters = moduleId ? issuesFilter?.getIssueFilters(moduleId) : undefined;
const activeLayout = workItemFilters?.displayFilters?.layout || undefined;
const { isLoading } = useSWR(
workspaceSlug && projectId && moduleId
@ -54,29 +62,45 @@ export const ModuleLayoutRoot: React.FC = observer(() => {
{ revalidateIfStale: false, revalidateOnFocus: false }
);
const issueFilters = issuesFilter?.getIssueFilters(moduleId?.toString());
if (!workspaceSlug || !projectId || !moduleId) return <></>;
if (isLoading && !issueFilters)
if (isLoading && !workItemFilters)
return (
<div className="h-full w-full flex items-center justify-center">
<LogoSpinner />
</div>
);
const activeLayout = issueFilters?.displayFilters?.layout || undefined;
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.MODULE}>
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ModuleAppliedFiltersRoot />
<Row variant={ERowVariant.HUGGING} className="h-full w-full overflow-auto">
<ModuleIssueLayout activeLayout={activeLayout} moduleId={moduleId?.toString()} />
</Row>
{/* peek overview */}
<IssuePeekOverview />
</div>
<ProjectLevelWorkItemFiltersHOC
enableSaveView
entityType={EIssuesStoreType.MODULE}
entityId={moduleId}
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.filters}
initialWorkItemFilters={workItemFilters}
updateFilters={issuesFilter?.updateFilterExpression.bind(issuesFilter, workspaceSlug, projectId, moduleId)}
projectId={projectId}
workspaceSlug={workspaceSlug}
>
{({ filter: moduleWorkItemsFilter }) => (
<div className="relative flex h-full w-full flex-col overflow-hidden">
{moduleWorkItemsFilter && (
<WorkItemFiltersRow
filter={moduleWorkItemsFilter}
trackerElements={{
saveView: PROJECT_VIEW_TRACKER_ELEMENTS.MODULE_HEADER_SAVE_AS_VIEW_BUTTON,
}}
/>
)}
<Row variant={ERowVariant.HUGGING} className="h-full w-full overflow-auto">
<ModuleIssueLayout activeLayout={activeLayout} moduleId={moduleId} />
</Row>
{/* peek overview */}
<IssuePeekOverview />
</div>
)}
</ProjectLevelWorkItemFiltersHOC>
</IssuesStoreContext.Provider>
);
});

View file

@ -1,21 +1,23 @@
"use client";
import { FC, Fragment } from "react";
import { FC } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// plane constants
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
// components
import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { EIssueLayoutTypes, EIssuesStoreType } from "@plane/types";
import { Spinner } from "@plane/ui";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/work-item-filters-row";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
// local imports
import { IssuePeekOverview } from "../../peek-overview";
import { CalendarLayout } from "../calendar/roots/project-root";
import { ProjectAppliedFiltersRoot } from "../filters";
import { BaseGanttRoot } from "../gantt";
import { KanBanLayout } from "../kanban/roots/project-root";
import { ListLayout } from "../list/roots/project-root";
@ -40,26 +42,28 @@ const ProjectIssueLayout = (props: { activeLayout: EIssueLayoutTypes | undefined
export const ProjectLayoutRoot: FC = observer(() => {
// router
const { workspaceSlug, projectId } = useParams();
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams();
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
// hooks
const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT);
// derived values
const workItemFilters = projectId ? issuesFilter?.getIssueFilters(projectId) : undefined;
const activeLayout = workItemFilters?.displayFilters?.layout;
const { isLoading } = useSWR(
workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null,
async () => {
if (workspaceSlug && projectId) {
await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString());
await issuesFilter?.fetchFilters(workspaceSlug, projectId);
}
},
{ revalidateIfStale: false, revalidateOnFocus: false }
);
const issueFilters = issuesFilter?.getIssueFilters(projectId?.toString());
const activeLayout = issueFilters?.displayFilters?.layout;
if (!workspaceSlug || !projectId) return <></>;
if (isLoading && !issueFilters)
if (isLoading && !workItemFilters)
return (
<div className="h-full w-full flex items-center justify-center">
<LogoSpinner />
@ -68,21 +72,40 @@ export const ProjectLayoutRoot: FC = observer(() => {
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.PROJECT}>
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ProjectAppliedFiltersRoot />
<div className="relative h-full w-full overflow-auto bg-custom-background-90">
{/* mutation loader */}
{issues?.getIssueLoader() === "mutation" && (
<div className="fixed w-[40px] h-[40px] z-50 right-[20px] top-[70px] flex justify-center items-center bg-custom-background-80 shadow-sm rounded">
<Spinner className="w-4 h-4" />
<ProjectLevelWorkItemFiltersHOC
enableSaveView
entityType={EIssuesStoreType.PROJECT}
entityId={projectId}
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.filters}
initialWorkItemFilters={workItemFilters}
updateFilters={issuesFilter?.updateFilterExpression.bind(issuesFilter, workspaceSlug, projectId)}
projectId={projectId}
workspaceSlug={workspaceSlug}
>
{({ filter: projectWorkItemsFilter }) => (
<div className="relative flex h-full w-full flex-col overflow-hidden">
{projectWorkItemsFilter && (
<WorkItemFiltersRow
filter={projectWorkItemsFilter}
trackerElements={{
saveView: PROJECT_VIEW_TRACKER_ELEMENTS.PROJECT_HEADER_SAVE_AS_VIEW_BUTTON,
}}
/>
)}
<div className="relative h-full w-full overflow-auto bg-custom-background-90">
{/* mutation loader */}
{issues?.getIssueLoader() === "mutation" && (
<div className="fixed w-[40px] h-[40px] z-50 right-[20px] top-[70px] flex justify-center items-center bg-custom-background-80 shadow-sm rounded">
<Spinner className="w-4 h-4" />
</div>
)}
<ProjectIssueLayout activeLayout={activeLayout} />
</div>
)}
<ProjectIssueLayout activeLayout={activeLayout} />
</div>
{/* peek overview */}
<IssuePeekOverview />
</div>
{/* peek overview */}
<IssuePeekOverview />
</div>
)}
</ProjectLevelWorkItemFiltersHOC>
</IssuesStoreContext.Provider>
);
});

View file

@ -1,23 +1,25 @@
import React from "react";
import React, { useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// plane constants
import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
// hooks
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/work-item-filters-row";
import { useIssues } from "@/hooks/store/use-issues";
import { useProjectView } from "@/hooks/store/use-project-view";
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
// local imports
import { IssuePeekOverview } from "../../peek-overview";
import { ProjectViewCalendarLayout } from "../calendar/roots/project-view-root";
import { ProjectViewAppliedFiltersRoot } from "../filters";
import { BaseGanttRoot } from "../gantt";
import { ProjectViewKanBanLayout } from "../kanban/roots/project-view-root";
import { ProjectViewListLayout } from "../list/roots/project-view-root";
import { ProjectViewSpreadsheetLayout } from "../spreadsheet/roots/project-view-root";
// types
const ProjectViewIssueLayout = (props: { activeLayout: EIssueLayoutTypes | undefined; viewId: string }) => {
switch (props.activeLayout) {
@ -38,26 +40,47 @@ const ProjectViewIssueLayout = (props: { activeLayout: EIssueLayoutTypes | undef
export const ProjectViewLayoutRoot: React.FC = observer(() => {
// router
const { workspaceSlug, projectId, viewId } = useParams();
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId, viewId: routerViewId } = useParams();
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug?.toString() : undefined;
const projectId = routerProjectId ? routerProjectId?.toString() : undefined;
const viewId = routerViewId ? routerViewId?.toString() : undefined;
// hooks
const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT_VIEW);
const { getViewById } = useProjectView();
// derived values
const projectView = viewId ? getViewById(viewId) : undefined;
const workItemFilters = viewId ? issuesFilter?.getIssueFilters(viewId) : undefined;
const activeLayout = workItemFilters?.displayFilters?.layout;
const initialWorkItemFilters = projectView
? {
displayFilters: workItemFilters?.displayFilters,
displayProperties: workItemFilters?.displayProperties,
kanbanFilters: workItemFilters?.kanbanFilters,
richFilters: projectView.rich_filters,
}
: undefined;
const { isLoading } = useSWR(
workspaceSlug && projectId && viewId ? `PROJECT_VIEW_ISSUES_${workspaceSlug}_${projectId}_${viewId}` : null,
async () => {
if (workspaceSlug && projectId && viewId) {
await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString(), viewId.toString());
await issuesFilter?.fetchFilters(workspaceSlug, projectId, viewId);
}
},
{ revalidateIfStale: false, revalidateOnFocus: false }
}
);
const issueFilters = issuesFilter?.getIssueFilters(viewId?.toString());
const activeLayout = issueFilters?.displayFilters?.layout;
useEffect(
() => () => {
if (workspaceSlug && viewId) {
issuesFilter?.resetFilters(workspaceSlug, viewId);
}
},
[issuesFilter, workspaceSlug, viewId]
);
if (!workspaceSlug || !projectId || !viewId) return <></>;
if (isLoading && !issueFilters) {
if (isLoading && !workItemFilters) {
return (
<div className="relative flex h-screen w-full items-center justify-center">
<LogoSpinner />
@ -67,15 +90,38 @@ export const ProjectViewLayoutRoot: React.FC = observer(() => {
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.PROJECT_VIEW}>
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ProjectViewAppliedFiltersRoot />
<div className="relative h-full w-full overflow-auto">
<ProjectViewIssueLayout activeLayout={activeLayout} viewId={viewId.toString()} />
</div>
{/* peek overview */}
<IssuePeekOverview />
</div>
<ProjectLevelWorkItemFiltersHOC
enableSaveView
saveViewOptions={{
label: "Save as",
}}
enableUpdateView
entityId={viewId}
entityType={EIssuesStoreType.PROJECT_VIEW}
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.filters}
initialWorkItemFilters={initialWorkItemFilters}
updateFilters={issuesFilter?.updateFilterExpression.bind(issuesFilter, workspaceSlug, projectId, viewId)}
projectId={projectId}
workspaceSlug={workspaceSlug}
>
{({ filter: projectViewWorkItemsFilter }) => (
<div className="relative flex h-full w-full flex-col overflow-hidden">
{projectViewWorkItemsFilter && (
<WorkItemFiltersRow
filter={projectViewWorkItemsFilter}
trackerElements={{
saveView: PROJECT_VIEW_TRACKER_ELEMENTS.HEADER_SAVE_VIEW_BUTTON,
}}
/>
)}
<div className="relative h-full w-full overflow-auto">
<ProjectViewIssueLayout activeLayout={activeLayout} viewId={viewId.toString()} />
</div>
{/* peek overview */}
<IssuePeekOverview />
</div>
)}
</ProjectLevelWorkItemFiltersHOC>
</IssuesStoreContext.Provider>
);
});

View file

@ -1,40 +0,0 @@
"use client";
import { FC, useState } from "react";
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
import { Button } from "@plane/ui";
// components
import { CreateUpdateProjectViewModal } from "@/components/views/modal";
interface ISaveFilterView {
workspaceSlug: string;
projectId: string;
filterParams: {
filters: IIssueFilterOptions;
display_filters?: IIssueDisplayFilterOptions;
display_properties?: IIssueDisplayProperties;
};
trackerElement: string;
}
export const SaveFilterView: FC<ISaveFilterView> = (props) => {
const { workspaceSlug, projectId, filterParams, trackerElement } = props;
const [viewModal, setViewModal] = useState<boolean>(false);
return (
<div>
<CreateUpdateProjectViewModal
workspaceSlug={workspaceSlug}
projectId={projectId}
preLoadedData={{ ...filterParams }}
isOpen={viewModal}
onClose={() => setViewModal(false)}
/>
<Button size="sm" onClick={() => setViewModal(true)} data-ph-element={trackerElement}>
Save View
</Button>
</div>
);
};

View file

@ -25,7 +25,7 @@ export const SpreadsheetLabelColumn: React.FC<Props> = observer((props: Props) =
<div className="h-11 border-b-[0.5px] border-custom-border-200 w-full">
<IssuePropertyLabels
projectId={issue.project_id ?? null}
value={issue.label_ids}
value={issue.label_ids || []}
defaultOptions={defaultLabelOptions}
onChange={(data) => onChange(issue, { label_ids: data }, { changed_property: "labels", change_details: data })}
className="h-full w-full "

View file

@ -9,11 +9,9 @@ import { SpreadsheetLayoutLoader } from "@/components/ui/loader/layouts/spreadsh
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useUserPermissions } from "@/hooks/store/user";
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
import { useIssuesActions } from "@/hooks/use-issues-actions";
import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties";
// store
import { IssuePeekOverview } from "../../../peek-overview";
import { IssueLayoutHOC } from "../../issue-layout-HOC";
import { TRenderQuickActions } from "../../list/list-view-types";
import { SpreadsheetView } from "../spreadsheet-view";
@ -108,23 +106,19 @@ export const WorkspaceSpreadsheetRoot: React.FC<Props> = observer((props: Props)
// Render spreadsheet
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.GLOBAL}>
<IssueLayoutHOC layout={EIssueLayoutTypes.SPREADSHEET}>
<SpreadsheetView
displayProperties={issueFilters?.displayProperties ?? {}}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFilterUpdate={handleDisplayFiltersUpdate}
issueIds={Array.isArray(issueIds) ? issueIds : []}
quickActions={renderQuickActions}
updateIssue={updateIssue}
canEditProperties={canEditProperties}
canLoadMoreIssues={!!nextPageResults}
loadMoreIssues={fetchNextPages}
isWorkspaceLevel
/>
{/* peek overview */}
<IssuePeekOverview />
</IssueLayoutHOC>
</IssuesStoreContext.Provider>
<IssueLayoutHOC layout={EIssueLayoutTypes.SPREADSHEET}>
<SpreadsheetView
displayProperties={issueFilters?.displayProperties ?? {}}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFilterUpdate={handleDisplayFiltersUpdate}
issueIds={Array.isArray(issueIds) ? issueIds : []}
quickActions={renderQuickActions}
updateIssue={updateIssue}
canEditProperties={canEditProperties}
canLoadMoreIssues={!!nextPageResults}
loadMoreIssues={fetchNextPages}
isWorkspaceLevel
/>
</IssueLayoutHOC>
);
});

View file

@ -4,7 +4,6 @@ import { CSSProperties, FC } from "react";
import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import clone from "lodash/clone";
import concat from "lodash/concat";
import isEqual from "lodash/isEqual";
import isNil from "lodash/isNil";
import pull from "lodash/pull";
import uniq from "lodash/uniq";
@ -24,9 +23,7 @@ import {
TIssueGroupByOptions,
IIssueFilterOptions,
IIssueFilters,
IProjectView,
TGroupedIssues,
IWorkspaceView,
IIssueDisplayFilterOptions,
TGetColumns,
} from "@plane/types";
@ -593,27 +590,6 @@ export const handleGroupDragDrop = async (
}
};
/**
* This Method compares filters and returns a boolean based on which and updateView button is shown
* @param appliedFilters
* @param issueFilters
* @param viewDetails
* @returns
*/
export const getAreFiltersEqual = (
appliedFilters: IIssueFilterOptions | undefined,
issueFilters: IIssueFilters | undefined,
viewDetails: IProjectView | IWorkspaceView | null
) => {
if (isNil(appliedFilters) || isNil(issueFilters) || isNil(viewDetails)) return true;
return (
isEqual(appliedFilters, viewDetails.filters) &&
isEqual(issueFilters.displayFilters, viewDetails.display_filters) &&
isEqual(removeNillKeys(issueFilters.displayProperties), removeNillKeys(viewDetails.display_properties))
);
};
/**
* method that removes Null or undefined Keys from object
* @param obj

View file

@ -1,14 +1,13 @@
"use client";
import { FC, Fragment, useCallback, useMemo, useState } from "react";
import isEqual from "lodash/isEqual";
import { FC, Fragment, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import { AlertCircle, ChevronUp, ChevronDown } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
import { EIssueFilterType, EEstimateSystem } from "@plane/constants";
import { EEstimateSystem } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EIssuesStoreType, IIssueFilterOptions, TModulePlotType } from "@plane/types";
import { EIssuesStoreType, TModulePlotType } from "@plane/types";
import { CustomSelect, Spinner } from "@plane/ui";
// components
// constants
@ -18,8 +17,8 @@ import ProgressChart from "@/components/core/sidebar/progress-chart";
import { ModuleProgressStats } from "@/components/modules";
// hooks
import { useProjectEstimates } from "@/hooks/store/estimates";
import { useIssues } from "@/hooks/store/use-issues";
import { useModule } from "@/hooks/store/use-module";
import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters";
// plane web constants
type TModuleAnalyticsProgress = {
workspaceSlug: string;
@ -38,31 +37,30 @@ export const ModuleAnalyticsProgress: FC<TModuleAnalyticsProgress> = observer((p
// router
const searchParams = useSearchParams();
const peekModule = searchParams.get("peekModule") || undefined;
// plane hooks
const { t } = useTranslation();
// hooks
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
const { getPlotTypeByModuleId, setPlotType, getModuleById, fetchModuleDetails, fetchArchivedModuleDetails } =
useModule();
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.MODULE);
const { getFilter, updateFilterValueFromSidebar } = useWorkItemFilters();
// state
const [loader, setLoader] = useState(false);
const { t } = useTranslation();
// derived values
const moduleFilter = getFilter(EIssuesStoreType.MODULE, moduleId);
const selectedAssignees = moduleFilter?.findFirstConditionByPropertyAndOperator("assignee_id", "in");
const selectedLabels = moduleFilter?.findFirstConditionByPropertyAndOperator("label_id", "in");
const selectedStateGroups = moduleFilter?.findFirstConditionByPropertyAndOperator("state_group", "in");
const moduleDetails = getModuleById(moduleId);
const plotType: TModulePlotType = getPlotTypeByModuleId(moduleId);
const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false;
const estimateDetails =
isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS;
const completedIssues = moduleDetails?.completed_issues || 0;
const totalIssues = moduleDetails?.total_issues || 0;
const completedEstimatePoints = moduleDetails?.completed_estimate_points || 0;
const totalEstimatePoints = moduleDetails?.total_estimate_points || 0;
const progressHeaderPercentage = moduleDetails
? plotType === "points"
? completedEstimatePoints != 0 && totalEstimatePoints != 0
@ -72,11 +70,9 @@ export const ModuleAnalyticsProgress: FC<TModuleAnalyticsProgress> = observer((p
? Math.round((completedIssues / totalIssues) * 100)
: 0
: 0;
const chartDistributionData =
plotType === "points" ? moduleDetails?.estimate_distribution : moduleDetails?.distribution || undefined;
const completionChartDistributionData = chartDistributionData?.completion_chart || undefined;
const groupedIssues = useMemo(
() => ({
backlog: plotType === "points" ? moduleDetails?.backlog_estimate_points || 0 : moduleDetails?.backlog_issues || 0,
@ -90,7 +86,6 @@ export const ModuleAnalyticsProgress: FC<TModuleAnalyticsProgress> = observer((p
}),
[plotType, moduleDetails]
);
const moduleStartDate = getDate(moduleDetails?.start_date);
const moduleEndDate = getDate(moduleDetails?.target_date);
const isModuleStartDateValid = moduleStartDate && moduleStartDate <= new Date();
@ -116,37 +111,6 @@ export const ModuleAnalyticsProgress: FC<TModuleAnalyticsProgress> = observer((p
}
};
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !projectId) return;
let newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) {
if (key === "state") {
if (isEqual(newValues, value)) newValues = [];
else newValues = value;
} else {
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
}
} else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{ [key]: newValues },
moduleId
);
},
[workspaceSlug, projectId, moduleId, issueFilters, updateFilters]
);
if (!moduleDetails) return <></>;
return (
<div className="border-t border-custom-border-200 space-y-4 py-4 px-3">
@ -234,17 +198,25 @@ export const ModuleAnalyticsProgress: FC<TModuleAnalyticsProgress> = observer((p
{chartDistributionData && (
<div className="w-full border-t border-custom-border-200 pt-5">
<ModuleProgressStats
moduleId={moduleId}
plotType={plotType}
distribution={chartDistributionData}
groupedIssues={groupedIssues}
totalIssuesCount={plotType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
isEditable={Boolean(!peekModule)}
size="xs"
roundedTab={false}
handleFiltersUpdate={updateFilterValueFromSidebar.bind(
updateFilterValueFromSidebar,
EIssuesStoreType.MODULE,
moduleId
)}
isEditable={Boolean(!peekModule) && moduleFilter !== undefined}
moduleId={moduleId}
noBackground={false}
filters={issueFilters}
handleFiltersUpdate={handleFiltersUpdate}
plotType={plotType}
roundedTab={false}
selectedFilters={{
assignees: selectedAssignees,
labels: selectedLabels,
stateGroups: selectedStateGroups,
}}
size="xs"
totalIssuesCount={plotType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
/>
</div>
)}

View file

@ -2,278 +2,65 @@
import { FC } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import { Tab } from "@headlessui/react";
import { useTranslation } from "@plane/i18n";
import { StateGroupIcon } from "@plane/propel/icons";
import {
IIssueFilterOptions,
IIssueFilters,
TModuleDistribution,
TModuleEstimateDistribution,
TModulePlotType,
TStateGroups,
} from "@plane/types";
import { Avatar } from "@plane/ui";
import { cn, getFileURL } from "@plane/utils";
import { TWorkItemFilterCondition } from "@plane/shared-state";
import { TModuleDistribution, TModuleEstimateDistribution, TModulePlotType } from "@plane/types";
import { cn, toFilterArray } from "@plane/utils";
// components
import { SingleProgressStats } from "@/components/core/sidebar/single-progress-stats";
// helpers
import { AssigneeStatComponent, TAssigneeData } from "@/components/core/sidebar/progress-stats/assignee";
import { LabelStatComponent, TLabelData } from "@/components/core/sidebar/progress-stats/label";
import {
createFilterUpdateHandler,
PROGRESS_STATS,
TSelectedFilterProgressStats,
} from "@/components/core/sidebar/progress-stats/shared";
import { StateGroupStatComponent, TStateGroupData } from "@/components/core/sidebar/progress-stats/state_group";
// hooks
import { useProjectState } from "@/hooks/store/use-project-state";
import useLocalStorage from "@/hooks/use-local-storage";
// public
import emptyLabel from "@/public/empty-state/empty_label.svg";
import emptyMembers from "@/public/empty-state/empty_members.svg";
// assignee types
type TAssigneeData = {
id: string | undefined;
title: string | undefined;
avatar_url: string | undefined;
completed: number;
total: number;
}[];
type TAssigneeStatComponent = {
distribution: TAssigneeData;
isEditable?: boolean;
filters?: IIssueFilters | undefined;
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
};
// labelTypes
type TLabelData = {
id: string | undefined;
title: string | undefined;
color: string | undefined;
completed: number;
total: number;
}[];
type TLabelStatComponent = {
distribution: TLabelData;
isEditable?: boolean;
filters?: IIssueFilters | undefined;
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
};
// stateTypes
type TStateData = {
state: string | undefined;
completed: number;
total: number;
}[];
type TStateStatComponent = {
distribution: TStateData;
totalIssuesCount: number;
isEditable?: boolean;
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
};
export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => {
const { distribution, isEditable, filters, handleFiltersUpdate } = props;
const { t } = useTranslation();
return (
<div>
{distribution && distribution.length > 0 ? (
distribution.map((assignee, index) => {
if (assignee?.id)
return (
<SingleProgressStats
key={assignee?.id}
title={
<div className="flex items-center gap-2">
<Avatar name={assignee?.title ?? undefined} src={getFileURL(assignee?.avatar_url ?? "")} />
<span>{assignee?.title ?? ""}</span>
</div>
}
completed={assignee?.completed ?? 0}
total={assignee?.total ?? 0}
{...(isEditable && {
onClick: () => handleFiltersUpdate("assignees", assignee.id ?? ""),
selected: filters?.filters?.assignees?.includes(assignee.id ?? ""),
})}
/>
);
else
return (
<SingleProgressStats
key={`unassigned-${index}`}
title={
<div className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
</div>
<span>{t("no_assignee")}</span>
</div>
}
completed={assignee?.completed ?? 0}
total={assignee?.total ?? 0}
/>
);
})
) : (
<div className="flex h-full flex-col items-center justify-center gap-2">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
<Image src={emptyMembers} className="h-12 w-12" alt="empty members" />
</div>
<h6 className="text-base text-custom-text-300">{t("no_assignees_yet")}</h6>
</div>
)}
</div>
);
});
export const LabelStatComponent = observer((props: TLabelStatComponent) => {
const { distribution, isEditable, filters, handleFiltersUpdate } = props;
return (
<div>
{distribution && distribution.length > 0 ? (
distribution.map((label, index) => {
if (label.id) {
return (
<SingleProgressStats
key={label.id}
title={
<div className="flex items-center gap-2 truncate">
<div
className="h-3 w-3 rounded-full flex-shrink-0"
style={{
backgroundColor: label.color ?? "transparent",
}}
/>
<p className="text-xs text-ellipsis truncate">{label.title ?? "No labels"}</p>
</div>
}
completed={label.completed}
total={label.total}
{...(isEditable && {
onClick: () => handleFiltersUpdate("labels", label.id ?? ""),
selected: filters?.filters?.labels?.includes(label.id ?? `no-label-${index}`),
})}
/>
);
} else {
return (
<SingleProgressStats
key={`no-label-${index}`}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: label.color ?? "transparent",
}}
/>
<span className="text-xs">{label.title ?? "No labels"}</span>
</div>
}
completed={label.completed}
total={label.total}
/>
);
}
})
) : (
<div className="flex h-full flex-col items-center justify-center gap-2">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
<Image src={emptyLabel} className="h-12 w-12" alt="empty label" />
</div>
<h6 className="text-base text-custom-text-300">No labels yet</h6>
</div>
)}
</div>
);
});
export const StateStatComponent = observer((props: TStateStatComponent) => {
const { distribution, isEditable, totalIssuesCount, handleFiltersUpdate } = props;
// hooks
const { groupedProjectStates } = useProjectState();
// derived values
const getStateGroupState = (stateGroup: string) => {
const stateGroupStates = groupedProjectStates?.[stateGroup];
const stateGroupStatesId = stateGroupStates?.map((state) => state.id);
return stateGroupStatesId;
};
return (
<div>
{distribution.map((group, index) => (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<StateGroupIcon stateGroup={group.state as TStateGroups} />
<span className="text-xs capitalize">{group.state}</span>
</div>
}
completed={group.completed}
total={totalIssuesCount}
{...(isEditable && {
onClick: () => group.state && handleFiltersUpdate("state", getStateGroupState(group.state) ?? []),
})}
/>
))}
</div>
);
});
const progressStats = [
{
key: "stat-assignees",
title: "Assignees",
},
{
key: "stat-labels",
title: "Labels",
},
{
key: "stat-states",
title: "States",
},
];
type TModuleProgressStats = {
moduleId: string;
plotType: TModulePlotType;
distribution: TModuleDistribution | TModuleEstimateDistribution | undefined;
groupedIssues: Record<string, number>;
totalIssuesCount: number;
handleFiltersUpdate: (condition: TWorkItemFilterCondition) => void;
isEditable?: boolean;
filters?: IIssueFilters | undefined;
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
size?: "xs" | "sm";
roundedTab?: boolean;
moduleId: string;
noBackground?: boolean;
plotType: TModulePlotType;
roundedTab?: boolean;
selectedFilters: TSelectedFilterProgressStats;
size?: "xs" | "sm";
totalIssuesCount: number;
};
export const ModuleProgressStats: FC<TModuleProgressStats> = observer((props) => {
const {
moduleId,
plotType,
distribution,
groupedIssues,
totalIssuesCount,
isEditable = false,
filters,
handleFiltersUpdate,
size = "sm",
roundedTab = false,
isEditable = false,
moduleId,
noBackground = false,
plotType,
roundedTab = false,
selectedFilters,
size = "sm",
totalIssuesCount,
} = props;
// plane imports
const { t } = useTranslation();
// hooks
const { storedValue: currentTab, setValue: setModuleTab } = useLocalStorage(
`module-analytics-tab-${moduleId}`,
"stat-assignees"
);
// derived values
const currentTabIndex = (tab: string): number => progressStats.findIndex((stat) => stat.key === tab);
const currentTabIndex = (tab: string): number => PROGRESS_STATS.findIndex((stat) => stat.key === tab);
const currentDistribution = distribution as TModuleDistribution;
const currentEstimateDistribution = distribution as TModuleEstimateDistribution;
const selectedAssigneeIds = toFilterArray(selectedFilters?.assignees?.value || []) as string[];
const selectedLabelIds = toFilterArray(selectedFilters?.labels?.value || []) as string[];
const selectedStateGroups = toFilterArray(selectedFilters?.stateGroups?.value || []) as string[];
const distributionAssigneeData: TAssigneeData =
plotType === "burndown"
@ -309,12 +96,24 @@ export const ModuleProgressStats: FC<TModuleProgressStats> = observer((props) =>
total: label.total_estimates,
}));
const distributionStateData: TStateData = Object.keys(groupedIssues || {}).map((state) => ({
const distributionStateData: TStateGroupData = Object.keys(groupedIssues || {}).map((state) => ({
state: state,
completed: groupedIssues?.[state] || 0,
total: totalIssuesCount || 0,
}));
const handleAssigneeFiltersUpdate = createFilterUpdateHandler(
"assignee_id",
selectedAssigneeIds,
handleFiltersUpdate
);
const handleLabelFiltersUpdate = createFilterUpdateHandler("label_id", selectedLabelIds, handleFiltersUpdate);
const handleStateGroupFiltersUpdate = createFilterUpdateHandler(
"state_group",
selectedStateGroups,
handleFiltersUpdate
);
return (
<div>
<Tab.Group defaultIndex={currentTabIndex(currentTab ? currentTab : "stat-assignees")}>
@ -327,7 +126,7 @@ export const ModuleProgressStats: FC<TModuleProgressStats> = observer((props) =>
size === "xs" ? `text-xs` : `text-sm`
)}
>
{progressStats.map((stat) => (
{PROGRESS_STATS.map((stat) => (
<Tab
className={cn(
`p-1 w-full text-custom-text-100 outline-none focus:outline-none cursor-pointer transition-all`,
@ -339,7 +138,7 @@ export const ModuleProgressStats: FC<TModuleProgressStats> = observer((props) =>
key={stat.key}
onClick={() => setModuleTab(stat.key)}
>
{stat.title}
{t(stat.i18n_title)}
</Tab>
))}
</Tab.List>
@ -347,25 +146,26 @@ export const ModuleProgressStats: FC<TModuleProgressStats> = observer((props) =>
<Tab.Panel key={"stat-assignees"}>
<AssigneeStatComponent
distribution={distributionAssigneeData}
handleAssigneeFiltersUpdate={handleAssigneeFiltersUpdate}
isEditable={isEditable}
filters={filters}
handleFiltersUpdate={handleFiltersUpdate}
selectedAssigneeIds={selectedAssigneeIds}
/>
</Tab.Panel>
<Tab.Panel key={"stat-labels"}>
<LabelStatComponent
distribution={distributionLabelData}
handleLabelFiltersUpdate={handleLabelFiltersUpdate}
isEditable={isEditable}
filters={filters}
handleFiltersUpdate={handleFiltersUpdate}
selectedLabelIds={selectedLabelIds}
/>
</Tab.Panel>
<Tab.Panel key={"stat-states"}>
<StateStatComponent
<StateGroupStatComponent
distribution={distributionStateData}
totalIssuesCount={totalIssuesCount}
handleStateGroupFiltersUpdate={handleStateGroupFiltersUpdate}
isEditable={isEditable}
handleFiltersUpdate={handleFiltersUpdate}
selectedStateGroups={selectedStateGroups}
totalIssuesCount={totalIssuesCount}
/>
</Tab.Panel>
</Tab.Panels>

View file

@ -6,25 +6,11 @@ import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constant
// i18n
import { useTranslation } from "@plane/i18n";
// types
import {
EIssuesStoreType,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
EIssueLayoutTypes,
} from "@plane/types";
import { EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types";
// components
import { isIssueFilterActive } from "@plane/utils";
import {
DisplayFiltersSelection,
FilterSelection,
FiltersDropdown,
LayoutSelection,
} from "@/components/issues/issue-layouts/filters";
// helpers
import { DisplayFiltersSelection, FiltersDropdown, LayoutSelection } from "@/components/issues/issue-layouts/filters";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useLabel } from "@/hooks/store/use-label";
export const ProfileIssuesFilter = observer(() => {
// i18n
@ -35,11 +21,7 @@ export const ProfileIssuesFilter = observer(() => {
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.PROFILE);
const { workspaceLabels } = useLabel();
// derived values
const states = undefined;
const members = undefined;
const activeLayout = issueFilters?.displayFilters?.layout;
const handleLayoutChange = useCallback(
@ -56,33 +38,6 @@ export const ProfileIssuesFilter = observer(() => {
[workspaceSlug, updateFilters, userId]
);
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !userId) return;
const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
} else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(
workspaceSlug.toString(),
undefined,
EIssueFilterType.FILTERS,
{ [key]: newValues },
userId.toString()
);
},
[workspaceSlug, issueFilters, updateFilters, userId]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !userId) return;
@ -118,30 +73,10 @@ export const ProfileIssuesFilter = observer(() => {
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
<FiltersDropdown
title={t("common.filters")}
placement="bottom-end"
isFiltersApplied={isIssueFilterActive(issueFilters)}
>
<FilterSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.profile_issues[activeLayout] : undefined
}
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
states={states}
labels={workspaceLabels}
memberIds={members}
/>
</FiltersDropdown>
<FiltersDropdown title={t("common.display")} placement="bottom-end">
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.profile_issues[activeLayout] : undefined
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.profile_issues.layoutOptions[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}

View file

@ -3,12 +3,14 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// plane imports
import { ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { EIssuesStoreType } from "@plane/types";
// components
import { ProfileIssuesAppliedFiltersRoot } from "@/components/issues/issue-layouts/filters";
import { ProfileIssuesKanBanLayout } from "@/components/issues/issue-layouts/kanban/roots/profile-issues-root";
import { ProfileIssuesListLayout } from "@/components/issues/issue-layouts/list/roots/profile-issues-root";
import { IssuePeekOverview } from "@/components/issues/peek-overview";
import { WorkspaceLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/workspace-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/work-item-filters-row";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
@ -19,7 +21,6 @@ type Props = {
export const ProfileIssuesPage = observer((props: Props) => {
const { type } = props;
const { workspaceSlug, userId } = useParams() as {
workspaceSlug: string;
userId: string;
@ -27,8 +28,10 @@ export const ProfileIssuesPage = observer((props: Props) => {
// store hooks
const {
issues: { setViewId },
issuesFilter: { issueFilters, fetchFilters },
issuesFilter: { issueFilters, fetchFilters, updateFilterExpression },
} = useIssues(EIssuesStoreType.PROFILE);
// derived values
const activeLayout = issueFilters?.displayFilters?.layout || undefined;
useEffect(() => {
if (setViewId) setViewId(type);
@ -44,22 +47,33 @@ export const ProfileIssuesPage = observer((props: Props) => {
{ revalidateIfStale: false, revalidateOnFocus: false }
);
const activeLayout = issueFilters?.displayFilters?.layout || undefined;
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.PROFILE}>
<div className="flex flex-col h-full w-full">
<ProfileIssuesAppliedFiltersRoot />
<div className="-z-1 relative h-full w-full overflow-auto">
{activeLayout === "list" ? (
<ProfileIssuesListLayout />
) : activeLayout === "kanban" ? (
<ProfileIssuesKanBanLayout />
) : null}
</div>
</div>
{/* peek overview */}
<IssuePeekOverview />
<WorkspaceLevelWorkItemFiltersHOC
entityId={userId}
entityType={EIssuesStoreType.PROFILE}
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.profile_issues.filters}
initialWorkItemFilters={issueFilters}
updateFilters={updateFilterExpression.bind(updateFilterExpression, workspaceSlug, userId)}
workspaceSlug={workspaceSlug}
>
{({ filter: profileWorkItemsFilter }) => (
<>
<div className="flex flex-col h-full w-full">
{profileWorkItemsFilter && <WorkItemFiltersRow filter={profileWorkItemsFilter} />}
<div className="-z-1 relative h-full w-full overflow-auto">
{activeLayout === "list" ? (
<ProfileIssuesListLayout />
) : activeLayout === "kanban" ? (
<ProfileIssuesKanBanLayout />
) : null}
</div>
</div>
{/* peek overview */}
<IssuePeekOverview />
</>
)}
</WorkspaceLevelWorkItemFiltersHOC>
</IssuesStoreContext.Provider>
);
});

View file

@ -119,7 +119,7 @@ export const ProfileSidebar: FC<TProfileSidebar> = observer((props) => {
className="h-full w-full rounded object-cover"
/>
) : (
<div className="flex h-[52px] w-[52px] items-center justify-center rounded bg-custom-background-90 capitalize text-custom-text-100">
<div className="flex h-[52px] w-[52px] items-center justify-center rounded bg-[#028375] capitalize text-white">
{userData?.first_name?.[0]}
</div>
)}

View file

@ -4,7 +4,7 @@ import { ListFilter } from "lucide-react";
// plane imports
import { IFilterInstance } from "@plane/shared-state";
import { LOGICAL_OPERATOR, TExternalFilter, TFilterProperty } from "@plane/types";
import { CustomSearchSelect, getButtonStyling, TButtonVariant } from "@plane/ui";
import { CustomSearchSelect, getButtonStyling, setToast, TButtonVariant, TOAST_TYPE } from "@plane/ui";
import { cn, getOperatorForPayload } from "@plane/utils";
export type TAddFilterButtonProps<P extends TFilterProperty, E extends TExternalFilter> = {
@ -15,7 +15,7 @@ export type TAddFilterButtonProps<P extends TFilterProperty, E extends TExternal
defaultOpen?: boolean;
iconConfig?: {
shouldShowIcon: boolean;
iconComponent?: React.ReactNode;
iconComponent?: React.ElementType;
};
isDisabled?: boolean;
};
@ -34,6 +34,8 @@ export const AddFilterButton = observer(
iconConfig = { shouldShowIcon: true },
isDisabled = false,
} = buttonConfig || {};
// derived values
const FilterIcon = iconConfig.iconComponent || ListFilter;
// Transform available filter configs to CustomSearchSelect options format
const filterOptions = filter.configManager.allAvailableConfigs.map((config) => ({
@ -64,7 +66,7 @@ export const AddFilterButton = observer(
const handleFilterSelect = (property: P) => {
const config = filter.configManager.getConfigByProperty(property);
if (config && config.firstOperator) {
if (config?.firstOperator) {
const { operator, isNegation } = getOperatorForPayload(config.firstOperator);
filter.addCondition(
LOGICAL_OPERATOR.AND,
@ -76,6 +78,12 @@ export const AddFilterButton = observer(
isNegation
);
onFilterSelect?.(property);
} else {
setToast({
title: "Filter configuration error",
message: "This filter is not properly configured and cannot be applied",
type: TOAST_TYPE.ERROR,
});
}
};
@ -91,11 +99,10 @@ export const AddFilterButton = observer(
maxHeight="full"
placement="bottom-start"
disabled={isDisabled}
customButtonClassName={cn(getButtonStyling(variant, "sm"), className)}
customButtonClassName={cn(getButtonStyling(variant, "sm"), "py-[5px]", className)}
customButton={
<div className="flex items-center gap-1">
{iconConfig.shouldShowIcon &&
(iconConfig.iconComponent || <ListFilter className="size-4 text-custom-text-200" />)}
{iconConfig.shouldShowIcon && <FilterIcon className="size-4 text-custom-text-200" />}
{label}
</div>
}

View file

@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from "react";
import React, { useCallback, useState } from "react";
import { observer } from "mobx-react";
import { Transition } from "@headlessui/react";
// plane imports
@ -15,7 +15,6 @@ export type TFiltersRowProps<K extends TFilterProperty, E extends TExternalFilte
filter: IFilterInstance<K, E>;
variant?: "default" | "header";
visible?: boolean;
maxVisibleConditions?: number;
trackerElements?: {
clearFilter?: string;
saveView?: string;
@ -31,25 +30,10 @@ export const FiltersRow = observer(
filter,
variant = "header",
visible = true,
maxVisibleConditions = 3,
trackerElements,
} = props;
// states
const [showAllConditions, setShowAllConditions] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
// derived values
const visibleConditions = useMemo(() => {
if (variant === "default" || !maxVisibleConditions || showAllConditions) {
return filter.allConditionsForDisplay;
}
return filter.allConditionsForDisplay.slice(0, maxVisibleConditions);
}, [filter.allConditionsForDisplay, maxVisibleConditions, showAllConditions, variant]);
const hiddenConditionsCount = useMemo(() => {
if (variant === "default" || !maxVisibleConditions || showAllConditions) {
return 0;
}
return Math.max(0, filter.allConditionsForDisplay.length - maxVisibleConditions);
}, [filter.allConditionsForDisplay.length, maxVisibleConditions, showAllConditions, variant]);
const handleUpdate = useCallback(async () => {
setIsUpdating(true);
@ -61,44 +45,17 @@ export const FiltersRow = observer(
const leftContent = (
<>
{filter.allConditionsForDisplay.map((condition) => (
<FilterItem key={condition.id} filter={filter} condition={condition} isDisabled={disabledAllOperations} />
))}
<AddFilterButton
filter={filter}
buttonConfig={{
variant: "neutral-primary",
...buttonConfig,
isDisabled: disabledAllOperations,
}}
onFilterSelect={() => {
if (variant === "header") {
setShowAllConditions(true);
}
}}
/>
{visibleConditions.map((condition) => (
<FilterItem key={condition.id} filter={filter} condition={condition} isDisabled={disabledAllOperations} />
))}
{variant === "header" && hiddenConditionsCount > 0 && (
<Button
variant="neutral-primary"
size="sm"
className={COMMON_VISIBILITY_BUTTON_CLASSNAME}
onClick={() => setShowAllConditions(true)}
>
+{hiddenConditionsCount} more
</Button>
)}
{variant === "header" &&
showAllConditions &&
maxVisibleConditions &&
filter.allConditionsForDisplay.length > maxVisibleConditions && (
<Button
variant="neutral-primary"
size="sm"
className={COMMON_VISIBILITY_BUTTON_CLASSNAME}
onClick={() => setShowAllConditions(false)}
>
Show less
</Button>
)}
</>
);
@ -162,7 +119,6 @@ export const FiltersRow = observer(
}
);
const COMMON_VISIBILITY_BUTTON_CLASSNAME = "py-0.5 px-2 text-custom-text-300 hover:text-custom-text-100 rounded-full";
const COMMON_OPERATION_BUTTON_CLASSNAME = "py-1";
type TElementTransitionProps = {

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" />}

View file

@ -0,0 +1,100 @@
import { useEffect, useMemo } from "react";
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
// plane imports
import { TSaveViewOptions, TUpdateViewOptions } from "@plane/constants";
import { IFilterInstance } from "@plane/shared-state";
import { IIssueFilters, TWorkItemFilterExpression, TWorkItemFilterProperty } from "@plane/types";
// store hooks
import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters";
// plane web imports
import {
TWorkItemFiltersEntityProps,
useWorkItemFiltersConfig,
} from "@/plane-web/hooks/work-item-filters/use-work-item-filters-config";
// local imports
import { TSharedWorkItemFiltersHOCProps, TSharedWorkItemFiltersProps } from "./shared";
type TAdditionalWorkItemFiltersProps = {
saveViewOptions?: TSaveViewOptions<TWorkItemFilterExpression>;
updateViewOptions?: TUpdateViewOptions<TWorkItemFilterExpression>;
} & TWorkItemFiltersEntityProps;
type TWorkItemFiltersHOCProps = TSharedWorkItemFiltersHOCProps & TAdditionalWorkItemFiltersProps;
export const WorkItemFiltersHOC = observer((props: TWorkItemFiltersHOCProps) => {
const { children, initialWorkItemFilters } = props;
// Only initialize filter instance when initial work item filters are defined
if (!initialWorkItemFilters)
return <>{typeof children === "function" ? children({ filter: undefined }) : children}</>;
return (
<WorkItemFilterRoot {...props} initialWorkItemFilters={initialWorkItemFilters}>
{children}
</WorkItemFilterRoot>
);
});
type TWorkItemFilterProps = TSharedWorkItemFiltersProps &
TAdditionalWorkItemFiltersProps & {
initialWorkItemFilters: IIssueFilters;
children:
| React.ReactNode
| ((props: { filter: IFilterInstance<TWorkItemFilterProperty, TWorkItemFilterExpression> }) => React.ReactNode);
};
const WorkItemFilterRoot = observer((props: TWorkItemFilterProps) => {
const {
children,
entityType,
entityId,
filtersToShowByLayout,
initialWorkItemFilters,
isTemporary,
saveViewOptions,
updateFilters,
updateViewOptions,
...entityConfigProps
} = props;
// store hooks
const { getOrCreateFilter, deleteFilter } = useWorkItemFilters();
// derived values
const workItemEntityID = useMemo(
() => (isTemporary ? `TEMP-${entityId ?? uuidv4()}` : entityId),
[isTemporary, entityId]
);
// memoize initial values to prevent re-computations when reference changes
const initialUserFilters = useMemo(
() => initialWorkItemFilters.richFilters,
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
); // Empty dependency array to capture only the initial value
const workItemFiltersConfig = useWorkItemFiltersConfig({
allowedFilters: filtersToShowByLayout ? filtersToShowByLayout : [],
...entityConfigProps,
});
// get or create filter instance
const workItemLayoutFilter = getOrCreateFilter({
entityType,
entityId: workItemEntityID,
initialExpression: initialUserFilters,
onExpressionChange: updateFilters,
expressionOptions: {
saveViewOptions,
updateViewOptions,
},
});
// delete filter instance when component unmounts
useEffect(
() => () => {
deleteFilter(entityType, workItemEntityID);
},
[deleteFilter, entityType, workItemEntityID]
);
workItemLayoutFilter.configManager.registerAll(workItemFiltersConfig.configs);
return <>{typeof children === "function" ? children({ filter: workItemLayoutFilter }) : children}</>;
});

View file

@ -0,0 +1,206 @@
import { useCallback, useMemo, useState } from "react";
import cloneDeep from "lodash/cloneDeep";
import isEqual from "lodash/isEqual";
import { observer } from "mobx-react";
// plane imports
import { EUserPermissionsLevel, PROJECT_VIEW_TRACKER_EVENTS } from "@plane/constants";
import { EUserProjectRoles, EViewAccess, IProjectView, TWorkItemFilterExpression } from "@plane/types";
// components
import { setToast, TOAST_TYPE } from "@plane/ui";
import { removeNillKeys } from "@/components/issues/issue-layouts/utils";
import { CreateUpdateProjectViewModal } from "@/components/views/modal";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useCycle } from "@/hooks/store/use-cycle";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useModule } from "@/hooks/store/use-module";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
import { useProjectView } from "@/hooks/store/use-project-view";
import { useUser, useUserPermissions } from "@/hooks/store/user";
// plane web imports
import { getAdditionalProjectLevelFiltersHOCProps } from "@/plane-web/helpers/work-item-filters/project-level";
// local imports
import { WorkItemFiltersHOC } from "./base";
import { TEnableSaveViewProps, TEnableUpdateViewProps, TSharedWorkItemFiltersHOCProps } from "./shared";
type TProjectLevelWorkItemFiltersHOCProps = TSharedWorkItemFiltersHOCProps & {
workspaceSlug: string;
projectId: string;
} & TEnableSaveViewProps &
TEnableUpdateViewProps;
export const ProjectLevelWorkItemFiltersHOC = observer((props: TProjectLevelWorkItemFiltersHOCProps) => {
const { children, enableSaveView, enableUpdateView, entityId, initialWorkItemFilters, projectId, workspaceSlug } =
props;
// states
const [isCreateViewModalOpen, setIsCreateViewModalOpen] = useState(false);
const [createViewPayload, setCreateViewPayload] = useState<Partial<IProjectView> | null>(null);
// hooks
const { getProjectById } = useProject();
const { getViewById, updateView } = useProjectView();
const { data: currentUser } = useUser();
const { allowPermissions } = useUserPermissions();
const { getProjectCycleIds } = useCycle();
const { getProjectLabelIds } = useLabel();
const {
project: { getProjectMemberIds },
} = useMember();
const { getProjectModuleIds } = useModule();
const { getProjectStateIds } = useProjectState();
// derived values
const hasProjectMemberLevelPermissions = allowPermissions(
[EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
projectId
);
const projectDetails = getProjectById(projectId);
const viewDetails = entityId ? getViewById(entityId) : null;
const isViewLocked = viewDetails ? viewDetails?.is_locked : false;
const isCurrentUserOwner = viewDetails ? viewDetails.owned_by === currentUser?.id : false;
const canCreateView = useMemo(
() =>
projectDetails?.issue_views_view === true &&
enableSaveView &&
!props.saveViewOptions?.isDisabled &&
hasProjectMemberLevelPermissions,
[
projectDetails?.issue_views_view,
enableSaveView,
props.saveViewOptions?.isDisabled,
hasProjectMemberLevelPermissions,
]
);
const canUpdateView = useMemo(
() =>
enableUpdateView &&
!props.updateViewOptions?.isDisabled &&
!isViewLocked &&
hasProjectMemberLevelPermissions &&
isCurrentUserOwner,
[
enableUpdateView,
props.updateViewOptions?.isDisabled,
isViewLocked,
hasProjectMemberLevelPermissions,
isCurrentUserOwner,
]
);
const getDefaultViewDetailPayload: () => Partial<IProjectView> = useCallback(
() => ({
name: viewDetails ? `${viewDetails?.name} 2` : "Untitled",
description: viewDetails ? viewDetails.description : "",
logo_props: viewDetails ? viewDetails.logo_props : undefined,
access: viewDetails ? viewDetails.access : EViewAccess.PUBLIC,
}),
[viewDetails]
);
const getViewFilterPayload: (filterExpression: TWorkItemFilterExpression) => Partial<IProjectView> = useCallback(
(filterExpression: TWorkItemFilterExpression) => ({
rich_filters: cloneDeep(filterExpression),
display_filters: cloneDeep(initialWorkItemFilters?.displayFilters),
display_properties: cloneDeep(initialWorkItemFilters?.displayProperties),
}),
[initialWorkItemFilters]
);
const handleViewUpdate = useCallback(
(filterExpression: TWorkItemFilterExpression) => {
if (!viewDetails) {
setToast({
type: TOAST_TYPE.ERROR,
title: "We couldn't find the view",
message: "The view you're trying to update doesn't exist.",
});
return;
}
updateView(workspaceSlug, projectId, viewDetails.id, {
...getViewFilterPayload(filterExpression),
})
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Your view has been updated successfully.",
});
captureSuccess({
eventName: PROJECT_VIEW_TRACKER_EVENTS.update,
payload: {
view_id: viewDetails.id,
},
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Your view could not be updated. Please try again.",
});
captureError({
eventName: PROJECT_VIEW_TRACKER_EVENTS.update,
payload: {
view_id: viewDetails.id,
},
});
});
},
[viewDetails, updateView, workspaceSlug, projectId, getViewFilterPayload]
);
return (
<>
<CreateUpdateProjectViewModal
workspaceSlug={workspaceSlug}
projectId={projectId}
preLoadedData={createViewPayload}
isOpen={isCreateViewModalOpen}
onClose={() => {
setCreateViewPayload(null);
setIsCreateViewModalOpen(false);
}}
/>
<WorkItemFiltersHOC
{...props}
{...getAdditionalProjectLevelFiltersHOCProps({
workspaceSlug,
projectId,
})}
cycleIds={getProjectCycleIds(projectId) ?? undefined}
labelIds={getProjectLabelIds(projectId)}
memberIds={getProjectMemberIds(projectId, false) ?? undefined}
moduleIds={getProjectModuleIds(projectId) ?? undefined}
stateIds={getProjectStateIds(projectId)}
saveViewOptions={{
label: props.saveViewOptions?.label,
isDisabled: !canCreateView,
onViewSave: (expression) => {
setCreateViewPayload({
...getDefaultViewDetailPayload(),
...getViewFilterPayload(expression),
});
setIsCreateViewModalOpen(true);
},
}}
updateViewOptions={{
label: props.updateViewOptions?.label,
isDisabled: !canUpdateView,
hasAdditionalChanges:
!isEqual(initialWorkItemFilters?.displayFilters, viewDetails?.display_filters) ||
!isEqual(
removeNillKeys(initialWorkItemFilters?.displayProperties),
removeNillKeys(viewDetails?.display_properties)
),
onViewUpdate: handleViewUpdate,
}}
>
{children}
</WorkItemFiltersHOC>
</>
);
});

View file

@ -0,0 +1,30 @@
// plane imports
import { TSaveViewOptions, TUpdateViewOptions } from "@plane/constants";
import { IFilterInstance } from "@plane/shared-state";
import { EIssuesStoreType, IIssueFilters, TWorkItemFilterExpression, TWorkItemFilterProperty } from "@plane/types";
export type TSharedWorkItemFiltersProps = {
entityType: EIssuesStoreType; // entity type (project, cycle, workspace, teamspace, etc)
filtersToShowByLayout: TWorkItemFilterProperty[];
updateFilters: (updatedFilters: TWorkItemFilterExpression) => void;
isTemporary?: boolean;
} & ({ isTemporary: true; entityId?: string } | { isTemporary?: false; entityId: string }); // entity id (project_id, cycle_id, workspace_id, etc)
export type TSharedWorkItemFiltersHOCProps = TSharedWorkItemFiltersProps & {
children:
| React.ReactNode
| ((props: {
filter: IFilterInstance<TWorkItemFilterProperty, TWorkItemFilterExpression> | undefined;
}) => React.ReactNode);
initialWorkItemFilters: IIssueFilters | undefined;
};
export type TEnableSaveViewProps = {
enableSaveView?: boolean;
saveViewOptions?: Omit<TSaveViewOptions<TWorkItemFilterExpression>, "onViewSave">;
};
export type TEnableUpdateViewProps = {
enableUpdateView?: boolean;
updateViewOptions?: Omit<TUpdateViewOptions<TWorkItemFilterExpression>, "onViewUpdate">;
};

View file

@ -0,0 +1,185 @@
import { useCallback, useMemo, useState } from "react";
import cloneDeep from "lodash/cloneDeep";
import isEqual from "lodash/isEqual";
import { observer } from "mobx-react";
// plane imports
import { DEFAULT_GLOBAL_VIEWS_LIST, EUserPermissionsLevel, GLOBAL_VIEW_TRACKER_EVENTS } from "@plane/constants";
import { EUserProjectRoles, EViewAccess, IWorkspaceView, TWorkItemFilterExpression } from "@plane/types";
// components
import { setToast, TOAST_TYPE } from "@plane/ui";
import { removeNillKeys } from "@/components/issues/issue-layouts/utils";
import { CreateUpdateWorkspaceViewModal } from "@/components/workspace/views/modal";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useGlobalView } from "@/hooks/store/use-global-view";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useUser, useUserPermissions } from "@/hooks/store/user";
// local imports
import { WorkItemFiltersHOC } from "./base";
import { TEnableSaveViewProps, TEnableUpdateViewProps, TSharedWorkItemFiltersHOCProps } from "./shared";
type TWorkspaceLevelWorkItemFiltersHOCProps = TSharedWorkItemFiltersHOCProps & {
workspaceSlug: string;
} & TEnableSaveViewProps &
TEnableUpdateViewProps;
export const WorkspaceLevelWorkItemFiltersHOC = observer((props: TWorkspaceLevelWorkItemFiltersHOCProps) => {
const { children, enableSaveView, enableUpdateView, entityId, initialWorkItemFilters, workspaceSlug } = props;
// states
const [isCreateViewModalOpen, setIsCreateViewModalOpen] = useState(false);
const [createViewPayload, setCreateViewPayload] = useState<Partial<IWorkspaceView> | undefined>(undefined);
// hooks
const { getViewDetailsById, updateGlobalView } = useGlobalView();
const { data: currentUser } = useUser();
const { allowPermissions } = useUserPermissions();
const { joinedProjectIds } = useProject();
const {
workspace: { getWorkspaceMemberIds },
} = useMember();
const { getWorkspaceLabelIds } = useLabel();
// derived values
const hasWorkspaceMemberLevelPermissions = allowPermissions(
[EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER],
EUserPermissionsLevel.WORKSPACE,
workspaceSlug
);
const viewDetails = entityId ? getViewDetailsById(entityId) : null;
const isDefaultView = typeof entityId === "string" && DEFAULT_GLOBAL_VIEWS_LIST.some((view) => view.key === entityId);
const isViewLocked = viewDetails ? viewDetails?.is_locked : false;
const isCurrentUserOwner = viewDetails ? viewDetails.owned_by === currentUser?.id : false;
const canCreateView = useMemo(
() => enableSaveView && !props.saveViewOptions?.isDisabled && hasWorkspaceMemberLevelPermissions,
[enableSaveView, props.saveViewOptions?.isDisabled, hasWorkspaceMemberLevelPermissions]
);
const canUpdateView = useMemo(
() =>
enableUpdateView &&
!isDefaultView &&
!props.updateViewOptions?.isDisabled &&
!isViewLocked &&
hasWorkspaceMemberLevelPermissions &&
isCurrentUserOwner,
[
enableUpdateView,
props.updateViewOptions?.isDisabled,
isDefaultView,
isViewLocked,
hasWorkspaceMemberLevelPermissions,
isCurrentUserOwner,
]
);
const getDefaultViewDetailPayload: () => Partial<IWorkspaceView> = useCallback(
() => ({
name: viewDetails ? `${viewDetails?.name} 2` : "Untitled",
description: viewDetails ? viewDetails.description : "",
access: viewDetails ? viewDetails.access : EViewAccess.PUBLIC,
}),
[viewDetails]
);
const getViewFilterPayload: (filterExpression: TWorkItemFilterExpression) => Partial<IWorkspaceView> = useCallback(
(filterExpression: TWorkItemFilterExpression) => ({
rich_filters: cloneDeep(filterExpression),
display_filters: cloneDeep(initialWorkItemFilters?.displayFilters),
display_properties: cloneDeep(initialWorkItemFilters?.displayProperties),
}),
[initialWorkItemFilters]
);
const handleViewUpdate = useCallback(
(filterExpression: TWorkItemFilterExpression) => {
if (!viewDetails) {
setToast({
type: TOAST_TYPE.ERROR,
title: "We couldn't find the view",
message: "The view you're trying to update doesn't exist.",
});
return;
}
updateGlobalView(
workspaceSlug,
viewDetails.id,
{
...getViewFilterPayload(filterExpression),
},
/* No need to sync filters here as updateFilters already handles it */
false
)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Your view has been updated successfully.",
});
captureSuccess({
eventName: GLOBAL_VIEW_TRACKER_EVENTS.update,
payload: {
view_id: viewDetails.id,
},
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Your view could not be updated. Please try again.",
});
captureError({
eventName: GLOBAL_VIEW_TRACKER_EVENTS.update,
payload: {
view_id: viewDetails.id,
},
});
});
},
[viewDetails, updateGlobalView, workspaceSlug, getViewFilterPayload]
);
return (
<>
<CreateUpdateWorkspaceViewModal
preLoadedData={createViewPayload}
isOpen={isCreateViewModalOpen}
onClose={() => {
setCreateViewPayload(undefined);
setIsCreateViewModalOpen(false);
}}
/>
<WorkItemFiltersHOC
{...props}
memberIds={getWorkspaceMemberIds(workspaceSlug)}
labelIds={getWorkspaceLabelIds(workspaceSlug)}
projectIds={joinedProjectIds}
saveViewOptions={{
label: props.saveViewOptions?.label,
isDisabled: !canCreateView,
onViewSave: (expression) => {
setCreateViewPayload({
...getDefaultViewDetailPayload(),
...getViewFilterPayload(expression),
});
setIsCreateViewModalOpen(true);
},
}}
updateViewOptions={{
label: props.updateViewOptions?.label,
isDisabled: !canUpdateView,
hasAdditionalChanges:
!isEqual(initialWorkItemFilters?.displayFilters, viewDetails?.display_filters) ||
!isEqual(
removeNillKeys(initialWorkItemFilters?.displayProperties),
removeNillKeys(viewDetails?.display_properties)
),
onViewUpdate: handleViewUpdate,
}}
>
{children}
</WorkItemFiltersHOC>
</>
);
});

View file

@ -0,0 +1,9 @@
import { observer } from "mobx-react";
// plane imports
import { TWorkItemFilterExpression, TWorkItemFilterProperty } from "@plane/types";
// components
import { FiltersRow, TFiltersRowProps } from "@/components/rich-filters/filters-row";
type TWorkItemFiltersRowProps = TFiltersRowProps<TWorkItemFilterProperty, TWorkItemFilterExpression>;
export const WorkItemFiltersRow = observer((props: TWorkItemFiltersRowProps) => <FiltersRow {...props} />);

View file

@ -2,12 +2,11 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { FileText, HelpCircle, MessagesSquare, User } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// ui
import { Tooltip } from "@plane/propel/tooltip";
import { CustomMenu, ToggleSwitch } from "@plane/ui";
import { CustomMenu } from "@plane/ui";
// components
import { cn } from "@plane/utils";
import { ProductUpdatesModal } from "@/components/global";
@ -16,7 +15,6 @@ import { ProductUpdatesModal } from "@/components/global";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useInstance } from "@/hooks/store/use-instance";
import { useTransient } from "@/hooks/store/use-transient";
import { useUserSettings } from "@/hooks/store/user";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { PlaneVersionNumber } from "@/plane-web/components/global";
@ -26,14 +24,12 @@ export interface WorkspaceHelpSectionProps {
}
export const HelpMenu: React.FC<WorkspaceHelpSectionProps> = observer(() => {
const { workspaceSlug, projectId } = useParams();
// store hooks
const { t } = useTranslation();
const { toggleShortcutModal } = useCommandPalette();
const { isMobile } = usePlatformOS();
const { config } = useInstance();
const { isIntercomToggle, toggleIntercom } = useTransient();
const { canUseLocalDB, toggleLocalDB } = useUserSettings();
// states
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
const [isProductUpdatesModalOpen, setProductUpdatesModalOpen] = useState(false);
@ -101,21 +97,6 @@ export const HelpMenu: React.FC<WorkspaceHelpSectionProps> = observer(() => {
</a>
</CustomMenu.MenuItem>
<div className="my-1 border-t border-custom-border-200" />
<CustomMenu.MenuItem>
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
className="flex w-full items-center justify-between text-xs hover:bg-custom-background-80"
>
<span className="racking-tight">{t("hyper_mode")}</span>
<ToggleSwitch
value={canUseLocalDB}
onChange={() => toggleLocalDB(workspaceSlug?.toString(), projectId?.toString())}
/>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem>
<button
type="button"

View file

@ -2,12 +2,11 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { FileText, HelpCircle, MessagesSquare, MoveLeft, User } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/propel/tooltip";
import { CustomMenu, ToggleSwitch } from "@plane/ui";
import { CustomMenu } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { ProductUpdatesModal } from "@/components/global";
@ -16,7 +15,6 @@ import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useInstance } from "@/hooks/store/use-instance";
import { useTransient } from "@/hooks/store/use-transient";
import { useUserSettings } from "@/hooks/store/user";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { PlaneVersionNumber } from "@/plane-web/components/global";
@ -27,7 +25,6 @@ export interface WorkspaceHelpSectionProps {
}
export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(() => {
const { workspaceSlug, projectId } = useParams();
// store hooks
const { t } = useTranslation();
const { sidebarCollapsed: isCollapsed, toggleSidebar, sidebarPeek, toggleSidebarPeek } = useAppTheme();
@ -35,7 +32,6 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
const { isMobile } = usePlatformOS();
const { config } = useInstance();
const { isIntercomToggle, toggleIntercom } = useTransient();
const { canUseLocalDB, toggleLocalDB } = useUserSettings();
// states
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
const [isProductUpdatesModalOpen, setProductUpdatesModalOpen] = useState(false);
@ -104,21 +100,6 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
</a>
</CustomMenu.MenuItem>
<div className="my-1 border-t border-custom-border-200" />
<CustomMenu.MenuItem>
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
className="flex w-full items-center justify-between text-xs hover:bg-custom-background-80"
>
<span className="racking-tight">{t("hyper_mode")}</span>
<ToggleSwitch
value={canUseLocalDB}
onChange={() => toggleLocalDB(workspaceSlug?.toString(), projectId?.toString())}
/>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem>
<button
type="button"

View file

@ -2,11 +2,10 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { FileText, HelpCircle, MessagesSquare, User } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// ui
import { CustomMenu, ToggleSwitch } from "@plane/ui";
import { CustomMenu } from "@plane/ui";
// components
import { ProductUpdatesModal } from "@/components/global";
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
@ -14,18 +13,15 @@ import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useInstance } from "@/hooks/store/use-instance";
import { useTransient } from "@/hooks/store/use-transient";
import { useUserSettings } from "@/hooks/store/user";
// plane web components
import { PlaneVersionNumber } from "@/plane-web/components/global";
export const HelpMenuRoot = observer(() => {
const { workspaceSlug, projectId } = useParams();
// store hooks
const { t } = useTranslation();
const { toggleShortcutModal } = useCommandPalette();
const { config } = useInstance();
const { isIntercomToggle, toggleIntercom } = useTransient();
const { canUseLocalDB, toggleLocalDB } = useUserSettings();
// states
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
const [isProductUpdatesModalOpen, setProductUpdatesModalOpen] = useState(false);
@ -88,21 +84,6 @@ export const HelpMenuRoot = observer(() => {
</a>
</CustomMenu.MenuItem>
<div className="my-1 border-t border-custom-border-200" />
<CustomMenu.MenuItem>
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
className="flex w-full items-center justify-between text-xs hover:bg-custom-background-80"
>
<span className="racking-tight">{t("hyper_mode")}</span>
<ToggleSwitch
value={canUseLocalDB}
onChange={() => toggleLocalDB(workspaceSlug?.toString(), projectId?.toString())}
/>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem>
<button
type="button"

View file

@ -1,35 +1,26 @@
"use client";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
// constant
// plane imports
import { ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
// i18n
import { useTranslation } from "@plane/i18n";
// types
import {
EViewAccess,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
IWorkspaceView,
EIssueLayoutTypes,
EIssuesStoreType,
IIssueFilters,
} from "@plane/types";
// ui
import { Button, Input, TextArea } from "@plane/ui";
// components
import { getComputedDisplayFilters, getComputedDisplayProperties } from "@plane/utils";
import {
AppliedFiltersList,
DisplayFiltersSelection,
FilterSelection,
FiltersDropdown,
} from "@/components/issues/issue-layouts/filters";
// helpers
// hooks
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
// components
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
import { WorkspaceLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/workspace-level";
// plane web imports
import { WorkItemFiltersRow } from "@/components/work-item-filters/work-item-filters-row";
import { AccessController } from "@/plane-web/components/views/access-controller";
type Props = {
@ -37,9 +28,10 @@ type Props = {
handleClose: () => void;
data?: IWorkspaceView;
preLoadedData?: Partial<IWorkspaceView>;
workspaceSlug: string;
};
const defaultValues: Partial<IWorkspaceView> = {
const DEFAULT_VALUES: Partial<IWorkspaceView> = {
name: "",
description: "",
access: EViewAccess.PUBLIC,
@ -51,78 +43,39 @@ const defaultValues: Partial<IWorkspaceView> = {
};
export const WorkspaceViewForm: React.FC<Props> = observer((props) => {
const { handleFormSubmit, handleClose, data, preLoadedData } = props;
const { handleFormSubmit, handleClose, data, preLoadedData, workspaceSlug } = props;
// i18n
const { t } = useTranslation();
// store hooks
const { workspaceLabels } = useLabel();
const {
workspace: { workspaceMemberIds },
} = useMember();
// form info
const defaultValues = {
...DEFAULT_VALUES,
...preLoadedData,
...data,
};
const {
formState: { errors, isSubmitting },
handleSubmit,
control,
reset,
setValue,
watch,
getValues,
} = useForm<IWorkspaceView>({
defaultValues,
});
// derived values
const workItemFilters: IIssueFilters = {
richFilters: getValues("rich_filters"),
displayFilters: getValues("display_filters"),
displayProperties: getValues("display_properties"),
kanbanFilters: undefined,
};
const handleCreateUpdateView = async (formData: Partial<IWorkspaceView>) => {
await handleFormSubmit(formData);
reset({
...defaultValues,
});
};
useEffect(() => {
reset({
...defaultValues,
...preLoadedData,
...data,
});
}, [data, preLoadedData, reset]);
const selectedFilters: IIssueFilterOptions = watch("filters");
// filters whose value not null or empty array
let appliedFilters: IIssueFilterOptions | undefined = undefined;
Object.entries(selectedFilters ?? {}).forEach(([key, value]) => {
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
if (!appliedFilters) appliedFilters = {};
appliedFilters[key as keyof IIssueFilterOptions] = value;
});
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
// To clear all filters of any particular filter key.
if (!value) {
setValue("filters", {
...selectedFilters,
[key]: [],
});
return;
}
let newValues = selectedFilters?.[key] ?? [];
newValues = newValues.filter((val) => val !== value);
setValue("filters", {
...selectedFilters,
[key]: newValues,
});
};
const clearAllFilters = () => {
if (!selectedFilters) return;
setValue("filters", {});
};
return (
<form onSubmit={handleSubmit(handleCreateUpdateView)}>
<div className="space-y-5 p-5">
@ -176,39 +129,6 @@ export const WorkspaceViewForm: React.FC<Props> = observer((props) => {
</div>
<div className="flex gap-2">
<AccessController control={control} />
{/* filters dropdown */}
<Controller
control={control}
name="filters"
render={({ field: { onChange, value: filters } }) => (
<FiltersDropdown title={t("common.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.my_issues.spreadsheet}
labels={workspaceLabels ?? undefined}
memberIds={workspaceMemberIds ?? undefined}
/>
</FiltersDropdown>
)}
/>
{/* display filters dropdown */}
<Controller
control={control}
@ -220,7 +140,7 @@ export const WorkspaceViewForm: React.FC<Props> = observer((props) => {
render={({ field: { onChange: onDisplayPropertiesChange, value: displayProperties } }) => (
<FiltersDropdown title={t("common.display")}>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues.spreadsheet}
layoutDisplayFiltersOptions={ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues.layoutOptions.spreadsheet}
displayFilters={displayFilters ?? {}}
handleDisplayFiltersUpdate={(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
onDisplayFiltersChange({
@ -242,18 +162,30 @@ export const WorkspaceViewForm: React.FC<Props> = observer((props) => {
)}
/>
</div>
{selectedFilters && Object.keys(selectedFilters).length > 0 && (
<div>
<AppliedFiltersList
appliedFilters={appliedFilters ?? {}}
handleClearAllFilters={clearAllFilters}
handleRemoveFilter={handleRemoveFilter}
labels={workspaceLabels ?? undefined}
states={undefined}
alwaysAllowEditing
/>
</div>
)}
<div>
{/* filters dropdown */}
<Controller
control={control}
name="rich_filters"
render={({ field: { onChange: onFiltersChange } }) => (
<WorkspaceLevelWorkItemFiltersHOC
entityId={data?.id}
entityType={EIssuesStoreType.GLOBAL}
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues.filters}
initialWorkItemFilters={workItemFilters}
isTemporary
updateFilters={(updateFilters) => onFiltersChange(updateFilters)}
workspaceSlug={workspaceSlug}
>
{({ filter: workspaceViewWorkItemsFilter }) =>
workspaceViewWorkItemsFilter && (
<WorkItemFiltersRow filter={workspaceViewWorkItemsFilter} variant="default" />
)
}
</WorkspaceLevelWorkItemFiltersHOC>
)}
/>
</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

@ -5,12 +5,13 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { GLOBAL_VIEW_TRACKER_EVENTS } from "@plane/constants";
import { IWorkspaceView } from "@plane/types";
import { EIssuesStoreType, IWorkspaceView } from "@plane/types";
import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui";
// helpers
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useGlobalView } from "@/hooks/store/use-global-view";
import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters";
import { useAppRouter } from "@/hooks/use-app-router";
// local imports
import { WorkspaceViewForm } from "./form";
@ -26,9 +27,11 @@ export const CreateUpdateWorkspaceViewModal: React.FC<Props> = observer((props)
const { isOpen, onClose, data, preLoadedData } = props;
// router
const router = useAppRouter();
const { workspaceSlug } = useParams();
const { workspaceSlug: routerWorkspaceSlug } = useParams();
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
// store hooks
const { createGlobalView, updateGlobalView } = useGlobalView();
const { resetExpression } = useWorkItemFilters();
const handleClose = () => {
onClose();
@ -39,12 +42,12 @@ export const CreateUpdateWorkspaceViewModal: React.FC<Props> = observer((props)
const payloadData: Partial<IWorkspaceView> = {
...payload,
filters: {
...payload?.filters,
rich_filters: {
...payload?.rich_filters,
},
};
await createGlobalView(workspaceSlug.toString(), payloadData)
await createGlobalView(workspaceSlug, payloadData)
.then((res) => {
captureSuccess({
eventName: GLOBAL_VIEW_TRACKER_EVENTS.create,
@ -79,13 +82,14 @@ export const CreateUpdateWorkspaceViewModal: React.FC<Props> = observer((props)
const payloadData: Partial<IWorkspaceView> = {
...payload,
query: {
...payload?.filters,
...payload?.rich_filters,
},
};
await updateGlobalView(workspaceSlug.toString(), data.id, payloadData)
await updateGlobalView(workspaceSlug, data.id, payloadData)
.then((res) => {
if (res) {
resetExpression(EIssuesStoreType.GLOBAL, data.id, res.rich_filters);
captureSuccess({
eventName: GLOBAL_VIEW_TRACKER_EVENTS.update,
payload: {
@ -122,6 +126,7 @@ export const CreateUpdateWorkspaceViewModal: React.FC<Props> = observer((props)
else await handleUpdateView(formData);
};
if (!workspaceSlug) return null;
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXL}>
<WorkspaceViewForm
@ -129,6 +134,7 @@ export const CreateUpdateWorkspaceViewModal: React.FC<Props> = observer((props)
handleClose={handleClose}
data={data}
preLoadedData={preLoadedData}
workspaceSlug={workspaceSlug}
/>
</ModalCore>
);

View file

@ -11,8 +11,8 @@ import { copyUrlToClipboard, cn } from "@plane/utils";
import { captureClick } from "@/helpers/event-tracker.helper";
// hooks
import { useUser, useUserPermissions } from "@/hooks/store/user";
// local imports
import { useViewMenuItems } from "@/plane-web/components/views/helper";
// local imports
import { DeleteGlobalViewModal } from "./delete-view-modal";
import { CreateUpdateWorkspaceViewModal } from "./modal";

View file

@ -8,7 +8,7 @@ import { Pencil, Trash2 } from "lucide-react";
// plane imports
import { GLOBAL_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { CustomMenu } from "@plane/ui";
import { calculateTotalFilters, truncateText } from "@plane/utils";
import { truncateText } from "@plane/utils";
// helpers
import { captureClick } from "@/helpers/event-tracker.helper";
// hooks
@ -33,8 +33,6 @@ export const GlobalViewListItem: React.FC<Props> = observer((props) => {
if (!view) return null;
const totalFilters = calculateTotalFilters(view.filters ?? {});
return (
<>
<CreateUpdateWorkspaceViewModal data={view} isOpen={updateViewModal} onClose={() => setUpdateViewModal(false)} />
@ -51,9 +49,6 @@ export const GlobalViewListItem: React.FC<Props> = observer((props) => {
</div>
<div className="ml-2 flex flex-shrink-0">
<div className="flex items-center gap-4">
<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>
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={(e) => {