diff --git a/plane-src/apps/web/ce/components/projects/external-contours/board-filters-row.tsx b/plane-src/apps/web/ce/components/projects/external-contours/board-filters-row.tsx new file mode 100644 index 0000000..a0c84ee --- /dev/null +++ b/plane-src/apps/web/ce/components/projects/external-contours/board-filters-row.tsx @@ -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( + () => [ + { + 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 ( +
+
+
+
+ + 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 && ( + + )} +
+ + 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) => ( + } + 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 ( +
+
+ + {option.data.label} +
+ {selected && } +
+ ); + }} + /> + + + 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) => ( + + )} + renderItem={({ value, selected }) => { + const option = assigneeOptions.find((item) => item.value === value); + if (!option) return null; + + return ( +
+
+ + {option.data.label} +
+ {selected && } +
+ ); + }} + /> + + + 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) => ( + + )} + renderItem={({ value, selected }) => { + const option = requesterOptions.find((item) => item.value === value); + if (!option) return null; + + return ( +
+
+ + {option.data.label} +
+ {selected && } +
+ ); + }} + /> + + + + {shouldShowClear && ( + + )} +
+ +
+
{t("external_contours_page.board.filters.sort")}
+ { + 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) => ( + + )} + renderItem={({ value, selected }) => { + const option = sortingOptions.find((item) => item.key === value); + if (!option) return null; + + return ( +
+ {option.label} + {selected && } +
+ ); + }} + /> +
+
+
+ ); +}); + +type TFilterTriggerProps = { + count?: number; + icon?: ReactNode; + isOpen: boolean; + label: string; +}; + +function FilterTrigger(props: TFilterTriggerProps) { + const { count = 0, icon, isOpen, label } = props; + + return ( +
0, + } + )} + > + {icon} + {label} + {count > 0 && {count}} + +
+ ); +} + +const getPriorityOptions = (requests: TExternalContourRequest[], t: ReturnType["t"]) => { + const priorityKeys = new Set(); + + 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(); + + 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(); + + 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 })); +}; diff --git a/plane-src/apps/web/ce/components/projects/external-contours/board-root.tsx b/plane-src/apps/web/ce/components/projects/external-contours/board-root.tsx index 88862f3..4da1971 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/board-root.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/board-root.tsx @@ -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 (
@@ -62,22 +63,34 @@ export const ExternalContoursBoardRoot = observer(function ExternalContoursBoard
- {loader === "init-loading" ? ( +
+ +
+ + {loader === "init-loading" && !hasAnyItems ? (
{t("loading")}...
) : ( -
- - +
+ {isFiltering && ( +
+ {t("updating")}... +
+ )} + +
+ + +
)}
diff --git a/plane-src/apps/web/core/services/external-contours/external-contour.service.ts b/plane-src/apps/web/core/services/external-contours/external-contour.service.ts index 6ce3d99..ea37c3d 100644 --- a/plane-src/apps/web/core/services/external-contours/external-contour.service.ts +++ b/plane-src/apps/web/core/services/external-contours/external-contour.service.ts @@ -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 = {} + filters: Partial = {}, + sorting: TExternalContourBoardSorting = {} ): Promise { 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)]]; diff --git a/plane-src/apps/web/core/store/external-contours/project-external-contours-board.store.ts b/plane-src/apps/web/core/store/external-contours/project-external-contours-board.store.ts index ba4d423..83d017d 100644 --- a/plane-src/apps/web/core/store/external-contours/project-external-contours-board.store.ts +++ b/plane-src/apps/web/core/store/external-contours/project-external-contours-board.store.ts @@ -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): Partial => + 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; export interface IProjectExternalContoursBoardStore { currentProjectId: string; @@ -29,12 +40,18 @@ export interface IProjectExternalContoursBoardStore { columnIdsMap: Record; columnCountMap: Record; tabCountMap: Record; + activeFiltersCount: number; fetchBoard: (workspaceSlug: string, projectId: string, tab?: TInboxIssueCurrentTab) => Promise; getColumnRequestIds: (direction: TExternalContourBoardDirection) => string[]; getColumnTotalCount: (direction: TExternalContourBoardDirection) => number; getRequestById: (requestId: string) => TExternalContourRequest | undefined; handleCurrentTab: (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => Promise; hasAnyItems: boolean; + isFiltering: boolean; + isSortingDefault: boolean; + clearFilters: (workspaceSlug: string, projectId: string) => Promise; + updateFilters: (workspaceSlug: string, projectId: string, filters: Partial) => Promise; + updateSorting: (workspaceSlug: string, projectId: string, sorting: TExternalContourBoardSorting) => Promise; upsertBoardItems: (items: TExternalContourRequest[]) => void; } @@ -45,7 +62,7 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou filters: Partial = { status: [EInboxIssueCurrentTab.OPEN] }; items: Record = {}; loader: TLoader = "init-loading"; - sorting: TExternalContourBoardSorting = { order_by: "updated_at", sort_by: "desc" }; + sorting: TExternalContourBoardSorting = DEFAULT_SORTING; columnIdsMap: Record = { 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 + ) => { + 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 = { diff --git a/plane-src/packages/i18n/src/locales/en/translations.ts b/plane-src/packages/i18n/src/locales/en/translations.ts index b9dfea3..8cc49e5 100644 --- a/plane-src/packages/i18n/src/locales/en/translations.ts +++ b/plane-src/packages/i18n/src/locales/en/translations.ts @@ -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.", diff --git a/plane-src/packages/i18n/src/locales/ru/translations.ts b/plane-src/packages/i18n/src/locales/ru/translations.ts index f010117..5d40f56 100644 --- a/plane-src/packages/i18n/src/locales/ru/translations.ts +++ b/plane-src/packages/i18n/src/locales/ru/translations.ts @@ -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: "Здесь будут видны запросы, которые этот контур отправил в другие проекты.",