Compare commits
No commits in common. "master" and "CROSSASSISTANT" have entirely different histories.
master
...
CROSSASSIS
|
|
@ -10,7 +10,6 @@ from plane.app.views import (
|
||||||
AIWorkspaceExecutorEventsEndpoint,
|
AIWorkspaceExecutorEventsEndpoint,
|
||||||
AIWorkspaceExecutorListEndpoint,
|
AIWorkspaceExecutorListEndpoint,
|
||||||
AIWorkspaceExecutorSelectEndpoint,
|
AIWorkspaceExecutorSelectEndpoint,
|
||||||
AIWorkspaceExecutorSetupCommandEndpoint,
|
|
||||||
AIWorkspaceExecutorWindowsAgentEndpoint,
|
AIWorkspaceExecutorWindowsAgentEndpoint,
|
||||||
AIWorkspaceSettingsEndpoint,
|
AIWorkspaceSettingsEndpoint,
|
||||||
AIWorkspaceThreadDispatchEndpoint,
|
AIWorkspaceThreadDispatchEndpoint,
|
||||||
|
|
@ -56,11 +55,6 @@ urlpatterns = [
|
||||||
AIWorkspaceExecutorWindowsAgentEndpoint.as_view(),
|
AIWorkspaceExecutorWindowsAgentEndpoint.as_view(),
|
||||||
name="ai-workspace-executor-windows-agent",
|
name="ai-workspace-executor-windows-agent",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/ai-workspace/executors/<uuid:executor_id>/agent/setup-command/",
|
|
||||||
AIWorkspaceExecutorSetupCommandEndpoint.as_view(),
|
|
||||||
name="ai-workspace-executor-setup-command",
|
|
||||||
),
|
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/ai-workspace/threads/",
|
"workspaces/<str:slug>/ai-workspace/threads/",
|
||||||
AIWorkspaceThreadListEndpoint.as_view(),
|
AIWorkspaceThreadListEndpoint.as_view(),
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,6 @@ from .ai_workspace import (
|
||||||
AIWorkspaceExecutorEventsEndpoint,
|
AIWorkspaceExecutorEventsEndpoint,
|
||||||
AIWorkspaceExecutorListEndpoint,
|
AIWorkspaceExecutorListEndpoint,
|
||||||
AIWorkspaceExecutorSelectEndpoint,
|
AIWorkspaceExecutorSelectEndpoint,
|
||||||
AIWorkspaceExecutorSetupCommandEndpoint,
|
|
||||||
AIWorkspaceExecutorWindowsAgentEndpoint,
|
AIWorkspaceExecutorWindowsAgentEndpoint,
|
||||||
AIWorkspaceSettingsEndpoint,
|
AIWorkspaceSettingsEndpoint,
|
||||||
AIWorkspaceThreadDispatchEndpoint,
|
AIWorkspaceThreadDispatchEndpoint,
|
||||||
|
|
|
||||||
|
|
@ -505,51 +505,6 @@ def ops_settings_payload(raw_payload, slug):
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def sync_ops_installer_context(request, slug, source):
|
|
||||||
request_data = request.data if isinstance(request.data, dict) else {}
|
|
||||||
ops_project_id = str(
|
|
||||||
request_data.get("opsProjectId")
|
|
||||||
or request_data.get("ops_project_id")
|
|
||||||
or request.query_params.get("ops_project_id")
|
|
||||||
or ""
|
|
||||||
).strip()
|
|
||||||
if not ops_project_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
ops_workspace_slug = str(
|
|
||||||
request_data.get("opsWorkspaceSlug")
|
|
||||||
or request_data.get("ops_workspace_slug")
|
|
||||||
or request.query_params.get("ops_workspace_slug")
|
|
||||||
or slug
|
|
||||||
or ""
|
|
||||||
).strip()
|
|
||||||
active_context = {
|
|
||||||
"surface": "ops",
|
|
||||||
"workspaceSlug": ops_workspace_slug,
|
|
||||||
"opsWorkspaceSlug": ops_workspace_slug,
|
|
||||||
"opsRouteWorkspaceSlug": slug,
|
|
||||||
"opsProjectId": ops_project_id,
|
|
||||||
}
|
|
||||||
sync_payload = ops_settings_payload(
|
|
||||||
{
|
|
||||||
"activeContext": active_context,
|
|
||||||
"enabledToolPacks": ["ops", "engine"],
|
|
||||||
"metadata": {
|
|
||||||
"source": source,
|
|
||||||
"updatedAt": timezone.now().isoformat(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
slug,
|
|
||||||
)
|
|
||||||
sync_payload, ops_grant_error = attach_ops_grant_for_active_context(request, slug, sync_payload)
|
|
||||||
if ops_grant_error is not None:
|
|
||||||
return ops_grant_error
|
|
||||||
sync_response = ai_workspace_request(request, "PATCH", "/settings", sync_payload)
|
|
||||||
if sync_response.status_code >= 400:
|
|
||||||
return sync_response
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class AIWorkspaceSettingsEndpoint(BaseAPIView):
|
class AIWorkspaceSettingsEndpoint(BaseAPIView):
|
||||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
def get(self, request, slug):
|
def get(self, request, slug):
|
||||||
|
|
@ -629,9 +584,33 @@ class AIWorkspaceExecutorWindowsAgentEndpoint(BaseAPIView):
|
||||||
params = {}
|
params = {}
|
||||||
if request.query_params.get("port"):
|
if request.query_params.get("port"):
|
||||||
params["port"] = request.query_params.get("port")
|
params["port"] = request.query_params.get("port")
|
||||||
sync_error = sync_ops_installer_context(request, slug, "ops-ai-workspace-installer")
|
ops_project_id = (request.query_params.get("ops_project_id") or "").strip()
|
||||||
if sync_error is not None:
|
if ops_project_id:
|
||||||
return sync_error
|
ops_workspace_slug = (request.query_params.get("ops_workspace_slug") or slug or "").strip()
|
||||||
|
active_context = {
|
||||||
|
"surface": "ops",
|
||||||
|
"workspaceSlug": ops_workspace_slug,
|
||||||
|
"opsWorkspaceSlug": ops_workspace_slug,
|
||||||
|
"opsRouteWorkspaceSlug": slug,
|
||||||
|
"opsProjectId": ops_project_id,
|
||||||
|
}
|
||||||
|
sync_payload = ops_settings_payload(
|
||||||
|
{
|
||||||
|
"activeContext": active_context,
|
||||||
|
"enabledToolPacks": ["ops", "engine"],
|
||||||
|
"metadata": {
|
||||||
|
"source": "ops-ai-workspace-installer",
|
||||||
|
"updatedAt": timezone.now().isoformat(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
slug,
|
||||||
|
)
|
||||||
|
sync_payload, ops_grant_error = attach_ops_grant_for_active_context(request, slug, sync_payload)
|
||||||
|
if ops_grant_error is not None:
|
||||||
|
return ops_grant_error
|
||||||
|
sync_response = ai_workspace_request(request, "PATCH", "/settings", sync_payload)
|
||||||
|
if sync_response.status_code >= 400:
|
||||||
|
return sync_response
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
|
|
@ -664,27 +643,6 @@ class AIWorkspaceExecutorWindowsAgentEndpoint(BaseAPIView):
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
class AIWorkspaceExecutorSetupCommandEndpoint(BaseAPIView):
|
|
||||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
|
||||||
def post(self, request, slug, executor_id):
|
|
||||||
sync_error = sync_ops_installer_context(request, slug, "ops-ai-workspace-npm-setup")
|
|
||||||
if sync_error is not None:
|
|
||||||
return sync_error
|
|
||||||
|
|
||||||
request_data = request.data if isinstance(request.data, dict) else {}
|
|
||||||
payload = {}
|
|
||||||
port = request_data.get("port") or request.query_params.get("port")
|
|
||||||
if port:
|
|
||||||
payload["port"] = port
|
|
||||||
return ai_workspace_request(
|
|
||||||
request,
|
|
||||||
"POST",
|
|
||||||
f"/executors/{executor_id}/agent/setup-command",
|
|
||||||
payload,
|
|
||||||
timeout_override=15,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AIWorkspaceThreadListEndpoint(BaseAPIView):
|
class AIWorkspaceThreadListEndpoint(BaseAPIView):
|
||||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||||
def get(self, request, slug):
|
def get(self, request, slug):
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,8 @@ function ProjectAttributes(props: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { control } = useFormContext<IProject>();
|
const { control } = useFormContext<IProject>();
|
||||||
const { getIndex } = getTabIndex(ETabIndices.PROJECT_CREATE, isMobile);
|
const { getIndex } = getTabIndex(ETabIndices.PROJECT_CREATE, isMobile);
|
||||||
const projectAttributeChipClassName = "nodedc-modal-chip !h-10 !rounded-[1.25rem] !px-4 !py-2 !text-13";
|
const projectAttributeChipClassName =
|
||||||
|
"nodedc-modal-chip !h-10 !rounded-[1.25rem] !px-4 !py-2 !text-13";
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Controller
|
<Controller
|
||||||
|
|
@ -85,7 +86,6 @@ function ProjectAttributes(props: Props) {
|
||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
buttonClassName={projectAttributeChipClassName}
|
buttonClassName={projectAttributeChipClassName}
|
||||||
buttonContainerClassName="!h-10"
|
buttonContainerClassName="!h-10"
|
||||||
optionsClassName="!z-[190]"
|
|
||||||
showUserDetails
|
showUserDetails
|
||||||
tabIndex={getIndex("lead")}
|
tabIndex={getIndex("lead")}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1547,29 +1547,6 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW
|
||||||
[opsWorkspaceSlug, runRegistryAction, selectedProjectId, updateRegistry, workspaceSlug]
|
[opsWorkspaceSlug, runRegistryAction, selectedProjectId, updateRegistry, workspaceSlug]
|
||||||
);
|
);
|
||||||
|
|
||||||
const copySetupCommand = useCallback(
|
|
||||||
async (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => {
|
|
||||||
await runRegistryAction(
|
|
||||||
"setup-command",
|
|
||||||
async () => {
|
|
||||||
const payload = await workspaceAIWorkspaceService.updateExecutor(workspaceSlug, executorId, input);
|
|
||||||
await updateRegistry(payload);
|
|
||||||
const setup = await workspaceAIWorkspaceService.createAgentSetupCommand(workspaceSlug, executorId, {
|
|
||||||
port,
|
|
||||||
opsWorkspaceSlug,
|
|
||||||
opsProjectId: selectedProjectId,
|
|
||||||
});
|
|
||||||
const command = String(setup.install?.command || "").trim();
|
|
||||||
if (!command) throw new Error("setup_command_empty");
|
|
||||||
if (!navigator.clipboard?.writeText) throw new Error("clipboard_unavailable");
|
|
||||||
await navigator.clipboard.writeText(command);
|
|
||||||
},
|
|
||||||
"npm setup command скопирована"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[opsWorkspaceSlug, runRegistryAction, selectedProjectId, updateRegistry, workspaceSlug]
|
|
||||||
);
|
|
||||||
|
|
||||||
const createThread = async () => {
|
const createThread = async () => {
|
||||||
try {
|
try {
|
||||||
setThreadError("");
|
setThreadError("");
|
||||||
|
|
@ -2259,7 +2236,6 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW
|
||||||
onDelete={deleteExecutor}
|
onDelete={deleteExecutor}
|
||||||
onOpsWorkspaceChange={setSelectedOpsWorkspaceSlug}
|
onOpsWorkspaceChange={setSelectedOpsWorkspaceSlug}
|
||||||
onProjectChange={setSelectedProjectId}
|
onProjectChange={setSelectedProjectId}
|
||||||
onCopySetupCommand={copySetupCommand}
|
|
||||||
onDownloadAgent={downloadAgent}
|
onDownloadAgent={downloadAgent}
|
||||||
getWindowsAgentInstallerUrl={(executorId, options) =>
|
getWindowsAgentInstallerUrl={(executorId, options) =>
|
||||||
workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(workspaceSlug, executorId, {
|
workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(workspaceSlug, executorId, {
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,6 @@ type TAIWorkspaceProductSettingsModalProps = {
|
||||||
onDelete: (executorId: string) => Promise<TAIWorkspaceExecutorListResponse | null>;
|
onDelete: (executorId: string) => Promise<TAIWorkspaceExecutorListResponse | null>;
|
||||||
onOpsWorkspaceChange: (workspaceSlug: string) => void;
|
onOpsWorkspaceChange: (workspaceSlug: string) => void;
|
||||||
onProjectChange: (projectId: string) => void;
|
onProjectChange: (projectId: string) => void;
|
||||||
onCopySetupCommand: (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => Promise<void>;
|
|
||||||
onDownloadAgent: (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => Promise<void>;
|
onDownloadAgent: (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => Promise<void>;
|
||||||
getWindowsAgentInstallerUrl: (
|
getWindowsAgentInstallerUrl: (
|
||||||
executorId: string,
|
executorId: string,
|
||||||
|
|
@ -325,7 +324,6 @@ export function AIWorkspaceProductSettingsModal({
|
||||||
onDelete,
|
onDelete,
|
||||||
onOpsWorkspaceChange,
|
onOpsWorkspaceChange,
|
||||||
onProjectChange,
|
onProjectChange,
|
||||||
onCopySetupCommand,
|
|
||||||
onDownloadAgent,
|
onDownloadAgent,
|
||||||
getWindowsAgentInstallerUrl,
|
getWindowsAgentInstallerUrl,
|
||||||
}: TAIWorkspaceProductSettingsModalProps) {
|
}: TAIWorkspaceProductSettingsModalProps) {
|
||||||
|
|
@ -452,11 +450,6 @@ export function AIWorkspaceProductSettingsModal({
|
||||||
await onDownloadAgent(editingExecutor.id, toInput(draft), draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT);
|
await onDownloadAgent(editingExecutor.id, toInput(draft), draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT);
|
||||||
};
|
};
|
||||||
|
|
||||||
const copySetupCommand = async () => {
|
|
||||||
if (!editingExecutor) return;
|
|
||||||
await onCopySetupCommand(editingExecutor.id, toInput(draft), draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT);
|
|
||||||
};
|
|
||||||
|
|
||||||
const activeCheckDetails = activeExecutor
|
const activeCheckDetails = activeExecutor
|
||||||
? [formatCheckTime(activeExecutor.lastSeenAt), activeExecutor.statusDetail].filter(Boolean).join(" · ")
|
? [formatCheckTime(activeExecutor.lastSeenAt), activeExecutor.statusDetail].filter(Boolean).join(" · ")
|
||||||
: "";
|
: "";
|
||||||
|
|
@ -703,26 +696,18 @@ export function AIWorkspaceProductSettingsModal({
|
||||||
placeholder="XXXX-XXXX-XXXX"
|
placeholder="XXXX-XXXX-XXXX"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="modal-btn ai-settings-download ai-settings-inline-download btn-primary"
|
|
||||||
disabled={isBusy}
|
|
||||||
onClick={() => void copySetupCommand()}
|
|
||||||
>
|
|
||||||
Copy npx
|
|
||||||
</button>
|
|
||||||
<a
|
<a
|
||||||
className="modal-btn ai-settings-download ai-settings-inline-download"
|
className="modal-btn ai-settings-download ai-settings-inline-download"
|
||||||
href={getWindowsAgentInstallerUrl(editingExecutor.id)}
|
href={getWindowsAgentInstallerUrl(editingExecutor.id)}
|
||||||
onClick={downloadAgent}
|
onClick={downloadAgent}
|
||||||
download
|
download
|
||||||
>
|
>
|
||||||
Legacy .ps1
|
Скачать агент
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
) : draft.connectionMode === "hub" ? (
|
) : draft.connectionMode === "hub" ? (
|
||||||
<div className="ai-settings-check-note">
|
<div className="ai-settings-check-note">
|
||||||
После сохранения общий AI Workspace создаст pairing code и npm setup command.
|
После сохранения общий AI Workspace создаст pairing code и installer агента.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import { useAppRouter } from "@/hooks/use-app-router";
|
||||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||||
// local imports
|
// local imports
|
||||||
import { WorkItemDetailQuickActions } from "../issue-layouts/quick-action-dropdowns";
|
import { WorkItemDetailQuickActions } from "../issue-layouts/quick-action-dropdowns";
|
||||||
import { IssueMarkdownExportButton } from "./markdown-export-button";
|
|
||||||
import { IssueSubscription } from "./subscription";
|
import { IssueSubscription } from "./subscription";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -151,7 +150,6 @@ export const IssueDetailQuickActions = observer(function IssueDetailQuickActions
|
||||||
<Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}>
|
<Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}>
|
||||||
<IconButton variant="secondary" size="lg" onClick={handleCopyText} icon={CopyLinkIcon} />
|
<IconButton variant="secondary" size="lg" onClick={handleCopyText} icon={CopyLinkIcon} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<IssueMarkdownExportButton workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
|
|
||||||
<WorkItemDetailQuickActions
|
<WorkItemDetailQuickActions
|
||||||
parentRef={parentRef}
|
parentRef={parentRef}
|
||||||
issue={issue}
|
issue={issue}
|
||||||
|
|
|
||||||
|
|
@ -1,201 +0,0 @@
|
||||||
/**
|
|
||||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
* See the LICENSE file for details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, type MouseEvent } from "react";
|
|
||||||
import { Download } from "lucide-react";
|
|
||||||
import { observer } from "mobx-react";
|
|
||||||
// plane imports
|
|
||||||
import { IconButton } from "@plane/propel/icon-button";
|
|
||||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
|
||||||
import { Tooltip } from "@plane/propel/tooltip";
|
|
||||||
import { generateWorkItemLink } from "@plane/utils";
|
|
||||||
// hooks
|
|
||||||
import { useCycle } from "@/hooks/store/use-cycle";
|
|
||||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
|
||||||
import { useLabel } from "@/hooks/store/use-label";
|
|
||||||
import { useMember } from "@/hooks/store/use-member";
|
|
||||||
import { useModule } from "@/hooks/store/use-module";
|
|
||||||
import { useProject } from "@/hooks/store/use-project";
|
|
||||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
|
||||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
|
||||||
// local imports
|
|
||||||
import { buildIssueMarkdownExport, downloadIssueMarkdownExport } from "./markdown-export";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
workspaceSlug: string;
|
|
||||||
projectId: string;
|
|
||||||
issueId: string;
|
|
||||||
isArchived?: boolean;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const IssueMarkdownExportButton = observer(function IssueMarkdownExportButton(props: Props) {
|
|
||||||
const { workspaceSlug, projectId, issueId, isArchived = false, className } = props;
|
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
|
||||||
const { isMobile } = usePlatformOS();
|
|
||||||
const { getProjectById, getProjectIdentifierById } = useProject();
|
|
||||||
const { getUserDetails } = useMember();
|
|
||||||
const { fetchProjectLabels, getLabelById } = useLabel();
|
|
||||||
const { fetchModules, getModuleNameById } = useModule();
|
|
||||||
const { fetchAllCycles, getCycleNameById } = useCycle();
|
|
||||||
const { fetchProjectStates, getStateById } = useProjectState();
|
|
||||||
const {
|
|
||||||
issue: { getIssueById },
|
|
||||||
link,
|
|
||||||
attachment,
|
|
||||||
subIssues,
|
|
||||||
relation,
|
|
||||||
comment,
|
|
||||||
activity,
|
|
||||||
reaction,
|
|
||||||
fetchIssue,
|
|
||||||
fetchLinks,
|
|
||||||
fetchAttachments,
|
|
||||||
fetchSubIssues,
|
|
||||||
fetchRelations,
|
|
||||||
fetchComments,
|
|
||||||
fetchActivities,
|
|
||||||
fetchReactions,
|
|
||||||
} = useIssueDetail();
|
|
||||||
|
|
||||||
const issue = getIssueById(issueId);
|
|
||||||
if (!issue) return <></>;
|
|
||||||
|
|
||||||
const issueProjectId = issue.project_id ?? projectId;
|
|
||||||
const projectIdentifier = getProjectIdentifierById(issueProjectId);
|
|
||||||
const project = getProjectById(issueProjectId);
|
|
||||||
const workItemLink = generateWorkItemLink({
|
|
||||||
workspaceSlug,
|
|
||||||
projectId: issueProjectId,
|
|
||||||
issueId,
|
|
||||||
projectIdentifier,
|
|
||||||
sequenceId: issue.sequence_id,
|
|
||||||
isArchived,
|
|
||||||
});
|
|
||||||
|
|
||||||
const getDisplayName = (userId: string | null | undefined) => {
|
|
||||||
if (!userId) return undefined;
|
|
||||||
const user = getUserDetails(userId);
|
|
||||||
return user?.display_name || user?.first_name || user?.email || userId;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIssueListByIds = (issueIds: string[] | undefined) =>
|
|
||||||
(issueIds ?? []).flatMap((currentIssueId) => {
|
|
||||||
const currentIssue = getIssueById(currentIssueId);
|
|
||||||
return currentIssue ? [currentIssue] : [];
|
|
||||||
});
|
|
||||||
|
|
||||||
const refreshExportData = async () => {
|
|
||||||
await fetchIssue(workspaceSlug, issueProjectId, issueId);
|
|
||||||
await Promise.allSettled([
|
|
||||||
fetchLinks(workspaceSlug, issueProjectId, issueId),
|
|
||||||
fetchAttachments(workspaceSlug, issueProjectId, issueId),
|
|
||||||
fetchSubIssues(workspaceSlug, issueProjectId, issueId),
|
|
||||||
fetchRelations(workspaceSlug, issueProjectId, issueId),
|
|
||||||
fetchComments(workspaceSlug, issueProjectId, issueId),
|
|
||||||
fetchActivities(workspaceSlug, issueProjectId, issueId),
|
|
||||||
fetchReactions(workspaceSlug, issueProjectId, issueId),
|
|
||||||
fetchProjectStates(workspaceSlug, issueProjectId),
|
|
||||||
fetchProjectLabels(workspaceSlug, issueProjectId),
|
|
||||||
fetchModules(workspaceSlug, issueProjectId),
|
|
||||||
fetchAllCycles(workspaceSlug, issueProjectId),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildExportData = () => {
|
|
||||||
const exportIssue = getIssueById(issueId) ?? issue;
|
|
||||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
|
||||||
const relationMap = relation.getRelationsByIssueId(issueId) ?? {};
|
|
||||||
const reactionsByEmoji = reaction.getReactionsByIssueId(issueId) ?? {};
|
|
||||||
|
|
||||||
return buildIssueMarkdownExport({
|
|
||||||
workspaceSlug,
|
|
||||||
projectName: project?.name,
|
|
||||||
projectIdentifier,
|
|
||||||
workItemUrl: `${originURL}${workItemLink}`,
|
|
||||||
issue: exportIssue,
|
|
||||||
subIssues: getIssueListByIds(subIssues.subIssuesByIssueId(issueId)),
|
|
||||||
relations: Object.fromEntries(
|
|
||||||
Object.entries(relationMap).map(([relationType, relatedIssueIds]) => [
|
|
||||||
relationType,
|
|
||||||
getIssueListByIds(Array.isArray(relatedIssueIds) ? relatedIssueIds : []),
|
|
||||||
])
|
|
||||||
),
|
|
||||||
links: (link.getLinksByIssueId(issueId) ?? []).flatMap((linkId) => {
|
|
||||||
const issueLink = link.getLinkById(linkId);
|
|
||||||
return issueLink ? [issueLink] : [];
|
|
||||||
}),
|
|
||||||
attachments: (attachment.getAttachmentsByIssueId(issueId) ?? []).flatMap((attachmentId) => {
|
|
||||||
const issueAttachment = attachment.getAttachmentById(attachmentId);
|
|
||||||
return issueAttachment ? [issueAttachment] : [];
|
|
||||||
}),
|
|
||||||
comments: (comment.getCommentsByIssueId(issueId) ?? []).flatMap((commentId) => {
|
|
||||||
const issueComment = comment.getCommentById(commentId);
|
|
||||||
return issueComment ? [issueComment] : [];
|
|
||||||
}),
|
|
||||||
activities: (activity.getActivitiesByIssueId(issueId) ?? []).flatMap((activityId) => {
|
|
||||||
const issueActivity = activity.getActivityById(activityId);
|
|
||||||
return issueActivity ? [issueActivity] : [];
|
|
||||||
}),
|
|
||||||
reactions: Object.entries(reactionsByEmoji).map(([emoji, reactionIds]) => {
|
|
||||||
const actors = reactionIds
|
|
||||||
.map((reactionId) => {
|
|
||||||
const issueReaction = reaction.getReactionById(reactionId);
|
|
||||||
return issueReaction?.display_name || getDisplayName(issueReaction?.actor);
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return `${emoji}: ${actors.length > 0 ? actors.join(", ") : reactionIds.length}`;
|
|
||||||
}),
|
|
||||||
getIssueById: (currentIssueId) => (currentIssueId ? getIssueById(currentIssueId) : undefined),
|
|
||||||
getUserDisplayName: getDisplayName,
|
|
||||||
getStateName: (stateId) => (stateId ? getStateById(stateId)?.name : undefined),
|
|
||||||
getLabelName: (labelId) => (labelId ? getLabelById(labelId)?.name : undefined),
|
|
||||||
getModuleName: (moduleId) => (moduleId ? getModuleNameById(moduleId) : undefined),
|
|
||||||
getCycleName: (cycleId) => (cycleId ? getCycleNameById(cycleId) : undefined),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownloadMarkdown = async (event: MouseEvent<HTMLButtonElement>) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (isExporting) return;
|
|
||||||
setIsExporting(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await refreshExportData();
|
|
||||||
const exportResult = buildExportData();
|
|
||||||
downloadIssueMarkdownExport(exportResult);
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.SUCCESS,
|
|
||||||
title: "Markdown скачан",
|
|
||||||
message: exportResult.fileName,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error exporting work item markdown:", error);
|
|
||||||
setToast({
|
|
||||||
title: "Не удалось скачать Markdown",
|
|
||||||
type: TOAST_TYPE.ERROR,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsExporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip tooltipContent="Скачать Markdown" isMobile={isMobile}>
|
|
||||||
<IconButton
|
|
||||||
variant="secondary"
|
|
||||||
size="lg"
|
|
||||||
onClick={handleDownloadMarkdown}
|
|
||||||
icon={Download}
|
|
||||||
loading={isExporting}
|
|
||||||
className={className}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -1,558 +0,0 @@
|
||||||
/**
|
|
||||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
* See the LICENSE file for details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { TIssue, TIssueActivity, TIssueAttachment, TIssueComment, TIssueLink } from "@plane/types";
|
|
||||||
import { orderBy } from "lodash-es";
|
|
||||||
// local imports
|
|
||||||
import {
|
|
||||||
extractIssueStructuredContent,
|
|
||||||
type TIssueStructuredBlock,
|
|
||||||
} from "../issue-detail-widgets/structured-content.helpers";
|
|
||||||
|
|
||||||
type TIssueLookup = (issueId: string | null | undefined) => TIssue | Partial<TIssue> | undefined;
|
|
||||||
type TEntityNameLookup = (entityId: string | null | undefined) => string | undefined;
|
|
||||||
|
|
||||||
export type TIssueMarkdownExportContext = {
|
|
||||||
workspaceSlug: string;
|
|
||||||
projectName?: string;
|
|
||||||
projectIdentifier?: string;
|
|
||||||
workItemUrl?: string;
|
|
||||||
issue: TIssue;
|
|
||||||
subIssues: TIssue[];
|
|
||||||
relations: Record<string, TIssue[]>;
|
|
||||||
links: TIssueLink[];
|
|
||||||
attachments: TIssueAttachment[];
|
|
||||||
comments: TIssueComment[];
|
|
||||||
activities: TIssueActivity[];
|
|
||||||
reactions: string[];
|
|
||||||
getIssueById: TIssueLookup;
|
|
||||||
getUserDisplayName: TEntityNameLookup;
|
|
||||||
getStateName: TEntityNameLookup;
|
|
||||||
getLabelName: TEntityNameLookup;
|
|
||||||
getModuleName: TEntityNameLookup;
|
|
||||||
getCycleName: TEntityNameLookup;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TMarkdownExportResult = {
|
|
||||||
fileName: string;
|
|
||||||
markdown: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyValue = "Нет";
|
|
||||||
|
|
||||||
const relationLabels: Record<string, string> = {
|
|
||||||
blocking: "Блокирует",
|
|
||||||
blocked_by: "Заблокировано",
|
|
||||||
duplicate: "Дубликат",
|
|
||||||
relates_to: "Связано",
|
|
||||||
related: "Связано",
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeText = (value: unknown) => `${value ?? ""}`.trim();
|
|
||||||
|
|
||||||
const removeControlCharacters = (value: string) =>
|
|
||||||
Array.from(value)
|
|
||||||
.map((character) => (character.charCodeAt(0) < 32 ? " " : character))
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const valueOrEmpty = (value: unknown) => {
|
|
||||||
const normalized = normalizeText(value);
|
|
||||||
return normalized || emptyValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
const listValue = (values: Array<string | undefined>) => {
|
|
||||||
const normalizedValues = values.map((value) => normalizeText(value)).filter(Boolean);
|
|
||||||
return normalizedValues.length > 0 ? normalizedValues.join(", ") : emptyValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (value: string | Date | null | undefined) => {
|
|
||||||
if (!value) return emptyValue;
|
|
||||||
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return `${value}`;
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat("ru-RU", {
|
|
||||||
dateStyle: "medium",
|
|
||||||
timeStyle: "short",
|
|
||||||
}).format(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatBytes = (value: number | null | undefined) => {
|
|
||||||
if (!value || value < 0) return emptyValue;
|
|
||||||
|
|
||||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
||||||
let size = value;
|
|
||||||
let unitIndex = 0;
|
|
||||||
|
|
||||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
||||||
size /= 1024;
|
|
||||||
unitIndex += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeMarkdownWhitespace = (value: string) =>
|
|
||||||
value
|
|
||||||
.replace(/\r\n/g, "\n")
|
|
||||||
.replace(/\n{3,}/g, "\n\n")
|
|
||||||
.replace(/[ \t]+\n/g, "\n")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
const plainTextFromHtml = (html: string) =>
|
|
||||||
normalizeMarkdownWhitespace(
|
|
||||||
html
|
|
||||||
.replace(/<br\s*\/?>/gi, "\n")
|
|
||||||
.replace(/<\/(p|div|li|h[1-6]|blockquote)>/gi, "\n")
|
|
||||||
.replace(/<[^>]*>/g, "")
|
|
||||||
.replace(/ /g, " ")
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
);
|
|
||||||
|
|
||||||
const nodeChildrenToMarkdown = (node: Node, depth = 0): string =>
|
|
||||||
Array.from(node.childNodes)
|
|
||||||
.map((childNode) => nodeToMarkdown(childNode, depth))
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const normalizeListItem = (value: string) => value.trim().replace(/\n+/g, "\n ");
|
|
||||||
|
|
||||||
const nodeToMarkdown = (node: Node, depth = 0): string => {
|
|
||||||
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? "";
|
|
||||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
|
||||||
|
|
||||||
const element = node as HTMLElement;
|
|
||||||
const tagName = element.tagName.toLowerCase();
|
|
||||||
const childMarkdown = () => nodeChildrenToMarkdown(element, depth);
|
|
||||||
|
|
||||||
if (tagName === "br") return "\n";
|
|
||||||
|
|
||||||
if (tagName === "p" || tagName === "div") {
|
|
||||||
const body = normalizeMarkdownWhitespace(childMarkdown());
|
|
||||||
return body ? `${body}\n\n` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/^h[1-6]$/.test(tagName)) {
|
|
||||||
const level = Number(tagName.slice(1));
|
|
||||||
const body = normalizeMarkdownWhitespace(childMarkdown());
|
|
||||||
return body ? `${"#".repeat(level)} ${body}\n\n` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tagName === "strong" || tagName === "b") {
|
|
||||||
const body = childMarkdown().trim();
|
|
||||||
return body ? `**${body}**` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tagName === "em" || tagName === "i") {
|
|
||||||
const body = childMarkdown().trim();
|
|
||||||
return body ? `_${body}_` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tagName === "code") {
|
|
||||||
const body = childMarkdown().trim();
|
|
||||||
return body ? `\`${body}\`` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tagName === "pre") {
|
|
||||||
const body = element.textContent?.trim() ?? "";
|
|
||||||
return body ? `\n\`\`\`\n${body}\n\`\`\`\n\n` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tagName === "blockquote") {
|
|
||||||
const body = normalizeMarkdownWhitespace(childMarkdown());
|
|
||||||
return body
|
|
||||||
? `${body
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => `> ${line}`)
|
|
||||||
.join("\n")}\n\n`
|
|
||||||
: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tagName === "a") {
|
|
||||||
const body = normalizeMarkdownWhitespace(childMarkdown()) || element.getAttribute("href") || "";
|
|
||||||
const href = element.getAttribute("href");
|
|
||||||
return href ? `[${body}](${href})` : body;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tagName === "img") {
|
|
||||||
const src = element.getAttribute("src");
|
|
||||||
if (!src) return "";
|
|
||||||
return ``;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tagName === "ul") {
|
|
||||||
return `${Array.from(element.children)
|
|
||||||
.filter((childElement) => childElement.tagName.toLowerCase() === "li")
|
|
||||||
.map(
|
|
||||||
(childElement) => `${" ".repeat(depth)}- ${normalizeListItem(nodeChildrenToMarkdown(childElement, depth + 1))}`
|
|
||||||
)
|
|
||||||
.join("\n")}\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tagName === "ol") {
|
|
||||||
return `${Array.from(element.children)
|
|
||||||
.filter((childElement) => childElement.tagName.toLowerCase() === "li")
|
|
||||||
.map(
|
|
||||||
(childElement, index) =>
|
|
||||||
`${" ".repeat(depth)}${index + 1}. ${normalizeListItem(nodeChildrenToMarkdown(childElement, depth + 1))}`
|
|
||||||
)
|
|
||||||
.join("\n")}\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tagName === "li") return `${" ".repeat(depth)}- ${normalizeListItem(childMarkdown())}\n`;
|
|
||||||
|
|
||||||
return childMarkdown();
|
|
||||||
};
|
|
||||||
|
|
||||||
const htmlToMarkdown = (html: string | null | undefined) => {
|
|
||||||
const normalizedHtml = normalizeText(html);
|
|
||||||
if (!normalizedHtml || normalizedHtml === "<p></p>") return "";
|
|
||||||
|
|
||||||
if (typeof document === "undefined") return plainTextFromHtml(normalizedHtml);
|
|
||||||
|
|
||||||
const template = document.createElement("template");
|
|
||||||
template.innerHTML = normalizedHtml;
|
|
||||||
|
|
||||||
return normalizeMarkdownWhitespace(nodeChildrenToMarkdown(template.content));
|
|
||||||
};
|
|
||||||
|
|
||||||
const absoluteUrl = (url: string | null | undefined) => {
|
|
||||||
const normalizedUrl = normalizeText(url);
|
|
||||||
if (!normalizedUrl) return undefined;
|
|
||||||
if (/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl;
|
|
||||||
if (typeof window !== "undefined" && normalizedUrl.startsWith("/"))
|
|
||||||
return `${window.location.origin}${normalizedUrl}`;
|
|
||||||
return normalizedUrl;
|
|
||||||
};
|
|
||||||
|
|
||||||
const issueIdentifier = (issue: Partial<TIssue>, projectIdentifier?: string) =>
|
|
||||||
projectIdentifier && issue.sequence_id ? `${projectIdentifier}-${issue.sequence_id}` : valueOrEmpty(issue.id);
|
|
||||||
|
|
||||||
const issueLine = (issue: Partial<TIssue>, context: TIssueMarkdownExportContext) => {
|
|
||||||
const projectIdentifier =
|
|
||||||
issue.project_id === context.issue.project_id
|
|
||||||
? context.projectIdentifier
|
|
||||||
: context.getIssueById(issue.id)?.project_id === context.issue.project_id
|
|
||||||
? context.projectIdentifier
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return `${issueIdentifier(issue, projectIdentifier)} — ${valueOrEmpty(issue.name)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const userName = (userId: string | null | undefined, context: TIssueMarkdownExportContext) =>
|
|
||||||
context.getUserDisplayName(userId) ?? valueOrEmpty(userId);
|
|
||||||
|
|
||||||
const namesByIds = (ids: string[] | null | undefined, lookup: TEntityNameLookup) =>
|
|
||||||
listValue((ids ?? []).map((id) => lookup(id) ?? id));
|
|
||||||
|
|
||||||
const issueStateName = (stateId: string | null | undefined, context: TIssueMarkdownExportContext) =>
|
|
||||||
context.getStateName(stateId) ?? valueOrEmpty(stateId);
|
|
||||||
|
|
||||||
const issueAssignees = (issue: Partial<TIssue>, context: TIssueMarkdownExportContext) =>
|
|
||||||
namesByIds(issue.assignee_ids ?? [], context.getUserDisplayName);
|
|
||||||
|
|
||||||
const formatIssueFacts = (issue: Partial<TIssue>, context: TIssueMarkdownExportContext) => [
|
|
||||||
` - Статус: ${issueStateName(issue.state_id, context)}`,
|
|
||||||
` - Приоритет: ${valueOrEmpty(issue.priority)}`,
|
|
||||||
` - Исполнители: ${issueAssignees(issue, context)}`,
|
|
||||||
` - Старт: ${formatDate(issue.start_date)}`,
|
|
||||||
` - Дедлайн: ${formatDate(issue.target_date)}`,
|
|
||||||
` - Завершено: ${formatDate(issue.completed_at)}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const renderStructuredBlocks = (blocks: TIssueStructuredBlock[]) => {
|
|
||||||
if (blocks.length === 0) return emptyValue;
|
|
||||||
|
|
||||||
return blocks
|
|
||||||
.map((block, index) => {
|
|
||||||
const title = valueOrEmpty(block.title) === emptyValue ? `Блок ${index + 1}` : block.title.trim();
|
|
||||||
|
|
||||||
if (block.type === "text") {
|
|
||||||
return [`### ${title}`, "", normalizeMarkdownWhitespace(block.body) || emptyValue].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
const items =
|
|
||||||
block.items.length > 0
|
|
||||||
? block.items.map((item) => `- [${item.checked ? "x" : " "}] ${valueOrEmpty(item.text)}`).join("\n")
|
|
||||||
: emptyValue;
|
|
||||||
|
|
||||||
return [`### ${title}`, "", items].join("\n");
|
|
||||||
})
|
|
||||||
.join("\n\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSubIssues = (context: TIssueMarkdownExportContext) => {
|
|
||||||
if (context.subIssues.length === 0) return emptyValue;
|
|
||||||
|
|
||||||
return context.subIssues
|
|
||||||
.map((subIssue) =>
|
|
||||||
[
|
|
||||||
`- ${issueLine(subIssue, context)}`,
|
|
||||||
...formatIssueFacts(subIssue, context),
|
|
||||||
` - Project UUID: ${valueOrEmpty(subIssue.project_id)}`,
|
|
||||||
` - Issue UUID: ${valueOrEmpty(subIssue.id)}`,
|
|
||||||
].join("\n")
|
|
||||||
)
|
|
||||||
.join("\n\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderRelations = (context: TIssueMarkdownExportContext) => {
|
|
||||||
const relationSections = Object.entries(context.relations).filter(([, issues]) => issues.length > 0);
|
|
||||||
if (relationSections.length === 0) return emptyValue;
|
|
||||||
|
|
||||||
return relationSections
|
|
||||||
.map(([relationType, issues]) => {
|
|
||||||
const relationTitle = relationLabels[relationType] ?? relationType;
|
|
||||||
const relationIssueList = issues.map((issue) => `- ${issueLine(issue, context)}`).join("\n");
|
|
||||||
|
|
||||||
return [`### ${relationTitle}`, "", relationIssueList].join("\n");
|
|
||||||
})
|
|
||||||
.join("\n\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLinks = (links: TIssueLink[]) => {
|
|
||||||
if (links.length === 0) return emptyValue;
|
|
||||||
|
|
||||||
return links
|
|
||||||
.map((link) => {
|
|
||||||
const url = valueOrEmpty(link.url);
|
|
||||||
const title = valueOrEmpty(link.title);
|
|
||||||
|
|
||||||
return [`- ${title}: ${url}`, ` - ID: ${link.id}`, ` - Created: ${formatDate(link.created_at)}`].join("\n");
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderAttachments = (attachments: TIssueAttachment[]) => {
|
|
||||||
if (attachments.length === 0) return emptyValue;
|
|
||||||
|
|
||||||
return attachments
|
|
||||||
.map((attachment) => {
|
|
||||||
const beamViewer = attachment.attributes?.beamViewer;
|
|
||||||
const lines = [
|
|
||||||
`- ${valueOrEmpty(attachment.attributes?.name)}`,
|
|
||||||
` - ID: ${attachment.id}`,
|
|
||||||
` - Type: ${valueOrEmpty(attachment.attributes?.type)}`,
|
|
||||||
` - Size: ${formatBytes(attachment.attributes?.size)}`,
|
|
||||||
` - URL: ${valueOrEmpty(absoluteUrl(attachment.asset_url))}`,
|
|
||||||
` - Created: ${formatDate(attachment.created_at)}`,
|
|
||||||
` - Updated: ${formatDate(attachment.updated_at)}`,
|
|
||||||
` - Created by: ${valueOrEmpty(attachment.created_by)}`,
|
|
||||||
` - Updated by: ${valueOrEmpty(attachment.updated_by)}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (beamViewer) {
|
|
||||||
lines.push(
|
|
||||||
` - BIM viewer: ${valueOrEmpty(beamViewer.viewerUrl)}`,
|
|
||||||
` - BIM download: ${valueOrEmpty(beamViewer.downloadUrl)}`,
|
|
||||||
` - BIM source: ${valueOrEmpty(beamViewer.conversion?.sourceSrc)}`,
|
|
||||||
` - BIM status: ${valueOrEmpty(beamViewer.conversion?.status)}`,
|
|
||||||
` - BIM version: ${valueOrEmpty(beamViewer.version)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
})
|
|
||||||
.join("\n\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderComments = (comments: TIssueComment[]) => {
|
|
||||||
if (comments.length === 0) return emptyValue;
|
|
||||||
|
|
||||||
return orderBy(comments, (comment) => new Date(comment.created_at).getTime(), "asc")
|
|
||||||
.map((comment) => {
|
|
||||||
const author = comment.actor_detail?.display_name || comment.actor_detail?.first_name || comment.actor;
|
|
||||||
const body = htmlToMarkdown(comment.comment_html) || valueOrEmpty(comment.comment_stripped);
|
|
||||||
const attachments =
|
|
||||||
comment.attachments && comment.attachments.length > 0
|
|
||||||
? `\n\nAttachments:\n\`\`\`json\n${JSON.stringify(comment.attachments, null, 2)}\n\`\`\``
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return [`### ${valueOrEmpty(author)} — ${formatDate(comment.created_at)}`, "", body, attachments].join("\n");
|
|
||||||
})
|
|
||||||
.join("\n\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatActivityValue = (
|
|
||||||
field: string | undefined,
|
|
||||||
value: string | undefined,
|
|
||||||
context: TIssueMarkdownExportContext
|
|
||||||
) => {
|
|
||||||
if (!value) return emptyValue;
|
|
||||||
|
|
||||||
const resolveSingleValue = (item: string) => {
|
|
||||||
const normalizedItem = item.trim();
|
|
||||||
if (!normalizedItem) return undefined;
|
|
||||||
|
|
||||||
if (field === "state") return context.getStateName(normalizedItem) ?? normalizedItem;
|
|
||||||
if (field === "assignees") return context.getUserDisplayName(normalizedItem) ?? normalizedItem;
|
|
||||||
if (field === "labels") return context.getLabelName(normalizedItem) ?? normalizedItem;
|
|
||||||
if (field === "modules") return context.getModuleName(normalizedItem) ?? normalizedItem;
|
|
||||||
if (field === "cycle") return context.getCycleName(normalizedItem) ?? normalizedItem;
|
|
||||||
|
|
||||||
return normalizedItem;
|
|
||||||
};
|
|
||||||
|
|
||||||
return listValue(value.split(",").map(resolveSingleValue));
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderActivities = (activities: TIssueActivity[], context: TIssueMarkdownExportContext) => {
|
|
||||||
if (activities.length === 0) return emptyValue;
|
|
||||||
|
|
||||||
return orderBy(activities, (activity) => new Date(activity.created_at).getTime(), "asc")
|
|
||||||
.map((activity) => {
|
|
||||||
const actor = activity.actor_detail?.display_name || activity.actor_detail?.first_name || activity.actor;
|
|
||||||
const field = valueOrEmpty(activity.field);
|
|
||||||
const oldValue = formatActivityValue(activity.field, activity.old_value, context);
|
|
||||||
const newValue = formatActivityValue(activity.field, activity.new_value, context);
|
|
||||||
const base = `- ${formatDate(activity.created_at)} — ${valueOrEmpty(actor)} — ${valueOrEmpty(activity.verb)} — ${field}: ${oldValue} -> ${newValue}`;
|
|
||||||
const comment = normalizeText(activity.comment);
|
|
||||||
|
|
||||||
return comment ? `${base}\n - Comment: ${comment}` : base;
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderReactions = (reactions: string[]) =>
|
|
||||||
reactions.length > 0 ? reactions.map((reaction) => `- ${reaction}`).join("\n") : emptyValue;
|
|
||||||
|
|
||||||
const buildRawSnapshot = (context: TIssueMarkdownExportContext) => ({
|
|
||||||
issue: context.issue,
|
|
||||||
sub_issues: context.subIssues,
|
|
||||||
relations: context.relations,
|
|
||||||
links: context.links,
|
|
||||||
attachments: context.attachments,
|
|
||||||
comments: context.comments,
|
|
||||||
activities: context.activities,
|
|
||||||
reactions: context.reactions,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sanitizeFileNameSegment = (value: unknown) =>
|
|
||||||
removeControlCharacters(valueOrEmpty(value))
|
|
||||||
.replace(/[\\/:*?"<>|]+/g, "-")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
const buildFileName = (context: TIssueMarkdownExportContext) => {
|
|
||||||
const projectName = sanitizeFileNameSegment(context.projectName || context.projectIdentifier || "TASKER");
|
|
||||||
const sequence = context.issue.sequence_id
|
|
||||||
? `${context.issue.sequence_id}`
|
|
||||||
: sanitizeFileNameSegment(context.issue.id);
|
|
||||||
const title = sanitizeFileNameSegment(context.issue.name);
|
|
||||||
const baseName = `${projectName} ${sequence}_${title}`.slice(0, 180).trim();
|
|
||||||
|
|
||||||
return `${baseName || "tasker-card"}.md`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildIssueMarkdownExport = (context: TIssueMarkdownExportContext): TMarkdownExportResult => {
|
|
||||||
const { issue } = context;
|
|
||||||
const parsedContent = extractIssueStructuredContent(issue.detail_layout, issue.description_html);
|
|
||||||
const parentIssue = issue.parent_id ? context.getIssueById(issue.parent_id) || issue.parent : undefined;
|
|
||||||
const projectDisplayName = context.projectName || context.projectIdentifier || issue.project_id;
|
|
||||||
const labels = namesByIds(issue.label_ids, context.getLabelName);
|
|
||||||
const modules = namesByIds(issue.module_ids ?? [], context.getModuleName);
|
|
||||||
const cycle = context.getCycleName(issue.cycle_id) ?? valueOrEmpty(issue.cycle_id);
|
|
||||||
const description = htmlToMarkdown(parsedContent.bodyHtml) || emptyValue;
|
|
||||||
const identifier = issueIdentifier(issue, context.projectIdentifier);
|
|
||||||
|
|
||||||
const markdown = normalizeMarkdownWhitespace(
|
|
||||||
[
|
|
||||||
`# ${identifier} — ${valueOrEmpty(issue.name)}`,
|
|
||||||
"",
|
|
||||||
"## Карточка",
|
|
||||||
"",
|
|
||||||
`- Workspace: ${context.workspaceSlug}`,
|
|
||||||
`- Project: ${valueOrEmpty(projectDisplayName)}`,
|
|
||||||
`- Project ID: ${valueOrEmpty(issue.project_id)}`,
|
|
||||||
`- Project identifier: ${valueOrEmpty(context.projectIdentifier)}`,
|
|
||||||
`- Issue ID: ${issue.id}`,
|
|
||||||
`- Sequence: ${valueOrEmpty(issue.sequence_id)}`,
|
|
||||||
`- Link: ${valueOrEmpty(context.workItemUrl)}`,
|
|
||||||
`- Status: ${issueStateName(issue.state_id, context)}`,
|
|
||||||
`- Priority: ${valueOrEmpty(issue.priority)}`,
|
|
||||||
`- Estimate: ${valueOrEmpty(issue.estimate_point)}`,
|
|
||||||
`- Assignees: ${issueAssignees(issue, context)}`,
|
|
||||||
`- Labels: ${labels}`,
|
|
||||||
`- Modules: ${modules}`,
|
|
||||||
`- Cycle: ${cycle}`,
|
|
||||||
`- Parent: ${parentIssue ? issueLine(parentIssue, context) : emptyValue}`,
|
|
||||||
`- Created: ${formatDate(issue.created_at)}`,
|
|
||||||
`- Created by: ${userName(issue.created_by, context)}`,
|
|
||||||
`- Updated: ${formatDate(issue.updated_at)}`,
|
|
||||||
`- Updated by: ${userName(issue.updated_by, context)}`,
|
|
||||||
`- Start date: ${formatDate(issue.start_date)}`,
|
|
||||||
`- Target date: ${formatDate(issue.target_date)}`,
|
|
||||||
`- Completed: ${formatDate(issue.completed_at)}`,
|
|
||||||
`- Archived: ${formatDate(issue.archived_at)}`,
|
|
||||||
`- External source: ${valueOrEmpty(issue.external_source)}`,
|
|
||||||
`- External ID: ${valueOrEmpty(issue.external_id)}`,
|
|
||||||
"",
|
|
||||||
"## Описание",
|
|
||||||
"",
|
|
||||||
description,
|
|
||||||
"",
|
|
||||||
"## Структурные блоки",
|
|
||||||
"",
|
|
||||||
renderStructuredBlocks(parsedContent.blocks),
|
|
||||||
"",
|
|
||||||
"## Подзадачи",
|
|
||||||
"",
|
|
||||||
renderSubIssues(context),
|
|
||||||
"",
|
|
||||||
"## Связи",
|
|
||||||
"",
|
|
||||||
renderRelations(context),
|
|
||||||
"",
|
|
||||||
"## Ссылки",
|
|
||||||
"",
|
|
||||||
renderLinks(context.links),
|
|
||||||
"",
|
|
||||||
"## Файлы",
|
|
||||||
"",
|
|
||||||
renderAttachments(context.attachments),
|
|
||||||
"",
|
|
||||||
"## Комментарии",
|
|
||||||
"",
|
|
||||||
renderComments(context.comments),
|
|
||||||
"",
|
|
||||||
"## Реакции",
|
|
||||||
"",
|
|
||||||
renderReactions(context.reactions),
|
|
||||||
"",
|
|
||||||
"## История изменений",
|
|
||||||
"",
|
|
||||||
renderActivities(context.activities, context),
|
|
||||||
"",
|
|
||||||
"## Raw data snapshot",
|
|
||||||
"",
|
|
||||||
"```json",
|
|
||||||
JSON.stringify(buildRawSnapshot(context), null, 2),
|
|
||||||
"```",
|
|
||||||
].join("\n")
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
fileName: buildFileName(context),
|
|
||||||
markdown,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const downloadIssueMarkdownExport = ({ fileName, markdown }: TMarkdownExportResult) => {
|
|
||||||
if (typeof document === "undefined") return;
|
|
||||||
|
|
||||||
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
|
|
||||||
link.href = url;
|
|
||||||
link.download = fileName;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
@ -26,7 +26,6 @@ import { IssueSubscription } from "../issue-detail/subscription";
|
||||||
import { WorkItemDetailQuickActions } from "../issue-layouts/quick-action-dropdowns";
|
import { WorkItemDetailQuickActions } from "../issue-layouts/quick-action-dropdowns";
|
||||||
import { NameDescriptionUpdateStatus } from "../issue-update-status";
|
import { NameDescriptionUpdateStatus } from "../issue-update-status";
|
||||||
import { IconButton } from "@plane/propel/icon-button";
|
import { IconButton } from "@plane/propel/icon-button";
|
||||||
import { IssueMarkdownExportButton } from "../issue-detail/markdown-export-button";
|
|
||||||
|
|
||||||
export type TPeekModes = "side-peek" | "modal" | "full-screen";
|
export type TPeekModes = "side-peek" | "modal" | "full-screen";
|
||||||
|
|
||||||
|
|
@ -105,31 +104,25 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
|
||||||
isArchived,
|
isArchived,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCopyText = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
copyUrlToClipboard(workItemLink).then(() => {
|
||||||
try {
|
|
||||||
await copyUrlToClipboard(workItemLink);
|
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.SUCCESS,
|
type: TOAST_TYPE.SUCCESS,
|
||||||
title: t("common.link_copied"),
|
title: t("common.link_copied"),
|
||||||
message: t("common.link_copied_to_clipboard"),
|
message: t("common.link_copied_to_clipboard"),
|
||||||
});
|
});
|
||||||
} catch (_error) {
|
});
|
||||||
setToast({
|
|
||||||
title: t("toast.error"),
|
|
||||||
type: TOAST_TYPE.ERROR,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteIssue = async () => {
|
const handleDeleteIssue = async () => {
|
||||||
try {
|
try {
|
||||||
const deleteIssue = issueDetails?.archived_at ? removeArchivedIssue : removeIssue;
|
const deleteIssue = issueDetails?.archived_at ? removeArchivedIssue : removeIssue;
|
||||||
|
|
||||||
await deleteIssue(workspaceSlug, projectId, issueId);
|
return deleteIssue(workspaceSlug, projectId, issueId).then(() => {
|
||||||
setPeekIssue(undefined);
|
setPeekIssue(undefined);
|
||||||
|
});
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
setToast({
|
setToast({
|
||||||
title: t("toast.error"),
|
title: t("toast.error"),
|
||||||
|
|
@ -180,15 +173,6 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
|
||||||
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
|
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
|
||||||
<div className="flex min-w-0 flex-wrap items-center justify-end gap-2">
|
<div className="flex min-w-0 flex-wrap items-center justify-end gap-2">
|
||||||
{actionSlot}
|
{actionSlot}
|
||||||
{issueDetails?.project_id && (
|
|
||||||
<IssueMarkdownExportButton
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={issueDetails.project_id}
|
|
||||||
issueId={issueId}
|
|
||||||
isArchived={isArchived}
|
|
||||||
className="size-10 rounded-[18px] border-transparent bg-layer-2/80 shadow-none backdrop-blur-xl hover:bg-layer-2-active focus-visible:outline-none"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showSubscription && currentUser && !isArchived && (
|
{showSubscription && currentUser && !isArchived && (
|
||||||
<IssueSubscription
|
<IssueSubscription
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { NETWORK_CHOICES } from "@plane/constants";
|
||||||
import { useTranslation } from "@plane/i18n";
|
import { useTranslation } from "@plane/i18n";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { Button } from "@plane/propel/button";
|
import { Button } from "@plane/propel/button";
|
||||||
import { Logo } from "@plane/propel/emoji-icon-picker";
|
import { EmojiPicker, EmojiIconPickerTypes, Logo } from "@plane/propel/emoji-icon-picker";
|
||||||
import { LockIcon } from "@plane/propel/icons";
|
import { LockIcon } from "@plane/propel/icons";
|
||||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||||
import { Tooltip } from "@plane/propel/tooltip";
|
import { Tooltip } from "@plane/propel/tooltip";
|
||||||
|
|
@ -32,7 +32,6 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||||
import { ProjectService } from "@/services/project";
|
import { ProjectService } from "@/services/project";
|
||||||
// local imports
|
// local imports
|
||||||
import { ProjectNetworkIcon } from "./project-network-icon";
|
import { ProjectNetworkIcon } from "./project-network-icon";
|
||||||
import { ProjectLogoPickerModal } from "./project-logo-picker-modal";
|
|
||||||
|
|
||||||
export interface IProjectDetailsForm {
|
export interface IProjectDetailsForm {
|
||||||
project: IProject;
|
project: IProject;
|
||||||
|
|
@ -46,7 +45,7 @@ export function ProjectDetailsForm(props: IProjectDetailsForm) {
|
||||||
const { project, workspaceSlug, projectId, isAdmin } = props;
|
const { project, workspaceSlug, projectId, isAdmin } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// states
|
// states
|
||||||
const [isLogoPickerOpen, setIsLogoPickerOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
// store hooks
|
// store hooks
|
||||||
const { updateProject } = useProject();
|
const { updateProject } = useProject();
|
||||||
|
|
@ -193,242 +192,261 @@ export function ProjectDetailsForm(props: IProjectDetailsForm) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<ProjectLogoPickerModal
|
<div className="relative h-44 w-full">
|
||||||
isOpen={isLogoPickerOpen}
|
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
||||||
logo={watch("logo_props")}
|
<CoverImage src={coverImage} alt="Project cover image" className="h-44 w-full rounded-md" />
|
||||||
onChange={(logo) => setValue("logo_props", logo, { shouldDirty: true })}
|
<div className="absolute bottom-4 z-5 flex w-full items-end justify-between gap-3 px-4">
|
||||||
onClose={() => setIsLogoPickerOpen(false)}
|
<div className="flex min-w-0 flex-grow gap-3 truncate">
|
||||||
/>
|
<div className="flex min-w-0 flex-1 items-center gap-3 rounded-[1.2rem] bg-white/10 px-2.5 py-2.5 backdrop-blur-2xl">
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<div className="relative h-44 w-full">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
|
||||||
<CoverImage src={coverImage} alt="Project cover image" className="h-44 w-full rounded-md" />
|
|
||||||
<div className="absolute bottom-4 z-5 flex w-full items-end justify-between gap-3 px-4">
|
|
||||||
<div className="flex min-w-0 flex-grow gap-3 truncate">
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3 rounded-[1.2rem] bg-white/10 px-2.5 py-2.5 backdrop-blur-2xl">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsLogoPickerOpen(true)}
|
|
||||||
className="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-lg bg-white/[0.06] transition-colors hover:bg-white/[0.12] disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
aria-label="Изменить иконку проекта"
|
|
||||||
disabled={!isAdmin}
|
|
||||||
>
|
|
||||||
<Logo logo={watch("logo_props")} size={28} />
|
|
||||||
</button>
|
|
||||||
<div className="flex flex-col gap-1 truncate text-on-color">
|
|
||||||
<span className="truncate text-16 font-semibold">{watch("name")}</span>
|
|
||||||
<span className="flex items-center gap-2 text-13">
|
|
||||||
<span>{watch("identifier")} .</span>
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
{project.network === 0 && <LockIcon className="h-2.5 w-2.5 text-on-color" />}
|
|
||||||
{currentNetwork && t(currentNetwork?.i18n_label)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-shrink-0 justify-center">
|
|
||||||
<div>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="cover_image_url"
|
|
||||||
render={({ field: { value, onChange } }) => (
|
|
||||||
<ImagePickerPopover
|
|
||||||
label={t("change_cover")}
|
|
||||||
control={control}
|
|
||||||
onChange={onChange}
|
|
||||||
value={value ?? null}
|
|
||||||
buttonClassName="nodedc-overlay-button min-w-[10.5rem]"
|
|
||||||
disabled={!isAdmin}
|
|
||||||
projectId={project.id}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-8 flex flex-col gap-8">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h4 className="text-13">{t("common.project_name")}</h4>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="name"
|
|
||||||
rules={{
|
|
||||||
required: t("name_is_required"),
|
|
||||||
maxLength: {
|
|
||||||
value: 255,
|
|
||||||
message: "Project name should be less than 255 characters",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
render={({ field: { value, onChange, ref } }) => (
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
type="text"
|
|
||||||
ref={ref}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
hasError={Boolean(errors.name)}
|
|
||||||
className="nodedc-settings-input !px-4 !py-3 font-medium"
|
|
||||||
placeholder={t("common.project_name")}
|
|
||||||
disabled={!isAdmin}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="text-11 text-danger-primary">{errors?.name?.message}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h4 className="text-13">{t("description")}</h4>
|
|
||||||
<Controller
|
|
||||||
name="description"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { value, onChange } }) => (
|
|
||||||
<TextArea
|
|
||||||
id="description"
|
|
||||||
name="description"
|
|
||||||
value={value}
|
|
||||||
placeholder={t("project_description_placeholder")}
|
|
||||||
onChange={onChange}
|
|
||||||
className="nodedc-settings-input min-h-[102px] !rounded-[1.35rem] text-13 font-medium"
|
|
||||||
hasError={Boolean(errors?.description)}
|
|
||||||
disabled={!isAdmin}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h4 className="text-13">{t("project_id")}</h4>
|
|
||||||
<div className="relative">
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="identifier"
|
|
||||||
rules={{
|
|
||||||
required: t("project_id_is_required"),
|
|
||||||
validate: (value) => /^[ÇŞĞIİÖÜA-Z0-9]+$/.test(value.toUpperCase()) || t("project_id_allowed_char"),
|
|
||||||
minLength: {
|
|
||||||
value: 1,
|
|
||||||
message: t("project_id_min_char"),
|
|
||||||
},
|
|
||||||
maxLength: {
|
|
||||||
value: 10,
|
|
||||||
message: t("project_id_max_char"),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
render={({ field: { value, ref } }) => (
|
|
||||||
<Input
|
|
||||||
id="identifier"
|
|
||||||
name="identifier"
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
onChange={handleIdentifierChange}
|
|
||||||
ref={ref}
|
|
||||||
hasError={Boolean(errors.identifier)}
|
|
||||||
placeholder={t("project_settings.general.enter_project_id")}
|
|
||||||
className="nodedc-settings-input w-full pr-10 font-medium"
|
|
||||||
disabled={!isAdmin}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
isMobile={isMobile}
|
|
||||||
tooltipContent={t("project_id_tooltip_content")}
|
|
||||||
className="text-13"
|
|
||||||
position="right-start"
|
|
||||||
>
|
|
||||||
<Info className="absolute top-2.5 right-2 h-4 w-4 text-placeholder" />
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<span className="text-11 text-danger-primary">
|
|
||||||
<>{errors?.identifier?.message}</>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h4 className="text-13">{t("workspace_projects.network.label")}</h4>
|
|
||||||
<Controller
|
<Controller
|
||||||
name="network"
|
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, onChange } }) => {
|
name="logo_props"
|
||||||
const selectedNetwork = NETWORK_CHOICES.find((n) => n.key === value);
|
|
||||||
return (
|
|
||||||
<SelectionDropdown
|
|
||||||
options={NETWORK_CHOICES.map((network) => ({
|
|
||||||
key: String(network.key),
|
|
||||||
title: (
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<ProjectNetworkIcon iconKey={network.iconKey} className="h-3.5 w-3.5" />
|
|
||||||
<div className="-mt-1">
|
|
||||||
<p>{t(network.i18n_label)}</p>
|
|
||||||
<p className="text-11 text-placeholder">{t(network.description)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
isChecked: value === network.key,
|
|
||||||
onClick: () => onChange(network.key),
|
|
||||||
}))}
|
|
||||||
menuButton={
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{selectedNetwork ? (
|
|
||||||
<>
|
|
||||||
<ProjectNetworkIcon iconKey={selectedNetwork.iconKey} className="h-3.5 w-3.5" />
|
|
||||||
{t(selectedNetwork.i18n_label)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-placeholder">{t("select_network")}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
menuButtonWrapperClassName="nodedc-settings-select !h-12 min-w-[12.75rem] px-4 font-medium"
|
|
||||||
disabled={!isAdmin}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-1 flex flex-col gap-1 sm:col-span-2 xl:col-span-1">
|
|
||||||
<h4 className="text-13">{t("common.project_timezone")}</h4>
|
|
||||||
<Controller
|
|
||||||
name="timezone"
|
|
||||||
control={control}
|
|
||||||
rules={{ required: t("project_settings.general.please_select_a_timezone") }}
|
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<>
|
<EmojiPicker
|
||||||
<TimezoneSelect
|
iconType="material"
|
||||||
value={value}
|
closeOnSelect={false}
|
||||||
onChange={(value: string) => {
|
isOpen={isOpen}
|
||||||
onChange(value);
|
handleToggle={(val: boolean) => setIsOpen(val)}
|
||||||
}}
|
className="flex items-center justify-center"
|
||||||
error={Boolean(errors.timezone)}
|
buttonClassName="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-lg bg-white/[0.06]"
|
||||||
buttonClassName="nodedc-settings-select !h-12 !border-0"
|
label={<Logo logo={value} size={28} />}
|
||||||
disabled={!isAdmin}
|
// TODO: fix types
|
||||||
/>
|
onChange={(val: any) => {
|
||||||
</>
|
let logoValue = {};
|
||||||
|
|
||||||
|
if (val?.type === "emoji")
|
||||||
|
logoValue = {
|
||||||
|
value: val.value,
|
||||||
|
};
|
||||||
|
else if (val?.type === "icon") logoValue = val.value;
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
in_use: val?.type,
|
||||||
|
[val?.type]: logoValue,
|
||||||
|
});
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
defaultIconColor={value?.in_use && value.in_use === "icon" ? value?.icon?.color : undefined}
|
||||||
|
defaultOpen={
|
||||||
|
value.in_use && value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON
|
||||||
|
}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{errors.timezone && <span className="text-11 text-danger-primary">{errors.timezone.message}</span>}
|
<div className="flex flex-col gap-1 truncate text-on-color">
|
||||||
|
<span className="truncate text-16 font-semibold">{watch("name")}</span>
|
||||||
|
<span className="flex items-center gap-2 text-13">
|
||||||
|
<span>{watch("identifier")} .</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{project.network === 0 && <LockIcon className="h-2.5 w-2.5 text-on-color" />}
|
||||||
|
{currentNetwork && t(currentNetwork?.i18n_label)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex flex-shrink-0 justify-center">
|
||||||
<>
|
<div>
|
||||||
<Button
|
<Controller
|
||||||
variant="ghost"
|
control={control}
|
||||||
size="lg"
|
name="cover_image_url"
|
||||||
type="submit"
|
render={({ field: { value, onChange } }) => (
|
||||||
loading={isLoading}
|
<ImagePickerPopover
|
||||||
disabled={!isAdmin}
|
label={t("change_cover")}
|
||||||
className="nodedc-settings-save-button min-w-[11.5rem]"
|
control={control}
|
||||||
>
|
onChange={onChange}
|
||||||
{isLoading ? t("updating") : t("common.update_project")}
|
value={value ?? null}
|
||||||
</Button>
|
buttonClassName="nodedc-overlay-button min-w-[10.5rem]"
|
||||||
<span className="text-13 text-placeholder italic">
|
disabled={!isAdmin}
|
||||||
{t("common.created_on")} {renderFormattedDate(project?.created_at)}
|
projectId={project.id}
|
||||||
</span>
|
/>
|
||||||
</>
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</>
|
<div className="mt-8 flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h4 className="text-13">{t("common.project_name")}</h4>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="name"
|
||||||
|
rules={{
|
||||||
|
required: t("name_is_required"),
|
||||||
|
maxLength: {
|
||||||
|
value: 255,
|
||||||
|
message: "Project name should be less than 255 characters",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
render={({ field: { value, onChange, ref } }) => (
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
ref={ref}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
hasError={Boolean(errors.name)}
|
||||||
|
className="nodedc-settings-input !px-4 !py-3 font-medium"
|
||||||
|
placeholder={t("common.project_name")}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-11 text-danger-primary">{errors?.name?.message}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h4 className="text-13">{t("description")}</h4>
|
||||||
|
<Controller
|
||||||
|
name="description"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<TextArea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
value={value}
|
||||||
|
placeholder={t("project_description_placeholder")}
|
||||||
|
onChange={onChange}
|
||||||
|
className="nodedc-settings-input min-h-[102px] !rounded-[1.35rem] text-13 font-medium"
|
||||||
|
hasError={Boolean(errors?.description)}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h4 className="text-13">{t("project_id")}</h4>
|
||||||
|
<div className="relative">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="identifier"
|
||||||
|
rules={{
|
||||||
|
required: t("project_id_is_required"),
|
||||||
|
validate: (value) => /^[ÇŞĞIİÖÜA-Z0-9]+$/.test(value.toUpperCase()) || t("project_id_allowed_char"),
|
||||||
|
minLength: {
|
||||||
|
value: 1,
|
||||||
|
message: t("project_id_min_char"),
|
||||||
|
},
|
||||||
|
maxLength: {
|
||||||
|
value: 10,
|
||||||
|
message: t("project_id_max_char"),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
render={({ field: { value, ref } }) => (
|
||||||
|
<Input
|
||||||
|
id="identifier"
|
||||||
|
name="identifier"
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={handleIdentifierChange}
|
||||||
|
ref={ref}
|
||||||
|
hasError={Boolean(errors.identifier)}
|
||||||
|
placeholder={t("project_settings.general.enter_project_id")}
|
||||||
|
className="nodedc-settings-input w-full pr-10 font-medium"
|
||||||
|
disabled={!isAdmin}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
isMobile={isMobile}
|
||||||
|
tooltipContent={t("project_id_tooltip_content")}
|
||||||
|
className="text-13"
|
||||||
|
position="right-start"
|
||||||
|
>
|
||||||
|
<Info className="absolute top-2.5 right-2 h-4 w-4 text-placeholder" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<span className="text-11 text-danger-primary">
|
||||||
|
<>{errors?.identifier?.message}</>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h4 className="text-13">{t("workspace_projects.network.label")}</h4>
|
||||||
|
<Controller
|
||||||
|
name="network"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange } }) => {
|
||||||
|
const selectedNetwork = NETWORK_CHOICES.find((n) => n.key === value);
|
||||||
|
return (
|
||||||
|
<SelectionDropdown
|
||||||
|
options={NETWORK_CHOICES.map((network) => ({
|
||||||
|
key: String(network.key),
|
||||||
|
title: (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<ProjectNetworkIcon iconKey={network.iconKey} className="h-3.5 w-3.5" />
|
||||||
|
<div className="-mt-1">
|
||||||
|
<p>{t(network.i18n_label)}</p>
|
||||||
|
<p className="text-11 text-placeholder">{t(network.description)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
isChecked: value === network.key,
|
||||||
|
onClick: () => onChange(network.key),
|
||||||
|
}))}
|
||||||
|
menuButton={
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{selectedNetwork ? (
|
||||||
|
<>
|
||||||
|
<ProjectNetworkIcon iconKey={selectedNetwork.iconKey} className="h-3.5 w-3.5" />
|
||||||
|
{t(selectedNetwork.i18n_label)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-placeholder">{t("select_network")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
menuButtonWrapperClassName="nodedc-settings-select !h-12 min-w-[12.75rem] px-4 font-medium"
|
||||||
|
disabled={!isAdmin}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 flex flex-col gap-1 sm:col-span-2 xl:col-span-1">
|
||||||
|
<h4 className="text-13">{t("common.project_timezone")}</h4>
|
||||||
|
<Controller
|
||||||
|
name="timezone"
|
||||||
|
control={control}
|
||||||
|
rules={{ required: t("project_settings.general.please_select_a_timezone") }}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<>
|
||||||
|
<TimezoneSelect
|
||||||
|
value={value}
|
||||||
|
onChange={(value: string) => {
|
||||||
|
onChange(value);
|
||||||
|
}}
|
||||||
|
error={Boolean(errors.timezone)}
|
||||||
|
buttonClassName="nodedc-settings-select !h-12 !border-0"
|
||||||
|
disabled={!isAdmin}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.timezone && <span className="text-11 text-danger-primary">{errors.timezone.message}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="lg"
|
||||||
|
type="submit"
|
||||||
|
loading={isLoading}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
className="nodedc-settings-save-button min-w-[11.5rem]"
|
||||||
|
>
|
||||||
|
{isLoading ? t("updating") : t("common.update_project")}
|
||||||
|
</Button>
|
||||||
|
<span className="text-13 text-placeholder italic">
|
||||||
|
{t("common.created_on")} {renderFormattedDate(project?.created_at)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
/**
|
|
||||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
* See the LICENSE file for details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { EmojiIconPickerTypes, EmojiRoot, IconRoot, emojiToString } from "@plane/propel/emoji-icon-picker";
|
|
||||||
import { CloseIcon } from "@plane/propel/icons";
|
|
||||||
import type { TLogoProps } from "@plane/types";
|
|
||||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
isOpen: boolean;
|
|
||||||
logo: TLogoProps;
|
|
||||||
onChange: (logo: TLogoProps) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ProjectLogoPickerModal(props: Props) {
|
|
||||||
const { isOpen, logo, onChange, onClose } = props;
|
|
||||||
const defaultTab =
|
|
||||||
logo?.in_use === EmojiIconPickerTypes.EMOJI ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON;
|
|
||||||
const defaultIconColor = logo?.in_use === EmojiIconPickerTypes.ICON ? (logo.icon?.color ?? "#6d7b8a") : "#6d7b8a";
|
|
||||||
const [activeTab, setActiveTab] = useState(defaultTab);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) setActiveTab(defaultTab);
|
|
||||||
}, [defaultTab, isOpen]);
|
|
||||||
|
|
||||||
const handleEmojiChange = (emoji: string) => {
|
|
||||||
onChange({
|
|
||||||
in_use: EmojiIconPickerTypes.EMOJI,
|
|
||||||
emoji: { value: emojiToString(emoji) },
|
|
||||||
});
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleIconChange = (icon: { name: string; color: string }) => {
|
|
||||||
onChange({
|
|
||||||
in_use: EmojiIconPickerTypes.ICON,
|
|
||||||
icon,
|
|
||||||
});
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalCore
|
|
||||||
isOpen={isOpen}
|
|
||||||
handleClose={onClose}
|
|
||||||
position={EModalPosition.CENTER}
|
|
||||||
width={EModalWidth.XL}
|
|
||||||
className="overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between border-b border-subtle px-5 py-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-16 font-semibold text-primary">Изменить иконку проекта</h3>
|
|
||||||
<p className="mt-1 text-12 text-secondary">Выберите emoji или иконку с цветом.</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="grid size-8 place-items-center rounded-lg text-tertiary transition-colors hover:bg-layer-1 hover:text-primary"
|
|
||||||
aria-label="Закрыть выбор иконки"
|
|
||||||
>
|
|
||||||
<CloseIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-1 px-5 pt-4" role="tablist" aria-label="Тип иконки">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={activeTab === EmojiIconPickerTypes.EMOJI}
|
|
||||||
onClick={() => setActiveTab(EmojiIconPickerTypes.EMOJI)}
|
|
||||||
className={
|
|
||||||
activeTab === EmojiIconPickerTypes.EMOJI
|
|
||||||
? "rounded-md border border-strong bg-surface-1 py-2 text-13 text-primary"
|
|
||||||
: "rounded-md border border-subtle bg-layer-1 py-2 text-13 text-placeholder hover:bg-layer-1/60 hover:text-tertiary"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Emoji
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={activeTab === EmojiIconPickerTypes.ICON}
|
|
||||||
onClick={() => setActiveTab(EmojiIconPickerTypes.ICON)}
|
|
||||||
className={
|
|
||||||
activeTab === EmojiIconPickerTypes.ICON
|
|
||||||
? "rounded-md border border-strong bg-surface-1 py-2 text-13 text-primary"
|
|
||||||
: "rounded-md border border-subtle bg-layer-1 py-2 text-13 text-placeholder hover:bg-layer-1/60 hover:text-tertiary"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Иконка
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{activeTab === EmojiIconPickerTypes.EMOJI && (
|
|
||||||
<div className="h-[25rem] overflow-hidden" role="tabpanel">
|
|
||||||
<EmojiRoot onChange={handleEmojiChange} searchPlaceholder="Поиск emoji" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{activeTab === EmojiIconPickerTypes.ICON && (
|
|
||||||
<div className="h-[25rem] overflow-y-auto" role="tabpanel">
|
|
||||||
<IconRoot iconType="material" defaultColor={defaultIconColor} onChange={handleIconChange} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ModalCore>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -139,21 +139,6 @@ export type TAIWorkspaceBridgeEventsResponse = {
|
||||||
events: TAIWorkspaceBridgeEvent[];
|
events: TAIWorkspaceBridgeEvent[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TAIWorkspaceAgentSetupCommandResponse = {
|
|
||||||
ok: boolean;
|
|
||||||
executorId?: string;
|
|
||||||
install?: {
|
|
||||||
command?: string;
|
|
||||||
packageName?: string;
|
|
||||||
gatewayUrl?: string;
|
|
||||||
expiresAt?: string;
|
|
||||||
};
|
|
||||||
setupCode?: {
|
|
||||||
suffix?: string;
|
|
||||||
expiresAt?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TAIWorkspaceOpsProject = {
|
export type TAIWorkspaceOpsProject = {
|
||||||
id: string;
|
id: string;
|
||||||
identifier?: string | null;
|
identifier?: string | null;
|
||||||
|
|
@ -299,22 +284,6 @@ export class WorkspaceAIWorkspaceService extends APIService {
|
||||||
return this.listExecutors(workspaceSlug);
|
return this.listExecutors(workspaceSlug);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAgentSetupCommand(
|
|
||||||
workspaceSlug: string,
|
|
||||||
executorId: string,
|
|
||||||
options: { port?: string | number; opsWorkspaceSlug?: string; opsProjectId?: string } = {}
|
|
||||||
): Promise<TAIWorkspaceAgentSetupCommandResponse> {
|
|
||||||
return this.post(`/api/workspaces/${workspaceSlug}/ai-workspace/executors/${executorId}/agent/setup-command/`, {
|
|
||||||
port: String(options.port || "").trim() || undefined,
|
|
||||||
opsWorkspaceSlug: String(options.opsWorkspaceSlug || "").trim() || undefined,
|
|
||||||
opsProjectId: String(options.opsProjectId || "").trim() || undefined,
|
|
||||||
})
|
|
||||||
.then((response) => response?.data)
|
|
||||||
.catch((error) => {
|
|
||||||
throw error?.response?.data ?? error;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getWindowsAgentInstallerUrl(
|
getWindowsAgentInstallerUrl(
|
||||||
workspaceSlug: string,
|
workspaceSlug: string,
|
||||||
executorId: string,
|
executorId: string,
|
||||||
|
|
|
||||||
|
|
@ -1163,7 +1163,7 @@
|
||||||
|
|
||||||
.ai-settings-field-row--download {
|
.ai-settings-field-row--download {
|
||||||
align-items: end;
|
align-items: end;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(96px, 112px) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-settings-field > span {
|
.ai-settings-field > span {
|
||||||
|
|
@ -1409,7 +1409,7 @@
|
||||||
|
|
||||||
.ai-settings-actions .modal-btn:hover:not(:disabled),
|
.ai-settings-actions .modal-btn:hover:not(:disabled),
|
||||||
.ai-settings-empty-context .modal-btn:hover:not(:disabled),
|
.ai-settings-empty-context .modal-btn:hover:not(:disabled),
|
||||||
.ai-settings-inline-download:hover:not(:disabled) {
|
.ai-settings-inline-download:hover {
|
||||||
background: var(--nodedc-glass-control-hover);
|
background: var(--nodedc-glass-control-hover);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
@ -1420,21 +1420,11 @@
|
||||||
color: var(--brand-contrast);
|
color: var(--brand-contrast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-settings-inline-download.btn-primary {
|
|
||||||
background: var(--brand);
|
|
||||||
color: var(--brand-contrast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-settings-actions .modal-btn:disabled {
|
.ai-settings-actions .modal-btn:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.48;
|
opacity: 0.48;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-settings-inline-download:disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: 0.48;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-settings-context-pane > .ai-settings-actions,
|
.ai-settings-context-pane > .ai-settings-actions,
|
||||||
.ai-settings-details-pane > .ai-settings-actions {
|
.ai-settings-details-pane > .ai-settings-actions {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./emoji-picker";
|
export * from "./emoji-picker";
|
||||||
export * from "./emoji";
|
|
||||||
export * from "./helper";
|
export * from "./helper";
|
||||||
export * from "./icon";
|
|
||||||
export * from "./logo";
|
export * from "./logo";
|
||||||
export * from "./lucide-icons";
|
export * from "./lucide-icons";
|
||||||
export * from "./material-icons";
|
export * from "./material-icons";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue