[WEB-4951] [WEB-4884] feat: work item filters revamp (#7810)
This commit is contained in:
parent
e6a7ca4c72
commit
9aef5d4aa9
160 changed files with 5879 additions and 4881 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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 });
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ export * from "./title";
|
|||
export * from "./root";
|
||||
export * from "./quick-action-button";
|
||||
export * from "./display-filters";
|
||||
export * from "./content";
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
export * from "./roots";
|
||||
export * from "./date";
|
||||
export * from "./filters-list";
|
||||
export * from "./label";
|
||||
export * from "./members";
|
||||
export * from "./priority";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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" />}
|
||||
|
|
|
|||
100
apps/web/core/components/work-item-filters/filters-hoc/base.tsx
Normal file
100
apps/web/core/components/work-item-filters/filters-hoc/base.tsx
Normal 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}</>;
|
||||
});
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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">;
|
||||
};
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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} />);
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue