ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: стабилизация 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

View File

@ -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 }));
};

View File

@ -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,10 +63,21 @@ 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">
<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"
@ -79,6 +91,7 @@ export const ExternalContoursBoardRoot = observer(function ExternalContoursBoard
workspaceSlug={workspaceSlug}
/>
</div>
</div>
)}
</div>
);

View File

@ -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)]];

View File

@ -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);
};
fetchBoard = async (workspaceSlug: string, projectId: string, tab = this.currentTab) => {
this.loader = "init-loading";
this.error = undefined;
this.currentProjectId = projectId;
this.currentTab = tab;
this.filters = {
updateFilters = async (
workspaceSlug: string,
projectId: string,
filters: Partial<TExternalContourBoardFilter>
) => {
this.filters = sanitizeBoardFilters({
...this.filters,
status: [tab],
};
try {
const response = await this.externalContourService.listBoard(workspaceSlug, projectId, {
status: tab,
...filters,
status: [this.currentTab],
});
runInAction(() => {
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) => {
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.filters = response.filters || { status: [tab] };
this.sorting = response.sorting || { order_by: "updated_at", sort_by: "desc" };
this.tabCountMap = {
[EInboxIssueCurrentTab.OPEN]: 0,
[EInboxIssueCurrentTab.CLOSED]: 0,
};
}
this.currentProjectId = projectId;
this.currentTab = tab;
this.filters = nextFilters;
this.sorting = nextSorting;
try {
const response = await this.externalContourService.listBoard(workspaceSlug, projectId, nextFilters, nextSorting);
if (requestId !== this.lastIssuedRequestId) return;
runInAction(() => {
this.columnIdsMap = { outgoing: [], incoming: [] };
this.columnCountMap = { outgoing: 0, incoming: 0 };
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 = {

View File

@ -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.",

View File

@ -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: "Здесь будут видны запросы, которые этот контур отправил в другие проекты.",