UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: перевод внешних контуров на shell предложений

This commit is contained in:
DCCONSTRUCTIONS 2026-04-18 21:04:29 +03:00
parent c76f519488
commit 390bcdbf38
20 changed files with 1586 additions and 96 deletions

View File

@ -5,53 +5,74 @@
*/
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
import { EInboxIssueCurrentTab } from "@plane/types";
import { useTranslation } from "@plane/i18n";
import { TransferIcon } from "@plane/propel/icons";
import darkIntakeAsset from "@/app/assets/empty-state/disabled-feature/intake-dark.webp?url";
import lightIntakeAsset from "@/app/assets/empty-state/disabled-feature/intake-light.webp?url";
import { PageHead } from "@/components/core/page-title";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { EUserPermissionsLevel } from "@plane/constants";
import { EUserProjectRoles } from "@plane/types";
import { ExternalContoursRoot } from "@/plane-web/components/projects/external-contours/root";
import type { Route } from "./+types/page";
function ProjectExternalContoursPage(_props: Route.ComponentProps) {
function ProjectExternalContoursPage({ params }: Route.ComponentProps) {
const router = useAppRouter();
const { workspaceSlug, projectId } = params;
const searchParams = useSearchParams();
const navigationTab = searchParams.get("currentTab");
const inboxIssueId = searchParams.get("inboxIssueId");
const { resolvedTheme } = useTheme();
const { t } = useTranslation();
const { currentProjectDetails } = useProject();
const { allowPermissions } = useUserPermissions();
const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT);
const resolvedPath = resolvedTheme === "light" ? lightIntakeAsset : darkIntakeAsset;
if (currentProjectDetails?.inbox_view === false)
return (
<div className="flex h-full w-full items-center justify-center">
<DetailedEmptyState
title={t("external_contours_page.disabled.title")}
description={t("external_contours_page.disabled.description")}
assetPath={resolvedPath}
primaryButton={{
text: t("external_contours_page.disabled.button"),
onClick: () => {
router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`);
},
disabled: !canPerformEmptyStateActions,
}}
/>
</div>
);
const pageTitle = currentProjectDetails?.name
? t("external_contours_page.page_label", { workspace: currentProjectDetails.name })
: t("external_contours_page.page_label", { workspace: "NODE.DC" });
const currentNavigationTab = navigationTab
? navigationTab === "open"
? EInboxIssueCurrentTab.OPEN
: EInboxIssueCurrentTab.CLOSED
: undefined;
return (
<div className="flex h-full flex-col">
<PageHead title={pageTitle} />
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-5 py-6 md:px-8">
<section className="rounded-xl border border-subtle bg-surface-1 p-6">
<div className="flex items-start gap-4">
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-accent-primary/10 text-accent-primary">
<TransferIcon className="h-5 w-5 fill-current" />
</div>
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold text-primary">{t("external_contours_page.empty_state.title")}</h1>
<p className="max-w-3xl text-sm text-secondary">
{t("external_contours_page.empty_state.description")}
</p>
</div>
</div>
</section>
<section className="grid gap-4 lg:grid-cols-2">
<div className="rounded-xl border border-dashed border-subtle bg-surface-1 p-6">
<p className="mb-2 text-base font-semibold text-primary">
{t("external_contours_page.empty_state.request_title")}
</p>
<p className="text-sm text-secondary">{t("external_contours_page.empty_state.request_description")}</p>
</div>
<div className="rounded-xl border border-dashed border-subtle bg-surface-1 p-6">
<p className="mb-2 text-base font-semibold text-primary">
{t("external_contours_page.empty_state.list_title")}
</p>
<p className="text-sm text-secondary">{t("external_contours_page.empty_state.list_description")}</p>
</div>
</section>
<div className="h-full w-full overflow-hidden">
<ExternalContoursRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxIssueId={inboxIssueId || undefined}
inboxAccessible={currentProjectDetails?.inbox_view || false}
navigationTab={currentNavigationTab}
/>
</div>
</div>
);

View File

@ -0,0 +1,85 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import type { TNameDescriptionLoader } from "@plane/types";
import { ContentWrapper } from "@plane/ui";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { ExternalContoursIssueActionsHeader } from "./issue-header";
import { ExternalContoursIssueMainContent } from "./issue-root";
type Props = {
workspaceSlug: string;
projectId: string;
inboxIssueId: string;
isMobileSidebar: boolean;
setIsMobileSidebar: (value: boolean) => void;
};
export const ExternalContoursContentRoot = observer(function ExternalContoursContentRoot(props: Props) {
const { workspaceSlug, projectId, inboxIssueId, isMobileSidebar, setIsMobileSidebar } = props;
const router = useAppRouter();
const [isSubmitting, setIsSubmitting] = useState<TNameDescriptionLoader>("saved");
const { data: currentUser } = useUser();
const { currentTab, fetchInboxIssueById, getIssueInboxByIssueId, getIsIssueAvailable } = useProjectInbox();
const inboxIssue = getIssueInboxByIssueId(inboxIssueId);
const { allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
const isIssueAvailable = getIsIssueAvailable(inboxIssueId?.toString() || "");
useEffect(() => {
if (!isIssueAvailable && inboxIssueId) {
router.replace(`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}`);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isIssueAvailable]);
useSWR(
workspaceSlug && projectId && inboxIssueId ? `PROJECT_EXTERNAL_CONTOUR_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}` : null,
workspaceSlug && projectId && inboxIssueId ? () => fetchInboxIssueById(workspaceSlug, projectId, inboxIssueId) : null,
{ revalidateOnFocus: false, revalidateIfStale: false }
);
const isEditable =
allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId) ||
inboxIssue?.issue.created_by === currentUser?.id;
const isGuest = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId) === EUserPermissions.GUEST;
const isOwner = inboxIssue?.issue.created_by === currentUser?.id;
const readOnly = !isOwner && isGuest;
if (!inboxIssue) return <></>;
return (
<div className="relative flex h-full w-full flex-col overflow-hidden">
<div className="z-[11] min-h-[52px] flex-shrink-0">
<ExternalContoursIssueActionsHeader
setIsMobileSidebar={setIsMobileSidebar}
isMobileSidebar={isMobileSidebar}
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxIssue={inboxIssue}
isSubmitting={isSubmitting}
/>
</div>
<ContentWrapper className="divide-y-2 divide-subtle-1">
<ExternalContoursIssueMainContent
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxIssue={inboxIssue}
isEditable={isEditable && !readOnly}
isSubmitting={isSubmitting}
setIsSubmitting={setIsSubmitting}
/>
</ContentWrapper>
</div>
);
});

View File

@ -0,0 +1,30 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
import { ExternalContoursCreateRoot } from "./create-root";
type Props = {
workspaceSlug: string;
projectId: string;
modalState: boolean;
handleModalClose: () => void;
};
export function ExternalContourCreateModalRoot(props: Props) {
const { workspaceSlug, projectId, modalState, handleModalClose } = props;
return (
<ModalCore
isOpen={modalState}
position={EModalPosition.TOP}
width={EModalWidth.XXXXL}
className="rounded-lg !bg-transparent shadow-none transition-[width] ease-linear"
>
<ExternalContoursCreateRoot workspaceSlug={workspaceSlug} projectId={projectId} handleModalClose={handleModalClose} />
</ModalCore>
);
}

View File

@ -0,0 +1,108 @@
/**
* 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 { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { Badge } from "@plane/propel/badge";
import type { TIssue, TIssuePriorities } from "@plane/types";
import { renderFormattedPayloadDate } from "@plane/utils";
import { DateDropdown } from "@/components/dropdowns/date";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { PriorityDropdown } from "@/components/dropdowns/priority";
import { ProjectDropdown } from "@/components/dropdowns/project/dropdown";
import { IssueLabelSelect } from "@/components/issues/select";
import { useProject } from "@/hooks/store/use-project";
type Props = {
currentProjectId: string;
currentProjectName?: string;
data: Partial<TIssue> & { target_project_id?: string | null; priority?: TIssuePriorities };
handleData: (issueKey: keyof Props["data"], issueValue: Props["data"][keyof Props["data"]]) => void;
};
export const ExternalContoursCreateProperties = observer(function ExternalContoursCreateProperties(props: Props) {
const { currentProjectId, currentProjectName, data, handleData } = props;
const { t } = useTranslation();
const { getProjectById } = useProject();
const selectedTargetProject = useMemo(
() => (data.target_project_id ? getProjectById(data.target_project_id) : undefined),
[data.target_project_id, getProjectById]
);
return (
<div className="relative flex flex-wrap items-center gap-2">
<div className="rounded-md border border-subtle bg-surface-2 px-3 py-1.5 text-11 text-secondary">
<span className="mr-2 text-tertiary">{t("external_contours_page.form.source_project")}</span>
<Badge variant="neutral">{currentProjectName || "NODE.DC"}</Badge>
</div>
<div className="h-7">
<ProjectDropdown
value={data.target_project_id ?? null}
onChange={(value) => {
if (!Array.isArray(value)) {
handleData("target_project_id", value);
handleData("assignee_ids", []);
handleData("label_ids", []);
}
}}
multiple={false}
buttonVariant="border-with-text"
placeholder={t("external_contours_page.form.target_project")}
renderCondition={(projectId) => projectId !== currentProjectId}
/>
</div>
{selectedTargetProject && (
<div className="rounded-md border border-subtle bg-surface-2 px-3 py-1.5 text-11 text-secondary">
<span className="mr-2 text-tertiary">{t("external_contours_page.form.selected_target")}</span>
<Badge variant="neutral">{selectedTargetProject.name}</Badge>
</div>
)}
<div className="h-7">
<PriorityDropdown
value={data.priority}
onChange={(priority) => handleData("priority", priority)}
buttonVariant="border-with-text"
/>
</div>
<div className="h-7">
<MemberDropdown
projectId={data.target_project_id ?? undefined}
value={data.assignee_ids || []}
onChange={(assigneeIds) => handleData("assignee_ids", assigneeIds)}
buttonVariant={(data.assignee_ids || []).length > 0 ? "transparent-without-text" : "border-with-text"}
buttonClassName={(data.assignee_ids || []).length > 0 ? "hover:bg-transparent" : ""}
placeholder={t("external_contours_page.form.assignee")}
disabled={!data.target_project_id}
multiple
/>
</div>
<div className="h-7">
<IssueLabelSelect
value={data.label_ids || []}
onChange={(labelIds) => handleData("label_ids", labelIds)}
projectId={data.target_project_id ?? undefined}
disabled={!data.target_project_id}
/>
</div>
<div className="h-7">
<DateDropdown
value={data.target_date || null}
onChange={(date) => handleData("target_date", date ? renderFormattedPayloadDate(date) : "")}
buttonVariant="border-with-text"
placeholder={t("external_contours_page.form.due_date")}
/>
</div>
</div>
);
});

View File

@ -0,0 +1,139 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { FormEvent } from "react";
import { useRef, useState } from "react";
import { observer } from "mobx-react";
import type { EditorRefApi } from "@plane/editor";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { useProject } from "@/hooks/store/use-project";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { InboxIssueDescription } from "@/components/inbox/modals/create-modal/issue-description";
import { InboxIssueTitle } from "@/components/inbox/modals/create-modal/issue-title";
import { ExternalContoursCreateProperties } from "./create-properties";
const defaultIssueData: Partial<TIssue> & { target_project_id?: string | null } = {
id: undefined,
name: "",
description_html: "",
priority: "none",
target_project_id: null,
label_ids: [],
assignee_ids: [],
target_date: "",
};
type Props = {
workspaceSlug: string;
projectId: string;
handleModalClose: () => void;
};
export const ExternalContoursCreateRoot = observer(function ExternalContoursCreateRoot(props: Props) {
const { workspaceSlug, projectId, handleModalClose } = props;
const { t } = useTranslation();
const { getWorkspaceBySlug } = useWorkspace();
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id;
const { currentProjectDetails } = useProject();
const descriptionEditorRef = useRef<EditorRefApi>(null);
const [createMore, setCreateMore] = useState(false);
const [formSubmitting, setFormSubmitting] = useState(false);
const [formData, setFormData] = useState<Partial<TIssue> & { target_project_id?: string | null }>(defaultIssueData);
const handleFormData = <T extends keyof typeof formData>(issueKey: T, issueValue: (typeof formData)[T]) => {
setFormData((current) => ({ ...current, [issueKey]: issueValue }));
};
const isTitleLengthMoreThan255Character = formData?.name ? formData.name.length > 255 : false;
const canSubmit = !!formData.name?.trim() && !!formData.target_project_id;
const handleFormSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!descriptionEditorRef.current?.isEditorReadyToDiscard()) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("editor_is_not_ready_to_discard_changes"),
});
return;
}
setFormSubmitting(true);
setToast({
type: TOAST_TYPE.INFO,
title: t("external_contours_page.modal.toast_title"),
message: t("external_contours_page.modal.toast_message"),
});
setFormSubmitting(false);
if (!createMore) {
handleModalClose();
}
};
if (!workspaceSlug || !projectId || !workspaceId) return <></>;
return (
<div className="flex w-full gap-2 bg-transparent">
<div className="w-full rounded-lg">
<form onSubmit={handleFormSubmit} className="flex w-full flex-col">
<div className="space-y-5 rounded-t-lg bg-surface-1 p-5">
<div className="flex items-center justify-between gap-2">
<h3 className="text-18 font-medium text-secondary">{t("external_contours_page.modal.title")}</h3>
</div>
<div className="space-y-3">
<InboxIssueTitle
data={formData}
handleData={handleFormData as any}
isTitleLengthMoreThan255Character={isTitleLengthMoreThan255Character}
/>
<InboxIssueDescription
workspaceSlug={workspaceSlug}
projectId={projectId}
workspaceId={workspaceId}
data={formData}
handleData={handleFormData as any}
editorRef={descriptionEditorRef}
containerClassName="min-h-[150px] border-[0.5px] border-subtle-1 bg-layer-2 py-3"
onAssetUpload={() => {}}
/>
<ExternalContoursCreateProperties
currentProjectId={projectId}
currentProjectName={currentProjectDetails?.name}
data={formData}
handleData={handleFormData as any}
/>
</div>
</div>
<div className="flex items-center justify-between gap-2 rounded-b-lg border-t-[0.5px] border-subtle bg-surface-1 px-5 py-4">
<div
className="inline-flex cursor-pointer items-center gap-1.5"
onClick={() => setCreateMore((prevData) => !prevData)}
role="button"
tabIndex={0}
>
<ToggleSwitch value={createMore} onChange={() => {}} size="sm" />
<span className="text-11">{t("create_more")}</span>
</div>
<div className="flex items-center gap-3">
<Button variant="secondary" size="lg" type="button" onClick={handleModalClose}>
{t("cancel")}
</Button>
<Button type="submit" variant="primary" size="lg" loading={formSubmitting} disabled={!canSubmit || isTitleLengthMoreThan255Character}>
{t("external_contours_page.modal.submit")}
</Button>
</div>
</div>
</form>
</div>
</div>
);
});

View File

@ -4,36 +4,77 @@
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { RefreshCcw } from "lucide-react";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { TransferIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { useProject } from "@/hooks/store/use-project";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useUserPermissions } from "@/hooks/store/user";
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { ExternalContourCreateModalRoot } from "./create-modal";
export const ProjectExternalContoursHeader = observer(function ProjectExternalContoursHeader() {
const [createIssueModal, setCreateIssueModal] = useState(false);
const { workspaceSlug, projectId } = useParams();
const { t } = useTranslation();
const { allowPermissions } = useUserPermissions();
const { currentProjectDetails, loader: currentProjectDetailsLoader } = useProject();
const { loader } = useProjectInbox();
const isAuthorized = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
EUserPermissionsLevel.PROJECT
);
return (
<Header>
<Header.LeftItem>
<Breadcrumbs>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t("external_contours_page.title")}
href={`/${workspaceSlug}/projects/${projectId}/external-contours/`}
icon={<TransferIcon className="h-4 w-4 text-tertiary" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>
<div className="flex flex-grow items-center gap-4">
<Breadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t("external_contours_page.title")}
href={`/${workspaceSlug}/projects/${projectId}/external-contours/`}
icon={<TransferIcon className="h-4 w-4 text-tertiary" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>
{loader === "pagination-loading" && (
<div className="flex items-center gap-1.5 text-tertiary">
<RefreshCcw className="h-3.5 w-3.5 animate-spin" />
<p className="text-13">{t("syncing")}...</p>
</div>
)}
</div>
</Header.LeftItem>
<Header.RightItem>
{currentProjectDetails?.inbox_view && workspaceSlug && projectId && isAuthorized ? (
<div className="flex items-center gap-2">
<ExternalContourCreateModalRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
modalState={createIssueModal}
handleModalClose={() => setCreateIssueModal(false)}
/>
<Button variant="primary" size="lg" onClick={() => setCreateIssueModal(true)}>
{t("external_contours_page.header.add_request")}
</Button>
</div>
) : null}
</Header.RightItem>
</Header>
);
});

View File

@ -0,0 +1,175 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useEffect } from "react";
import { observer } from "mobx-react";
import { PanelLeft } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { IconButton } from "@plane/propel/icon-button";
import { CheckCircleFilledIcon, ChevronDownIcon, ChevronUpIcon, CloseCircleFilledIcon, LinkIcon, NewTabIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TNameDescriptionLoader } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types";
import { ControlLink, Header, Row } from "@plane/ui";
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status";
import { useProject } from "@/hooks/store/use-project";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useAppRouter } from "@/hooks/use-app-router";
import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
import { InboxIssueStatus } from "@/components/inbox/inbox-issue-status";
type Props = {
workspaceSlug: string;
projectId: string;
inboxIssue: IInboxIssueStore | undefined;
isSubmitting: TNameDescriptionLoader;
isMobileSidebar: boolean;
setIsMobileSidebar: (value: boolean) => void;
};
export const ExternalContoursIssueActionsHeader = observer(function ExternalContoursIssueActionsHeader(props: Props) {
const { workspaceSlug, projectId, inboxIssue, isSubmitting, isMobileSidebar, setIsMobileSidebar } = props;
const { t } = useTranslation();
const router = useAppRouter();
const { currentTab, filteredInboxIssueIds } = useProjectInbox();
const { getProjectById } = useProject();
const issue = inboxIssue?.issue;
const currentInboxIssueId = issue?.id;
const redirectToRelativeIssue = useCallback(
(direction: "next" | "prev") => {
if (!filteredInboxIssueIds || !currentInboxIssueId) return;
const currentIssueIndex = filteredInboxIssueIds.findIndex((issueId) => issueId === currentInboxIssueId);
const nextIssueIndex =
direction === "next"
? (currentIssueIndex + 1) % filteredInboxIssueIds.length
: (currentIssueIndex - 1 + filteredInboxIssueIds.length) % filteredInboxIssueIds.length;
const nextIssueId = filteredInboxIssueIds[nextIssueIndex];
if (!nextIssueId) return;
router.push(`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${nextIssueId}`);
},
[currentInboxIssueId, currentTab, filteredInboxIssueIds, projectId, router, workspaceSlug]
);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowUp") redirectToRelativeIssue("prev");
if (event.key === "ArrowDown") redirectToRelativeIssue("next");
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [redirectToRelativeIssue]);
if (!issue || !inboxIssue) return null;
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId: issue.project_id,
issueId: currentInboxIssueId,
projectIdentifier: getProjectById(issue.project_id)?.identifier,
sequenceId: issue.sequence_id,
});
const showWorkflowToast = (actionLabel: string) =>
setToast({
type: TOAST_TYPE.INFO,
title: t("external_contours_page.actions.unsupported_title"),
message: t("external_contours_page.actions.unsupported_message", { action: actionLabel.toLowerCase() }),
});
const handleCopyLink = () =>
copyUrlToClipboard(workItemLink).then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
message: t("common.copied_to_clipboard"),
})
);
const isOpenTab = currentTab === EInboxIssueCurrentTab.OPEN;
return (
<>
<Row className="relative z-15 hidden h-full w-full items-center justify-between gap-2 border-b border-subtle bg-surface-1 lg:flex">
<div className="flex items-center gap-4">
{issue?.project_id && issue.sequence_id && (
<h3 className="flex-shrink-0 text-14 font-medium text-tertiary">
{getProjectById(issue.project_id)?.identifier}-{issue.sequence_id}
</h3>
)}
<InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />
<div className="flex w-full items-center justify-end">
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-x-2">
<IconButton variant="secondary" size="lg" icon={ChevronUpIcon} aria-label="Previous request" onClick={() => redirectToRelativeIssue("prev")} />
<IconButton variant="secondary" size="lg" icon={ChevronDownIcon} aria-label="Next request" onClick={() => redirectToRelativeIssue("next")} />
</div>
<div className="flex flex-wrap items-center gap-2">
{isOpenTab ? (
<>
<Button variant="secondary" size="lg" onClick={() => showWorkflowToast(t("external_contours_page.actions.send"))}>
<CheckCircleFilledIcon className="size-4 shrink-0 text-success-secondary" />
{t("external_contours_page.actions.send")}
</Button>
<Button variant="secondary" size="lg" onClick={() => showWorkflowToast(t("external_contours_page.actions.decline"))}>
<CloseCircleFilledIcon className="size-4 shrink-0 text-danger-secondary" />
{t("external_contours_page.actions.decline")}
</Button>
</>
) : (
<>
<Button variant="secondary" size="lg" onClick={() => showWorkflowToast(t("external_contours_page.actions.accept"))}>
<CheckCircleFilledIcon className="size-4 shrink-0 text-success-secondary" />
{t("external_contours_page.actions.accept")}
</Button>
<Button variant="secondary" size="lg" onClick={() => showWorkflowToast(t("external_contours_page.actions.decline"))}>
<CloseCircleFilledIcon className="size-4 shrink-0 text-danger-secondary" />
{t("external_contours_page.actions.decline")}
</Button>
</>
)}
<Button variant="secondary" size="lg" prependIcon={<LinkIcon className="h-2.5 w-2.5" />} onClick={handleCopyLink}>
{t("external_contours_page.actions.copy")}
</Button>
<ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self">
<Button variant="secondary" size="lg" prependIcon={<NewTabIcon className="h-2.5 w-2.5" />}>
{t("external_contours_page.actions.open")}
</Button>
</ControlLink>
</div>
</div>
</Row>
<Header className="justify-start lg:hidden">
<PanelLeft
onClick={() => setIsMobileSidebar(!isMobileSidebar)}
className={`my-auto mr-2 h-4 w-4 flex-shrink-0 ${isMobileSidebar ? "text-accent-primary" : "text-secondary"}`}
/>
<div className="flex w-full items-center gap-2">
<InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />
<div className="ml-auto flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => showWorkflowToast(isOpenTab ? t("external_contours_page.actions.send") : t("external_contours_page.actions.accept"))}>
{isOpenTab ? t("external_contours_page.actions.send") : t("external_contours_page.actions.accept")}
</Button>
<Button variant="secondary" size="sm" onClick={() => showWorkflowToast(t("external_contours_page.actions.decline"))}>
{t("external_contours_page.actions.decline")}
</Button>
</div>
</div>
</Header>
</>
);
});

View File

@ -0,0 +1,178 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import {
DuplicatePropertyIcon,
DueDatePropertyIcon,
LabelPropertyIcon,
MembersPropertyIcon,
PriorityPropertyIcon,
StatePropertyIcon,
} from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import type { TInboxDuplicateIssueDetails, TIssue } from "@plane/types";
import { ControlLink } from "@plane/ui";
import { generateWorkItemLink, getDate, renderFormattedPayloadDate } from "@plane/utils";
import { DateDropdown } from "@/components/dropdowns/date";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { PriorityDropdown } from "@/components/dropdowns/priority";
import type { TIssueOperations } from "@/components/issues/issue-detail";
import { IssueLabel } from "@/components/issues/issue-detail/label";
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
type Props = {
workspaceSlug: string;
projectId: string;
issue: Partial<TIssue>;
issueOperations: TIssueOperations;
isEditable: boolean;
duplicateIssueDetails: TInboxDuplicateIssueDetails | undefined;
};
export const ExternalContoursIssueContentProperties = observer(function ExternalContoursIssueContentProperties(props: Props) {
const { workspaceSlug, projectId, issue, issueOperations, isEditable, duplicateIssueDetails } = props;
const { t } = useTranslation();
const router = useAppRouter();
const { currentProjectDetails } = useProject();
const minDate = issue.start_date ? getDate(issue.start_date) : null;
minDate?.setDate(minDate.getDate());
if (!issue || !issue?.id) return <></>;
const duplicateWorkItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId,
issueId: duplicateIssueDetails?.id,
projectIdentifier: currentProjectDetails?.identifier,
sequenceId: duplicateIssueDetails?.sequence_id,
});
return (
<div className="flex w-full flex-col divide-y-2 divide-subtle-1">
<div className="w-full overflow-y-auto">
<h5 className="mb-2 text-body-sm-medium">{t("external_contours_page.properties.section_title")}</h5>
<div className={`divide-y-2 divide-subtle-1 ${!isEditable ? "opacity-60" : ""}`}>
<div className="flex flex-col gap-3">
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
<StatePropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("external_contours_page.properties.target_contour")}</span>
</div>
<div className="w-3/5 flex-grow text-13 text-placeholder">
{t("external_contours_page.properties.target_contour_placeholder")}
</div>
</div>
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
<MembersPropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("assignees")}</span>
</div>
<MemberDropdown
value={issue?.assignee_ids ?? []}
onChange={(val) => issue?.id && issueOperations.update(workspaceSlug, projectId, issue.id, { assignee_ids: val })}
disabled={!isEditable}
projectId={projectId?.toString() ?? ""}
placeholder={t("assignee")}
multiple
buttonVariant={(issue?.assignee_ids || []).length > 0 ? "transparent-without-text" : "transparent-with-text"}
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName={`text-13 justify-between ${(issue?.assignee_ids || []).length > 0 ? "" : "text-placeholder"}`}
hideIcon={issue.assignee_ids?.length === 0}
dropdownArrow
dropdownArrowClassName="hidden h-3.5 w-3.5 group-hover:inline"
/>
</div>
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
<PriorityPropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("priority")}</span>
</div>
<PriorityDropdown
value={issue?.priority}
onChange={(val) => issue?.id && issueOperations.update(workspaceSlug, projectId, issue.id, { priority: val })}
disabled={!isEditable}
buttonVariant="border-with-text"
className="w-3/5 flex-grow rounded-sm px-2 hover:bg-layer-1"
buttonContainerClassName="w-full text-left"
buttonClassName="h-auto w-min whitespace-nowrap"
/>
</div>
</div>
</div>
<div className={`mt-3 divide-y-2 divide-subtle-1 ${!isEditable ? "opacity-60" : ""}`}>
<div className="flex flex-col gap-3">
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
<DueDatePropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("due_date")}</span>
</div>
<DateDropdown
placeholder={t("external_contours_page.properties.add_due_date")}
value={issue.target_date || null}
onChange={(val) =>
issue?.id &&
issueOperations.update(workspaceSlug, projectId, issue.id, {
target_date: val ? renderFormattedPayloadDate(val) : null,
})
}
minDate={minDate ?? undefined}
disabled={!isEditable}
buttonVariant="transparent-with-text"
className="group w-3/5 flex-grow"
buttonContainerClassName="w-full text-left"
buttonClassName={`text-13 ${issue?.target_date ? "" : "text-placeholder"}`}
hideIcon
clearIconClassName="hidden h-3 w-3 group-hover:inline"
/>
</div>
<div className="flex min-h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
<LabelPropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("labels")}</span>
</div>
<div className="h-full min-h-8 w-3/5 flex-grow pt-1">
{issue?.id && (
<IssueLabel
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issue.id}
disabled={!isEditable}
isInboxIssue
onLabelUpdate={(val: string[]) => issue?.id && issueOperations.update(workspaceSlug, projectId, issue.id, { label_ids: val })}
/>
)}
</div>
</div>
{duplicateIssueDetails && (
<div className="flex min-h-8 gap-2">
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-13 text-tertiary">
<DuplicatePropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("external_contours_page.properties.duplicate_of")}</span>
</div>
<ControlLink href={duplicateWorkItemLink} onClick={() => router.push(duplicateWorkItemLink)} target="_self">
<Tooltip tooltipContent={`${duplicateIssueDetails?.name}`}>
<span className="flex cursor-pointer items-center gap-1 rounded-sm bg-layer-1 px-1.5 py-1 pb-0.5 text-11 text-secondary">
{`${currentProjectDetails?.identifier}-${duplicateIssueDetails?.sequence_id}`}
</span>
</Tooltip>
</ControlLink>
</div>
)}
</div>
</div>
</div>
</div>
);
});

View File

@ -0,0 +1,206 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useMemo, useRef } from "react";
import { observer } from "mobx-react";
import type { EditorRefApi } from "@plane/editor";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue, TNameDescriptionLoader } from "@plane/types";
import { EFileAssetType, EInboxIssueSource } from "@plane/types";
import { getTextContent } from "@plane/utils";
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
import { DescriptionInput } from "@/components/editor/rich-text/description-input";
import { DescriptionInputLoader } from "@/components/editor/rich-text/description-input/loader";
import { IssueAttachmentRoot } from "@/components/issues/attachment";
import type { TIssueOperations } from "@/components/issues/issue-detail";
import { IssueActivity } from "@/components/issues/issue-detail/issue-activity";
import { IssueReaction } from "@/components/issues/issue-detail/reactions";
import { IssueTitleInput } from "@/components/issues/title-input";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useUser } from "@/hooks/store/user";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate-popover";
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
import { IntakeWorkItemVersionService } from "@/services/inbox";
import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
import { ExternalContoursIssueContentProperties } from "./issue-properties";
const intakeWorkItemVersionService = new IntakeWorkItemVersionService();
type Props = {
workspaceSlug: string;
projectId: string;
inboxIssue: IInboxIssueStore;
isEditable: boolean;
isSubmitting: TNameDescriptionLoader;
setIsSubmitting: Dispatch<SetStateAction<TNameDescriptionLoader>>;
};
export const ExternalContoursIssueMainContent = observer(function ExternalContoursIssueMainContent(props: Props) {
const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting } = props;
const { t } = useTranslation();
const editorRef = useRef<EditorRefApi>(null);
const { data: currentUser } = useUser();
const { getUserDetails } = useMember();
const { loader } = useProjectInbox();
const { getProjectById } = useProject();
const { removeIssue, archiveIssue } = useIssueDetail();
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
useEffect(() => {
if (isSubmitting === "submitted") {
setShowAlert(false);
setTimeout(async () => setIsSubmitting("saved"), 3000);
} else if (isSubmitting === "submitting") {
setShowAlert(true);
}
}, [isSubmitting, setIsSubmitting, setShowAlert]);
const issue = inboxIssue.issue;
const projectDetails = issue?.project_id ? getProjectById(issue.project_id) : undefined;
const { duplicateIssues } = useDebouncedDuplicateIssues(workspaceSlug, projectDetails?.workspace.toString(), projectId, {
name: issue?.name,
description_html: getTextContent(issue?.description_html),
issueId: issue?.id,
});
const issueOperations: TIssueOperations = useMemo(
() => ({
fetch: async () => undefined,
remove: async (_workspaceSlug: string, _projectId: string, issueId: string) => {
try {
await removeIssue(workspaceSlug, projectId, issueId);
setToast({ title: t("success"), type: TOAST_TYPE.SUCCESS, message: t("inbox_issue.modals.delete.success") });
} catch (error) {
console.log("Error in deleting work item:", error);
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("something_went_wrong_please_try_again") });
}
},
update: async (_workspaceSlug: string, _projectId: string, _issueId: string, data: Partial<TIssue>) => {
try {
await inboxIssue.updateIssue(data);
} catch {
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("issue_could_not_be_updated") });
}
},
archive: async (_workspaceSlug: string, _projectId: string, issueId: string) => {
try {
await archiveIssue(workspaceSlug, projectId, issueId);
} catch (error) {
console.error("Error in archiving issue:", error);
}
},
}),
[archiveIssue, inboxIssue, projectId, removeIssue, workspaceSlug]
);
if (!issue || !issue.project_id || !issue.id) return <></>;
return (
<>
<div className="space-y-4 pb-4">
{duplicateIssues.length > 0 && (
<DeDupeIssuePopoverRoot
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
rootIssueId={issue.id}
issues={duplicateIssues}
issueOperations={issueOperations}
isIntakeIssue
/>
)}
<IssueTitleInput
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={!isEditable}
value={issue.name}
containerClassName="-ml-3"
/>
{loader === "issue-loading" || issue.description_html === undefined ? (
<DescriptionInputLoader />
) : (
<DescriptionInput
issueSequenceId={issue.sequence_id}
containerClassName="-ml-3 border-none"
disabled={!isEditable}
editorRef={editorRef}
entityId={issue.id}
fileAssetType={EFileAssetType.ISSUE_DESCRIPTION}
initialValue={!issue.description_html || issue.description_html === "" ? "<p></p>" : issue.description_html}
key={issue.id}
onSubmit={async (value, isMigrationUpdate) => {
await issueOperations.update(workspaceSlug, issue.project_id, issue.id, {
description_html: value.description_html,
...(isMigrationUpdate ? { skip_activity: "true" } : {}),
});
}}
projectId={issue.project_id}
setIsSubmitting={(value) => setIsSubmitting(value)}
workspaceSlug={workspaceSlug}
/>
)}
<div className="flex items-center justify-between gap-2">
{currentUser && (
<IssueReaction workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} currentUser={currentUser} />
)}
{isEditable && (
<DescriptionVersionsRoot
className="flex-shrink-0"
entityInformation={{
createdAt: issue.created_at ? new Date(issue.created_at) : new Date(),
createdByDisplayName:
inboxIssue.source === EInboxIssueSource.FORMS
? t("inbox_issue.source.form_user")
: (getUserDetails(issue.created_by ?? "")?.display_name ?? ""),
id: issue.id,
isRestoreDisabled: !isEditable,
}}
fetchHandlers={{
listDescriptionVersions: (issueId) => intakeWorkItemVersionService.listDescriptionVersions(workspaceSlug, projectId, issueId),
retrieveDescriptionVersion: (issueId, versionId) =>
intakeWorkItemVersionService.retrieveDescriptionVersion(workspaceSlug, projectId, issueId, versionId),
}}
handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
)}
</div>
</div>
<div className="py-4">
<IssueAttachmentRoot workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} disabled={!isEditable} />
</div>
<div className="py-4">
<ExternalContoursIssueContentProperties
workspaceSlug={workspaceSlug}
projectId={projectId}
issue={issue}
issueOperations={issueOperations}
isEditable={isEditable}
duplicateIssueDetails={inboxIssue.duplicate_issue_detail}
/>
</div>
<div className="pt-4">
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} isIntakeIssue />
</div>
</>
);
});

View File

@ -0,0 +1,129 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { MouseEvent } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useTranslation } from "@plane/i18n";
import { PriorityIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { Avatar, Row } from "@plane/ui";
import { cn, getFileURL, renderFormattedDate } from "@plane/utils";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { InboxSourcePill } from "@/plane-web/components/inbox/source-pill";
import { InboxIssueStatus } from "@/components/inbox/inbox-issue-status";
type Props = {
workspaceSlug: string;
projectId: string;
projectIdentifier?: string;
inboxIssueId: string;
setIsMobileSidebar: (value: boolean) => void;
};
export const ExternalContoursListItem = observer(function ExternalContoursListItem(props: Props) {
const { workspaceSlug, projectId, inboxIssueId, projectIdentifier, setIsMobileSidebar } = props;
const searchParams = useSearchParams();
const selectedInboxIssueId = searchParams.get("inboxIssueId");
const { t } = useTranslation();
const { currentTab, getIssueInboxByIssueId } = useProjectInbox();
const { projectLabels } = useLabel();
const { isMobile } = usePlatformOS();
const { getUserDetails } = useMember();
const inboxIssue = getIssueInboxByIssueId(inboxIssueId);
const issue = inboxIssue?.issue;
const handleIssueRedirection = (event: MouseEvent, currentIssueId: string | undefined) => {
if (selectedInboxIssueId === currentIssueId) event.preventDefault();
setIsMobileSidebar(false);
};
if (!issue) return <></>;
const createdByDetails = issue?.created_by ? getUserDetails(issue?.created_by) : undefined;
return (
<Link
id={`external-contour-list-item-${issue.id}`}
key={`${projectId}_${issue.id}`}
href={`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${issue.id}`}
onClick={(e) => handleIssueRedirection(e, issue.id)}
>
<Row
className={cn(
"relative flex cursor-pointer flex-col gap-2 border border-t-transparent border-r-transparent border-b-subtle-1 border-l-transparent py-4 transition-all hover:bg-accent-primary/5",
{ "border border-accent-strong": selectedInboxIssueId === issue.id }
)}
>
<div className="space-y-1">
<div className="relative flex items-center justify-between gap-2">
<div className="flex-shrink-0 text-11 font-medium text-tertiary">
{projectIdentifier}-{issue.sequence_id}
</div>
<div className="flex items-center gap-2">
{inboxIssue.source && <InboxSourcePill source={inboxIssue.source} />}
{inboxIssue.status !== -2 && <InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />}
</div>
</div>
<h3 className="w-full truncate text-13">{issue.name}</h3>
</div>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center gap-2">
<Tooltip
tooltipHeading={t("issues.properties.created_on")}
tooltipContent={`${renderFormattedDate(issue.created_at ?? "")}`}
isMobile={isMobile}
>
<div className="text-11 text-secondary">{renderFormattedDate(issue.created_at ?? "")}</div>
</Tooltip>
<div className="rounded-full border-2 border-strong-1" />
{issue.priority && (
<Tooltip tooltipHeading={t("priority")} tooltipContent={`${issue.priority ?? t("none")}`}>
<PriorityIcon priority={issue.priority} withContainer className="h-3 w-3" />
</Tooltip>
)}
{issue.label_ids && issue.label_ids.length > 3 ? (
<div className="relative flex !h-[17.5px] items-center gap-1 rounded-sm border border-strong px-1 text-11">
<span className="h-2 w-2 rounded-full bg-orange-400" />
<span className="max-w-28 truncate normal-case">{`${issue.label_ids.length} ${t("labels").toLowerCase()}`}</span>
</div>
) : (
<>
{(issue.label_ids ?? []).map((labelId) => {
const labelDetails = projectLabels?.find((l) => l.id === labelId);
if (!labelDetails) return null;
return (
<div
key={labelId}
className="relative flex !h-[17.5px] items-center gap-1 rounded-sm border border-strong px-1 text-11"
>
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: labelDetails.color }} />
<span className="max-w-28 truncate normal-case">{labelDetails.name}</span>
</div>
);
})}
</>
)}
</div>
{createdByDetails && createdByDetails.email?.includes("intake@plane.so") ? (
<Avatar src={getFileURL("")} name={"NODE.DC"} size="md" showTooltip />
) : createdByDetails ? (
<ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />
) : null}
</div>
</Row>
</Link>
);
});

View File

@ -0,0 +1,110 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { PanelLeft } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { EmptyStateCompact } from "@plane/propel/empty-state";
import { TransferIcon } from "@plane/propel/icons";
import type { TInboxIssueCurrentTab } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types";
import { cn } from "@plane/utils";
import { InboxLayoutLoader } from "@/components/ui/loader/layouts/project-inbox/inbox-layout-loader";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { ExternalContoursContentRoot } from "./content-root";
import { ExternalContoursSidebar } from "./sidebar";
type TExternalContoursRoot = {
workspaceSlug: string;
projectId: string;
inboxIssueId: string | undefined;
inboxAccessible: boolean;
navigationTab?: TInboxIssueCurrentTab | undefined;
};
export const ExternalContoursRoot = observer(function ExternalContoursRoot(props: TExternalContoursRoot) {
const { workspaceSlug, projectId, inboxIssueId, inboxAccessible, navigationTab } = props;
const [isMobileSidebar, setIsMobileSidebar] = useState(true);
const { t } = useTranslation();
const { loader, error, currentTab, currentInboxProjectId, handleCurrentTab, fetchInboxIssues } = useProjectInbox();
useEffect(() => {
if (!inboxAccessible || !workspaceSlug || !projectId) return;
const hasProjectChanged = currentInboxProjectId && currentInboxProjectId !== projectId;
if (navigationTab && navigationTab !== currentTab) {
handleCurrentTab(workspaceSlug, projectId, navigationTab);
} else if (hasProjectChanged) {
handleCurrentTab(workspaceSlug, projectId, EInboxIssueCurrentTab.OPEN);
} else {
fetchInboxIssues(workspaceSlug.toString(), projectId.toString(), undefined, navigationTab || EInboxIssueCurrentTab.OPEN);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inboxAccessible, workspaceSlug, projectId]);
if (loader === "init-loading") {
return (
<div className="relative flex h-full w-full flex-col">
<InboxLayoutLoader />
</div>
);
}
if (error && error?.status === "init-error") {
return (
<div className="relative flex h-full w-full flex-col items-center justify-center gap-3">
<TransferIcon className="size-[60px]" strokeWidth={1.5} />
<div className="text-secondary">{error?.message}</div>
</div>
);
}
return (
<>
{!inboxIssueId && (
<div className="flex h-12 w-full items-center border-b border-subtle px-4 lg:hidden">
<PanelLeft
onClick={() => setIsMobileSidebar(!isMobileSidebar)}
className={cn("h-4 w-4", isMobileSidebar ? "text-accent-primary" : "text-secondary")}
/>
</div>
)}
<div className="flex h-full w-full overflow-hidden bg-surface-1">
<div
className={cn(
"absolute top-[50px] bottom-0 z-10 w-full flex-shrink-0 bg-surface-1 transition-all lg:!relative lg:!top-0 lg:w-2/6",
isMobileSidebar ? "translate-x-0" : "-translate-x-full lg:!translate-x-0"
)}
>
<ExternalContoursSidebar
setIsMobileSidebar={setIsMobileSidebar}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
inboxIssueId={inboxIssueId}
/>
</div>
{inboxIssueId ? (
<ExternalContoursContentRoot
setIsMobileSidebar={setIsMobileSidebar}
isMobileSidebar={isMobileSidebar}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
inboxIssueId={inboxIssueId.toString()}
/>
) : (
<EmptyStateCompact
assetKey="intake"
title={t("external_contours_page.empty_state.detail_title")}
assetClassName="size-20"
/>
)}
</div>
</>
);
});

View File

@ -0,0 +1,164 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
import type { TInboxIssueCurrentTab } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types";
import { EHeaderVariant, Header, Loader } from "@plane/ui";
import { cn } from "@plane/utils";
import { InboxSidebarLoader } from "@/components/ui/loader/layouts/project-inbox/inbox-sidebar-loader";
import { useProject } from "@/hooks/store/use-project";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useAppRouter } from "@/hooks/use-app-router";
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
import { FiltersRoot } from "@/components/inbox/inbox-filter";
import { InboxIssueAppliedFilters } from "@/components/inbox/inbox-filter/applied-filters/root";
import { ExternalContoursListItem } from "./list-item";
type Props = {
workspaceSlug: string;
projectId: string;
inboxIssueId: string | undefined;
setIsMobileSidebar: (value: boolean) => void;
};
const tabNavigationOptions: { key: TInboxIssueCurrentTab; i18n_label: string }[] = [
{ key: EInboxIssueCurrentTab.OPEN, i18n_label: "external_contours_page.tabs.open" },
{ key: EInboxIssueCurrentTab.CLOSED, i18n_label: "external_contours_page.tabs.closed" },
];
export const ExternalContoursSidebar = observer(function ExternalContoursSidebar(props: Props) {
const { workspaceSlug, projectId, inboxIssueId, setIsMobileSidebar } = props;
const router = useAppRouter();
const containerRef = useRef<HTMLDivElement>(null);
const [elementRef, setElementRef] = useState<HTMLDivElement | null>(null);
const { t } = useTranslation();
const { currentProjectDetails } = useProject();
const {
currentTab,
handleCurrentTab,
loader,
filteredInboxIssueIds,
inboxIssuePaginationInfo,
fetchInboxPaginationIssues,
getAppliedFiltersCount,
} = useProjectInbox();
const fetchNextPages = useCallback(() => {
if (!workspaceSlug || !projectId) return;
fetchInboxPaginationIssues(workspaceSlug.toString(), projectId.toString());
}, [workspaceSlug, projectId, fetchInboxPaginationIssues]);
useIntersectionObserver(containerRef, elementRef, fetchNextPages, "20%");
useEffect(() => {
if (workspaceSlug && projectId && currentTab && filteredInboxIssueIds.length > 0 && inboxIssueId === undefined) {
router.push(
`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${filteredInboxIssueIds[0]}`
);
}
}, [currentTab, filteredInboxIssueIds, inboxIssueId, projectId, router, workspaceSlug]);
return (
<div className="h-full w-full flex-shrink-0 border-r border-strong bg-surface-1">
<div className="relative flex h-full w-full flex-col overflow-hidden">
<Header variant={EHeaderVariant.SECONDARY}>
{tabNavigationOptions.map((option) => (
<div
key={option.key}
className={cn(
"relative flex h-full cursor-pointer items-center gap-1 px-3 text-13 font-medium transition-all",
currentTab === option.key ? "text-accent-primary" : "hover:text-secondary"
)}
onClick={() => {
if (currentTab !== option.key) {
handleCurrentTab(workspaceSlug, projectId, option.key);
router.push(`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${option.key}`);
}
}}
>
<div>{t(option.i18n_label)}</div>
{option.key === EInboxIssueCurrentTab.OPEN && currentTab === option.key && (
<div className="rounded-full bg-accent-primary/20 px-1.5 py-0.5 text-11 font-semibold text-accent-primary">
{inboxIssuePaginationInfo?.total_results || 0}
</div>
)}
<div
className={cn(
"absolute right-0 bottom-0 left-0 rounded-t-md border",
currentTab === option.key ? "border-accent-strong" : "border-transparent"
)}
/>
</div>
))}
<div className="m-auto mr-0">
<FiltersRoot />
</div>
</Header>
<InboxIssueAppliedFilters />
{loader === "filter-loading" && !inboxIssuePaginationInfo?.next_page_results ? (
<InboxSidebarLoader />
) : (
<div className="vertical-scrollbar scrollbar-md h-full w-full overflow-hidden overflow-y-auto" ref={containerRef}>
{filteredInboxIssueIds.length > 0 ? (
filteredInboxIssueIds.map((inboxId) => (
<ExternalContoursListItem
key={inboxId}
setIsMobileSidebar={setIsMobileSidebar}
workspaceSlug={workspaceSlug}
projectId={projectId}
projectIdentifier={currentProjectDetails?.identifier}
inboxIssueId={inboxId}
/>
))
) : (
<div className="flex h-full w-full items-center justify-center">
{getAppliedFiltersCount > 0 ? (
<EmptyStateDetailed
assetKey="search"
title={t("external_contours_page.empty_state.filtered_title")}
description={t("external_contours_page.empty_state.filtered_description")}
assetClassName="size-20"
rootClassName="px-page-x"
/>
) : currentTab === EInboxIssueCurrentTab.OPEN ? (
<EmptyStateDetailed
assetKey="inbox"
title={t("external_contours_page.empty_state.open_title")}
description={t("external_contours_page.empty_state.open_description")}
assetClassName="size-20"
rootClassName="px-page-x"
/>
) : (
<EmptyStateDetailed
assetKey="inbox"
title={t("external_contours_page.empty_state.closed_title")}
description={t("external_contours_page.empty_state.closed_description")}
assetClassName="size-20"
className="px-10"
/>
)}
</div>
)}
<div ref={setElementRef}>
{inboxIssuePaginationInfo?.next_page_results && (
<Loader className="mx-auto w-full space-y-4 px-2 py-4">
<Loader.Item height="64px" width="w-100" />
<Loader.Item height="64px" width="w-100" />
</Loader>
)}
</div>
</div>
)}
</div>
</div>
);
});

View File

@ -51,7 +51,7 @@ export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Intake"
label={t("sidebar.intake")}
href={`/${workspaceSlug}/projects/${projectId}/intake/`}
icon={<IntakeIcon className="h-4 w-4 text-tertiary" />}
isLast

View File

@ -217,7 +217,7 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader
else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Permission denied",
title: t("permission_denied"),
message: errorMessage,
});
}
@ -407,7 +407,7 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader
handleActionWithPermission(
isProjectAdmin,
() => setSelectDuplicateIssue(true),
"Only project admins can mark work item as duplicate"
t("inbox_issue.errors.duplicate_permission")
)
}
>

View File

@ -6,6 +6,7 @@
import { observer } from "mobx-react";
import { Clock, FileStack, MoreHorizontal, PanelLeft, MoveRight } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { IconButton, getIconButtonStyling } from "@plane/propel/icon-button";
import {
LinkIcon,
@ -79,6 +80,7 @@ export const InboxIssueActionsMobileHeader = observer(function InboxIssueActions
isProjectAdmin,
handleActionWithPermission,
} = props;
const { t } = useTranslation();
const router = useAppRouter();
const { getProjectIdentifierById } = useProject();
@ -143,7 +145,7 @@ export const InboxIssueActionsMobileHeader = observer(function InboxIssueActions
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2">
<LinkIcon width={14} height={14} strokeWidth={2} />
Copy work item link
{t("inbox_issue.actions.copy")}
</div>
</CustomMenu.MenuItem>
)}
@ -151,7 +153,7 @@ export const InboxIssueActionsMobileHeader = observer(function InboxIssueActions
<CustomMenu.MenuItem onClick={() => router.push(workItemLink)}>
<div className="flex items-center gap-2">
<NewTabIcon width={14} height={14} strokeWidth={2} />
Open work item
{t("inbox_issue.actions.open")}
</div>
</CustomMenu.MenuItem>
)}
@ -161,13 +163,15 @@ export const InboxIssueActionsMobileHeader = observer(function InboxIssueActions
handleActionWithPermission(
isProjectAdmin,
handleIssueSnoozeAction,
"Only project admins can snooze/Un-snooze work items"
t("inbox_issue.errors.snooze_permission")
)
}
>
<div className="flex items-center gap-2">
<Clock size={14} strokeWidth={2} />
{inboxIssue?.snoozed_till && numberOfDaysLeft && numberOfDaysLeft > 0 ? "Un-snooze" : "Snooze"}
{inboxIssue?.snoozed_till && numberOfDaysLeft && numberOfDaysLeft > 0
? t("inbox_issue.actions.unsnooze")
: t("inbox_issue.actions.snooze")}
</div>
</CustomMenu.MenuItem>
)}
@ -177,13 +181,13 @@ export const InboxIssueActionsMobileHeader = observer(function InboxIssueActions
handleActionWithPermission(
isProjectAdmin,
() => setSelectDuplicateIssue(true),
"Only project admins can mark work items as duplicate"
t("inbox_issue.errors.duplicate_permission")
)
}
>
<div className="flex items-center gap-2">
<FileStack size={14} strokeWidth={2} />
Mark as duplicate
{t("inbox_issue.actions.mark_as_duplicate")}
</div>
</CustomMenu.MenuItem>
)}
@ -193,13 +197,13 @@ export const InboxIssueActionsMobileHeader = observer(function InboxIssueActions
handleActionWithPermission(
isProjectAdmin,
() => setAcceptIssueModal(true),
"Only project admins can accept work items"
t("inbox_issue.errors.accept_permission")
)
}
>
<div className="flex items-center gap-2 text-success-secondary">
<CheckCircleFilledIcon width={14} height={14} />
Accept
{t("inbox_issue.actions.accept")}
</div>
</CustomMenu.MenuItem>
)}
@ -209,13 +213,13 @@ export const InboxIssueActionsMobileHeader = observer(function InboxIssueActions
handleActionWithPermission(
isProjectAdmin,
() => setDeclineIssueModal(true),
"Only project admins can deny work items"
t("inbox_issue.errors.decline_permission")
)
}
>
<div className="flex items-center gap-2 text-danger-secondary">
<CloseCircleFilledIcon width={14} height={14} />
Decline
{t("inbox_issue.actions.decline")}
</div>
</CustomMenu.MenuItem>
)}
@ -223,7 +227,7 @@ export const InboxIssueActionsMobileHeader = observer(function InboxIssueActions
<CustomMenu.MenuItem onClick={() => setDeleteIssueModal(true)}>
<div className="flex items-center gap-2 text-danger-primary">
<TrashIcon height={14} width={14} strokeWidth={2} />
Delete
{t("inbox_issue.actions.delete")}
</div>
</CustomMenu.MenuItem>
)}

View File

@ -5,6 +5,7 @@
*/
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import {
StatePropertyIcon,
MembersPropertyIcon,
@ -44,6 +45,7 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr
props;
const router = useAppRouter();
const { t } = useTranslation();
// store hooks
const { currentProjectDetails } = useProject();
@ -63,14 +65,14 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr
return (
<div className="flex w-full flex-col divide-y-2 divide-subtle-1">
<div className="w-full overflow-y-auto">
<h5 className="mb-2 text-body-sm-medium">Properties</h5>
<h5 className="mb-2 text-body-sm-medium">{t("properties")}</h5>
<div className={`divide-y-2 divide-subtle-1 ${!isEditable ? "opacity-60" : ""}`}>
<div className="flex flex-col gap-3">
{/* Intake State */}
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
<StatePropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>State</span>
<span>{t("state")}</span>
</div>
{issue?.state_id && (
<DropdownComponent
@ -91,7 +93,7 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
<MembersPropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>Assignees</span>
<span>{t("assignees")}</span>
</div>
<MemberDropdown
value={issue?.assignee_ids ?? []}
@ -100,7 +102,7 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr
}
disabled={!isEditable}
projectId={projectId?.toString() ?? ""}
placeholder="Add assignees"
placeholder={t("assignee")}
multiple
buttonVariant={
(issue?.assignee_ids || [])?.length > 0 ? "transparent-without-text" : "transparent-with-text"
@ -119,7 +121,7 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
<PriorityPropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>Priority</span>
<span>{t("priority")}</span>
</div>
<PriorityDropdown
value={issue?.priority}
@ -141,10 +143,10 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
<DueDatePropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>Due date</span>
<span>{t("due_date")}</span>
</div>
<DateDropdown
placeholder="Add due date"
placeholder={t("issues.properties.add_due_date")}
value={issue.target_date || null}
onChange={(val) =>
issue?.id &&
@ -166,7 +168,7 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr
<div className="flex min-h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
<LabelPropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>Labels</span>
<span>{t("labels")}</span>
</div>
<div className="h-full min-h-8 w-3/5 flex-grow pt-1">
{issue?.id && (
@ -187,10 +189,10 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr
{/* duplicate to*/}
{duplicateIssueDetails && (
<div className="flex min-h-8 gap-2">
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-13 text-tertiary">
<DuplicatePropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>Duplicate of</span>
</div>
<div className="flex w-2/5 flex-shrink-0 gap-1 pt-2 text-13 text-tertiary">
<DuplicatePropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>{t("issues.properties.duplicate_of")}</span>
</div>
<ControlLink
href={duplicateWorkItemLink}

View File

@ -7,6 +7,7 @@
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useMemo, useRef } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
@ -52,6 +53,7 @@ type Props = {
export const InboxIssueMainContent = observer(function InboxIssueMainContent(props: Props) {
const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting } = props;
const { t } = useTranslation();
// refs
const editorRef = useRef<EditorRefApi>(null);
// store hooks
@ -101,16 +103,16 @@ export const InboxIssueMainContent = observer(function InboxIssueMainContent(pro
try {
await removeIssue(workspaceSlug, projectId, _issueId);
setToast({
title: "Success!",
title: t("success"),
type: TOAST_TYPE.SUCCESS,
message: "Work item deleted successfully",
message: t("inbox_issue.modals.delete.success"),
});
} catch (error) {
console.log("Error in deleting work item:", error);
setToast({
title: "Error!",
title: t("error"),
type: TOAST_TYPE.ERROR,
message: "Work item delete failed",
message: t("something_went_wrong_please_try_again"),
});
}
},
@ -119,9 +121,9 @@ export const InboxIssueMainContent = observer(function InboxIssueMainContent(pro
await inboxIssue.updateIssue(data);
} catch (_error) {
setToast({
title: "Work item update failed",
title: t("error"),
type: TOAST_TYPE.ERROR,
message: "Work item update failed",
message: t("issue_could_not_be_updated"),
});
}
},
@ -206,7 +208,7 @@ export const InboxIssueMainContent = observer(function InboxIssueMainContent(pro
createdAt: issue.created_at ? new Date(issue.created_at) : new Date(),
createdByDisplayName:
inboxIssue.source === EInboxIssueSource.FORMS
? "Intake Form user"
? t("inbox_issue.source.form_user")
: (getUserDetails(issue.created_by ?? "")?.display_name ?? ""),
id: issue.id,
isRestoreDisabled: !isEditable,

View File

@ -161,11 +161,10 @@ export const InboxSidebar = observer(function InboxSidebar(props: IInboxSidebarP
rootClassName="px-page-x"
/>
) : (
// TODO: Add translation
<EmptyStateDetailed
assetKey="inbox"
title="No request closed yet"
description="All the work items whether accepted or declined can be found here."
title={t("inbox_issue.empty_state.sidebar_closed_tab.title")}
description={t("inbox_issue.empty_state.sidebar_closed_tab.description")}
assetClassName="size-20"
className="px-10"
/>

View File

@ -33,6 +33,7 @@ export default {
success: "Success",
warning: "Warning",
info: "Info",
permission_denied: "Permission denied",
close: "Close",
yes: "Yes",
no: "No",
@ -283,16 +284,60 @@ export default {
external_contours_page: {
page_label: "{workspace} - External contours",
title: "External contours",
disabled: {
title: "External contours are disabled in this project",
description: "Enable the intake feature first to use cross-project communication in this project.",
button: "Open project features",
},
header: {
add_request: "Add request",
},
tabs: {
open: "Open",
closed: "Closed",
},
empty_state: {
title: "External contours module is ready for the next stage",
description:
"This screen will host the cross-project request form, the sent requests list, and status pills for each routed task.",
request_title: "Send to an external contour",
request_description:
"The next stage will add the form for selecting the target project, assignee, priority, due date, and description.",
list_title: "Sent requests",
list_description:
"This area will show sent cross-project requests with their current status, a link to the target task, and later synchronization.",
detail_title: "Select a request to view its details.",
open_title: "No open requests",
open_description: "Sent and active cross-contour requests will appear here.",
closed_title: "No closed requests",
closed_description: "Completed, accepted, and declined requests will appear here.",
filtered_title: "No matching requests",
filtered_description: "No requests match the current filters.",
},
form: {
source_project: "Source internal contour",
target_project: "Target external contour",
selected_target: "Selected contour",
assignee: "Assignee",
due_date: "Due date",
},
modal: {
title: "Add request",
submit: "Send",
toast_title: "Routing is not connected yet",
toast_message:
"The UI now uses the intake shell. The next stage will connect backend routing and real delivery to the target contour.",
},
properties: {
section_title: "Properties",
target_contour: "External contour",
target_contour_placeholder: "Will be available after routing is connected",
add_due_date: "Add due date",
duplicate_of: "Duplicate of",
},
actions: {
send: "Send",
accept: "Accept",
decline: "Decline",
copy: "Copy link",
open: "Open request",
unsupported_title: "This action will be connected in the next stage",
unsupported_message:
"The “{action}” button is already placed in the right UI slot. Real routing and the reverse flow will be connected next.",
},
},
deactivate_your_account: "Deactivate your account",
@ -324,6 +369,7 @@ export default {
no_matching_results: "No matching results",
title_is_required: "Title is required",
title: "Title",
properties: "Properties",
state: "State",
priority: "Priority",
none: "None",
@ -1101,6 +1147,7 @@ export default {
snooze_permission: "Only project admins can snooze/Un-snooze work items",
accept_permission: "Only project admins can accept work items",
decline_permission: "Only project admins can deny work items",
duplicate_permission: "Only project admins can mark work items as duplicate",
},
actions: {
accept: "Accept",
@ -1115,6 +1162,7 @@ export default {
},
source: {
"in-app": "in-app",
form_user: "Form user",
},
order_by: {
created_at: "Created at",

View File

@ -185,6 +185,7 @@ export default {
success: "Успешно",
warning: "Предупреждение",
info: "Информация",
permission_denied: "Доступ запрещён",
close: "Закрыть",
yes: "Да",
no: "Нет",
@ -439,16 +440,61 @@ export default {
external_contours_page: {
page_label: "{workspace} - Внешние контуры",
title: "Внешние контуры",
disabled: {
title: "Внешние контуры отключены в проекте",
description:
"Чтобы использовать межпроектную коммуникацию, сначала включите модуль предложений в функциях проекта.",
button: "Открыть функции проекта",
},
header: {
add_request: "Добавить запрос",
},
tabs: {
open: "Открытые",
closed: "Закрытые",
},
empty_state: {
title: "Модуль внешних контуров подготовлен",
description:
"Здесь появятся форма отправки задачи в другой проект, список отправленных запросов и их статусные маркеры.",
request_title: "Отправка во внешний контур",
request_description:
"На следующем этапе здесь будет форма выбора целевого проекта, исполнителя, приоритета, срока и описания.",
list_title: "Отправленные запросы",
list_description:
"Здесь будет список межпроектных запросов с текущим статусом, ссылкой на целевую задачу и дальнейшей синхронизацией.",
detail_title: "Выберите запрос для просмотра деталей.",
open_title: "Нет открытых запросов",
open_description: "Здесь будут отображаться отправленные и активные межконтурные запросы.",
closed_title: "Нет закрытых запросов",
closed_description: "Здесь будут видны завершённые, принятые и отклонённые запросы.",
filtered_title: "Нет подходящих запросов",
filtered_description: "По текущим фильтрам запросы не найдены.",
},
form: {
source_project: "Исходный внутренний контур",
target_project: "Целевой внешний контур",
selected_target: "Выбранный контур",
assignee: "Исполнитель",
due_date: "Срок",
},
modal: {
title: "Добавить запрос",
submit: "Отправить",
toast_title: "Маршрутизация ещё не подключена",
toast_message:
"UI уже переведён на shell предложений. На следующем этапе подключим backend-маршрутизацию и реальную отправку в целевой контур.",
},
properties: {
section_title: "Свойства",
target_contour: "Внешний контур",
target_contour_placeholder: "Будет доступно после подключения маршрутизации",
add_due_date: "Добавить срок выполнения",
duplicate_of: "Дубликат",
},
actions: {
send: "Отправить",
accept: "Принять",
decline: "Отклонить",
copy: "Копировать ссылку",
open: "Открыть запрос",
unsupported_title: "Действие будет подключено следующим этапом",
unsupported_message:
"Кнопка «{action}» уже стоит на правильном месте в UI. Реальную маршрутизацию и обратный поток подключим следующим этапом.",
},
},
deactivate_your_account: "Деактивировать ваш аккаунт",
@ -480,6 +526,7 @@ export default {
no_matching_results: "Нет совпадений",
title_is_required: "Требуется заголовок",
title: "Заголовок",
properties: "Свойства",
state: "Статус",
priority: "Приоритет",
none: "Нет",
@ -1257,6 +1304,7 @@ export default {
snooze_permission: "Только администраторы проекта могут откладывать/возобновлять рабочие элементы",
accept_permission: "Только администраторы проекта могут принимать рабочие элементы",
decline_permission: "Только администраторы проекта могут отклонять рабочие элементы",
duplicate_permission: "Только администраторы проекта могут помечать рабочие элементы как дубликат",
},
actions: {
accept: "Принять",
@ -1271,14 +1319,15 @@ export default {
},
source: {
"in-app": "в приложении",
form_user: "Пользователь формы",
},
order_by: {
created_at: "Дата создания",
updated_at: "Дата обновления",
id: "ID",
},
label: "Входящие",
page_label: "{workspace} - Входящие",
label: "Предложения",
page_label: "{workspace} - Предложения",
modal: {
title: "Создать входящий рабочий элемент",
},