ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: унификация фильтров внешнего контура с внутренним rich-filters слоем

This commit is contained in:
DCCONSTRUCTIONS 2026-04-21 12:29:32 +03:00
parent 2c54a8f274
commit 0a85ea3cb2
16 changed files with 588 additions and 564 deletions

View File

@ -323,6 +323,7 @@ class ExternalContourReadMixin:
for key in [ for key in [
"direction", "direction",
"status", "status",
"state_groups",
"state_ids", "state_ids",
"priority", "priority",
"assignee_ids", "assignee_ids",
@ -337,6 +338,9 @@ class ExternalContourReadMixin:
filters["has_unread_updates"] = self.parse_bool_param(request, "has_unread_updates") filters["has_unread_updates"] = self.parse_bool_param(request, "has_unread_updates")
filters["search"] = (request.query_params.get("search") or "").strip() filters["search"] = (request.query_params.get("search") or "").strip()
filters["target_date_exact"] = (request.query_params.get("target_date_exact") or "").strip()
filters["target_date_from"] = (request.query_params.get("target_date_from") or "").strip()
filters["target_date_to"] = (request.query_params.get("target_date_to") or "").strip()
return filters return filters
def get_sorting(self, request): def get_sorting(self, request):
@ -392,6 +396,8 @@ class ExternalContourReadMixin:
queryset = self.apply_status_filter(queryset, filters["status"]) queryset = self.apply_status_filter(queryset, filters["status"])
if filters["state_groups"]:
queryset = queryset.filter(issue__state__group__in=filters["state_groups"])
if filters["state_ids"]: if filters["state_ids"]:
queryset = queryset.filter(issue__state_id__in=filters["state_ids"]) queryset = queryset.filter(issue__state_id__in=filters["state_ids"])
if filters["priority"]: if filters["priority"]:
@ -419,6 +425,12 @@ class ExternalContourReadMixin:
) )
if filters["label_ids"]: if filters["label_ids"]:
queryset = queryset.filter(issue__label_issue__label_id__in=filters["label_ids"]) queryset = queryset.filter(issue__label_issue__label_id__in=filters["label_ids"])
if filters["target_date_exact"]:
queryset = queryset.filter(issue__target_date=filters["target_date_exact"])
if filters["target_date_from"]:
queryset = queryset.filter(issue__target_date__gte=filters["target_date_from"])
if filters["target_date_to"]:
queryset = queryset.filter(issue__target_date__lte=filters["target_date_to"])
if filters["has_unread_updates"] is not None: if filters["has_unread_updates"] is not None:
unread_request_ids = self.get_unread_request_ids(request.user) unread_request_ids = self.get_unread_request_ids(request.user)
if filters["has_unread_updates"]: if filters["has_unread_updates"]:

View File

@ -5,17 +5,23 @@
*/ */
import { Outlet } from "react-router"; import { Outlet } from "react-router";
import { useParams } from "react-router";
import { AppHeader } from "@/components/core/app-header"; import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper"; import { ContentWrapper } from "@/components/core/content-wrapper";
import { ProjectExternalContoursHeader } from "@/plane-web/components/projects/external-contours/header"; import { ProjectExternalContoursHeader } from "@/plane-web/components/projects/external-contours/header";
import { ProjectExternalContoursFiltersProvider } from "@/plane-web/components/projects/external-contours/filters/provider";
export default function ProjectExternalContoursLayout() { export default function ProjectExternalContoursLayout() {
const { projectId, workspaceSlug } = useParams();
if (!projectId || !workspaceSlug) return <Outlet />;
return ( return (
<> <ProjectExternalContoursFiltersProvider projectId={projectId} workspaceSlug={workspaceSlug}>
<AppHeader header={<ProjectExternalContoursHeader />} /> <AppHeader header={<ProjectExternalContoursHeader />} />
<ContentWrapper> <ContentWrapper>
<Outlet /> <Outlet />
</ContentWrapper> </ContentWrapper>
</> </ProjectExternalContoursFiltersProvider>
); );
} }

View File

@ -1,544 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { ISSUE_PRIORITIES } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { CheckIcon, ChevronDownIcon, PriorityIcon, SearchIcon, CloseIcon } from "@plane/propel/icons";
import type { TExternalContourBoardSorting, TExternalContourRequest } from "@plane/types";
import { Avatar, Dropdown, MultiSelectDropdown } from "@plane/ui";
import { cn } from "@plane/utils";
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
import useDebounce from "@/hooks/use-debounce";
type Props = {
projectId: string;
workspaceSlug: string;
};
type TFilterOption = {
avatarUrl?: string | null;
color?: string | null;
id: string;
label: string;
};
type TSortingOption = {
key: string;
label: string;
value: TExternalContourBoardSorting;
};
const DEFAULT_SORTING_KEY = "updated_at:desc";
export const ExternalContoursBoardFiltersRow = observer(function ExternalContoursBoardFiltersRow(props: Props) {
const { projectId, workspaceSlug } = props;
const { t } = useTranslation();
const {
activeFiltersCount,
clearFilters,
filters,
getColumnRequestIds,
getRequestById,
isSortingDefault,
items,
sorting,
updateFilters,
updateSorting,
} = useProjectExternalContoursBoard();
const [searchQuery, setSearchQuery] = useState(filters.search ?? "");
const debouncedSearchQuery = useDebounce(searchQuery, 400);
useEffect(() => {
setSearchQuery(filters.search ?? "");
}, [filters.search]);
useEffect(() => {
const nextSearch = debouncedSearchQuery.trim();
const liveSearch = searchQuery.trim();
const currentSearch = filters.search?.trim() ?? "";
if (nextSearch !== liveSearch) return;
if (nextSearch === currentSearch) return;
void updateFilters(workspaceSlug, projectId, { search: nextSearch || undefined });
}, [debouncedSearchQuery, filters.search, projectId, searchQuery, updateFilters, workspaceSlug]);
const visibleRequestIds = [...getColumnRequestIds("outgoing"), ...getColumnRequestIds("incoming")];
const visibleRequests = visibleRequestIds.flatMap((requestId) => {
const request = getRequestById(requestId);
return request ? [request] : [];
});
const cachedRequests = Object.values(items);
const contourOptions = useMemo(
() =>
getCounterpartyProjectOptions(
visibleRequests,
cachedRequests,
filters.counterparty_project_ids ?? [],
projectId
),
[cachedRequests, filters.counterparty_project_ids, projectId, visibleRequests]
);
const stateOptions = useMemo(
() => getStateOptions(visibleRequests, cachedRequests, filters.state_ids ?? []),
[cachedRequests, filters.state_ids, visibleRequests]
);
const assigneeOptions = useMemo(() => getAssigneeOptions(visibleRequests, cachedRequests, filters.assignee_ids ?? []), [cachedRequests, filters.assignee_ids, visibleRequests]);
const requesterOptions = useMemo(
() => getRequesterOptions(visibleRequests, cachedRequests, filters.requested_by_ids ?? []),
[cachedRequests, filters.requested_by_ids, visibleRequests]
);
const priorityOptions = useMemo(() => getPriorityOptions(visibleRequests, cachedRequests, filters.priority ?? [], t), [cachedRequests, filters.priority, t, visibleRequests]);
const sortingOptions = useMemo<TSortingOption[]>(
() => [
{
key: "updated_at:desc",
label: t("external_contours_page.board.filters.sorting.updated_at_desc"),
value: { order_by: "updated_at", sort_by: "desc" },
},
{
key: "requested_at:desc",
label: t("external_contours_page.board.filters.sorting.requested_at_desc"),
value: { order_by: "requested_at", sort_by: "desc" },
},
{
key: "target_date:asc",
label: t("external_contours_page.board.filters.sorting.target_date_asc"),
value: { order_by: "target_date", sort_by: "asc" },
},
],
[t]
);
const selectedSortingKey = `${sorting.order_by ?? "updated_at"}:${sorting.sort_by ?? "desc"}`;
const selectedSortingLabel =
sortingOptions.find((option) => option.key === selectedSortingKey)?.label ??
sortingOptions.find((option) => option.key === DEFAULT_SORTING_KEY)?.label ??
t("external_contours_page.board.filters.sort");
const shouldShowClear = activeFiltersCount > 0 || !isSortingDefault;
return (
<div className="nodedc-filter-row-shell flex w-full flex-col gap-3 rounded-[1.75rem] px-4 py-4">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="flex flex-1 flex-wrap items-center gap-2">
<div className="relative min-w-[17rem] flex-1 xl:max-w-[24rem]">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2 text-placeholder" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("external_contours_page.board.filters.search_placeholder")}
className="h-10 w-full rounded-full border-0 bg-white/5 pr-10 pl-9 text-13 text-primary outline-none placeholder:text-placeholder focus:bg-white/7"
/>
{searchQuery && (
<button
type="button"
className="absolute top-1/2 right-3 -translate-y-1/2 text-placeholder transition-colors hover:text-primary"
onClick={() => setSearchQuery("")}
>
<CloseIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
<MultiSelectDropdown
value={filters.counterparty_project_ids ?? []}
onChange={(value) =>
void updateFilters(workspaceSlug, projectId, {
counterparty_project_ids: value.length > 0 ? value : undefined,
})
}
options={contourOptions}
keyExtractor={(option) => option.value}
queryArray={["label"]}
inputPlaceholder={t("external_contours_page.board.filters.search_contour")}
buttonContainerClassName="h-10"
optionsContainerClassName="w-72"
disabled={contourOptions.length === 0 && (filters.counterparty_project_ids?.length ?? 0) === 0}
buttonContent={(isOpen) => (
<FilterTrigger
label={t("external_contours_page.board.filters.contour")}
count={filters.counterparty_project_ids?.length ?? 0}
isOpen={isOpen}
/>
)}
renderItem={({ value, selected }) => {
const option = contourOptions.find((item) => item.value === value);
if (!option) return null;
return (
<div className="flex w-full items-center justify-between gap-2 text-secondary">
<span className="truncate text-12 font-medium">{option.data.label}</span>
{selected && <CheckIcon className="h-3.5 w-3.5 flex-shrink-0" />}
</div>
);
}}
/>
<MultiSelectDropdown
value={filters.state_ids ?? []}
onChange={(value) => void updateFilters(workspaceSlug, projectId, { state_ids: value.length > 0 ? value : undefined })}
options={stateOptions}
keyExtractor={(option) => option.value}
queryArray={["label"]}
inputPlaceholder={t("external_contours_page.board.filters.search_state")}
buttonContainerClassName="h-10"
optionsContainerClassName="w-64"
disabled={stateOptions.length === 0 && (filters.state_ids?.length ?? 0) === 0}
buttonContent={(isOpen) => (
<FilterTrigger label={t("state")} count={filters.state_ids?.length ?? 0} isOpen={isOpen} />
)}
renderItem={({ value, selected }) => {
const option = stateOptions.find((item) => item.value === value);
if (!option) return null;
return (
<div className="flex w-full items-center justify-between gap-2 text-secondary">
<div className="flex min-w-0 items-center gap-2">
<span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{ backgroundColor: option.data.color || "rgba(255,255,255,0.3)" }}
/>
<span className="truncate text-12 font-medium">{option.data.label}</span>
</div>
{selected && <CheckIcon className="h-3.5 w-3.5 flex-shrink-0" />}
</div>
);
}}
/>
<MultiSelectDropdown
value={filters.priority ?? []}
onChange={(value) => void updateFilters(workspaceSlug, projectId, { priority: value.length > 0 ? value : undefined })}
options={priorityOptions}
keyExtractor={(option) => option.value}
queryArray={["label"]}
inputPlaceholder={t("external_contours_page.board.filters.search_priority")}
buttonContainerClassName="h-10"
optionsContainerClassName="w-56"
disableSearch={priorityOptions.length <= 7}
disabled={priorityOptions.length === 0 && (filters.priority?.length ?? 0) === 0}
buttonContent={(isOpen) => (
<FilterTrigger
icon={<PriorityIcon priority="medium" className="h-3.5 w-3.5" />}
label={t("priority")}
count={filters.priority?.length ?? 0}
isOpen={isOpen}
/>
)}
renderItem={({ value, selected }) => {
const option = priorityOptions.find((item) => item.value === value);
if (!option) return null;
return (
<div className="flex w-full items-center justify-between gap-2 text-secondary">
<div className="flex items-center gap-2">
<PriorityIcon priority={option.value} className="h-3.5 w-3.5" />
<span className="text-12 font-medium">{option.data.label}</span>
</div>
{selected && <CheckIcon className="h-3.5 w-3.5 flex-shrink-0" />}
</div>
);
}}
/>
<MultiSelectDropdown
value={filters.assignee_ids ?? []}
onChange={(value) =>
void updateFilters(workspaceSlug, projectId, { assignee_ids: value.length > 0 ? value : undefined })
}
options={assigneeOptions}
keyExtractor={(option) => option.value}
queryArray={["label"]}
inputPlaceholder={t("external_contours_page.board.filters.search_assignee")}
buttonContainerClassName="h-10"
optionsContainerClassName="w-64"
disabled={assigneeOptions.length === 0 && (filters.assignee_ids?.length ?? 0) === 0}
buttonContent={(isOpen) => (
<FilterTrigger label={t("assignee")} count={filters.assignee_ids?.length ?? 0} isOpen={isOpen} />
)}
renderItem={({ value, selected }) => {
const option = assigneeOptions.find((item) => item.value === value);
if (!option) return null;
return (
<div className="flex w-full items-center justify-between gap-2 text-secondary">
<div className="flex min-w-0 items-center gap-2">
<Avatar src={option.data.avatarUrl || ""} name={option.data.label} size="sm" />
<span className="truncate text-12 font-medium">{option.data.label}</span>
</div>
{selected && <CheckIcon className="h-3.5 w-3.5 flex-shrink-0" />}
</div>
);
}}
/>
<MultiSelectDropdown
value={filters.requested_by_ids ?? []}
onChange={(value) =>
void updateFilters(workspaceSlug, projectId, { requested_by_ids: value.length > 0 ? value : undefined })
}
options={requesterOptions}
keyExtractor={(option) => option.value}
queryArray={["label"]}
inputPlaceholder={t("external_contours_page.board.filters.search_requester")}
buttonContainerClassName="h-10"
optionsContainerClassName="w-64"
disabled={requesterOptions.length === 0 && (filters.requested_by_ids?.length ?? 0) === 0}
buttonContent={(isOpen) => (
<FilterTrigger
label={t("external_contours_page.board.filters.requester")}
count={filters.requested_by_ids?.length ?? 0}
isOpen={isOpen}
/>
)}
renderItem={({ value, selected }) => {
const option = requesterOptions.find((item) => item.value === value);
if (!option) return null;
return (
<div className="flex w-full items-center justify-between gap-2 text-secondary">
<div className="flex min-w-0 items-center gap-2">
<Avatar src={option.data.avatarUrl || ""} name={option.data.label} size="sm" />
<span className="truncate text-12 font-medium">{option.data.label}</span>
</div>
{selected && <CheckIcon className="h-3.5 w-3.5 flex-shrink-0" />}
</div>
);
}}
/>
<button
type="button"
className={cn(
"nodedc-toolbar-pill !min-h-10 !px-4 text-12 font-medium",
filters.has_unread_updates
? "!bg-[rgb(var(--nodedc-accent-rgb))] !text-[#0b1117]"
: "text-secondary"
)}
onClick={() =>
void updateFilters(workspaceSlug, projectId, {
has_unread_updates: filters.has_unread_updates ? undefined : true,
})
}
>
<span className="size-2 rounded-full bg-current" />
<span>{t("external_contours_page.board.filters.unread_only")}</span>
</button>
{shouldShowClear && (
<Button
variant="secondary"
size="lg"
className="nodedc-toolbar-pill !h-10 !rounded-full !px-4"
onClick={() => void clearFilters(workspaceSlug, projectId)}
>
{t("common.clear")}
</Button>
)}
</div>
<div className="flex items-center gap-2 self-start xl:self-auto">
<div className="text-11 font-medium text-secondary">{t("external_contours_page.board.filters.sort")}</div>
<Dropdown
value={selectedSortingKey}
onChange={(value) => {
const nextSorting = sortingOptions.find((option) => option.key === value)?.value;
if (!nextSorting) return;
void updateSorting(workspaceSlug, projectId, nextSorting);
}}
options={sortingOptions.map((option) => ({
data: option,
value: option.key,
}))}
keyExtractor={(option) => option.value}
queryArray={["label"]}
disableSearch
buttonContainerClassName="h-10"
optionsContainerClassName="w-64"
buttonContent={(isOpen) => (
<FilterTrigger label={selectedSortingLabel} isOpen={isOpen} />
)}
renderItem={({ value, selected }) => {
const option = sortingOptions.find((item) => item.key === value);
if (!option) return null;
return (
<div className="flex w-full items-center justify-between gap-2 text-secondary">
<span className="text-12 font-medium">{option.label}</span>
{selected && <CheckIcon className="h-3.5 w-3.5 flex-shrink-0" />}
</div>
);
}}
/>
</div>
</div>
</div>
);
});
type TFilterTriggerProps = {
count?: number;
icon?: ReactNode;
isOpen: boolean;
label: string;
};
function FilterTrigger(props: TFilterTriggerProps) {
const { count = 0, icon, isOpen, label } = props;
return (
<div
data-active={isOpen || count > 0}
className={cn(
"nodedc-toolbar-pill !min-h-10 gap-2 !px-4 text-12 font-medium",
isOpen || count > 0 ? "text-[rgb(var(--nodedc-accent-rgb))]" : "text-secondary"
)}
>
{icon}
<span>{label}</span>
{count > 0 && <span className="rounded-full bg-accent-primary/15 px-1.5 py-0.5 text-[10px] font-semibold text-accent-primary">{count}</span>}
<ChevronDownIcon className={cn("h-3 w-3 flex-shrink-0 transition-transform", { "rotate-180": isOpen })} />
</div>
);
}
const sortFilterOptions = (options: TFilterOption[]) => options.sort((left, right) => left.label.localeCompare(right.label));
const buildOptionsWithSelectedFallback = (
visibleRequests: TExternalContourRequest[],
cachedRequests: TExternalContourRequest[],
selectedIds: string[],
getOption: (request: TExternalContourRequest) => TFilterOption | null
) => {
const optionMap = new Map<string, TFilterOption>();
const upsertOption = (request: TExternalContourRequest) => {
const option = getOption(request);
if (!option?.id || optionMap.has(option.id)) return;
optionMap.set(option.id, option);
};
visibleRequests.forEach(upsertOption);
cachedRequests.forEach(upsertOption);
return sortFilterOptions(Array.from(optionMap.values())).map((option) => ({
data: option,
value: option.id,
}));
};
const getCounterpartyProjectOptions = (
visibleRequests: TExternalContourRequest[],
cachedRequests: TExternalContourRequest[],
selectedIds: string[],
currentProjectId: string
) =>
buildOptionsWithSelectedFallback(visibleRequests, cachedRequests, selectedIds, (request) => {
const project = request.direction === "incoming" ? request.source_project : request.target_project;
const fallbackName = request.direction === "incoming" ? request.source_project_name : request.target_project_name;
if (!project?.id || project.id === currentProjectId) return null;
return {
id: project.id,
label: project.name || project.identifier || fallbackName || "NODE.DC",
};
});
const getStateOptions = (
visibleRequests: TExternalContourRequest[],
cachedRequests: TExternalContourRequest[],
selectedIds: string[]
) =>
buildOptionsWithSelectedFallback(visibleRequests, cachedRequests, selectedIds, (request) => {
const state = request.issue.state_detail;
if (!state?.id) return null;
return {
id: state.id,
label: state.name || "Без статуса",
color: state.color || null,
};
});
const getPriorityOptions = (
visibleRequests: TExternalContourRequest[],
cachedRequests: TExternalContourRequest[],
selectedIds: string[],
t: ReturnType<typeof useTranslation>["t"]
) => {
const priorityKeys = new Set<string>();
[...visibleRequests, ...cachedRequests].forEach((request) => {
if (request.issue.priority && request.issue.priority !== "none") priorityKeys.add(request.issue.priority);
});
selectedIds.forEach((priority) => {
if (priority && priority !== "none") priorityKeys.add(priority);
});
return ISSUE_PRIORITIES.filter((priority) => priority.key !== "none" && priorityKeys.has(priority.key))
.map((priority) => ({
data: {
id: priority.key,
label: t(priority.key),
},
value: priority.key,
}))
.sort((left, right) => left.data.label.localeCompare(right.data.label));
};
const getAssigneeOptions = (
visibleRequests: TExternalContourRequest[],
cachedRequests: TExternalContourRequest[],
selectedIds: string[]
) => {
const assigneeMap = new Map<string, TFilterOption>();
const upsertAssignees = (request: TExternalContourRequest) => {
request.issue.assignee_details?.forEach((assignee) => {
if (!assignee?.id || assigneeMap.has(assignee.id)) return;
assigneeMap.set(assignee.id, {
id: assignee.id,
label: assignee.display_name || "NODE.DC",
avatarUrl: assignee.avatar_url || "",
});
});
};
visibleRequests.forEach(upsertAssignees);
cachedRequests.forEach(upsertAssignees);
return sortFilterOptions(Array.from(assigneeMap.values())).map((option) => ({ data: option, value: option.id }));
};
const getRequesterOptions = (
visibleRequests: TExternalContourRequest[],
cachedRequests: TExternalContourRequest[],
selectedIds: string[]
) =>
buildOptionsWithSelectedFallback(visibleRequests, cachedRequests, selectedIds, (request) => {
const requesterId = request.requested_by?.id || request.requested_by_id || request.issue.created_by_detail?.id;
const requesterLabel =
request.requested_by?.display_name || request.requested_by_name || request.issue.created_by_detail?.display_name;
if (!requesterId || !requesterLabel) return null;
return {
id: requesterId,
label: requesterLabel,
avatarUrl: request.issue.created_by_detail?.avatar_url || "",
};
});

View File

@ -8,7 +8,6 @@ import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board"; import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
import { ExternalContoursBoardFiltersRow } from "./board-filters-row";
import { ExternalContoursBoardColumn } from "./board-column"; import { ExternalContoursBoardColumn } from "./board-column";
type Props = { type Props = {
@ -23,10 +22,6 @@ export const ExternalContoursBoardRoot = observer(function ExternalContoursBoard
return ( return (
<div className="flex h-full min-h-0 flex-col overflow-hidden px-8 pb-6"> <div className="flex h-full min-h-0 flex-col overflow-hidden px-8 pb-6">
<div className="shrink-0 py-4">
<ExternalContoursBoardFiltersRow workspaceSlug={workspaceSlug} projectId={projectId} />
</div>
{loader === "init-loading" && !hasAnyItems ? ( {loader === "init-loading" && !hasAnyItems ? (
<div className="flex flex-1 items-center justify-center text-13 text-secondary">{t("loading")}...</div> <div className="flex flex-1 items-center justify-center text-13 text-secondary">{t("loading")}...</div>
) : ( ) : (

View File

@ -0,0 +1,80 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { createContext, useContext, useEffect, useMemo } from "react";
import type { ReactNode } from "react";
import { isEqual } from "lodash-es";
import { observer } from "mobx-react";
import { FilterInstance, workItemFiltersAdapter } from "@plane/shared-state";
import type { IFilterInstance } from "@plane/shared-state";
import type { TWorkItemFilterExpression, TWorkItemFilterProperty } from "@plane/types";
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
import { useExternalContoursFiltersConfig } from "./use-external-contours-filters-config";
import {
buildExternalContourBoardFilters,
buildExternalContourRichFilterExpression,
} from "./utils";
const ExternalContoursFilterContext = createContext<
IFilterInstance<TWorkItemFilterProperty, TWorkItemFilterExpression> | undefined
>(undefined);
type Props = {
children: ReactNode;
projectId: string;
workspaceSlug: string;
};
export const ProjectExternalContoursFiltersProvider = observer(function ProjectExternalContoursFiltersProvider(props: Props) {
const { children, projectId, workspaceSlug } = props;
const { filters, items, replaceFilters } = useProjectExternalContoursBoard();
const requests = useMemo(() => Object.values(items), [items]);
const { areAllConfigsInitialized, configs } = useExternalContoursFiltersConfig({
projectId,
requests,
workspaceSlug,
});
const filter = useMemo(
() =>
new FilterInstance<TWorkItemFilterProperty, TWorkItemFilterExpression>({
adapter: workItemFiltersAdapter,
initialExpression: buildExternalContourRichFilterExpression(filters),
onExpressionChange: (expression) => {
void replaceFilters(workspaceSlug, projectId, buildExternalContourBoardFilters(expression));
},
}),
[projectId, workspaceSlug]
);
useEffect(() => {
filter.onExpressionChange = (expression) => {
void replaceFilters(workspaceSlug, projectId, buildExternalContourBoardFilters(expression));
};
}, [filter, projectId, replaceFilters, workspaceSlug]);
useEffect(() => {
filter.configManager.setAreConfigsReady(areAllConfigsInitialized);
filter.configManager.registerAll(configs);
}, [areAllConfigsInitialized, configs, filter.configManager]);
useEffect(() => {
const nextExpression = buildExternalContourRichFilterExpression(filters);
const currentExpression = workItemFiltersAdapter.toExternal(filter.expression);
if (isEqual(currentExpression, nextExpression)) return;
const onExpressionChange = filter.onExpressionChange;
filter.onExpressionChange = undefined;
filter.resetExpression(nextExpression);
filter.onExpressionChange = onExpressionChange;
}, [filter, filters]);
return <ExternalContoursFilterContext.Provider value={filter}>{children}</ExternalContoursFilterContext.Provider>;
});
export const useExternalContoursFilter = () => useContext(ExternalContoursFilterContext);

View File

@ -0,0 +1,273 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo } from "react";
import { Briefcase } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { Logo } from "@plane/propel/emoji-icon-picker";
import {
DueDatePropertyIcon,
LabelPropertyIcon,
MembersPropertyIcon,
PriorityIcon,
PriorityPropertyIcon,
StateGroupIcon,
StatePropertyIcon,
UserCirclePropertyIcon,
} from "@plane/propel/icons";
import type {
IProject,
IState,
IIssueLabel,
IUserLite,
TExternalContourRequest,
TFilterConfig,
TWorkItemFilterProperty,
} from "@plane/types";
import { Avatar } from "@plane/ui";
import {
getAssigneeFilterConfig,
getCreatedByFilterConfig,
getFileURL,
getLabelFilterConfig,
getPriorityFilterConfig,
getProjectFilterConfig,
getStateFilterConfig,
getStateGroupFilterConfig,
getTargetDateFilterConfig,
} from "@plane/utils";
import { useFiltersOperatorConfigs } from "@/plane-web/hooks/rich-filters/use-filters-operator-configs";
const sortByName = <T extends { name?: string | null; display_name?: string | null }>(items: T[]) =>
[...items].sort((left, right) =>
(left.display_name || left.name || "").localeCompare(right.display_name || right.name || "", "ru")
);
const buildCounterpartyProjects = (requests: TExternalContourRequest[], projectId: string): IProject[] => {
const projectMap = new Map<string, IProject>();
requests.forEach((request) => {
const project =
request.direction === "incoming"
? request.source_project
: request.target_project || request.issue.project_detail || null;
if (!project?.id || project.id === projectId || projectMap.has(project.id)) return;
projectMap.set(
project.id,
{
id: project.id,
name: project.name,
logo_props: project.logo_props,
} as IProject
);
});
return sortByName(Array.from(projectMap.values()));
};
const buildStates = (requests: TExternalContourRequest[]): IState[] => {
const stateMap = new Map<string, IState>();
requests.forEach((request, index) => {
const state = request.issue.state_detail;
if (!state?.id || stateMap.has(state.id)) return;
stateMap.set(
state.id,
{
id: state.id,
color: state.color,
default: false,
description: "",
group: state.group,
name: state.name,
order: index + 1,
project_id: request.issue.project_id || request.target_project?.id || request.source_project?.id || "",
sequence: index + 1,
workspace_id: "",
} as IState
);
});
return sortByName(Array.from(stateMap.values()));
};
const buildLabels = (requests: TExternalContourRequest[]): IIssueLabel[] => {
const labelMap = new Map<string, IIssueLabel>();
requests.forEach((request) => {
request.issue.label_details?.forEach((label) => {
if (!label.id || labelMap.has(label.id)) return;
labelMap.set(
label.id,
{
id: label.id,
color: label.color,
name: label.name,
parent: null,
project_id: request.issue.project_id || "",
sort_order: 0,
workspace_id: "",
} as IIssueLabel
);
});
});
return sortByName(Array.from(labelMap.values()));
};
const buildAssignees = (requests: TExternalContourRequest[]): IUserLite[] => {
const memberMap = new Map<string, IUserLite>();
requests.forEach((request) => {
request.issue.assignee_details?.forEach((assignee) => {
if (!assignee.id || memberMap.has(assignee.id)) return;
memberMap.set(
assignee.id,
{
id: assignee.id,
avatar_url: assignee.avatar_url,
display_name: assignee.display_name,
} as IUserLite
);
});
});
return sortByName(Array.from(memberMap.values()));
};
const buildRequesters = (requests: TExternalContourRequest[]): IUserLite[] => {
const memberMap = new Map<string, IUserLite>();
requests.forEach((request) => {
const requesterId = request.requested_by?.id || request.requested_by_id || request.issue.created_by_detail?.id;
const requesterName =
request.requested_by?.display_name || request.requested_by_name || request.issue.created_by_detail?.display_name;
if (!requesterId || !requesterName || memberMap.has(requesterId)) return;
memberMap.set(
requesterId,
{
id: requesterId,
avatar_url: request.issue.created_by_detail?.avatar_url,
display_name: requesterName,
} as IUserLite
);
});
return sortByName(Array.from(memberMap.values()));
};
type TUseExternalContoursFiltersConfigProps = {
projectId: string;
requests: TExternalContourRequest[];
workspaceSlug: string;
};
export type TExternalContoursFiltersConfig = {
areAllConfigsInitialized: boolean;
configs: TFilterConfig<TWorkItemFilterProperty>[];
};
export const useExternalContoursFiltersConfig = (
props: TUseExternalContoursFiltersConfigProps
): TExternalContoursFiltersConfig => {
const { projectId, requests, workspaceSlug } = props;
const { t } = useTranslation();
const operatorConfigs = useFiltersOperatorConfigs({ workspaceSlug });
const counterpartyProjects = useMemo(() => buildCounterpartyProjects(requests, projectId), [projectId, requests]);
const states = useMemo(() => buildStates(requests), [requests]);
const labels = useMemo(() => buildLabels(requests), [requests]);
const assignees = useMemo(() => buildAssignees(requests), [requests]);
const requesters = useMemo(() => buildRequesters(requests), [requests]);
const configs = useMemo(
() => [
getProjectFilterConfig<TWorkItemFilterProperty>("project_id")({
isEnabled: true,
filterLabel: "Contour",
filterIcon: Briefcase,
projects: counterpartyProjects,
getOptionIcon: (project) => <Logo logo={project.logo_props} size={12} />,
...operatorConfigs,
}),
getStateGroupFilterConfig<TWorkItemFilterProperty>("state_group")({
isEnabled: true,
filterIcon: StatePropertyIcon,
getItemLabel: (stateGroupKey) => t(`workspace_projects.state.${stateGroupKey}`),
getOptionIcon: (stateGroupKey) => <StateGroupIcon stateGroup={stateGroupKey} />,
...operatorConfigs,
}),
getStateFilterConfig<TWorkItemFilterProperty>("state_id")({
isEnabled: true,
filterIcon: StatePropertyIcon,
getOptionIcon: (state) => <StateGroupIcon stateGroup={state.group} color={state.color} />,
states,
...operatorConfigs,
}),
getPriorityFilterConfig<TWorkItemFilterProperty>("priority")({
isEnabled: true,
filterLabel: t("common.priority"),
filterIcon: PriorityPropertyIcon,
getItemLabel: (priorityKey) => (priorityKey === "none" ? t("common.none") : t(priorityKey)),
getOptionIcon: (priority) => <PriorityIcon priority={priority} />,
...operatorConfigs,
}),
getAssigneeFilterConfig<TWorkItemFilterProperty>("assignee_id")({
isEnabled: true,
filterIcon: MembersPropertyIcon,
members: assignees,
getOptionIcon: (memberDetails) => (
<Avatar
name={memberDetails.display_name}
src={getFileURL(memberDetails.avatar_url)}
showTooltip={false}
size="sm"
/>
),
...operatorConfigs,
}),
getCreatedByFilterConfig<TWorkItemFilterProperty>("created_by_id")({
isEnabled: true,
filterIcon: UserCirclePropertyIcon,
members: requesters,
getOptionIcon: (memberDetails) => (
<Avatar
name={memberDetails.display_name}
src={getFileURL(memberDetails.avatar_url)}
showTooltip={false}
size="sm"
/>
),
...operatorConfigs,
}),
getLabelFilterConfig<TWorkItemFilterProperty>("label_id")({
isEnabled: true,
filterIcon: LabelPropertyIcon,
labels,
getOptionIcon: (color) => (
<span className="flex size-2.5 flex-shrink-0 rounded-full" style={{ backgroundColor: color }} />
),
...operatorConfigs,
}),
getTargetDateFilterConfig<TWorkItemFilterProperty>("target_date")({
isEnabled: true,
filterIcon: DueDatePropertyIcon,
...operatorConfigs,
}),
],
[assignees, counterpartyProjects, labels, operatorConfigs, requesters, states, t]
);
return {
areAllConfigsInitialized: true,
configs,
};
};

View File

@ -0,0 +1,170 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import {
buildWorkItemFilterExpressionFromConditions,
workItemFiltersAdapter,
} from "@plane/shared-state";
import type { TWorkItemFilterCondition } from "@plane/shared-state";
import type {
TExternalContourBoardFilter,
TFilterValue,
TWorkItemFilterExpression,
TWorkItemFilterProperty,
} from "@plane/types";
import { COLLECTION_OPERATOR, COMPARISON_OPERATOR, EQUALITY_OPERATOR } from "@plane/types";
import { extractConditionsWithDisplayOperators, renderFormattedPayloadDate } from "@plane/utils";
export const EXTERNAL_CONTOUR_FILTER_PROPERTIES = [
"project_id",
"state_group",
"state_id",
"priority",
"assignee_id",
"created_by_id",
"label_id",
"target_date",
] as const satisfies TWorkItemFilterProperty[];
const toStringArray = (value: TFilterValue | TFilterValue[] | undefined): string[] => {
if (value === undefined || value === null || value === "") return [];
return (Array.isArray(value) ? value : [value]).map((item) => String(item)).filter(Boolean);
};
const toPayloadDate = (value: TFilterValue | undefined): string | undefined => {
if (value === undefined || value === null || value === "") return undefined;
return renderFormattedPayloadDate(value instanceof Date ? value : String(value)) ?? undefined;
};
export const buildExternalContourRichFilterExpression = (
filters: Partial<TExternalContourBoardFilter>
): TWorkItemFilterExpression => {
const conditions: TWorkItemFilterCondition[] = [];
if (filters.counterparty_project_ids?.length) {
conditions.push({
property: "project_id",
operator: COLLECTION_OPERATOR.IN,
value: filters.counterparty_project_ids,
});
}
if (filters.state_groups?.length) {
conditions.push({
property: "state_group",
operator: COLLECTION_OPERATOR.IN,
value: filters.state_groups,
});
}
if (filters.state_ids?.length) {
conditions.push({
property: "state_id",
operator: COLLECTION_OPERATOR.IN,
value: filters.state_ids,
});
}
if (filters.priority?.length) {
conditions.push({
property: "priority",
operator: COLLECTION_OPERATOR.IN,
value: filters.priority,
});
}
if (filters.assignee_ids?.length) {
conditions.push({
property: "assignee_id",
operator: COLLECTION_OPERATOR.IN,
value: filters.assignee_ids,
});
}
const authorIds = filters.requested_by_ids?.length ? filters.requested_by_ids : filters.created_by_ids;
if (authorIds?.length) {
conditions.push({
property: "created_by_id",
operator: COLLECTION_OPERATOR.IN,
value: authorIds,
});
}
if (filters.label_ids?.length) {
conditions.push({
property: "label_id",
operator: COLLECTION_OPERATOR.IN,
value: filters.label_ids,
});
}
if (filters.target_date_exact) {
conditions.push({
property: "target_date",
operator: EQUALITY_OPERATOR.EXACT,
value: filters.target_date_exact,
});
} else if (filters.target_date_from && filters.target_date_to) {
conditions.push({
property: "target_date",
operator: COMPARISON_OPERATOR.RANGE,
value: [filters.target_date_from, filters.target_date_to],
});
}
return buildWorkItemFilterExpressionFromConditions({ conditions }) ?? {};
};
export const buildExternalContourBoardFilters = (
expression: TWorkItemFilterExpression
): Partial<TExternalContourBoardFilter> => {
const internalExpression = workItemFiltersAdapter.toInternal(expression);
if (!internalExpression) return {};
const filters: Partial<TExternalContourBoardFilter> = {};
const conditions = extractConditionsWithDisplayOperators(internalExpression);
conditions.forEach((condition) => {
switch (condition.property) {
case "project_id":
filters.counterparty_project_ids = toStringArray(condition.value);
break;
case "state_group":
filters.state_groups = toStringArray(condition.value);
break;
case "state_id":
filters.state_ids = toStringArray(condition.value);
break;
case "priority":
filters.priority = toStringArray(condition.value);
break;
case "assignee_id":
filters.assignee_ids = toStringArray(condition.value);
break;
case "created_by_id":
filters.requested_by_ids = toStringArray(condition.value);
break;
case "label_id":
filters.label_ids = toStringArray(condition.value);
break;
case "target_date":
if (condition.operator === COMPARISON_OPERATOR.RANGE) {
const [from, to] = toStringArray(condition.value);
filters.target_date_from = toPayloadDate(from);
filters.target_date_to = toPayloadDate(to);
} else {
filters.target_date_exact = toPayloadDate(
Array.isArray(condition.value) ? condition.value[0] : condition.value
);
}
break;
default:
break;
}
});
return filters;
};

View File

@ -14,10 +14,12 @@ import { TransferIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui"; import { Breadcrumbs, Header } from "@plane/ui";
import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button"; import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button";
import { FiltersToggle } from "@/components/rich-filters/filters-toggle";
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours"; import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useUserPermissions } from "@/hooks/store/user"; import { useUserPermissions } from "@/hooks/store/user";
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { useExternalContoursFilter } from "./filters/provider";
import { ExternalContourCreateModalRoot } from "./create-modal"; import { ExternalContourCreateModalRoot } from "./create-modal";
export const ProjectExternalContoursHeader = observer(function ProjectExternalContoursHeader() { export const ProjectExternalContoursHeader = observer(function ProjectExternalContoursHeader() {
@ -27,6 +29,7 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
const { allowPermissions } = useUserPermissions(); const { allowPermissions } = useUserPermissions();
const { loader: currentProjectDetailsLoader } = useProject(); const { loader: currentProjectDetailsLoader } = useProject();
const { loader } = useProjectExternalContours(); const { loader } = useProjectExternalContours();
const filter = useExternalContoursFilter();
const isAuthorized = allowPermissions( const isAuthorized = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
@ -63,6 +66,9 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
<Header.RightItem> <Header.RightItem>
{workspaceSlug && projectId && isAuthorized ? ( {workspaceSlug && projectId && isAuthorized ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="nodedc-top-toolbar-cluster flex items-center gap-2">
<FiltersToggle filter={filter} showAddFilterButtonWhenEmpty={false} />
</div>
<ExternalContourCreateModalRoot <ExternalContourCreateModalRoot
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()} projectId={projectId.toString()}

View File

@ -9,10 +9,12 @@ import { observer } from "mobx-react";
import { TransferIcon } from "@plane/propel/icons"; import { TransferIcon } from "@plane/propel/icons";
import type { TInboxIssueCurrentTab } from "@plane/types"; import type { TInboxIssueCurrentTab } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types"; import { EInboxIssueCurrentTab } from "@plane/types";
import { FiltersRow } from "@/components/rich-filters/filters-row";
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board"; import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours"; import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { ExternalContoursBoardRoot } from "./board-root"; import { ExternalContoursBoardRoot } from "./board-root";
import { ExternalContoursContentRoot } from "./content-root"; import { ExternalContoursContentRoot } from "./content-root";
import { useExternalContoursFilter } from "./filters/provider";
type TExternalContoursRoot = { type TExternalContoursRoot = {
workspaceSlug: string; workspaceSlug: string;
@ -31,6 +33,7 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
fetchBoard, fetchBoard,
loader: boardLoader, loader: boardLoader,
} = useProjectExternalContoursBoard(); } = useProjectExternalContoursBoard();
const filter = useExternalContoursFilter();
useEffect(() => { useEffect(() => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
@ -84,7 +87,10 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
} }
return ( return (
<div className="flex h-full w-full overflow-hidden bg-surface-1 pt-2"> <div className="flex h-full w-full flex-col overflow-hidden bg-surface-1">
{filter && <FiltersRow filter={filter} />}
<div className="flex min-h-0 flex-1 overflow-hidden pt-2">
<ExternalContoursBoardRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} /> <ExternalContoursBoardRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
{inboxIssueId && ( {inboxIssueId && (
<ExternalContoursContentRoot <ExternalContoursContentRoot
@ -94,5 +100,6 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
/> />
)} )}
</div> </div>
</div>
); );
}); });

View File

@ -16,6 +16,7 @@ import { AddFilterButton } from "@/components/rich-filters/add-filters/button";
type TFiltersToggleProps<P extends TFilterProperty, E extends TExternalFilter> = { type TFiltersToggleProps<P extends TFilterProperty, E extends TExternalFilter> = {
filter: IFilterInstance<P, E> | undefined; filter: IFilterInstance<P, E> | undefined;
showAddFilterButtonWhenEmpty?: boolean;
}; };
const COMMON_CLASSNAME = const COMMON_CLASSNAME =
@ -24,13 +25,13 @@ const COMMON_CLASSNAME =
export const FiltersToggle = observer(function FiltersToggle<P extends TFilterProperty, E extends TExternalFilter>( export const FiltersToggle = observer(function FiltersToggle<P extends TFilterProperty, E extends TExternalFilter>(
props: TFiltersToggleProps<P, E> props: TFiltersToggleProps<P, E>
) { ) {
const { filter } = props; const { filter, showAddFilterButtonWhenEmpty = true } = props;
// derived values // derived values
const hasAnyConditions = (filter?.allConditionsForDisplay.length ?? 0) > 0; const hasAnyConditions = (filter?.allConditionsForDisplay.length ?? 0) > 0;
const isFilterRowVisible = filter?.isVisible ?? false; const isFilterRowVisible = filter?.isVisible ?? false;
const hasUpdates = filter?.canUpdateView === true && filter?.hasChanges === true; const hasUpdates = filter?.canUpdateView === true && filter?.hasChanges === true;
const showFilterRowChangesPill = hasUpdates || hasAnyConditions === true; const showFilterRowChangesPill = hasUpdates || hasAnyConditions === true;
const showAddFilterButton = !hasAnyConditions && !isFilterRowVisible && !hasUpdates; const showAddFilterButton = showAddFilterButtonWhenEmpty && !hasAnyConditions && !isFilterRowVisible && !hasUpdates;
const handleToggleFilter = () => { const handleToggleFilter = () => {
if (!filter) { if (!filter) {

View File

@ -9,6 +9,8 @@ const FILTER_LABEL_MAP: Record<string, string> = {
Label: "Метка", Label: "Метка",
Labels: "Метки", Labels: "Метки",
Project: "Проект", Project: "Проект",
Projects: "Проекты",
Contour: "Контур",
"Created by": "Автор", "Created by": "Автор",
"Created at": "Дата создания", "Created at": "Дата создания",
"Updated at": "Дата обновления", "Updated at": "Дата обновления",

View File

@ -24,5 +24,5 @@ export const WorkItemFiltersToggle = observer(function WorkItemFiltersToggle(pro
// derived values // derived values
const filter = getFilter(entityType, entityId); const filter = getFilter(entityType, entityId);
return <FiltersToggle filter={filter} />; return <FiltersToggle filter={filter} showAddFilterButtonWhenEmpty={false} />;
}); });

View File

@ -50,6 +50,7 @@ export interface IProjectExternalContoursBoardStore {
isFiltering: boolean; isFiltering: boolean;
isSortingDefault: boolean; isSortingDefault: boolean;
clearFilters: (workspaceSlug: string, projectId: string) => Promise<void>; clearFilters: (workspaceSlug: string, projectId: string) => Promise<void>;
replaceFilters: (workspaceSlug: string, projectId: string, filters: Partial<TExternalContourBoardFilter>) => Promise<void>;
updateFilters: (workspaceSlug: string, projectId: string, filters: Partial<TExternalContourBoardFilter>) => Promise<void>; updateFilters: (workspaceSlug: string, projectId: string, filters: Partial<TExternalContourBoardFilter>) => Promise<void>;
updateSorting: (workspaceSlug: string, projectId: string, sorting: TExternalContourBoardSorting) => Promise<void>; updateSorting: (workspaceSlug: string, projectId: string, sorting: TExternalContourBoardSorting) => Promise<void>;
upsertBoardItems: (items: TExternalContourRequest[]) => void; upsertBoardItems: (items: TExternalContourRequest[]) => void;
@ -99,6 +100,7 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
clearFilters: action, clearFilters: action,
fetchBoard: action, fetchBoard: action,
handleCurrentTab: action, handleCurrentTab: action,
replaceFilters: action,
updateFilters: action, updateFilters: action,
updateSorting: action, updateSorting: action,
upsertBoardItems: action, upsertBoardItems: action,
@ -161,6 +163,15 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
await this.fetchBoard(workspaceSlug, projectId); await this.fetchBoard(workspaceSlug, projectId);
}; };
replaceFilters = async (
workspaceSlug: string,
projectId: string,
filters: Partial<TExternalContourBoardFilter>
) => {
this.filters = sanitizeBoardFilters(filters);
await this.fetchBoard(workspaceSlug, projectId);
};
updateSorting = async (workspaceSlug: string, projectId: string, sorting: TExternalContourBoardSorting) => { updateSorting = async (workspaceSlug: string, projectId: string, sorting: TExternalContourBoardSorting) => {
this.sorting = sorting; this.sorting = sorting;
await this.fetchBoard(workspaceSlug, projectId); await this.fetchBoard(workspaceSlug, projectId);

View File

@ -105,6 +105,7 @@ export type TExternalContourRequestResponse = TPaginationInfo & {
export type TExternalContourBoardFilter = { export type TExternalContourBoardFilter = {
direction?: TExternalContourBoardDirection[]; direction?: TExternalContourBoardDirection[];
status?: TExternalContourRequest["status"] | TExternalContourRequest["status"][]; status?: TExternalContourRequest["status"] | TExternalContourRequest["status"][];
state_groups?: string[];
state_ids?: string[]; state_ids?: string[];
priority?: string[]; priority?: string[];
assignee_ids?: string[]; assignee_ids?: string[];
@ -114,6 +115,9 @@ export type TExternalContourBoardFilter = {
source_project_ids?: string[]; source_project_ids?: string[];
target_project_ids?: string[]; target_project_ids?: string[];
label_ids?: string[]; label_ids?: string[];
target_date_exact?: string;
target_date_from?: string;
target_date_to?: string;
has_unread_updates?: boolean; has_unread_updates?: boolean;
search?: string; search?: string;
}; };

View File

@ -24,7 +24,7 @@ export const getProjectFilterConfig =
(params: TCreateProjectFilterParams) => (params: TCreateProjectFilterParams) =>
createFilterConfig<P>({ createFilterConfig<P>({
id: key, id: key,
label: "Projects", label: params.filterLabel ?? "Projects",
...params, ...params,
icon: params.filterIcon, icon: params.filterIcon,
supportedOperatorConfigsMap: new Map([ supportedOperatorConfigsMap: new Map([

View File

@ -33,6 +33,7 @@ export const getSupportedDateOperators = (params: TCreateDateFilterParams): TOpe
*/ */
export type TCreateProjectFilterParams = TCreateFilterConfigParams & export type TCreateProjectFilterParams = TCreateFilterConfigParams &
IFilterIconConfig<IProject> & { IFilterIconConfig<IProject> & {
filterLabel?: string;
projects: IProject[]; projects: IProject[];
}; };