diff --git a/plane-src/apps/api/plane/app/views/external_contours.py b/plane-src/apps/api/plane/app/views/external_contours.py index 66112a1..27502ef 100644 --- a/plane-src/apps/api/plane/app/views/external_contours.py +++ b/plane-src/apps/api/plane/app/views/external_contours.py @@ -323,6 +323,7 @@ class ExternalContourReadMixin: for key in [ "direction", "status", + "state_groups", "state_ids", "priority", "assignee_ids", @@ -337,6 +338,9 @@ class ExternalContourReadMixin: filters["has_unread_updates"] = self.parse_bool_param(request, "has_unread_updates") filters["search"] = (request.query_params.get("search") or "").strip() + filters["target_date_exact"] = (request.query_params.get("target_date_exact") or "").strip() + filters["target_date_from"] = (request.query_params.get("target_date_from") or "").strip() + filters["target_date_to"] = (request.query_params.get("target_date_to") or "").strip() return filters def get_sorting(self, request): @@ -392,6 +396,8 @@ class ExternalContourReadMixin: queryset = self.apply_status_filter(queryset, filters["status"]) + if filters["state_groups"]: + queryset = queryset.filter(issue__state__group__in=filters["state_groups"]) if filters["state_ids"]: queryset = queryset.filter(issue__state_id__in=filters["state_ids"]) if filters["priority"]: @@ -419,6 +425,12 @@ class ExternalContourReadMixin: ) if filters["label_ids"]: queryset = queryset.filter(issue__label_issue__label_id__in=filters["label_ids"]) + if filters["target_date_exact"]: + queryset = queryset.filter(issue__target_date=filters["target_date_exact"]) + if filters["target_date_from"]: + queryset = queryset.filter(issue__target_date__gte=filters["target_date_from"]) + if filters["target_date_to"]: + queryset = queryset.filter(issue__target_date__lte=filters["target_date_to"]) if filters["has_unread_updates"] is not None: unread_request_ids = self.get_unread_request_ids(request.user) if filters["has_unread_updates"]: diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/external-contours/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/external-contours/layout.tsx index c4b8934..3627e0b 100644 --- a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/external-contours/layout.tsx +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/external-contours/layout.tsx @@ -5,17 +5,23 @@ */ import { Outlet } from "react-router"; +import { useParams } from "react-router"; import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; import { ProjectExternalContoursHeader } from "@/plane-web/components/projects/external-contours/header"; +import { ProjectExternalContoursFiltersProvider } from "@/plane-web/components/projects/external-contours/filters/provider"; export default function ProjectExternalContoursLayout() { + const { projectId, workspaceSlug } = useParams(); + + if (!projectId || !workspaceSlug) return ; + return ( - <> + } /> - + ); } 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 deleted file mode 100644 index dc91cf0..0000000 --- a/plane-src/apps/web/ce/components/projects/external-contours/board-filters-row.tsx +++ /dev/null @@ -1,544 +0,0 @@ -/** - * 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; - color?: 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, - getColumnRequestIds, - getRequestById, - 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 visibleRequestIds = [...getColumnRequestIds("outgoing"), ...getColumnRequestIds("incoming")]; - const visibleRequests = visibleRequestIds.flatMap((requestId) => { - const request = getRequestById(requestId); - return request ? [request] : []; - }); - const cachedRequests = Object.values(items); - - const contourOptions = useMemo( - () => - getCounterpartyProjectOptions( - visibleRequests, - cachedRequests, - filters.counterparty_project_ids ?? [], - projectId - ), - [cachedRequests, filters.counterparty_project_ids, projectId, visibleRequests] - ); - const stateOptions = useMemo( - () => getStateOptions(visibleRequests, cachedRequests, filters.state_ids ?? []), - [cachedRequests, filters.state_ids, visibleRequests] - ); - const assigneeOptions = useMemo(() => getAssigneeOptions(visibleRequests, cachedRequests, filters.assignee_ids ?? []), [cachedRequests, filters.assignee_ids, visibleRequests]); - const requesterOptions = useMemo( - () => getRequesterOptions(visibleRequests, cachedRequests, filters.requested_by_ids ?? []), - [cachedRequests, filters.requested_by_ids, visibleRequests] - ); - const priorityOptions = useMemo(() => getPriorityOptions(visibleRequests, cachedRequests, filters.priority ?? [], t), [cachedRequests, filters.priority, t, visibleRequests]); - - 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, { - counterparty_project_ids: value.length > 0 ? value : undefined, - }) - } - options={contourOptions} - keyExtractor={(option) => option.value} - queryArray={["label"]} - inputPlaceholder={t("external_contours_page.board.filters.search_contour")} - buttonContainerClassName="h-10" - optionsContainerClassName="w-72" - disabled={contourOptions.length === 0 && (filters.counterparty_project_ids?.length ?? 0) === 0} - buttonContent={(isOpen) => ( - - )} - renderItem={({ value, selected }) => { - const option = contourOptions.find((item) => item.value === value); - if (!option) return null; - - return ( -
- {option.data.label} - {selected && } -
- ); - }} - /> - - void updateFilters(workspaceSlug, projectId, { state_ids: value.length > 0 ? value : undefined })} - options={stateOptions} - keyExtractor={(option) => option.value} - queryArray={["label"]} - inputPlaceholder={t("external_contours_page.board.filters.search_state")} - buttonContainerClassName="h-10" - optionsContainerClassName="w-64" - disabled={stateOptions.length === 0 && (filters.state_ids?.length ?? 0) === 0} - buttonContent={(isOpen) => ( - - )} - renderItem={({ value, selected }) => { - const option = stateOptions.find((item) => item.value === value); - if (!option) return null; - - return ( -
-
- - {option.data.label} -
- {selected && } -
- ); - }} - /> - - 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} - className={cn( - "nodedc-toolbar-pill !min-h-10 gap-2 !px-4 text-12 font-medium", - isOpen || count > 0 ? "text-[rgb(var(--nodedc-accent-rgb))]" : "text-secondary" - )} - > - {icon} - {label} - {count > 0 && {count}} - -
- ); -} - -const sortFilterOptions = (options: TFilterOption[]) => options.sort((left, right) => left.label.localeCompare(right.label)); - -const buildOptionsWithSelectedFallback = ( - visibleRequests: TExternalContourRequest[], - cachedRequests: TExternalContourRequest[], - selectedIds: string[], - getOption: (request: TExternalContourRequest) => TFilterOption | null -) => { - const optionMap = new Map(); - - const upsertOption = (request: TExternalContourRequest) => { - const option = getOption(request); - if (!option?.id || optionMap.has(option.id)) return; - optionMap.set(option.id, option); - }; - - visibleRequests.forEach(upsertOption); - cachedRequests.forEach(upsertOption); - - return sortFilterOptions(Array.from(optionMap.values())).map((option) => ({ - data: option, - value: option.id, - })); -}; - -const getCounterpartyProjectOptions = ( - visibleRequests: TExternalContourRequest[], - cachedRequests: TExternalContourRequest[], - selectedIds: string[], - currentProjectId: string -) => - buildOptionsWithSelectedFallback(visibleRequests, cachedRequests, selectedIds, (request) => { - const project = request.direction === "incoming" ? request.source_project : request.target_project; - const fallbackName = request.direction === "incoming" ? request.source_project_name : request.target_project_name; - - if (!project?.id || project.id === currentProjectId) return null; - - return { - id: project.id, - label: project.name || project.identifier || fallbackName || "NODE.DC", - }; - }); - -const getStateOptions = ( - visibleRequests: TExternalContourRequest[], - cachedRequests: TExternalContourRequest[], - selectedIds: string[] -) => - buildOptionsWithSelectedFallback(visibleRequests, cachedRequests, selectedIds, (request) => { - const state = request.issue.state_detail; - if (!state?.id) return null; - - return { - id: state.id, - label: state.name || "Без статуса", - color: state.color || null, - }; - }); - -const getPriorityOptions = ( - visibleRequests: TExternalContourRequest[], - cachedRequests: TExternalContourRequest[], - selectedIds: string[], - t: ReturnType["t"] -) => { - const priorityKeys = new Set(); - - [...visibleRequests, ...cachedRequests].forEach((request) => { - if (request.issue.priority && request.issue.priority !== "none") priorityKeys.add(request.issue.priority); - }); - - selectedIds.forEach((priority) => { - if (priority && priority !== "none") priorityKeys.add(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, - })) - .sort((left, right) => left.data.label.localeCompare(right.data.label)); -}; - -const getAssigneeOptions = ( - visibleRequests: TExternalContourRequest[], - cachedRequests: TExternalContourRequest[], - selectedIds: string[] -) => { - const assigneeMap = new Map(); - - const upsertAssignees = (request: TExternalContourRequest) => { - 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 || "", - }); - }); - }; - - visibleRequests.forEach(upsertAssignees); - cachedRequests.forEach(upsertAssignees); - - return sortFilterOptions(Array.from(assigneeMap.values())).map((option) => ({ data: option, value: option.id })); -}; - -const getRequesterOptions = ( - visibleRequests: TExternalContourRequest[], - cachedRequests: TExternalContourRequest[], - selectedIds: string[] -) => - buildOptionsWithSelectedFallback(visibleRequests, cachedRequests, selectedIds, (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) return null; - - return { - id: requesterId, - label: requesterLabel, - avatarUrl: request.issue.created_by_detail?.avatar_url || "", - }; - }); 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 cd1f99c..78c75c1 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 @@ -8,7 +8,6 @@ import { observer } from "mobx-react"; import { useTranslation } from "@plane/i18n"; import { cn } from "@plane/utils"; import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board"; -import { ExternalContoursBoardFiltersRow } from "./board-filters-row"; import { ExternalContoursBoardColumn } from "./board-column"; type Props = { @@ -23,10 +22,6 @@ export const ExternalContoursBoardRoot = observer(function ExternalContoursBoard return (
-
- -
- {loader === "init-loading" && !hasAnyItems ? (
{t("loading")}...
) : ( diff --git a/plane-src/apps/web/ce/components/projects/external-contours/filters/provider.tsx b/plane-src/apps/web/ce/components/projects/external-contours/filters/provider.tsx new file mode 100644 index 0000000..223a8bc --- /dev/null +++ b/plane-src/apps/web/ce/components/projects/external-contours/filters/provider.tsx @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { createContext, useContext, useEffect, useMemo } from "react"; +import type { ReactNode } from "react"; +import { isEqual } from "lodash-es"; +import { observer } from "mobx-react"; +import { FilterInstance, workItemFiltersAdapter } from "@plane/shared-state"; +import type { IFilterInstance } from "@plane/shared-state"; +import type { TWorkItemFilterExpression, TWorkItemFilterProperty } from "@plane/types"; +import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board"; +import { useExternalContoursFiltersConfig } from "./use-external-contours-filters-config"; +import { + buildExternalContourBoardFilters, + buildExternalContourRichFilterExpression, +} from "./utils"; + +const ExternalContoursFilterContext = createContext< + IFilterInstance | undefined +>(undefined); + +type Props = { + children: ReactNode; + projectId: string; + workspaceSlug: string; +}; + +export const ProjectExternalContoursFiltersProvider = observer(function ProjectExternalContoursFiltersProvider(props: Props) { + const { children, projectId, workspaceSlug } = props; + const { filters, items, replaceFilters } = useProjectExternalContoursBoard(); + + const requests = useMemo(() => Object.values(items), [items]); + const { areAllConfigsInitialized, configs } = useExternalContoursFiltersConfig({ + projectId, + requests, + workspaceSlug, + }); + + const filter = useMemo( + () => + new FilterInstance({ + adapter: workItemFiltersAdapter, + initialExpression: buildExternalContourRichFilterExpression(filters), + onExpressionChange: (expression) => { + void replaceFilters(workspaceSlug, projectId, buildExternalContourBoardFilters(expression)); + }, + }), + [projectId, workspaceSlug] + ); + + useEffect(() => { + filter.onExpressionChange = (expression) => { + void replaceFilters(workspaceSlug, projectId, buildExternalContourBoardFilters(expression)); + }; + }, [filter, projectId, replaceFilters, workspaceSlug]); + + useEffect(() => { + filter.configManager.setAreConfigsReady(areAllConfigsInitialized); + filter.configManager.registerAll(configs); + }, [areAllConfigsInitialized, configs, filter.configManager]); + + useEffect(() => { + const nextExpression = buildExternalContourRichFilterExpression(filters); + const currentExpression = workItemFiltersAdapter.toExternal(filter.expression); + + if (isEqual(currentExpression, nextExpression)) return; + + const onExpressionChange = filter.onExpressionChange; + filter.onExpressionChange = undefined; + filter.resetExpression(nextExpression); + filter.onExpressionChange = onExpressionChange; + }, [filter, filters]); + + return {children}; +}); + +export const useExternalContoursFilter = () => useContext(ExternalContoursFilterContext); diff --git a/plane-src/apps/web/ce/components/projects/external-contours/filters/use-external-contours-filters-config.tsx b/plane-src/apps/web/ce/components/projects/external-contours/filters/use-external-contours-filters-config.tsx new file mode 100644 index 0000000..503af75 --- /dev/null +++ b/plane-src/apps/web/ce/components/projects/external-contours/filters/use-external-contours-filters-config.tsx @@ -0,0 +1,273 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useMemo } from "react"; +import { Briefcase } from "lucide-react"; +import { useTranslation } from "@plane/i18n"; +import { Logo } from "@plane/propel/emoji-icon-picker"; +import { + DueDatePropertyIcon, + LabelPropertyIcon, + MembersPropertyIcon, + PriorityIcon, + PriorityPropertyIcon, + StateGroupIcon, + StatePropertyIcon, + UserCirclePropertyIcon, +} from "@plane/propel/icons"; +import type { + IProject, + IState, + IIssueLabel, + IUserLite, + TExternalContourRequest, + TFilterConfig, + TWorkItemFilterProperty, +} from "@plane/types"; +import { Avatar } from "@plane/ui"; +import { + getAssigneeFilterConfig, + getCreatedByFilterConfig, + getFileURL, + getLabelFilterConfig, + getPriorityFilterConfig, + getProjectFilterConfig, + getStateFilterConfig, + getStateGroupFilterConfig, + getTargetDateFilterConfig, +} from "@plane/utils"; +import { useFiltersOperatorConfigs } from "@/plane-web/hooks/rich-filters/use-filters-operator-configs"; + +const sortByName = (items: T[]) => + [...items].sort((left, right) => + (left.display_name || left.name || "").localeCompare(right.display_name || right.name || "", "ru") + ); + +const buildCounterpartyProjects = (requests: TExternalContourRequest[], projectId: string): IProject[] => { + const projectMap = new Map(); + + requests.forEach((request) => { + const project = + request.direction === "incoming" + ? request.source_project + : request.target_project || request.issue.project_detail || null; + + if (!project?.id || project.id === projectId || projectMap.has(project.id)) return; + + projectMap.set( + project.id, + { + id: project.id, + name: project.name, + logo_props: project.logo_props, + } as IProject + ); + }); + + return sortByName(Array.from(projectMap.values())); +}; + +const buildStates = (requests: TExternalContourRequest[]): IState[] => { + const stateMap = new Map(); + + requests.forEach((request, index) => { + const state = request.issue.state_detail; + if (!state?.id || stateMap.has(state.id)) return; + + stateMap.set( + state.id, + { + id: state.id, + color: state.color, + default: false, + description: "", + group: state.group, + name: state.name, + order: index + 1, + project_id: request.issue.project_id || request.target_project?.id || request.source_project?.id || "", + sequence: index + 1, + workspace_id: "", + } as IState + ); + }); + + return sortByName(Array.from(stateMap.values())); +}; + +const buildLabels = (requests: TExternalContourRequest[]): IIssueLabel[] => { + const labelMap = new Map(); + + requests.forEach((request) => { + request.issue.label_details?.forEach((label) => { + if (!label.id || labelMap.has(label.id)) return; + labelMap.set( + label.id, + { + id: label.id, + color: label.color, + name: label.name, + parent: null, + project_id: request.issue.project_id || "", + sort_order: 0, + workspace_id: "", + } as IIssueLabel + ); + }); + }); + + return sortByName(Array.from(labelMap.values())); +}; + +const buildAssignees = (requests: TExternalContourRequest[]): IUserLite[] => { + const memberMap = new Map(); + + requests.forEach((request) => { + request.issue.assignee_details?.forEach((assignee) => { + if (!assignee.id || memberMap.has(assignee.id)) return; + memberMap.set( + assignee.id, + { + id: assignee.id, + avatar_url: assignee.avatar_url, + display_name: assignee.display_name, + } as IUserLite + ); + }); + }); + + return sortByName(Array.from(memberMap.values())); +}; + +const buildRequesters = (requests: TExternalContourRequest[]): IUserLite[] => { + const memberMap = new Map(); + + requests.forEach((request) => { + const requesterId = request.requested_by?.id || request.requested_by_id || request.issue.created_by_detail?.id; + const requesterName = + request.requested_by?.display_name || request.requested_by_name || request.issue.created_by_detail?.display_name; + + if (!requesterId || !requesterName || memberMap.has(requesterId)) return; + + memberMap.set( + requesterId, + { + id: requesterId, + avatar_url: request.issue.created_by_detail?.avatar_url, + display_name: requesterName, + } as IUserLite + ); + }); + + return sortByName(Array.from(memberMap.values())); +}; + +type TUseExternalContoursFiltersConfigProps = { + projectId: string; + requests: TExternalContourRequest[]; + workspaceSlug: string; +}; + +export type TExternalContoursFiltersConfig = { + areAllConfigsInitialized: boolean; + configs: TFilterConfig[]; +}; + +export const useExternalContoursFiltersConfig = ( + props: TUseExternalContoursFiltersConfigProps +): TExternalContoursFiltersConfig => { + const { projectId, requests, workspaceSlug } = props; + const { t } = useTranslation(); + const operatorConfigs = useFiltersOperatorConfigs({ workspaceSlug }); + + const counterpartyProjects = useMemo(() => buildCounterpartyProjects(requests, projectId), [projectId, requests]); + const states = useMemo(() => buildStates(requests), [requests]); + const labels = useMemo(() => buildLabels(requests), [requests]); + const assignees = useMemo(() => buildAssignees(requests), [requests]); + const requesters = useMemo(() => buildRequesters(requests), [requests]); + + const configs = useMemo( + () => [ + getProjectFilterConfig("project_id")({ + isEnabled: true, + filterLabel: "Contour", + filterIcon: Briefcase, + projects: counterpartyProjects, + getOptionIcon: (project) => , + ...operatorConfigs, + }), + getStateGroupFilterConfig("state_group")({ + isEnabled: true, + filterIcon: StatePropertyIcon, + getItemLabel: (stateGroupKey) => t(`workspace_projects.state.${stateGroupKey}`), + getOptionIcon: (stateGroupKey) => , + ...operatorConfigs, + }), + getStateFilterConfig("state_id")({ + isEnabled: true, + filterIcon: StatePropertyIcon, + getOptionIcon: (state) => , + states, + ...operatorConfigs, + }), + getPriorityFilterConfig("priority")({ + isEnabled: true, + filterLabel: t("common.priority"), + filterIcon: PriorityPropertyIcon, + getItemLabel: (priorityKey) => (priorityKey === "none" ? t("common.none") : t(priorityKey)), + getOptionIcon: (priority) => , + ...operatorConfigs, + }), + getAssigneeFilterConfig("assignee_id")({ + isEnabled: true, + filterIcon: MembersPropertyIcon, + members: assignees, + getOptionIcon: (memberDetails) => ( + + ), + ...operatorConfigs, + }), + getCreatedByFilterConfig("created_by_id")({ + isEnabled: true, + filterIcon: UserCirclePropertyIcon, + members: requesters, + getOptionIcon: (memberDetails) => ( + + ), + ...operatorConfigs, + }), + getLabelFilterConfig("label_id")({ + isEnabled: true, + filterIcon: LabelPropertyIcon, + labels, + getOptionIcon: (color) => ( + + ), + ...operatorConfigs, + }), + getTargetDateFilterConfig("target_date")({ + isEnabled: true, + filterIcon: DueDatePropertyIcon, + ...operatorConfigs, + }), + ], + [assignees, counterpartyProjects, labels, operatorConfigs, requesters, states, t] + ); + + return { + areAllConfigsInitialized: true, + configs, + }; +}; diff --git a/plane-src/apps/web/ce/components/projects/external-contours/filters/utils.ts b/plane-src/apps/web/ce/components/projects/external-contours/filters/utils.ts new file mode 100644 index 0000000..a3ef45f --- /dev/null +++ b/plane-src/apps/web/ce/components/projects/external-contours/filters/utils.ts @@ -0,0 +1,170 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { + buildWorkItemFilterExpressionFromConditions, + workItemFiltersAdapter, +} from "@plane/shared-state"; +import type { TWorkItemFilterCondition } from "@plane/shared-state"; +import type { + TExternalContourBoardFilter, + TFilterValue, + TWorkItemFilterExpression, + TWorkItemFilterProperty, +} from "@plane/types"; +import { COLLECTION_OPERATOR, COMPARISON_OPERATOR, EQUALITY_OPERATOR } from "@plane/types"; +import { extractConditionsWithDisplayOperators, renderFormattedPayloadDate } from "@plane/utils"; + +export const EXTERNAL_CONTOUR_FILTER_PROPERTIES = [ + "project_id", + "state_group", + "state_id", + "priority", + "assignee_id", + "created_by_id", + "label_id", + "target_date", +] as const satisfies TWorkItemFilterProperty[]; + +const toStringArray = (value: TFilterValue | TFilterValue[] | undefined): string[] => { + if (value === undefined || value === null || value === "") return []; + return (Array.isArray(value) ? value : [value]).map((item) => String(item)).filter(Boolean); +}; + +const toPayloadDate = (value: TFilterValue | undefined): string | undefined => { + if (value === undefined || value === null || value === "") return undefined; + return renderFormattedPayloadDate(value instanceof Date ? value : String(value)) ?? undefined; +}; + +export const buildExternalContourRichFilterExpression = ( + filters: Partial +): TWorkItemFilterExpression => { + const conditions: TWorkItemFilterCondition[] = []; + + if (filters.counterparty_project_ids?.length) { + conditions.push({ + property: "project_id", + operator: COLLECTION_OPERATOR.IN, + value: filters.counterparty_project_ids, + }); + } + + if (filters.state_groups?.length) { + conditions.push({ + property: "state_group", + operator: COLLECTION_OPERATOR.IN, + value: filters.state_groups, + }); + } + + if (filters.state_ids?.length) { + conditions.push({ + property: "state_id", + operator: COLLECTION_OPERATOR.IN, + value: filters.state_ids, + }); + } + + if (filters.priority?.length) { + conditions.push({ + property: "priority", + operator: COLLECTION_OPERATOR.IN, + value: filters.priority, + }); + } + + if (filters.assignee_ids?.length) { + conditions.push({ + property: "assignee_id", + operator: COLLECTION_OPERATOR.IN, + value: filters.assignee_ids, + }); + } + + const authorIds = filters.requested_by_ids?.length ? filters.requested_by_ids : filters.created_by_ids; + if (authorIds?.length) { + conditions.push({ + property: "created_by_id", + operator: COLLECTION_OPERATOR.IN, + value: authorIds, + }); + } + + if (filters.label_ids?.length) { + conditions.push({ + property: "label_id", + operator: COLLECTION_OPERATOR.IN, + value: filters.label_ids, + }); + } + + if (filters.target_date_exact) { + conditions.push({ + property: "target_date", + operator: EQUALITY_OPERATOR.EXACT, + value: filters.target_date_exact, + }); + } else if (filters.target_date_from && filters.target_date_to) { + conditions.push({ + property: "target_date", + operator: COMPARISON_OPERATOR.RANGE, + value: [filters.target_date_from, filters.target_date_to], + }); + } + + return buildWorkItemFilterExpressionFromConditions({ conditions }) ?? {}; +}; + +export const buildExternalContourBoardFilters = ( + expression: TWorkItemFilterExpression +): Partial => { + const internalExpression = workItemFiltersAdapter.toInternal(expression); + if (!internalExpression) return {}; + + const filters: Partial = {}; + const conditions = extractConditionsWithDisplayOperators(internalExpression); + + conditions.forEach((condition) => { + switch (condition.property) { + case "project_id": + filters.counterparty_project_ids = toStringArray(condition.value); + break; + case "state_group": + filters.state_groups = toStringArray(condition.value); + break; + case "state_id": + filters.state_ids = toStringArray(condition.value); + break; + case "priority": + filters.priority = toStringArray(condition.value); + break; + case "assignee_id": + filters.assignee_ids = toStringArray(condition.value); + break; + case "created_by_id": + filters.requested_by_ids = toStringArray(condition.value); + break; + case "label_id": + filters.label_ids = toStringArray(condition.value); + break; + case "target_date": + if (condition.operator === COMPARISON_OPERATOR.RANGE) { + const [from, to] = toStringArray(condition.value); + filters.target_date_from = toPayloadDate(from); + filters.target_date_to = toPayloadDate(to); + } else { + filters.target_date_exact = toPayloadDate( + Array.isArray(condition.value) ? condition.value[0] : condition.value + ); + } + break; + default: + break; + } + }); + + return filters; +}; diff --git a/plane-src/apps/web/ce/components/projects/external-contours/header.tsx b/plane-src/apps/web/ce/components/projects/external-contours/header.tsx index b3ace3f..daac7eb 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/header.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/header.tsx @@ -14,10 +14,12 @@ import { TransferIcon } from "@plane/propel/icons"; import { Breadcrumbs, Header } from "@plane/ui"; import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button"; +import { FiltersToggle } from "@/components/rich-filters/filters-toggle"; import { useProject } from "@/hooks/store/use-project"; import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours"; import { useUserPermissions } from "@/hooks/store/user"; import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; +import { useExternalContoursFilter } from "./filters/provider"; import { ExternalContourCreateModalRoot } from "./create-modal"; export const ProjectExternalContoursHeader = observer(function ProjectExternalContoursHeader() { @@ -27,6 +29,7 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo const { allowPermissions } = useUserPermissions(); const { loader: currentProjectDetailsLoader } = useProject(); const { loader } = useProjectExternalContours(); + const filter = useExternalContoursFilter(); const isAuthorized = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], @@ -63,6 +66,9 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo {workspaceSlug && projectId && isAuthorized ? (
+
+ +
{ if (!workspaceSlug || !projectId) return; @@ -84,15 +87,19 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props } return ( -
- - {inboxIssueId && ( - - )} +
+ {filter && } + +
+ + {inboxIssueId && ( + + )} +
); }); diff --git a/plane-src/apps/web/core/components/rich-filters/filters-toggle.tsx b/plane-src/apps/web/core/components/rich-filters/filters-toggle.tsx index 5764c52..0178b46 100644 --- a/plane-src/apps/web/core/components/rich-filters/filters-toggle.tsx +++ b/plane-src/apps/web/core/components/rich-filters/filters-toggle.tsx @@ -16,6 +16,7 @@ import { AddFilterButton } from "@/components/rich-filters/add-filters/button"; type TFiltersToggleProps

= { filter: IFilterInstance | undefined; + showAddFilterButtonWhenEmpty?: boolean; }; const COMMON_CLASSNAME = @@ -24,13 +25,13 @@ const COMMON_CLASSNAME = export const FiltersToggle = observer(function FiltersToggle

( props: TFiltersToggleProps ) { - const { filter } = props; + const { filter, showAddFilterButtonWhenEmpty = true } = props; // derived values const hasAnyConditions = (filter?.allConditionsForDisplay.length ?? 0) > 0; const isFilterRowVisible = filter?.isVisible ?? false; const hasUpdates = filter?.canUpdateView === true && filter?.hasChanges === true; const showFilterRowChangesPill = hasUpdates || hasAnyConditions === true; - const showAddFilterButton = !hasAnyConditions && !isFilterRowVisible && !hasUpdates; + const showAddFilterButton = showAddFilterButtonWhenEmpty && !hasAnyConditions && !isFilterRowVisible && !hasUpdates; const handleToggleFilter = () => { if (!filter) { diff --git a/plane-src/apps/web/core/components/rich-filters/i18n.ts b/plane-src/apps/web/core/components/rich-filters/i18n.ts index 79b69cb..46ce39a 100644 --- a/plane-src/apps/web/core/components/rich-filters/i18n.ts +++ b/plane-src/apps/web/core/components/rich-filters/i18n.ts @@ -9,6 +9,8 @@ const FILTER_LABEL_MAP: Record = { Label: "Метка", Labels: "Метки", Project: "Проект", + Projects: "Проекты", + Contour: "Контур", "Created by": "Автор", "Created at": "Дата создания", "Updated at": "Дата обновления", diff --git a/plane-src/apps/web/core/components/work-item-filters/filters-toggle.tsx b/plane-src/apps/web/core/components/work-item-filters/filters-toggle.tsx index 17021b4..45622b3 100644 --- a/plane-src/apps/web/core/components/work-item-filters/filters-toggle.tsx +++ b/plane-src/apps/web/core/components/work-item-filters/filters-toggle.tsx @@ -24,5 +24,5 @@ export const WorkItemFiltersToggle = observer(function WorkItemFiltersToggle(pro // derived values const filter = getFilter(entityType, entityId); - return ; + return ; }); 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 1814e18..37771a7 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 @@ -50,6 +50,7 @@ export interface IProjectExternalContoursBoardStore { isFiltering: boolean; isSortingDefault: boolean; clearFilters: (workspaceSlug: string, projectId: string) => Promise; + replaceFilters: (workspaceSlug: string, projectId: string, filters: Partial) => Promise; updateFilters: (workspaceSlug: string, projectId: string, filters: Partial) => Promise; updateSorting: (workspaceSlug: string, projectId: string, sorting: TExternalContourBoardSorting) => Promise; upsertBoardItems: (items: TExternalContourRequest[]) => void; @@ -99,6 +100,7 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou clearFilters: action, fetchBoard: action, handleCurrentTab: action, + replaceFilters: action, updateFilters: action, updateSorting: action, upsertBoardItems: action, @@ -161,6 +163,15 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou await this.fetchBoard(workspaceSlug, projectId); }; + replaceFilters = async ( + workspaceSlug: string, + projectId: string, + filters: Partial + ) => { + this.filters = sanitizeBoardFilters(filters); + await this.fetchBoard(workspaceSlug, projectId); + }; + updateSorting = async (workspaceSlug: string, projectId: string, sorting: TExternalContourBoardSorting) => { this.sorting = sorting; await this.fetchBoard(workspaceSlug, projectId); diff --git a/plane-src/packages/types/src/external-contours.ts b/plane-src/packages/types/src/external-contours.ts index a276290..9e74823 100644 --- a/plane-src/packages/types/src/external-contours.ts +++ b/plane-src/packages/types/src/external-contours.ts @@ -105,6 +105,7 @@ export type TExternalContourRequestResponse = TPaginationInfo & { export type TExternalContourBoardFilter = { direction?: TExternalContourBoardDirection[]; status?: TExternalContourRequest["status"] | TExternalContourRequest["status"][]; + state_groups?: string[]; state_ids?: string[]; priority?: string[]; assignee_ids?: string[]; @@ -114,6 +115,9 @@ export type TExternalContourBoardFilter = { source_project_ids?: string[]; target_project_ids?: string[]; label_ids?: string[]; + target_date_exact?: string; + target_date_from?: string; + target_date_to?: string; has_unread_updates?: boolean; search?: string; }; diff --git a/plane-src/packages/utils/src/work-item-filters/configs/filters/project.ts b/plane-src/packages/utils/src/work-item-filters/configs/filters/project.ts index 81e33ff..2074725 100644 --- a/plane-src/packages/utils/src/work-item-filters/configs/filters/project.ts +++ b/plane-src/packages/utils/src/work-item-filters/configs/filters/project.ts @@ -24,7 +24,7 @@ export const getProjectFilterConfig = (params: TCreateProjectFilterParams) => createFilterConfig

({ id: key, - label: "Projects", + label: params.filterLabel ?? "Projects", ...params, icon: params.filterIcon, supportedOperatorConfigsMap: new Map([ diff --git a/plane-src/packages/utils/src/work-item-filters/configs/filters/shared.ts b/plane-src/packages/utils/src/work-item-filters/configs/filters/shared.ts index 0343fd3..979cb9b 100644 --- a/plane-src/packages/utils/src/work-item-filters/configs/filters/shared.ts +++ b/plane-src/packages/utils/src/work-item-filters/configs/filters/shared.ts @@ -33,6 +33,7 @@ export const getSupportedDateOperators = (params: TCreateDateFilterParams): TOpe */ export type TCreateProjectFilterParams = TCreateFilterConfigParams & IFilterIconConfig & { + filterLabel?: string; projects: IProject[]; };