diff --git a/plane-src/apps/api/plane/api/serializers/external_contours.py b/plane-src/apps/api/plane/api/serializers/external_contours.py index 782a27c..798e3eb 100644 --- a/plane-src/apps/api/plane/api/serializers/external_contours.py +++ b/plane-src/apps/api/plane/api/serializers/external_contours.py @@ -44,6 +44,11 @@ class ExternalContourRequestReplySerializer(serializers.Serializer): class ExternalContourRequestUpdateSerializer(serializers.Serializer): name = serializers.CharField(max_length=255, required=False) description_html = serializers.CharField(required=False, allow_blank=True, allow_null=True) + priority = serializers.ChoiceField(choices=Issue.PRIORITY_CHOICES, required=False) + assignee_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + label_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + state_id = serializers.UUIDField(required=False) + target_date = serializers.DateField(required=False, allow_null=True) def validate(self, data): if not data: @@ -69,6 +74,7 @@ class ExternalContourTargetProjectSerializer(BaseSerializer): class ExternalContourTargetOptionsSerializer(serializers.Serializer): project = ExternalContourTargetProjectSerializer(read_only=True) member_ids = serializers.ListField(child=serializers.UUIDField(), read_only=True) + states = StateLiteSerializer(many=True, read_only=True) labels = LabelSerializer(many=True, read_only=True) diff --git a/plane-src/apps/api/plane/api/views/external_contours.py b/plane-src/apps/api/plane/api/views/external_contours.py index 9c5a9df..9f64380 100644 --- a/plane-src/apps/api/plane/api/views/external_contours.py +++ b/plane-src/apps/api/plane/api/views/external_contours.py @@ -247,11 +247,15 @@ class ExternalContourTargetOptionsEndpoint(BaseAPIView): ) labels = Label.objects.filter(project=target_project).order_by("sort_order", "name") + states = State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by( + "sequence", "created_at" + ) serializer = ExternalContourTargetOptionsSerializer( { "project": target_project, "member_ids": member_ids, + "states": states, "labels": labels, } ) @@ -326,10 +330,17 @@ class ExternalContourDetailEndpoint(BaseAPIView): serializer = ExternalContourRequestUpdateSerializer(data=request.data) serializer.is_valid(raise_exception=True) + issue_update_data = serializer.validated_data.copy() + assignee_ids = issue_update_data.pop("assignee_ids", None) + label_ids = issue_update_data.pop("label_ids", None) + if assignee_ids is not None: + issue_update_data["assignees"] = assignee_ids + if label_ids is not None: + issue_update_data["labels"] = label_ids issue_serializer = IssueCreateSerializer( issue, - data=serializer.validated_data, + data=issue_update_data, partial=True, context={ "project_id": str(issue.project_id), 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 80c45d4..66112a1 100644 --- a/plane-src/apps/api/plane/app/views/external_contours.py +++ b/plane-src/apps/api/plane/app/views/external_contours.py @@ -249,11 +249,15 @@ class ExternalContourTargetOptionsEndpoint(BaseAPIView): ) labels = Label.objects.filter(project=target_project).order_by("sort_order", "name") + states = State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by( + "sequence", "created_at" + ) serializer = ExternalContourTargetOptionsSerializer( { "project": target_project, "member_ids": member_ids, + "states": states, "labels": labels, } ) @@ -324,6 +328,7 @@ class ExternalContourReadMixin: "assignee_ids", "created_by_ids", "requested_by_ids", + "counterparty_project_ids", "source_project_ids", "target_project_ids", "label_ids", @@ -377,7 +382,7 @@ class ExternalContourReadMixin: return queryset.filter(issue__state__group__in=self.CLOSED_STATE_GROUPS) return queryset - def apply_board_filters(self, queryset, request): + def apply_board_filters(self, queryset, request, direction=None): filters = self.get_applied_filters(request) if filters["search"]: @@ -397,6 +402,14 @@ class ExternalContourReadMixin: queryset = queryset.filter(issue__created_by_id__in=filters["created_by_ids"]) if filters["requested_by_ids"]: queryset = queryset.filter(extra__requested_by_id__in=filters["requested_by_ids"]) + if filters["counterparty_project_ids"]: + if direction == "outgoing": + queryset = queryset.filter( + Q(extra__target_project_id__in=filters["counterparty_project_ids"]) + | Q(issue__project_id__in=filters["counterparty_project_ids"]) + ) + elif direction == "incoming": + queryset = queryset.filter(extra__source_project_id__in=filters["counterparty_project_ids"]) if filters["source_project_ids"]: queryset = queryset.filter(extra__source_project_id__in=filters["source_project_ids"]) if filters["target_project_ids"]: @@ -494,8 +507,8 @@ class ExternalContourBoardEndpoint(ExternalContourReadMixin, BaseAPIView): requested_directions = self.get_requested_directions(request) current_project_id = str(project_id) - outgoing_queryset = self.apply_board_filters(self.get_outgoing_queryset(), request) - incoming_queryset = self.apply_board_filters(self.get_incoming_queryset(), request) + outgoing_queryset = self.apply_board_filters(self.get_outgoing_queryset(), request, direction="outgoing") + incoming_queryset = self.apply_board_filters(self.get_incoming_queryset(), request, direction="incoming") outgoing_requests = list(outgoing_queryset) if "outgoing" in requested_directions else [] incoming_requests = list(incoming_queryset) if "incoming" in requested_directions else [] @@ -624,10 +637,17 @@ class ExternalContourDetailEndpoint(BaseAPIView): serializer = ExternalContourRequestUpdateSerializer(data=request.data) serializer.is_valid(raise_exception=True) + issue_update_data = serializer.validated_data.copy() + assignee_ids = issue_update_data.pop("assignee_ids", None) + label_ids = issue_update_data.pop("label_ids", None) + if assignee_ids is not None: + issue_update_data["assignees"] = assignee_ids + if label_ids is not None: + issue_update_data["labels"] = label_ids issue_serializer = IssueCreateSerializer( issue, - data=serializer.validated_data, + data=issue_update_data, partial=True, context={ "project_id": str(issue.project_id), 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 index a0c84ee..b22f8ca 100644 --- 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 @@ -24,6 +24,7 @@ type Props = { type TFilterOption = { avatarUrl?: string | null; + color?: string | null; id: string; label: string; }; @@ -39,8 +40,18 @@ 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 { + activeFiltersCount, + clearFilters, + filters, + getColumnRequestIds, + getRequestById, + isSortingDefault, + items, + sorting, + updateFilters, + updateSorting, + } = useProjectExternalContoursBoard(); const [searchQuery, setSearchQuery] = useState(filters.search ?? ""); const debouncedSearchQuery = useDebounce(searchQuery, 400); @@ -60,11 +71,33 @@ export const ExternalContoursBoardFiltersRow = observer(function ExternalContour void updateFilters(workspaceSlug, projectId, { search: nextSearch || undefined }); }, [debouncedSearchQuery, filters.search, projectId, searchQuery, updateFilters, workspaceSlug]); - const requests = Object.values(items); + const visibleRequestIds = [...getColumnRequestIds("outgoing"), ...getColumnRequestIds("incoming")]; + const visibleRequests = visibleRequestIds.flatMap((requestId) => { + const request = getRequestById(requestId); + return request ? [request] : []; + }); + const cachedRequests = Object.values(items); - const assigneeOptions = useMemo(() => getAssigneeOptions(requests), [requests]); - const requesterOptions = useMemo(() => getRequesterOptions(requests), [requests]); - const priorityOptions = useMemo(() => getPriorityOptions(requests, t), [requests, t]); + 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( () => [ @@ -119,6 +152,72 @@ export const ExternalContoursBoardFiltersRow = observer(function ExternalContour )} + + 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 })} @@ -223,10 +322,10 @@ export const ExternalContoursBoardFiltersRow = observer(function ExternalContour {shouldShowClear && ( - )} @@ -296,11 +400,10 @@ function FilterTrigger(props: TFilterTriggerProps) { return (
0} 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, - } + "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} @@ -311,26 +414,107 @@ function FilterTrigger(props: TFilterTriggerProps) { ); } -const getPriorityOptions = (requests: TExternalContourRequest[], t: ReturnType["t"]) => { - const priorityKeys = new Set(); +const sortFilterOptions = (options: TFilterOption[]) => options.sort((left, right) => left.label.localeCompare(right.label)); - requests.forEach((request) => { - if (request.issue.priority && request.issue.priority !== "none") priorityKeys.add(request.issue.priority); - }); +const buildOptionsWithSelectedFallback = ( + visibleRequests: TExternalContourRequest[], + cachedRequests: TExternalContourRequest[], + selectedIds: string[], + getOption: (request: TExternalContourRequest) => TFilterOption | null +) => { + const optionMap = new Map(); - 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 upsertOption = (request: TExternalContourRequest) => { + const option = getOption(request); + if (!option?.id || optionMap.has(option.id)) return; + optionMap.set(option.id, option); + }; + + visibleRequests.forEach(upsertOption); + + if (selectedIds.length > 0) { + cachedRequests.forEach((request) => { + const option = getOption(request); + if (!option?.id || !selectedIds.includes(option.id) || optionMap.has(option.id)) return; + optionMap.set(option.id, option); + }); + } + + return sortFilterOptions(Array.from(optionMap.values())).map((option) => ({ + data: option, + value: option.id, })); }; -const getAssigneeOptions = (requests: TExternalContourRequest[]) => { +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(); - requests.forEach((request) => { + const upsertAssignees = (request: TExternalContourRequest) => { request.issue.assignee_details?.forEach((assignee) => { if (!assignee?.id || assigneeMap.has(assignee.id)) return; assigneeMap.set(assignee.id, { @@ -339,31 +523,41 @@ const getAssigneeOptions = (requests: TExternalContourRequest[]) => { avatarUrl: assignee.avatar_url || "", }); }); - }); + }; - return Array.from(assigneeMap.values()) - .sort((left, right) => left.label.localeCompare(right.label)) - .map((option) => ({ data: option, value: option.id })); + visibleRequests.forEach(upsertAssignees); + + if (selectedIds.length > 0) { + cachedRequests.forEach((request) => { + request.issue.assignee_details?.forEach((assignee) => { + if (!assignee?.id || !selectedIds.includes(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 sortFilterOptions(Array.from(assigneeMap.values())).map((option) => ({ data: option, value: option.id })); }; -const getRequesterOptions = (requests: TExternalContourRequest[]) => { - const requesterMap = new Map(); - - requests.forEach((request) => { +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 || requesterMap.has(requesterId)) return; + if (!requesterId || !requesterLabel) return null; - requesterMap.set(requesterId, { + return { 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-item.tsx b/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx index ffcaf7e..a654ad4 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx @@ -4,14 +4,37 @@ * See the LICENSE file for details. */ -import Link from "next/link"; +import { useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { CalendarDays } from "lucide-react"; import { observer } from "mobx-react"; +import { EUserPermissions } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { PriorityIcon } from "@plane/propel/icons"; +import { PriorityIcon, StateGroupIcon } from "@plane/propel/icons"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { + IState, + TExternalContourBoardDirection, + TExternalContourRequest, + TInboxIssueCurrentTab, + TIssue, +} from "@plane/types"; import { Avatar } from "@plane/ui"; -import { cn, renderFormattedDate } from "@plane/utils"; -import type { TExternalContourBoardDirection, TExternalContourRequest, TInboxIssueCurrentTab } from "@plane/types"; -import { ExternalContourStatePill } from "./state-pill"; +import { cn, renderFormattedDate, renderFormattedPayloadDate } from "@plane/utils"; +import { DateDropdown } from "@/components/dropdowns/date"; +import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; +import { MemberDropdown } from "@/components/dropdowns/member/dropdown"; +import { MemberDropdownBase } from "@/components/dropdowns/member/base"; +import { PriorityDropdown } from "@/components/dropdowns/priority"; +import { WorkItemStateDropdownBase } from "@/components/dropdowns/state/base"; +import { StateDropdown } from "@/components/dropdowns/state/dropdown"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useMember } from "@/hooks/store/use-member"; +import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board"; +import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours"; +import { useProjectState } from "@/hooks/store/use-project-state"; +import { useUserPermissions } from "@/hooks/store/user"; +import { IssueService } from "@/services/issue/issue.service"; type Props = { currentTab: TInboxIssueCurrentTab; @@ -21,72 +44,340 @@ type Props = { workspaceSlug: string; }; +const issueService = new IssueService(); + +const basePillClasses = + "inline-flex min-h-9 items-center gap-1.5 rounded-full border-0 px-2.5 py-1 text-[11px] font-medium shadow-none outline-none transition-colors"; + +const buildSourceStateMap = ( + states: { id: string; name: string; color: string; group: IState["group"] }[] | undefined, + projectId: string | null +) => + Object.fromEntries( + (states ?? []).map((state, index) => [ + state.id, + { + id: state.id, + color: state.color, + default: false, + description: "", + group: state.group, + name: state.name, + order: index + 1, + project_id: projectId ?? "", + sequence: index + 1, + workspace_id: "", + } satisfies IState, + ]) + ); + +const resolveRequestStatus = (issue: TExternalContourRequest["issue"], fallbackStatus: TExternalContourRequest["status"]) => { + const stateGroup = issue.state_detail?.group; + if (!stateGroup) return fallbackStatus; + return stateGroup === "completed" || stateGroup === "cancelled" ? "closed" : "open"; +}; + export const ExternalContoursBoardItem = observer(function ExternalContoursBoardItem(props: Props) { const { currentTab, direction, projectId, request, workspaceSlug } = props; + const router = useAppRouter(); + const searchParams = useSearchParams(); const { t } = useTranslation(); + const { getUserDetails, workspace } = useMember(); + const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions(); + const { getStateById, getProjectStateIds } = useProjectState(); + const { + currentTab: boardCurrentTab, + fetchBoard, + upsertBoardItems, + } = useProjectExternalContoursBoard(); + const { + fetchTargetOptions, + getTargetOptionsByProjectId, + updateRequest, + updateRequestIssue, + } = useProjectExternalContours(); + const [isUpdating, setIsUpdating] = useState(false); + const [isSourceOptionsLoading, setIsSourceOptionsLoading] = useState(false); + const issue = request.issue; + const selectedInboxIssueId = searchParams.get("inboxIssueId"); + const isActive = selectedInboxIssueId === request.id; const requester = request.requested_by?.display_name || request.requested_by_name || issue.created_by_detail?.display_name || "NODE.DC"; const requesterAvatar = issue.created_by_detail?.avatar_url || ""; const counterpartContourName = direction === "outgoing" ? request.target_project?.name || request.target_project_name || issue.project_detail?.name : request.source_project?.name || request.source_project_name; - const assigneeDetails = issue.assignee_details?.slice(0, 2) ?? []; - const lastUpdatedAt = issue.updated_at || request.updated_at; + const targetProjectId = issue.project_id || request.target_project?.id || request.target_project_id || null; + const projectRole = targetProjectId + ? getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId) + : undefined; + const canEditTargetIssue = + direction === "incoming" && !!targetProjectId && projectRole !== undefined && projectRole !== EUserPermissions.GUEST; + const canEditSourceRequest = direction === "outgoing" && !!request.capabilities?.can_edit_request && !!targetProjectId; + const canEditCard = canEditTargetIssue || canEditSourceRequest; + const requestLink = `/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${request.id}`; + const targetOptions = getTargetOptionsByProjectId(targetProjectId); + const sourceStateMap = useMemo( + () => buildSourceStateMap(targetOptions?.states, targetProjectId), + [targetOptions?.states, targetProjectId] + ); + const sourceStateIds = useMemo(() => targetOptions?.states?.map((state) => state.id) ?? [], [targetOptions?.states]); + const selectedState = canEditTargetIssue ? getStateById(issue.state_id) : sourceStateMap[issue.state_id ?? ""]; + const projectStateIds = issue.project_id ? getProjectStateIds(issue.project_id) : []; + const foregroundClasses = isActive ? "text-[#111111]" : "text-white"; + const subtleTextClasses = isActive ? "text-[#2F4721]" : "text-[#B3B3B8]"; + const pillBackgroundClasses = + isActive ? "bg-black/10 text-[#111111]" : "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white"; + const iconBubbleClasses = isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white"; + const statusIconColor = selectedState?.color ?? (isActive ? "#111111" : "var(--text-color-primary)"); + const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none"); + + if (!issue) return null; + + const stopCardPropagation = (event: React.MouseEvent) => { + event.stopPropagation(); + }; + + const openDetail = () => { + if (isActive) return; + router.push(requestLink); + }; + + const syncBoardAfterMutation = async () => { + await fetchBoard(workspaceSlug, projectId, boardCurrentTab ?? currentTab); + }; + + const ensureSourceOptions = async () => { + if (!canEditSourceRequest || !targetProjectId) return; + + const tasks: Promise[] = []; + if (!targetOptions) { + setIsSourceOptionsLoading(true); + tasks.push(fetchTargetOptions(workspaceSlug, projectId, targetProjectId)); + } + if (!workspace.workspaceMemberIds) { + tasks.push(workspace.fetchWorkspaceMembers(workspaceSlug)); + } + + if (tasks.length === 0) return; + + try { + await Promise.all(tasks); + } finally { + setIsSourceOptionsLoading(false); + } + }; + + const handleTargetIssueUpdate = async (data: Partial) => { + if (!targetProjectId || !issue.id || isUpdating) return; + + setIsUpdating(true); + try { + const updatedIssue = await issueService.patchIssue(workspaceSlug, targetProjectId, issue.id, data); + const nextIssue = { ...issue, ...updatedIssue }; + const nextRequest = { + ...request, + issue: nextIssue, + status: resolveRequestStatus(nextIssue, request.status), + }; + + updateRequestIssue(request.id, nextIssue); + upsertBoardItems([nextRequest]); + await syncBoardAfterMutation(); + } catch { + setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("issue_could_not_be_updated") }); + } finally { + setIsUpdating(false); + } + }; + + const handleSourceRequestUpdate = async (data: Partial) => { + if (!canEditSourceRequest || isUpdating) return; + + setIsUpdating(true); + try { + const updatedRequest = await updateRequest(workspaceSlug, projectId, request.id, data); + if (updatedRequest) { + upsertBoardItems([updatedRequest]); + } + await syncBoardAfterMutation(); + } catch { + setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("issue_could_not_be_updated") }); + } finally { + setIsUpdating(false); + } + }; + + const handleCardUpdate = async (data: Partial) => { + if (canEditTargetIssue) { + await handleTargetIssueUpdate(data); + return; + } + + await handleSourceRequestUpdate(data); + }; return ( - -
-
-
-
- -
-
{requester}
-
-
- -
- {request.has_unread_updates && } - -
-
- -
- {counterpartContourName || t("common.none")} -
-
- -
-

{issue.name}

-
- -
-
- {assigneeDetails.length > 0 ? ( - assigneeDetails.map((assignee, index) => ( -
0 && "-ml-2")}> - +
+
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openDetail(); + } + }} + > +
+
+
+
+
+
- )) - ) : ( -
{t("external_contours_page.list.unassigned")}
- )} +
{requester}
+
+ +
+ {request.has_unread_updates && ( + + )} + + void handleCardUpdate({ priority })} + disabled={!canEditCard || isUpdating} + buttonVariant="transparent-without-text" + button={ +
+ +
+ } + /> + + {canEditTargetIssue ? ( + void handleCardUpdate({ state_id: stateId })} + disabled={!canEditCard || isUpdating} + buttonVariant="transparent-without-text" + button={ +
+ +
+ } + /> + ) : ( + (stateId ? sourceStateMap[stateId] : undefined)} + onChange={(stateId) => void handleCardUpdate({ state_id: stateId })} + disabled={!canEditCard || isUpdating || !targetProjectId} + isInitializing={isSourceOptionsLoading} + onDropdownOpen={() => { + void ensureSourceOptions(); + }} + buttonVariant="transparent-without-text" + button={ +
+ +
+ } + /> + )} +
+
+ +
+ {counterpartContourName || t("common.none")} +
-
-
{renderFormattedDate(lastUpdatedAt ?? "")}
- {issue.priority && issue.priority !== "none" && ( -
- -
+
+
{issue.name}
+
+ +
+ {canEditTargetIssue ? ( + void handleCardUpdate({ assignee_ids: assigneeIds })} + disabled={!canEditCard || isUpdating} + buttonVariant="transparent-without-text" + button={ +
+ +
+ } + /> + ) : ( + void handleCardUpdate({ assignee_ids: assigneeIds })} + disabled={!canEditCard || isUpdating || !targetProjectId} + onDropdownOpen={() => { + void ensureSourceOptions(); + }} + buttonVariant="transparent-without-text" + button={ +
+ +
+ } + /> )} + + + void handleCardUpdate({ + target_date: targetDate ? renderFormattedPayloadDate(targetDate) : null, + }) + } + disabled={!canEditCard || isUpdating} + buttonVariant="transparent-without-text" + button={ +
+ + {dueDateLabel} +
+ } + />
- +
); }); diff --git a/plane-src/apps/web/ce/components/projects/external-contours/issue-root.tsx b/plane-src/apps/web/ce/components/projects/external-contours/issue-root.tsx index d0babe8..a058bb9 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/issue-root.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/issue-root.tsx @@ -125,10 +125,7 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou remove: async () => undefined, update: async (_workspaceSlug: string, _projectId: string, requestId: string, data: Partial) => { try { - await updateRequest(workspaceSlug, sourceProjectId, requestId, { - name: data.name, - description_html: data.description_html, - }); + await updateRequest(workspaceSlug, sourceProjectId, requestId, data); } catch { setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("issue_could_not_be_updated") }); } diff --git a/plane-src/apps/web/core/components/dropdowns/state/base.tsx b/plane-src/apps/web/core/components/dropdowns/state/base.tsx index 2f5de61..b980aee 100644 --- a/plane-src/apps/web/core/components/dropdowns/state/base.tsx +++ b/plane-src/apps/web/core/components/dropdowns/state/base.tsx @@ -7,6 +7,7 @@ import type { ReactNode } from "react"; import { useRef, useState } from "react"; import { observer } from "mobx-react"; +import { createPortal } from "react-dom"; import { usePopper } from "react-popper"; import { Combobox } from "@headlessui/react"; // plane imports @@ -211,60 +212,62 @@ export const WorkItemStateDropdownBase = observer(function WorkItemStateDropdown button={comboButton} renderByDefault={renderByDefault} > - {isOpen && ( - -
-
- - setQuery(e.target.value)} - placeholder={t("common.search.label")} - displayValue={(assigned: any) => assigned?.name} - onKeyDown={searchInputKeyDown} - /> -
-
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - cn( - `flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-[0.9rem] px-2 py-2 select-none outline-none ${ - active ? "bg-white/6" : "" - } ${selected ? "text-primary" : "text-secondary"}` - ) - } - > - {({ selected }) => ( - <> - {option.content} - {selected && } - - )} - - )) + {isOpen && + createPortal( + +
+
+ + setQuery(e.target.value)} + placeholder={t("common.search.label")} + displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} + /> +
+
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + cn( + `flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-[0.9rem] px-2 py-2 select-none outline-none ${ + active ? "bg-white/6" : "" + } ${selected ? "text-primary" : "text-secondary"}` + ) + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

{t("no_matching_results")}

+ ) ) : ( -

{t("no_matching_results")}

- ) - ) : ( -

{t("loading")}

- )} +

{t("loading")}

+ )} +
-
- - )} + , + document.body + )} ); }); 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 ea37c3d..589291d 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 @@ -71,7 +71,7 @@ export class ExternalContourService extends APIService { workspaceSlug: string, projectId: string, requestId: string, - data: Pick, "name" | "description_html"> + data: Pick, "name" | "description_html" | "priority" | "target_date" | "assignee_ids" | "label_ids" | "state_id"> ): Promise { return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/`, data) .then((response) => response?.data) diff --git a/plane-src/apps/web/core/store/external-contours/project-external-contours.store.ts b/plane-src/apps/web/core/store/external-contours/project-external-contours.store.ts index a0d6cdc..2b3e352 100644 --- a/plane-src/apps/web/core/store/external-contours/project-external-contours.store.ts +++ b/plane-src/apps/web/core/store/external-contours/project-external-contours.store.ts @@ -38,7 +38,7 @@ export interface IProjectExternalContoursStore { workspaceSlug: string, projectId: string, requestId: string, - data: Pick, "name" | "description_html"> + data: Pick, "name" | "description_html" | "priority" | "target_date" | "assignee_ids" | "label_ids" | "state_id"> ) => Promise; decideRequest: ( workspaceSlug: string, @@ -251,7 +251,7 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto workspaceSlug: string, projectId: string, requestId: string, - data: Pick, "name" | "description_html"> + data: Pick, "name" | "description_html" | "priority" | "target_date" | "assignee_ids" | "label_ids" | "state_id"> ) => { this.loader = "mutation-loading"; try { diff --git a/plane-src/packages/i18n/src/locales/en/translations.ts b/plane-src/packages/i18n/src/locales/en/translations.ts index 8cc49e5..a633c0e 100644 --- a/plane-src/packages/i18n/src/locales/en/translations.ts +++ b/plane-src/packages/i18n/src/locales/en/translations.ts @@ -303,9 +303,12 @@ export default { }, filters: { sort: "Sorting", + contour: "Contour", requester: "Requester", unread_only: "Only with updates", search_placeholder: "Search by request title", + search_contour: "Search by contour", + search_state: "Search by state", search_priority: "Search by priority", search_assignee: "Search by assignee", search_requester: "Search by requester", diff --git a/plane-src/packages/i18n/src/locales/ru/translations.ts b/plane-src/packages/i18n/src/locales/ru/translations.ts index 5d40f56..14aa842 100644 --- a/plane-src/packages/i18n/src/locales/ru/translations.ts +++ b/plane-src/packages/i18n/src/locales/ru/translations.ts @@ -460,9 +460,12 @@ export default { }, filters: { sort: "Сортировка", + contour: "Контур", requester: "Отправитель", unread_only: "Только с изменениями", search_placeholder: "Поиск по названию запроса", + search_contour: "Поиск по контуру", + search_state: "Поиск по статусу", search_priority: "Поиск по приоритету", search_assignee: "Поиск по исполнителю", search_requester: "Поиск по отправителю", diff --git a/plane-src/packages/types/src/external-contours.ts b/plane-src/packages/types/src/external-contours.ts index ce4ac4c..a276290 100644 --- a/plane-src/packages/types/src/external-contours.ts +++ b/plane-src/packages/types/src/external-contours.ts @@ -110,6 +110,7 @@ export type TExternalContourBoardFilter = { assignee_ids?: string[]; created_by_ids?: string[]; requested_by_ids?: string[]; + counterparty_project_ids?: string[]; source_project_ids?: string[]; target_project_ids?: string[]; label_ids?: string[]; @@ -143,5 +144,6 @@ export type TExternalContourTargetProject = IProjectLite & { export type TExternalContourTargetOptions = { project: TExternalContourTargetProject; member_ids: string[]; + states: Pick[]; labels: Pick[]; }; diff --git a/plane-src/packages/ui/src/dropdown/common/input-search.tsx b/plane-src/packages/ui/src/dropdown/common/input-search.tsx index 9b96a23..54c7d45 100644 --- a/plane-src/packages/ui/src/dropdown/common/input-search.tsx +++ b/plane-src/packages/ui/src/dropdown/common/input-search.tsx @@ -44,7 +44,7 @@ export function InputSearch(props: IInputSearch) { return (
@@ -53,7 +53,7 @@ export function InputSearch(props: IInputSearch) { as="input" ref={inputRef} className={cn( - "w-full bg-transparent py-1 text-11 text-secondary placeholder:text-placeholder focus:outline-none", + "w-full bg-transparent py-0 text-12 text-secondary placeholder:text-placeholder focus:outline-none", inputClassName )} value={query} diff --git a/plane-src/packages/ui/src/dropdown/common/options.tsx b/plane-src/packages/ui/src/dropdown/common/options.tsx index fb8a1e5..ca097ea 100644 --- a/plane-src/packages/ui/src/dropdown/common/options.tsx +++ b/plane-src/packages/ui/src/dropdown/common/options.tsx @@ -46,7 +46,7 @@ export function DropdownOptions(props: IMultiSelectDropdownOptions | ISingleSele isMobile={isMobile} /> )} -
+
<> {options ? ( options.length > 0 ? ( @@ -57,9 +57,9 @@ export function DropdownOptions(props: IMultiSelectDropdownOptions | ISingleSele disabled={option.disabled} className={({ active, selected }) => cn( - "flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 select-none", + "nodedc-dropdown-option", { - "bg-layer-1": active, + "bg-white/6": active, "text-primary": selected, "text-secondary": !selected, }, diff --git a/plane-src/packages/ui/src/dropdown/multi-select.tsx b/plane-src/packages/ui/src/dropdown/multi-select.tsx index ba3f3ea..bd42b25 100644 --- a/plane-src/packages/ui/src/dropdown/multi-select.tsx +++ b/plane-src/packages/ui/src/dropdown/multi-select.tsx @@ -7,6 +7,7 @@ import { Combobox } from "@headlessui/react"; import { sortBy } from "lodash-es"; import React, { useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { usePopper } from "react-popper"; // plane imports import { useOutsideClickDetector } from "@plane/hooks"; @@ -139,35 +140,34 @@ export function MultiSelectDropdown(props: IMultiSelectDropdown) { disabled={disabled} /> - {isOpen && ( - -
- -
-
- )} + {isOpen && + createPortal( + +
+ +
+
, + document.body + )} ); } diff --git a/plane-src/packages/ui/src/dropdown/single-select.tsx b/plane-src/packages/ui/src/dropdown/single-select.tsx index 4c16c4c..bd2ccf9 100644 --- a/plane-src/packages/ui/src/dropdown/single-select.tsx +++ b/plane-src/packages/ui/src/dropdown/single-select.tsx @@ -7,6 +7,7 @@ import { Combobox } from "@headlessui/react"; import { sortBy } from "lodash-es"; import React, { useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { usePopper } from "react-popper"; // plane imports import { useOutsideClickDetector } from "@plane/hooks"; @@ -138,36 +139,35 @@ export function Dropdown(props: ISingleSelectDropdown) { disabled={disabled} /> - {isOpen && ( - -
- -
-
- )} + {isOpen && + createPortal( + +
+ +
+
, + document.body + )} ); }