ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: унификация фильтров внешнего контура с внутренним rich-filters слоем
This commit is contained in:
parent
2c54a8f274
commit
0a85ea3cb2
|
|
@ -323,6 +323,7 @@ class ExternalContourReadMixin:
|
|||
for key in [
|
||||
"direction",
|
||||
"status",
|
||||
"state_groups",
|
||||
"state_ids",
|
||||
"priority",
|
||||
"assignee_ids",
|
||||
|
|
@ -337,6 +338,9 @@ class ExternalContourReadMixin:
|
|||
|
||||
filters["has_unread_updates"] = self.parse_bool_param(request, "has_unread_updates")
|
||||
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
|
||||
|
||||
def get_sorting(self, request):
|
||||
|
|
@ -392,6 +396,8 @@ class ExternalContourReadMixin:
|
|||
|
||||
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"]:
|
||||
queryset = queryset.filter(issue__state_id__in=filters["state_ids"])
|
||||
if filters["priority"]:
|
||||
|
|
@ -419,6 +425,12 @@ class ExternalContourReadMixin:
|
|||
)
|
||||
if 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:
|
||||
unread_request_ids = self.get_unread_request_ids(request.user)
|
||||
if filters["has_unread_updates"]:
|
||||
|
|
|
|||
|
|
@ -5,17 +5,23 @@
|
|||
*/
|
||||
|
||||
import { Outlet } from "react-router";
|
||||
import { useParams } from "react-router";
|
||||
import { AppHeader } from "@/components/core/app-header";
|
||||
import { ContentWrapper } from "@/components/core/content-wrapper";
|
||||
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() {
|
||||
const { projectId, workspaceSlug } = useParams();
|
||||
|
||||
if (!projectId || !workspaceSlug) return <Outlet />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectExternalContoursFiltersProvider projectId={projectId} workspaceSlug={workspaceSlug}>
|
||||
<AppHeader header={<ProjectExternalContoursHeader />} />
|
||||
<ContentWrapper>
|
||||
<Outlet />
|
||||
</ContentWrapper>
|
||||
</>
|
||||
</ProjectExternalContoursFiltersProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 || "",
|
||||
};
|
||||
});
|
||||
|
|
@ -8,7 +8,6 @@ import { observer } from "mobx-react";
|
|||
import { useTranslation } from "@plane/i18n";
|
||||
import { cn } from "@plane/utils";
|
||||
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
|
||||
import { ExternalContoursBoardFiltersRow } from "./board-filters-row";
|
||||
import { ExternalContoursBoardColumn } from "./board-column";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -23,10 +22,6 @@ export const ExternalContoursBoardRoot = observer(function ExternalContoursBoard
|
|||
|
||||
return (
|
||||
<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 ? (
|
||||
<div className="flex flex-1 items-center justify-center text-13 text-secondary">{t("loading")}...</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -14,10 +14,12 @@ import { TransferIcon } from "@plane/propel/icons";
|
|||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
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 { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
import { useExternalContoursFilter } from "./filters/provider";
|
||||
import { ExternalContourCreateModalRoot } from "./create-modal";
|
||||
|
||||
export const ProjectExternalContoursHeader = observer(function ProjectExternalContoursHeader() {
|
||||
|
|
@ -27,6 +29,7 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
|
|||
const { allowPermissions } = useUserPermissions();
|
||||
const { loader: currentProjectDetailsLoader } = useProject();
|
||||
const { loader } = useProjectExternalContours();
|
||||
const filter = useExternalContoursFilter();
|
||||
|
||||
const isAuthorized = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
|
|
@ -63,6 +66,9 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
|
|||
<Header.RightItem>
|
||||
{workspaceSlug && projectId && isAuthorized ? (
|
||||
<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
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ import { observer } from "mobx-react";
|
|||
import { TransferIcon } from "@plane/propel/icons";
|
||||
import type { TInboxIssueCurrentTab } 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 { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
import { ExternalContoursBoardRoot } from "./board-root";
|
||||
import { ExternalContoursContentRoot } from "./content-root";
|
||||
import { useExternalContoursFilter } from "./filters/provider";
|
||||
|
||||
type TExternalContoursRoot = {
|
||||
workspaceSlug: string;
|
||||
|
|
@ -31,6 +33,7 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
|
|||
fetchBoard,
|
||||
loader: boardLoader,
|
||||
} = useProjectExternalContoursBoard();
|
||||
const filter = useExternalContoursFilter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
|
@ -84,15 +87,19 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full overflow-hidden bg-surface-1 pt-2">
|
||||
<ExternalContoursBoardRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
|
||||
{inboxIssueId && (
|
||||
<ExternalContoursContentRoot
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
inboxIssueId={inboxIssueId.toString()}
|
||||
/>
|
||||
)}
|
||||
<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()} />
|
||||
{inboxIssueId && (
|
||||
<ExternalContoursContentRoot
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
inboxIssueId={inboxIssueId.toString()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { AddFilterButton } from "@/components/rich-filters/add-filters/button";
|
|||
|
||||
type TFiltersToggleProps<P extends TFilterProperty, E extends TExternalFilter> = {
|
||||
filter: IFilterInstance<P, E> | undefined;
|
||||
showAddFilterButtonWhenEmpty?: boolean;
|
||||
};
|
||||
|
||||
const COMMON_CLASSNAME =
|
||||
|
|
@ -24,13 +25,13 @@ const COMMON_CLASSNAME =
|
|||
export const FiltersToggle = observer(function FiltersToggle<P extends TFilterProperty, E extends TExternalFilter>(
|
||||
props: TFiltersToggleProps<P, E>
|
||||
) {
|
||||
const { filter } = props;
|
||||
const { filter, showAddFilterButtonWhenEmpty = true } = props;
|
||||
// derived values
|
||||
const hasAnyConditions = (filter?.allConditionsForDisplay.length ?? 0) > 0;
|
||||
const isFilterRowVisible = filter?.isVisible ?? false;
|
||||
const hasUpdates = filter?.canUpdateView === true && filter?.hasChanges === true;
|
||||
const showFilterRowChangesPill = hasUpdates || hasAnyConditions === true;
|
||||
const showAddFilterButton = !hasAnyConditions && !isFilterRowVisible && !hasUpdates;
|
||||
const showAddFilterButton = showAddFilterButtonWhenEmpty && !hasAnyConditions && !isFilterRowVisible && !hasUpdates;
|
||||
|
||||
const handleToggleFilter = () => {
|
||||
if (!filter) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ const FILTER_LABEL_MAP: Record<string, string> = {
|
|||
Label: "Метка",
|
||||
Labels: "Метки",
|
||||
Project: "Проект",
|
||||
Projects: "Проекты",
|
||||
Contour: "Контур",
|
||||
"Created by": "Автор",
|
||||
"Created at": "Дата создания",
|
||||
"Updated at": "Дата обновления",
|
||||
|
|
|
|||
|
|
@ -24,5 +24,5 @@ export const WorkItemFiltersToggle = observer(function WorkItemFiltersToggle(pro
|
|||
// derived values
|
||||
const filter = getFilter(entityType, entityId);
|
||||
|
||||
return <FiltersToggle filter={filter} />;
|
||||
return <FiltersToggle filter={filter} showAddFilterButtonWhenEmpty={false} />;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export interface IProjectExternalContoursBoardStore {
|
|||
isFiltering: boolean;
|
||||
isSortingDefault: boolean;
|
||||
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>;
|
||||
updateSorting: (workspaceSlug: string, projectId: string, sorting: TExternalContourBoardSorting) => Promise<void>;
|
||||
upsertBoardItems: (items: TExternalContourRequest[]) => void;
|
||||
|
|
@ -99,6 +100,7 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
|
|||
clearFilters: action,
|
||||
fetchBoard: action,
|
||||
handleCurrentTab: action,
|
||||
replaceFilters: action,
|
||||
updateFilters: action,
|
||||
updateSorting: action,
|
||||
upsertBoardItems: action,
|
||||
|
|
@ -161,6 +163,15 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
|
|||
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) => {
|
||||
this.sorting = sorting;
|
||||
await this.fetchBoard(workspaceSlug, projectId);
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ export type TExternalContourRequestResponse = TPaginationInfo & {
|
|||
export type TExternalContourBoardFilter = {
|
||||
direction?: TExternalContourBoardDirection[];
|
||||
status?: TExternalContourRequest["status"] | TExternalContourRequest["status"][];
|
||||
state_groups?: string[];
|
||||
state_ids?: string[];
|
||||
priority?: string[];
|
||||
assignee_ids?: string[];
|
||||
|
|
@ -114,6 +115,9 @@ export type TExternalContourBoardFilter = {
|
|||
source_project_ids?: string[];
|
||||
target_project_ids?: string[];
|
||||
label_ids?: string[];
|
||||
target_date_exact?: string;
|
||||
target_date_from?: string;
|
||||
target_date_to?: string;
|
||||
has_unread_updates?: boolean;
|
||||
search?: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export const getProjectFilterConfig =
|
|||
(params: TCreateProjectFilterParams) =>
|
||||
createFilterConfig<P>({
|
||||
id: key,
|
||||
label: "Projects",
|
||||
label: params.filterLabel ?? "Projects",
|
||||
...params,
|
||||
icon: params.filterIcon,
|
||||
supportedOperatorConfigsMap: new Map([
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export const getSupportedDateOperators = (params: TCreateDateFilterParams): TOpe
|
|||
*/
|
||||
export type TCreateProjectFilterParams = TCreateFilterConfigParams &
|
||||
IFilterIconConfig<IProject> & {
|
||||
filterLabel?: string;
|
||||
projects: IProject[];
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue