ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: стабилизация read-only фильтров двусторонней доски внешних контуров

This commit is contained in:
DCCONSTRUCTIONS
2026-04-20 21:51:02 +03:00
parent c880c0a319
commit 6a3adcd245
6 changed files with 529 additions and 33 deletions
@@ -0,0 +1,369 @@
/**
* 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;
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, 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 requests = Object.values(items);
const assigneeOptions = useMemo(() => getAssigneeOptions(requests), [requests]);
const requesterOptions = useMemo(() => getRequesterOptions(requests), [requests]);
const priorityOptions = useMemo(() => getPriorityOptions(requests, t), [requests, t]);
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.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(
"flex h-10 items-center gap-2 rounded-full px-3 text-12 font-medium transition-all",
filters.has_unread_updates
? "bg-accent-primary text-accent-on-primary"
: "bg-white/5 text-secondary hover:bg-white/8 hover:text-primary"
)}
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="!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
className={cn(
"flex h-10 items-center gap-2 rounded-full bg-white/5 px-3 text-12 font-medium text-secondary transition-all hover:bg-white/8 hover:text-primary",
{
"bg-white/8 text-primary": isOpen || count > 0,
}
)}
>
{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 getPriorityOptions = (requests: TExternalContourRequest[], t: ReturnType<typeof useTranslation>["t"]) => {
const priorityKeys = new Set<string>();
requests.forEach((request) => {
if (request.issue.priority && request.issue.priority !== "none") priorityKeys.add(request.issue.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,
}));
};
const getAssigneeOptions = (requests: TExternalContourRequest[]) => {
const assigneeMap = new Map<string, TFilterOption>();
requests.forEach((request) => {
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 || "",
});
});
});
return Array.from(assigneeMap.values())
.sort((left, right) => left.label.localeCompare(right.label))
.map((option) => ({ data: option, value: option.id }));
};
const getRequesterOptions = (requests: TExternalContourRequest[]) => {
const requesterMap = new Map<string, TFilterOption>();
requests.forEach((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 || requesterMap.has(requesterId)) return;
requesterMap.set(requesterId, {
id: requesterId,
label: requesterLabel,
avatarUrl: request.issue.created_by_detail?.avatar_url || "",
});
});
return Array.from(requesterMap.values())
.sort((left, right) => left.label.localeCompare(right.label))
.map((option) => ({ data: option, value: option.id }));
};
@@ -11,6 +11,7 @@ import { EInboxIssueCurrentTab } from "@plane/types";
import { cn } from "@plane/utils";
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
import { useAppRouter } from "@/hooks/use-app-router";
import { ExternalContoursBoardFiltersRow } from "./board-filters-row";
import { ExternalContoursBoardColumn } from "./board-column";
type Props = {
@@ -27,7 +28,7 @@ export const ExternalContoursBoardRoot = observer(function ExternalContoursBoard
const { projectId, workspaceSlug } = props;
const { t } = useTranslation();
const router = useAppRouter();
const { currentTab, loader, tabCountMap, handleCurrentTab } = useProjectExternalContoursBoard();
const { currentTab, hasAnyItems, isFiltering, loader, tabCountMap, handleCurrentTab } = useProjectExternalContoursBoard();
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden px-8 pb-6">
@@ -62,22 +63,34 @@ export const ExternalContoursBoardRoot = observer(function ExternalContoursBoard
</div>
</div>
{loader === "init-loading" ? (
<div className="shrink-0 pb-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>
) : (
<div className="grid min-h-0 flex-1 grid-cols-1 gap-5 xl:grid-cols-2">
<ExternalContoursBoardColumn
currentTab={currentTab}
direction="outgoing"
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
<ExternalContoursBoardColumn
currentTab={currentTab}
direction="incoming"
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
<div className="relative min-h-0 flex-1">
{isFiltering && (
<div className="pointer-events-none absolute top-0 right-0 z-10 rounded-full bg-black/35 px-3 py-1 text-11 font-medium text-secondary backdrop-blur-sm">
{t("updating")}...
</div>
)}
<div className={cn("grid min-h-0 h-full grid-cols-1 gap-5 transition-opacity xl:grid-cols-2", { "opacity-60": isFiltering })}>
<ExternalContoursBoardColumn
currentTab={currentTab}
direction="outgoing"
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
<ExternalContoursBoardColumn
currentTab={currentTab}
direction="incoming"
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
</div>
</div>
)}
</div>