UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: перевод внешних контуров на shell предложений
This commit is contained in:
parent
c76f519488
commit
390bcdbf38
|
|
@ -5,53 +5,74 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { observer } from "mobx-react";
|
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 { 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 { PageHead } from "@/components/core/page-title";
|
||||||
|
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
|
||||||
import { useProject } from "@/hooks/store/use-project";
|
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";
|
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 { t } = useTranslation();
|
||||||
const { currentProjectDetails } = useProject();
|
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
|
const pageTitle = currentProjectDetails?.name
|
||||||
? t("external_contours_page.page_label", { workspace: currentProjectDetails.name })
|
? t("external_contours_page.page_label", { workspace: currentProjectDetails.name })
|
||||||
: t("external_contours_page.page_label", { workspace: "NODE.DC" });
|
: t("external_contours_page.page_label", { workspace: "NODE.DC" });
|
||||||
|
|
||||||
|
const currentNavigationTab = navigationTab
|
||||||
|
? navigationTab === "open"
|
||||||
|
? EInboxIssueCurrentTab.OPEN
|
||||||
|
: EInboxIssueCurrentTab.CLOSED
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<PageHead title={pageTitle} />
|
<PageHead title={pageTitle} />
|
||||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-5 py-6 md:px-8">
|
<div className="h-full w-full overflow-hidden">
|
||||||
<section className="rounded-xl border border-subtle bg-surface-1 p-6">
|
<ExternalContoursRoot
|
||||||
<div className="flex items-start gap-4">
|
workspaceSlug={workspaceSlug}
|
||||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-accent-primary/10 text-accent-primary">
|
projectId={projectId}
|
||||||
<TransferIcon className="h-5 w-5 fill-current" />
|
inboxIssueId={inboxIssueId || undefined}
|
||||||
</div>
|
inboxAccessible={currentProjectDetails?.inbox_view || false}
|
||||||
<div className="flex flex-col gap-2">
|
navigationTab={currentNavigationTab}
|
||||||
<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>
|
</div>
|
||||||
</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,22 +4,40 @@
|
||||||
* See the LICENSE file for details.
|
* See the LICENSE file for details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
|
import { RefreshCcw } from "lucide-react";
|
||||||
|
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||||
import { useTranslation } from "@plane/i18n";
|
import { useTranslation } from "@plane/i18n";
|
||||||
|
import { Button } from "@plane/propel/button";
|
||||||
import { TransferIcon } from "@plane/propel/icons";
|
import { TransferIcon } from "@plane/propel/icons";
|
||||||
import { Breadcrumbs, Header } from "@plane/ui";
|
import { Breadcrumbs, Header } from "@plane/ui";
|
||||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
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 { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||||
|
import { ExternalContourCreateModalRoot } from "./create-modal";
|
||||||
|
|
||||||
export const ProjectExternalContoursHeader = observer(function ProjectExternalContoursHeader() {
|
export const ProjectExternalContoursHeader = observer(function ProjectExternalContoursHeader() {
|
||||||
|
const [createIssueModal, setCreateIssueModal] = useState(false);
|
||||||
const { workspaceSlug, projectId } = useParams();
|
const { workspaceSlug, projectId } = useParams();
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<Header>
|
<Header>
|
||||||
<Header.LeftItem>
|
<Header.LeftItem>
|
||||||
<Breadcrumbs>
|
<div className="flex flex-grow items-center gap-4">
|
||||||
|
<Breadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}>
|
||||||
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
|
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
|
||||||
<Breadcrumbs.Item
|
<Breadcrumbs.Item
|
||||||
component={
|
component={
|
||||||
|
|
@ -33,7 +51,30 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
|
||||||
isLast
|
isLast
|
||||||
/>
|
/>
|
||||||
</Breadcrumbs>
|
</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.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>
|
</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
|
<Breadcrumbs.Item
|
||||||
component={
|
component={
|
||||||
<BreadcrumbLink
|
<BreadcrumbLink
|
||||||
label="Intake"
|
label={t("sidebar.intake")}
|
||||||
href={`/${workspaceSlug}/projects/${projectId}/intake/`}
|
href={`/${workspaceSlug}/projects/${projectId}/intake/`}
|
||||||
icon={<IntakeIcon className="h-4 w-4 text-tertiary" />}
|
icon={<IntakeIcon className="h-4 w-4 text-tertiary" />}
|
||||||
isLast
|
isLast
|
||||||
|
|
|
||||||
|
|
@ -217,7 +217,7 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader
|
||||||
else {
|
else {
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.ERROR,
|
type: TOAST_TYPE.ERROR,
|
||||||
title: "Permission denied",
|
title: t("permission_denied"),
|
||||||
message: errorMessage,
|
message: errorMessage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -407,7 +407,7 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader
|
||||||
handleActionWithPermission(
|
handleActionWithPermission(
|
||||||
isProjectAdmin,
|
isProjectAdmin,
|
||||||
() => setSelectDuplicateIssue(true),
|
() => 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 { observer } from "mobx-react";
|
||||||
import { Clock, FileStack, MoreHorizontal, PanelLeft, MoveRight } from "lucide-react";
|
import { Clock, FileStack, MoreHorizontal, PanelLeft, MoveRight } from "lucide-react";
|
||||||
|
import { useTranslation } from "@plane/i18n";
|
||||||
import { IconButton, getIconButtonStyling } from "@plane/propel/icon-button";
|
import { IconButton, getIconButtonStyling } from "@plane/propel/icon-button";
|
||||||
import {
|
import {
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
|
|
@ -79,6 +80,7 @@ export const InboxIssueActionsMobileHeader = observer(function InboxIssueActions
|
||||||
isProjectAdmin,
|
isProjectAdmin,
|
||||||
handleActionWithPermission,
|
handleActionWithPermission,
|
||||||
} = props;
|
} = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useAppRouter();
|
const router = useAppRouter();
|
||||||
const { getProjectIdentifierById } = useProject();
|
const { getProjectIdentifierById } = useProject();
|
||||||
|
|
||||||
|
|
@ -143,7 +145,7 @@ export const InboxIssueActionsMobileHeader = observer(function InboxIssueActions
|
||||||
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<LinkIcon width={14} height={14} strokeWidth={2} />
|
<LinkIcon width={14} height={14} strokeWidth={2} />
|
||||||
Copy work item link
|
{t("inbox_issue.actions.copy")}
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
@ -151,7 +153,7 @@ export const InboxIssueActionsMobileHeader = observer(function InboxIssueActions
|
||||||
<CustomMenu.MenuItem onClick={() => router.push(workItemLink)}>
|
<CustomMenu.MenuItem onClick={() => router.push(workItemLink)}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<NewTabIcon width={14} height={14} strokeWidth={2} />
|
<NewTabIcon width={14} height={14} strokeWidth={2} />
|
||||||
Open work item
|
{t("inbox_issue.actions.open")}
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
@ -161,13 +163,15 @@ export const InboxIssueActionsMobileHeader = observer(function InboxIssueActions
|
||||||
handleActionWithPermission(
|
handleActionWithPermission(
|
||||||
isProjectAdmin,
|
isProjectAdmin,
|
||||||
handleIssueSnoozeAction,
|
handleIssueSnoozeAction,
|
||||||
"Only project admins can snooze/Un-snooze work items"
|
t("inbox_issue.errors.snooze_permission")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Clock size={14} strokeWidth={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>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
@ -177,13 +181,13 @@ export const InboxIssueActionsMobileHeader = observer(function InboxIssueActions
|
||||||
handleActionWithPermission(
|
handleActionWithPermission(
|
||||||
isProjectAdmin,
|
isProjectAdmin,
|
||||||
() => setSelectDuplicateIssue(true),
|
() => setSelectDuplicateIssue(true),
|
||||||
"Only project admins can mark work items as duplicate"
|
t("inbox_issue.errors.duplicate_permission")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileStack size={14} strokeWidth={2} />
|
<FileStack size={14} strokeWidth={2} />
|
||||||
Mark as duplicate
|
{t("inbox_issue.actions.mark_as_duplicate")}
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
@ -193,13 +197,13 @@ export const InboxIssueActionsMobileHeader = observer(function InboxIssueActions
|
||||||
handleActionWithPermission(
|
handleActionWithPermission(
|
||||||
isProjectAdmin,
|
isProjectAdmin,
|
||||||
() => setAcceptIssueModal(true),
|
() => 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">
|
<div className="flex items-center gap-2 text-success-secondary">
|
||||||
<CheckCircleFilledIcon width={14} height={14} />
|
<CheckCircleFilledIcon width={14} height={14} />
|
||||||
Accept
|
{t("inbox_issue.actions.accept")}
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
@ -209,13 +213,13 @@ export const InboxIssueActionsMobileHeader = observer(function InboxIssueActions
|
||||||
handleActionWithPermission(
|
handleActionWithPermission(
|
||||||
isProjectAdmin,
|
isProjectAdmin,
|
||||||
() => setDeclineIssueModal(true),
|
() => 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">
|
<div className="flex items-center gap-2 text-danger-secondary">
|
||||||
<CloseCircleFilledIcon width={14} height={14} />
|
<CloseCircleFilledIcon width={14} height={14} />
|
||||||
Decline
|
{t("inbox_issue.actions.decline")}
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
@ -223,7 +227,7 @@ export const InboxIssueActionsMobileHeader = observer(function InboxIssueActions
|
||||||
<CustomMenu.MenuItem onClick={() => setDeleteIssueModal(true)}>
|
<CustomMenu.MenuItem onClick={() => setDeleteIssueModal(true)}>
|
||||||
<div className="flex items-center gap-2 text-danger-primary">
|
<div className="flex items-center gap-2 text-danger-primary">
|
||||||
<TrashIcon height={14} width={14} strokeWidth={2} />
|
<TrashIcon height={14} width={14} strokeWidth={2} />
|
||||||
Delete
|
{t("inbox_issue.actions.delete")}
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
import { useTranslation } from "@plane/i18n";
|
||||||
import {
|
import {
|
||||||
StatePropertyIcon,
|
StatePropertyIcon,
|
||||||
MembersPropertyIcon,
|
MembersPropertyIcon,
|
||||||
|
|
@ -44,6 +45,7 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr
|
||||||
props;
|
props;
|
||||||
|
|
||||||
const router = useAppRouter();
|
const router = useAppRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
// store hooks
|
// store hooks
|
||||||
const { currentProjectDetails } = useProject();
|
const { currentProjectDetails } = useProject();
|
||||||
|
|
||||||
|
|
@ -63,14 +65,14 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col divide-y-2 divide-subtle-1">
|
<div className="flex w-full flex-col divide-y-2 divide-subtle-1">
|
||||||
<div className="w-full overflow-y-auto">
|
<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={`divide-y-2 divide-subtle-1 ${!isEditable ? "opacity-60" : ""}`}>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* Intake State */}
|
{/* Intake State */}
|
||||||
<div className="flex h-8 items-center gap-2">
|
<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">
|
<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" />
|
<StatePropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
<span>State</span>
|
<span>{t("state")}</span>
|
||||||
</div>
|
</div>
|
||||||
{issue?.state_id && (
|
{issue?.state_id && (
|
||||||
<DropdownComponent
|
<DropdownComponent
|
||||||
|
|
@ -91,7 +93,7 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr
|
||||||
<div className="flex h-8 items-center gap-2">
|
<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">
|
<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" />
|
<MembersPropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
<span>Assignees</span>
|
<span>{t("assignees")}</span>
|
||||||
</div>
|
</div>
|
||||||
<MemberDropdown
|
<MemberDropdown
|
||||||
value={issue?.assignee_ids ?? []}
|
value={issue?.assignee_ids ?? []}
|
||||||
|
|
@ -100,7 +102,7 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr
|
||||||
}
|
}
|
||||||
disabled={!isEditable}
|
disabled={!isEditable}
|
||||||
projectId={projectId?.toString() ?? ""}
|
projectId={projectId?.toString() ?? ""}
|
||||||
placeholder="Add assignees"
|
placeholder={t("assignee")}
|
||||||
multiple
|
multiple
|
||||||
buttonVariant={
|
buttonVariant={
|
||||||
(issue?.assignee_ids || [])?.length > 0 ? "transparent-without-text" : "transparent-with-text"
|
(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 h-8 items-center gap-2">
|
||||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
|
<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" />
|
<PriorityPropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
<span>Priority</span>
|
<span>{t("priority")}</span>
|
||||||
</div>
|
</div>
|
||||||
<PriorityDropdown
|
<PriorityDropdown
|
||||||
value={issue?.priority}
|
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 h-8 items-center gap-2">
|
||||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
|
<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" />
|
<DueDatePropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
<span>Due date</span>
|
<span>{t("due_date")}</span>
|
||||||
</div>
|
</div>
|
||||||
<DateDropdown
|
<DateDropdown
|
||||||
placeholder="Add due date"
|
placeholder={t("issues.properties.add_due_date")}
|
||||||
value={issue.target_date || null}
|
value={issue.target_date || null}
|
||||||
onChange={(val) =>
|
onChange={(val) =>
|
||||||
issue?.id &&
|
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 min-h-8 items-center gap-2">
|
||||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-13 text-tertiary">
|
<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" />
|
<LabelPropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
<span>Labels</span>
|
<span>{t("labels")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full min-h-8 w-3/5 flex-grow pt-1">
|
<div className="h-full min-h-8 w-3/5 flex-grow pt-1">
|
||||||
{issue?.id && (
|
{issue?.id && (
|
||||||
|
|
@ -189,7 +191,7 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr
|
||||||
<div className="flex min-h-8 gap-2">
|
<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">
|
<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" />
|
<DuplicatePropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
<span>Duplicate of</span>
|
<span>{t("issues.properties.duplicate_of")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ControlLink
|
<ControlLink
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
import { useTranslation } from "@plane/i18n";
|
||||||
// plane imports
|
// plane imports
|
||||||
import type { EditorRefApi } from "@plane/editor";
|
import type { EditorRefApi } from "@plane/editor";
|
||||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||||
|
|
@ -52,6 +53,7 @@ type Props = {
|
||||||
|
|
||||||
export const InboxIssueMainContent = observer(function InboxIssueMainContent(props: Props) {
|
export const InboxIssueMainContent = observer(function InboxIssueMainContent(props: Props) {
|
||||||
const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting } = props;
|
const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
// refs
|
// refs
|
||||||
const editorRef = useRef<EditorRefApi>(null);
|
const editorRef = useRef<EditorRefApi>(null);
|
||||||
// store hooks
|
// store hooks
|
||||||
|
|
@ -101,16 +103,16 @@ export const InboxIssueMainContent = observer(function InboxIssueMainContent(pro
|
||||||
try {
|
try {
|
||||||
await removeIssue(workspaceSlug, projectId, _issueId);
|
await removeIssue(workspaceSlug, projectId, _issueId);
|
||||||
setToast({
|
setToast({
|
||||||
title: "Success!",
|
title: t("success"),
|
||||||
type: TOAST_TYPE.SUCCESS,
|
type: TOAST_TYPE.SUCCESS,
|
||||||
message: "Work item deleted successfully",
|
message: t("inbox_issue.modals.delete.success"),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error in deleting work item:", error);
|
console.log("Error in deleting work item:", error);
|
||||||
setToast({
|
setToast({
|
||||||
title: "Error!",
|
title: t("error"),
|
||||||
type: TOAST_TYPE.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);
|
await inboxIssue.updateIssue(data);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
setToast({
|
setToast({
|
||||||
title: "Work item update failed",
|
title: t("error"),
|
||||||
type: TOAST_TYPE.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(),
|
createdAt: issue.created_at ? new Date(issue.created_at) : new Date(),
|
||||||
createdByDisplayName:
|
createdByDisplayName:
|
||||||
inboxIssue.source === EInboxIssueSource.FORMS
|
inboxIssue.source === EInboxIssueSource.FORMS
|
||||||
? "Intake Form user"
|
? t("inbox_issue.source.form_user")
|
||||||
: (getUserDetails(issue.created_by ?? "")?.display_name ?? ""),
|
: (getUserDetails(issue.created_by ?? "")?.display_name ?? ""),
|
||||||
id: issue.id,
|
id: issue.id,
|
||||||
isRestoreDisabled: !isEditable,
|
isRestoreDisabled: !isEditable,
|
||||||
|
|
|
||||||
|
|
@ -161,11 +161,10 @@ export const InboxSidebar = observer(function InboxSidebar(props: IInboxSidebarP
|
||||||
rootClassName="px-page-x"
|
rootClassName="px-page-x"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// TODO: Add translation
|
|
||||||
<EmptyStateDetailed
|
<EmptyStateDetailed
|
||||||
assetKey="inbox"
|
assetKey="inbox"
|
||||||
title="No request closed yet"
|
title={t("inbox_issue.empty_state.sidebar_closed_tab.title")}
|
||||||
description="All the work items whether accepted or declined can be found here."
|
description={t("inbox_issue.empty_state.sidebar_closed_tab.description")}
|
||||||
assetClassName="size-20"
|
assetClassName="size-20"
|
||||||
className="px-10"
|
className="px-10"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export default {
|
||||||
success: "Success",
|
success: "Success",
|
||||||
warning: "Warning",
|
warning: "Warning",
|
||||||
info: "Info",
|
info: "Info",
|
||||||
|
permission_denied: "Permission denied",
|
||||||
close: "Close",
|
close: "Close",
|
||||||
yes: "Yes",
|
yes: "Yes",
|
||||||
no: "No",
|
no: "No",
|
||||||
|
|
@ -283,16 +284,60 @@ export default {
|
||||||
external_contours_page: {
|
external_contours_page: {
|
||||||
page_label: "{workspace} - External contours",
|
page_label: "{workspace} - External contours",
|
||||||
title: "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: {
|
empty_state: {
|
||||||
title: "External contours module is ready for the next stage",
|
title: "External contours module is ready for the next stage",
|
||||||
description:
|
description:
|
||||||
"This screen will host the cross-project request form, the sent requests list, and status pills for each routed task.",
|
"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",
|
detail_title: "Select a request to view its details.",
|
||||||
request_description:
|
open_title: "No open requests",
|
||||||
"The next stage will add the form for selecting the target project, assignee, priority, due date, and description.",
|
open_description: "Sent and active cross-contour requests will appear here.",
|
||||||
list_title: "Sent requests",
|
closed_title: "No closed requests",
|
||||||
list_description:
|
closed_description: "Completed, accepted, and declined requests will appear here.",
|
||||||
"This area will show sent cross-project requests with their current status, a link to the target task, and later synchronization.",
|
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",
|
deactivate_your_account: "Deactivate your account",
|
||||||
|
|
@ -324,6 +369,7 @@ export default {
|
||||||
no_matching_results: "No matching results",
|
no_matching_results: "No matching results",
|
||||||
title_is_required: "Title is required",
|
title_is_required: "Title is required",
|
||||||
title: "Title",
|
title: "Title",
|
||||||
|
properties: "Properties",
|
||||||
state: "State",
|
state: "State",
|
||||||
priority: "Priority",
|
priority: "Priority",
|
||||||
none: "None",
|
none: "None",
|
||||||
|
|
@ -1101,6 +1147,7 @@ export default {
|
||||||
snooze_permission: "Only project admins can snooze/Un-snooze work items",
|
snooze_permission: "Only project admins can snooze/Un-snooze work items",
|
||||||
accept_permission: "Only project admins can accept work items",
|
accept_permission: "Only project admins can accept work items",
|
||||||
decline_permission: "Only project admins can deny work items",
|
decline_permission: "Only project admins can deny work items",
|
||||||
|
duplicate_permission: "Only project admins can mark work items as duplicate",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
accept: "Accept",
|
accept: "Accept",
|
||||||
|
|
@ -1115,6 +1162,7 @@ export default {
|
||||||
},
|
},
|
||||||
source: {
|
source: {
|
||||||
"in-app": "in-app",
|
"in-app": "in-app",
|
||||||
|
form_user: "Form user",
|
||||||
},
|
},
|
||||||
order_by: {
|
order_by: {
|
||||||
created_at: "Created at",
|
created_at: "Created at",
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,7 @@ export default {
|
||||||
success: "Успешно",
|
success: "Успешно",
|
||||||
warning: "Предупреждение",
|
warning: "Предупреждение",
|
||||||
info: "Информация",
|
info: "Информация",
|
||||||
|
permission_denied: "Доступ запрещён",
|
||||||
close: "Закрыть",
|
close: "Закрыть",
|
||||||
yes: "Да",
|
yes: "Да",
|
||||||
no: "Нет",
|
no: "Нет",
|
||||||
|
|
@ -439,16 +440,61 @@ export default {
|
||||||
external_contours_page: {
|
external_contours_page: {
|
||||||
page_label: "{workspace} - Внешние контуры",
|
page_label: "{workspace} - Внешние контуры",
|
||||||
title: "Внешние контуры",
|
title: "Внешние контуры",
|
||||||
|
disabled: {
|
||||||
|
title: "Внешние контуры отключены в проекте",
|
||||||
|
description:
|
||||||
|
"Чтобы использовать межпроектную коммуникацию, сначала включите модуль предложений в функциях проекта.",
|
||||||
|
button: "Открыть функции проекта",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
add_request: "Добавить запрос",
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
open: "Открытые",
|
||||||
|
closed: "Закрытые",
|
||||||
|
},
|
||||||
empty_state: {
|
empty_state: {
|
||||||
title: "Модуль внешних контуров подготовлен",
|
title: "Модуль внешних контуров подготовлен",
|
||||||
description:
|
description:
|
||||||
"Здесь появятся форма отправки задачи в другой проект, список отправленных запросов и их статусные маркеры.",
|
"Здесь появятся форма отправки задачи в другой проект, список отправленных запросов и их статусные маркеры.",
|
||||||
request_title: "Отправка во внешний контур",
|
detail_title: "Выберите запрос для просмотра деталей.",
|
||||||
request_description:
|
open_title: "Нет открытых запросов",
|
||||||
"На следующем этапе здесь будет форма выбора целевого проекта, исполнителя, приоритета, срока и описания.",
|
open_description: "Здесь будут отображаться отправленные и активные межконтурные запросы.",
|
||||||
list_title: "Отправленные запросы",
|
closed_title: "Нет закрытых запросов",
|
||||||
list_description:
|
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: "Деактивировать ваш аккаунт",
|
deactivate_your_account: "Деактивировать ваш аккаунт",
|
||||||
|
|
@ -480,6 +526,7 @@ export default {
|
||||||
no_matching_results: "Нет совпадений",
|
no_matching_results: "Нет совпадений",
|
||||||
title_is_required: "Требуется заголовок",
|
title_is_required: "Требуется заголовок",
|
||||||
title: "Заголовок",
|
title: "Заголовок",
|
||||||
|
properties: "Свойства",
|
||||||
state: "Статус",
|
state: "Статус",
|
||||||
priority: "Приоритет",
|
priority: "Приоритет",
|
||||||
none: "Нет",
|
none: "Нет",
|
||||||
|
|
@ -1257,6 +1304,7 @@ export default {
|
||||||
snooze_permission: "Только администраторы проекта могут откладывать/возобновлять рабочие элементы",
|
snooze_permission: "Только администраторы проекта могут откладывать/возобновлять рабочие элементы",
|
||||||
accept_permission: "Только администраторы проекта могут принимать рабочие элементы",
|
accept_permission: "Только администраторы проекта могут принимать рабочие элементы",
|
||||||
decline_permission: "Только администраторы проекта могут отклонять рабочие элементы",
|
decline_permission: "Только администраторы проекта могут отклонять рабочие элементы",
|
||||||
|
duplicate_permission: "Только администраторы проекта могут помечать рабочие элементы как дубликат",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
accept: "Принять",
|
accept: "Принять",
|
||||||
|
|
@ -1271,14 +1319,15 @@ export default {
|
||||||
},
|
},
|
||||||
source: {
|
source: {
|
||||||
"in-app": "в приложении",
|
"in-app": "в приложении",
|
||||||
|
form_user: "Пользователь формы",
|
||||||
},
|
},
|
||||||
order_by: {
|
order_by: {
|
||||||
created_at: "Дата создания",
|
created_at: "Дата создания",
|
||||||
updated_at: "Дата обновления",
|
updated_at: "Дата обновления",
|
||||||
id: "ID",
|
id: "ID",
|
||||||
},
|
},
|
||||||
label: "Входящие",
|
label: "Предложения",
|
||||||
page_label: "{workspace} - Входящие",
|
page_label: "{workspace} - Предложения",
|
||||||
modal: {
|
modal: {
|
||||||
title: "Создать входящий рабочий элемент",
|
title: "Создать входящий рабочий элемент",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue