ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: стабилизация read-only фильтров двусторонней доски внешних контуров
This commit is contained in:
parent
c880c0a319
commit
6a3adcd245
|
|
@ -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 { 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 { useAppRouter } from "@/hooks/use-app-router";
|
import { useAppRouter } from "@/hooks/use-app-router";
|
||||||
|
import { ExternalContoursBoardFiltersRow } from "./board-filters-row";
|
||||||
import { ExternalContoursBoardColumn } from "./board-column";
|
import { ExternalContoursBoardColumn } from "./board-column";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -27,7 +28,7 @@ export const ExternalContoursBoardRoot = observer(function ExternalContoursBoard
|
||||||
const { projectId, workspaceSlug } = props;
|
const { projectId, workspaceSlug } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useAppRouter();
|
const router = useAppRouter();
|
||||||
const { currentTab, loader, tabCountMap, handleCurrentTab } = useProjectExternalContoursBoard();
|
const { currentTab, hasAnyItems, isFiltering, loader, tabCountMap, handleCurrentTab } = useProjectExternalContoursBoard();
|
||||||
|
|
||||||
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">
|
||||||
|
|
@ -62,22 +63,34 @@ export const ExternalContoursBoardRoot = observer(function ExternalContoursBoard
|
||||||
</div>
|
</div>
|
||||||
</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="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">
|
<div className="relative min-h-0 flex-1">
|
||||||
<ExternalContoursBoardColumn
|
{isFiltering && (
|
||||||
currentTab={currentTab}
|
<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">
|
||||||
direction="outgoing"
|
{t("updating")}...
|
||||||
projectId={projectId}
|
</div>
|
||||||
workspaceSlug={workspaceSlug}
|
)}
|
||||||
/>
|
|
||||||
<ExternalContoursBoardColumn
|
<div className={cn("grid min-h-0 h-full grid-cols-1 gap-5 transition-opacity xl:grid-cols-2", { "opacity-60": isFiltering })}>
|
||||||
currentTab={currentTab}
|
<ExternalContoursBoardColumn
|
||||||
direction="incoming"
|
currentTab={currentTab}
|
||||||
projectId={projectId}
|
direction="outgoing"
|
||||||
workspaceSlug={workspaceSlug}
|
projectId={projectId}
|
||||||
/>
|
workspaceSlug={workspaceSlug}
|
||||||
|
/>
|
||||||
|
<ExternalContoursBoardColumn
|
||||||
|
currentTab={currentTab}
|
||||||
|
direction="incoming"
|
||||||
|
projectId={projectId}
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { API_BASE_URL } from "@plane/constants";
|
||||||
import type {
|
import type {
|
||||||
TExternalContourBoardFilter,
|
TExternalContourBoardFilter,
|
||||||
TExternalContourBoardResponse,
|
TExternalContourBoardResponse,
|
||||||
|
TExternalContourBoardSorting,
|
||||||
TExternalContourRequest,
|
TExternalContourRequest,
|
||||||
TExternalContourRequestResponse,
|
TExternalContourRequestResponse,
|
||||||
TExternalContourTargetOptions,
|
TExternalContourTargetOptions,
|
||||||
|
|
@ -32,10 +33,11 @@ export class ExternalContourService extends APIService {
|
||||||
async listBoard(
|
async listBoard(
|
||||||
workspaceSlug: string,
|
workspaceSlug: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
filters: Partial<TExternalContourBoardFilter> = {}
|
filters: Partial<TExternalContourBoardFilter> = {},
|
||||||
|
sorting: TExternalContourBoardSorting = {}
|
||||||
): Promise<TExternalContourBoardResponse> {
|
): Promise<TExternalContourBoardResponse> {
|
||||||
const params = Object.fromEntries(
|
const params = Object.fromEntries(
|
||||||
Object.entries(filters).flatMap(([key, value]) => {
|
Object.entries({ ...filters, ...sorting }).flatMap(([key, value]) => {
|
||||||
if (value === undefined || value === null || value === "") return [];
|
if (value === undefined || value === null || value === "") return [];
|
||||||
if (Array.isArray(value)) return [[key, value.join(",")]];
|
if (Array.isArray(value)) return [[key, value.join(",")]];
|
||||||
return [[key, String(value)]];
|
return [[key, String(value)]];
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,18 @@ import { EInboxIssueCurrentTab } from "@plane/types";
|
||||||
import { ExternalContourService } from "@/services/external-contours";
|
import { ExternalContourService } from "@/services/external-contours";
|
||||||
import type { CoreRootStore } from "../root.store";
|
import type { CoreRootStore } from "../root.store";
|
||||||
|
|
||||||
type TLoader = "init-loading" | undefined;
|
type TLoader = "init-loading" | "loading" | undefined;
|
||||||
|
|
||||||
|
const DEFAULT_SORTING: TExternalContourBoardSorting = { order_by: "updated_at", sort_by: "desc" };
|
||||||
|
|
||||||
|
const sanitizeBoardFilters = (filters: Partial<TExternalContourBoardFilter>): Partial<TExternalContourBoardFilter> =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(filters).flatMap(([key, value]) => {
|
||||||
|
if (value === undefined || value === null || value === "") return [];
|
||||||
|
if (Array.isArray(value) && value.length === 0) return [];
|
||||||
|
return [[key, value]];
|
||||||
|
})
|
||||||
|
) as Partial<TExternalContourBoardFilter>;
|
||||||
|
|
||||||
export interface IProjectExternalContoursBoardStore {
|
export interface IProjectExternalContoursBoardStore {
|
||||||
currentProjectId: string;
|
currentProjectId: string;
|
||||||
|
|
@ -29,12 +40,18 @@ export interface IProjectExternalContoursBoardStore {
|
||||||
columnIdsMap: Record<TExternalContourBoardDirection, string[]>;
|
columnIdsMap: Record<TExternalContourBoardDirection, string[]>;
|
||||||
columnCountMap: Record<TExternalContourBoardDirection, number>;
|
columnCountMap: Record<TExternalContourBoardDirection, number>;
|
||||||
tabCountMap: Record<TInboxIssueCurrentTab, number>;
|
tabCountMap: Record<TInboxIssueCurrentTab, number>;
|
||||||
|
activeFiltersCount: number;
|
||||||
fetchBoard: (workspaceSlug: string, projectId: string, tab?: TInboxIssueCurrentTab) => Promise<void>;
|
fetchBoard: (workspaceSlug: string, projectId: string, tab?: TInboxIssueCurrentTab) => Promise<void>;
|
||||||
getColumnRequestIds: (direction: TExternalContourBoardDirection) => string[];
|
getColumnRequestIds: (direction: TExternalContourBoardDirection) => string[];
|
||||||
getColumnTotalCount: (direction: TExternalContourBoardDirection) => number;
|
getColumnTotalCount: (direction: TExternalContourBoardDirection) => number;
|
||||||
getRequestById: (requestId: string) => TExternalContourRequest | undefined;
|
getRequestById: (requestId: string) => TExternalContourRequest | undefined;
|
||||||
handleCurrentTab: (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => Promise<void>;
|
handleCurrentTab: (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => Promise<void>;
|
||||||
hasAnyItems: boolean;
|
hasAnyItems: boolean;
|
||||||
|
isFiltering: boolean;
|
||||||
|
isSortingDefault: boolean;
|
||||||
|
clearFilters: (workspaceSlug: string, projectId: string) => 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;
|
upsertBoardItems: (items: TExternalContourRequest[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,7 +62,7 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
|
||||||
filters: Partial<TExternalContourBoardFilter> = { status: [EInboxIssueCurrentTab.OPEN] };
|
filters: Partial<TExternalContourBoardFilter> = { status: [EInboxIssueCurrentTab.OPEN] };
|
||||||
items: Record<string, TExternalContourRequest> = {};
|
items: Record<string, TExternalContourRequest> = {};
|
||||||
loader: TLoader = "init-loading";
|
loader: TLoader = "init-loading";
|
||||||
sorting: TExternalContourBoardSorting = { order_by: "updated_at", sort_by: "desc" };
|
sorting: TExternalContourBoardSorting = DEFAULT_SORTING;
|
||||||
columnIdsMap: Record<TExternalContourBoardDirection, string[]> = {
|
columnIdsMap: Record<TExternalContourBoardDirection, string[]> = {
|
||||||
outgoing: [],
|
outgoing: [],
|
||||||
incoming: [],
|
incoming: [],
|
||||||
|
|
@ -58,6 +75,8 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
|
||||||
[EInboxIssueCurrentTab.OPEN]: 0,
|
[EInboxIssueCurrentTab.OPEN]: 0,
|
||||||
[EInboxIssueCurrentTab.CLOSED]: 0,
|
[EInboxIssueCurrentTab.CLOSED]: 0,
|
||||||
};
|
};
|
||||||
|
hydratedProjectId = "";
|
||||||
|
lastIssuedRequestId = 0;
|
||||||
|
|
||||||
externalContourService;
|
externalContourService;
|
||||||
|
|
||||||
|
|
@ -73,9 +92,15 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
|
||||||
columnIdsMap: observable,
|
columnIdsMap: observable,
|
||||||
columnCountMap: observable,
|
columnCountMap: observable,
|
||||||
tabCountMap: observable,
|
tabCountMap: observable,
|
||||||
|
activeFiltersCount: computed,
|
||||||
hasAnyItems: computed,
|
hasAnyItems: computed,
|
||||||
|
isFiltering: computed,
|
||||||
|
isSortingDefault: computed,
|
||||||
|
clearFilters: action,
|
||||||
fetchBoard: action,
|
fetchBoard: action,
|
||||||
handleCurrentTab: action,
|
handleCurrentTab: action,
|
||||||
|
updateFilters: action,
|
||||||
|
updateSorting: action,
|
||||||
upsertBoardItems: action,
|
upsertBoardItems: action,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -86,6 +111,24 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
|
||||||
return this.columnIdsMap.outgoing.length > 0 || this.columnIdsMap.incoming.length > 0;
|
return this.columnIdsMap.outgoing.length > 0 || this.columnIdsMap.incoming.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isFiltering() {
|
||||||
|
return this.loader === "loading";
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSortingDefault() {
|
||||||
|
return this.sorting.order_by === DEFAULT_SORTING.order_by && this.sorting.sort_by === DEFAULT_SORTING.sort_by;
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeFiltersCount() {
|
||||||
|
return Object.entries(this.filters).reduce((count, [key, value]) => {
|
||||||
|
if (key === "status") return count;
|
||||||
|
if (value === undefined || value === null || value === "") return count;
|
||||||
|
if (Array.isArray(value)) return count + (value.length > 0 ? 1 : 0);
|
||||||
|
if (typeof value === "boolean") return count + (value ? 1 : 0);
|
||||||
|
return count + 1;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
getRequestById = (requestId: string) => this.items[requestId];
|
getRequestById = (requestId: string) => this.items[requestId];
|
||||||
|
|
||||||
getColumnRequestIds = (direction: TExternalContourBoardDirection) => this.columnIdsMap[direction] ?? [];
|
getColumnRequestIds = (direction: TExternalContourBoardDirection) => this.columnIdsMap[direction] ?? [];
|
||||||
|
|
@ -101,36 +144,75 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
|
||||||
};
|
};
|
||||||
|
|
||||||
handleCurrentTab = async (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => {
|
handleCurrentTab = async (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => {
|
||||||
this.currentProjectId = projectId;
|
|
||||||
this.currentTab = tab;
|
this.currentTab = tab;
|
||||||
this.filters = {
|
this.filters = sanitizeBoardFilters({
|
||||||
...this.filters,
|
...this.filters,
|
||||||
status: [tab],
|
status: [tab],
|
||||||
};
|
});
|
||||||
await this.fetchBoard(workspaceSlug, projectId, tab);
|
await this.fetchBoard(workspaceSlug, projectId, tab);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
updateFilters = async (
|
||||||
|
workspaceSlug: string,
|
||||||
|
projectId: string,
|
||||||
|
filters: Partial<TExternalContourBoardFilter>
|
||||||
|
) => {
|
||||||
|
this.filters = sanitizeBoardFilters({
|
||||||
|
...this.filters,
|
||||||
|
...filters,
|
||||||
|
status: [this.currentTab],
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.fetchBoard(workspaceSlug, projectId, this.currentTab);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateSorting = async (workspaceSlug: string, projectId: string, sorting: TExternalContourBoardSorting) => {
|
||||||
|
this.sorting = sorting;
|
||||||
|
await this.fetchBoard(workspaceSlug, projectId, this.currentTab);
|
||||||
|
};
|
||||||
|
|
||||||
|
clearFilters = async (workspaceSlug: string, projectId: string) => {
|
||||||
|
this.filters = { status: [this.currentTab] };
|
||||||
|
this.sorting = DEFAULT_SORTING;
|
||||||
|
await this.fetchBoard(workspaceSlug, projectId, this.currentTab);
|
||||||
|
};
|
||||||
|
|
||||||
fetchBoard = async (workspaceSlug: string, projectId: string, tab = this.currentTab) => {
|
fetchBoard = async (workspaceSlug: string, projectId: string, tab = this.currentTab) => {
|
||||||
this.loader = "init-loading";
|
const hasProjectChanged = !!this.currentProjectId && this.currentProjectId !== projectId;
|
||||||
|
const isInitialLoad = this.hydratedProjectId !== projectId;
|
||||||
|
const nextFilters = sanitizeBoardFilters({
|
||||||
|
...(hasProjectChanged ? {} : this.filters),
|
||||||
|
status: [tab],
|
||||||
|
});
|
||||||
|
const nextSorting = hasProjectChanged ? DEFAULT_SORTING : this.sorting;
|
||||||
|
const requestId = ++this.lastIssuedRequestId;
|
||||||
|
|
||||||
|
this.loader = isInitialLoad ? "init-loading" : "loading";
|
||||||
this.error = undefined;
|
this.error = undefined;
|
||||||
|
if (hasProjectChanged) {
|
||||||
|
this.items = {};
|
||||||
|
this.columnIdsMap = { outgoing: [], incoming: [] };
|
||||||
|
this.columnCountMap = { outgoing: 0, incoming: 0 };
|
||||||
|
this.tabCountMap = {
|
||||||
|
[EInboxIssueCurrentTab.OPEN]: 0,
|
||||||
|
[EInboxIssueCurrentTab.CLOSED]: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
this.currentProjectId = projectId;
|
this.currentProjectId = projectId;
|
||||||
this.currentTab = tab;
|
this.currentTab = tab;
|
||||||
this.filters = {
|
this.filters = nextFilters;
|
||||||
...this.filters,
|
this.sorting = nextSorting;
|
||||||
status: [tab],
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.externalContourService.listBoard(workspaceSlug, projectId, {
|
const response = await this.externalContourService.listBoard(workspaceSlug, projectId, nextFilters, nextSorting);
|
||||||
status: tab,
|
if (requestId !== this.lastIssuedRequestId) return;
|
||||||
});
|
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.items = {};
|
|
||||||
this.columnIdsMap = { outgoing: [], incoming: [] };
|
this.columnIdsMap = { outgoing: [], incoming: [] };
|
||||||
this.columnCountMap = { outgoing: 0, incoming: 0 };
|
this.columnCountMap = { outgoing: 0, incoming: 0 };
|
||||||
this.filters = response.filters || { status: [tab] };
|
this.filters = sanitizeBoardFilters(response.filters || nextFilters);
|
||||||
this.sorting = response.sorting || { order_by: "updated_at", sort_by: "desc" };
|
this.sorting = response.sorting || nextSorting;
|
||||||
|
this.hydratedProjectId = projectId;
|
||||||
|
|
||||||
response.columns.forEach((column) => {
|
response.columns.forEach((column) => {
|
||||||
this.columnIdsMap[column.key] = column.results.map((request) => request.id);
|
this.columnIdsMap[column.key] = column.results.map((request) => request.id);
|
||||||
|
|
@ -146,6 +228,8 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
|
||||||
this.loader = undefined;
|
this.loader = undefined;
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (requestId !== this.lastIssuedRequestId) return;
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.loader = undefined;
|
this.loader = undefined;
|
||||||
this.error = {
|
this.error = {
|
||||||
|
|
|
||||||
|
|
@ -301,6 +301,20 @@ export default {
|
||||||
outgoing: "Outgoing",
|
outgoing: "Outgoing",
|
||||||
incoming: "Incoming",
|
incoming: "Incoming",
|
||||||
},
|
},
|
||||||
|
filters: {
|
||||||
|
sort: "Sorting",
|
||||||
|
requester: "Requester",
|
||||||
|
unread_only: "Only with updates",
|
||||||
|
search_placeholder: "Search by request title",
|
||||||
|
search_priority: "Search by priority",
|
||||||
|
search_assignee: "Search by assignee",
|
||||||
|
search_requester: "Search by requester",
|
||||||
|
sorting: {
|
||||||
|
updated_at_desc: "Latest updates first",
|
||||||
|
requested_at_desc: "Newest requests first",
|
||||||
|
target_date_asc: "Closest due date first",
|
||||||
|
},
|
||||||
|
},
|
||||||
empty: {
|
empty: {
|
||||||
outgoing_title: "No outgoing requests",
|
outgoing_title: "No outgoing requests",
|
||||||
outgoing_description: "Requests sent from this contour to other projects will appear here.",
|
outgoing_description: "Requests sent from this contour to other projects will appear here.",
|
||||||
|
|
|
||||||
|
|
@ -458,6 +458,20 @@ export default {
|
||||||
outgoing: "Исходящие",
|
outgoing: "Исходящие",
|
||||||
incoming: "Входящие",
|
incoming: "Входящие",
|
||||||
},
|
},
|
||||||
|
filters: {
|
||||||
|
sort: "Сортировка",
|
||||||
|
requester: "Отправитель",
|
||||||
|
unread_only: "Только с изменениями",
|
||||||
|
search_placeholder: "Поиск по названию запроса",
|
||||||
|
search_priority: "Поиск по приоритету",
|
||||||
|
search_assignee: "Поиск по исполнителю",
|
||||||
|
search_requester: "Поиск по отправителю",
|
||||||
|
sorting: {
|
||||||
|
updated_at_desc: "Сначала последние изменения",
|
||||||
|
requested_at_desc: "Сначала новые отправки",
|
||||||
|
target_date_asc: "Сначала ближайший срок",
|
||||||
|
},
|
||||||
|
},
|
||||||
empty: {
|
empty: {
|
||||||
outgoing_title: "Нет исходящих запросов",
|
outgoing_title: "Нет исходящих запросов",
|
||||||
outgoing_description: "Здесь будут видны запросы, которые этот контур отправил в другие проекты.",
|
outgoing_description: "Здесь будут видны запросы, которые этот контур отправил в другие проекты.",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue