ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: стабилизация 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 { 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>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { API_BASE_URL } from "@plane/constants";
|
|||
import type {
|
||||
TExternalContourBoardFilter,
|
||||
TExternalContourBoardResponse,
|
||||
TExternalContourBoardSorting,
|
||||
TExternalContourRequest,
|
||||
TExternalContourRequestResponse,
|
||||
TExternalContourTargetOptions,
|
||||
|
|
@ -32,10 +33,11 @@ export class ExternalContourService extends APIService {
|
|||
async listBoard(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
filters: Partial<TExternalContourBoardFilter> = {}
|
||||
filters: Partial<TExternalContourBoardFilter> = {},
|
||||
sorting: TExternalContourBoardSorting = {}
|
||||
): Promise<TExternalContourBoardResponse> {
|
||||
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 (Array.isArray(value)) return [[key, value.join(",")]];
|
||||
return [[key, String(value)]];
|
||||
|
|
|
|||
|
|
@ -16,7 +16,18 @@ import { EInboxIssueCurrentTab } from "@plane/types";
|
|||
import { ExternalContourService } from "@/services/external-contours";
|
||||
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 {
|
||||
currentProjectId: string;
|
||||
|
|
@ -29,12 +40,18 @@ export interface IProjectExternalContoursBoardStore {
|
|||
columnIdsMap: Record<TExternalContourBoardDirection, string[]>;
|
||||
columnCountMap: Record<TExternalContourBoardDirection, number>;
|
||||
tabCountMap: Record<TInboxIssueCurrentTab, number>;
|
||||
activeFiltersCount: number;
|
||||
fetchBoard: (workspaceSlug: string, projectId: string, tab?: TInboxIssueCurrentTab) => Promise<void>;
|
||||
getColumnRequestIds: (direction: TExternalContourBoardDirection) => string[];
|
||||
getColumnTotalCount: (direction: TExternalContourBoardDirection) => number;
|
||||
getRequestById: (requestId: string) => TExternalContourRequest | undefined;
|
||||
handleCurrentTab: (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => Promise<void>;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -45,7 +62,7 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
|
|||
filters: Partial<TExternalContourBoardFilter> = { status: [EInboxIssueCurrentTab.OPEN] };
|
||||
items: Record<string, TExternalContourRequest> = {};
|
||||
loader: TLoader = "init-loading";
|
||||
sorting: TExternalContourBoardSorting = { order_by: "updated_at", sort_by: "desc" };
|
||||
sorting: TExternalContourBoardSorting = DEFAULT_SORTING;
|
||||
columnIdsMap: Record<TExternalContourBoardDirection, string[]> = {
|
||||
outgoing: [],
|
||||
incoming: [],
|
||||
|
|
@ -58,6 +75,8 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
|
|||
[EInboxIssueCurrentTab.OPEN]: 0,
|
||||
[EInboxIssueCurrentTab.CLOSED]: 0,
|
||||
};
|
||||
hydratedProjectId = "";
|
||||
lastIssuedRequestId = 0;
|
||||
|
||||
externalContourService;
|
||||
|
||||
|
|
@ -73,9 +92,15 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
|
|||
columnIdsMap: observable,
|
||||
columnCountMap: observable,
|
||||
tabCountMap: observable,
|
||||
activeFiltersCount: computed,
|
||||
hasAnyItems: computed,
|
||||
isFiltering: computed,
|
||||
isSortingDefault: computed,
|
||||
clearFilters: action,
|
||||
fetchBoard: action,
|
||||
handleCurrentTab: action,
|
||||
updateFilters: action,
|
||||
updateSorting: action,
|
||||
upsertBoardItems: action,
|
||||
});
|
||||
|
||||
|
|
@ -86,6 +111,24 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
|
|||
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];
|
||||
|
||||
getColumnRequestIds = (direction: TExternalContourBoardDirection) => this.columnIdsMap[direction] ?? [];
|
||||
|
|
@ -101,36 +144,75 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
|
|||
};
|
||||
|
||||
handleCurrentTab = async (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => {
|
||||
this.currentProjectId = projectId;
|
||||
this.currentTab = tab;
|
||||
this.filters = {
|
||||
this.filters = sanitizeBoardFilters({
|
||||
...this.filters,
|
||||
status: [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) => {
|
||||
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;
|
||||
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.currentTab = tab;
|
||||
this.filters = {
|
||||
...this.filters,
|
||||
status: [tab],
|
||||
};
|
||||
this.filters = nextFilters;
|
||||
this.sorting = nextSorting;
|
||||
|
||||
try {
|
||||
const response = await this.externalContourService.listBoard(workspaceSlug, projectId, {
|
||||
status: tab,
|
||||
});
|
||||
const response = await this.externalContourService.listBoard(workspaceSlug, projectId, nextFilters, nextSorting);
|
||||
if (requestId !== this.lastIssuedRequestId) return;
|
||||
|
||||
runInAction(() => {
|
||||
this.items = {};
|
||||
this.columnIdsMap = { outgoing: [], incoming: [] };
|
||||
this.columnCountMap = { outgoing: 0, incoming: 0 };
|
||||
this.filters = response.filters || { status: [tab] };
|
||||
this.sorting = response.sorting || { order_by: "updated_at", sort_by: "desc" };
|
||||
this.filters = sanitizeBoardFilters(response.filters || nextFilters);
|
||||
this.sorting = response.sorting || nextSorting;
|
||||
this.hydratedProjectId = projectId;
|
||||
|
||||
response.columns.forEach((column) => {
|
||||
this.columnIdsMap[column.key] = column.results.map((request) => request.id);
|
||||
|
|
@ -146,6 +228,8 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
|
|||
this.loader = undefined;
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (requestId !== this.lastIssuedRequestId) return;
|
||||
|
||||
runInAction(() => {
|
||||
this.loader = undefined;
|
||||
this.error = {
|
||||
|
|
|
|||
|
|
@ -301,6 +301,20 @@ export default {
|
|||
outgoing: "Outgoing",
|
||||
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: {
|
||||
outgoing_title: "No outgoing requests",
|
||||
outgoing_description: "Requests sent from this contour to other projects will appear here.",
|
||||
|
|
|
|||
|
|
@ -458,6 +458,20 @@ export default {
|
|||
outgoing: "Исходящие",
|
||||
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: {
|
||||
outgoing_title: "Нет исходящих запросов",
|
||||
outgoing_description: "Здесь будут видны запросы, которые этот контур отправил в другие проекты.",
|
||||
|
|
|
|||
Loading…
Reference in New Issue