UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: перевод внешних контуров на shell предложений
This commit is contained in:
parent
c76f519488
commit
390bcdbf38
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
)
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "Создать входящий рабочий элемент",
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue