Compare commits

...

5 Commits

21 changed files with 658 additions and 595 deletions

View File

@ -1,31 +1,12 @@
/**
* 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";
// components
import { PageHead } from "@/components/core/page-title";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { AIVoiceTaskerSettingsContent } from "@/components/workspace/settings/ai-voice-tasker-settings";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
import { redirect } from "react-router";
// local imports
import type { Route } from "./+types/page";
import { AIVoiceTaskerWorkspaceSettingsHeader } from "./header";
function AIVoiceTaskerSettingsPage({ params }: Route.ComponentProps) {
export function clientLoader({ params }: Route.ClientLoaderArgs) {
const { workspaceSlug } = params;
const { currentWorkspace } = useWorkspace();
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - AI / Voice Tasker` : undefined;
return (
<SettingsContentWrapper header={<AIVoiceTaskerWorkspaceSettingsHeader />}>
<PageHead title={pageTitle} />
<AIVoiceTaskerSettingsContent workspaceSlug={workspaceSlug} />
</SettingsContentWrapper>
);
throw redirect(`/${workspaceSlug}/?workspaceSettings=ai-voice-tasker`);
}
export default observer(AIVoiceTaskerSettingsPage);
export default function AIVoiceTaskerSettingsPage() {
return null;
}

View File

@ -1,30 +1,12 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { redirect } from "react-router";
// local imports
import type { Route } from "./+types/page";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { LogoSpinner } from "@/components/common/logo-spinner";
import { useAppRouter } from "@/hooks/use-app-router";
function BillingSettingsPage() {
const router = useAppRouter();
const { workspaceSlug } = useParams();
useEffect(() => {
if (workspaceSlug) {
router.replace(`/${workspaceSlug}/settings`);
}
}, [router, workspaceSlug]);
return (
<div className="grid h-full place-items-center">
<LogoSpinner />
</div>
);
export function clientLoader({ params }: Route.ClientLoaderArgs) {
const { workspaceSlug } = params;
throw redirect(`/${workspaceSlug}/?workspaceSettings=general`);
}
export default observer(BillingSettingsPage);
export default function BillingSettingsPage() {
return null;
}

View File

@ -1,61 +1,12 @@
/**
* 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 { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils";
// components
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import { ExportGuide } from "@/components/exporter/guide";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { SettingsHeading } from "@/components/settings/heading";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
import { redirect } from "react-router";
// local imports
import { ExportsWorkspaceSettingsHeader } from "./header";
import type { Route } from "./+types/page";
function ExportsPage() {
// store hooks
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { currentWorkspace } = useWorkspace();
const { t } = useTranslation();
// derived values
const canPerformWorkspaceMemberActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
const pageTitle = currentWorkspace?.name
? `${currentWorkspace.name} - ${t("workspace_settings.settings.exports.title")}`
: undefined;
// if user is not authorized to view this page
if (workspaceUserInfo && !canPerformWorkspaceMemberActions) {
return <NotAuthorizedView section="settings" className="h-auto" />;
}
return (
<SettingsContentWrapper header={<ExportsWorkspaceSettingsHeader />} hugging>
<PageHead title={pageTitle} />
<div
className={cn("flex w-full flex-col gap-y-6", {
"opacity-60": !canPerformWorkspaceMemberActions,
})}
>
<SettingsHeading
title={t("workspace_settings.settings.exports.heading")}
description={t("workspace_settings.settings.exports.description")}
/>
<ExportGuide />
</div>
</SettingsContentWrapper>
);
export function clientLoader({ params }: Route.ClientLoaderArgs) {
const { workspaceSlug } = params;
throw redirect(`/${workspaceSlug}/?workspaceSettings=export`);
}
export default observer(ExportsPage);
export default function ExportsPage() {
return null;
}

View File

@ -1,160 +1,12 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
// types
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { SearchIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspaceBulkInviteFormData } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { CountChip } from "@/components/common/count-chip";
import { PageHead } from "@/components/core/page-title";
import { MemberListFiltersDropdown } from "@/components/project/dropdowns/filters/member-list";
import { WorkspaceMembersList } from "@/components/workspace/settings/members-list";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
// plane web components
import { BillingActionsButton } from "@/plane-web/components/workspace/billing/billing-actions-button";
import { SendWorkspaceInvitationModal, MembersActivityButton } from "@/plane-web/components/workspace/members";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { redirect } from "react-router";
// local imports
import type { Route } from "./+types/page";
import { MembersWorkspaceSettingsHeader } from "./header";
const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsPage({ params }: Route.ComponentProps) {
// states
const [inviteModal, setInviteModal] = useState(false);
const [searchQuery, setSearchQuery] = useState<string>("");
// router
export function clientLoader({ params }: Route.ClientLoaderArgs) {
const { workspaceSlug } = params;
// store hooks
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const {
workspace: { workspaceMemberIds, inviteMembersToWorkspace, filtersStore },
} = useMember();
const { currentWorkspace } = useWorkspace();
const { t } = useTranslation();
throw redirect(`/${workspaceSlug}/?workspaceSettings=members`);
}
// derived values
const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const canPerformWorkspaceMemberActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
const handleWorkspaceInvite = async (data: IWorkspaceBulkInviteFormData) => {
try {
await inviteMembersToWorkspace(workspaceSlug, data);
setInviteModal(false);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: t("workspace_settings.settings.members.invitations_sent_successfully"),
});
} catch (error: unknown) {
let message = undefined;
if (error instanceof Error) {
const err = error as Error & { error?: string };
message = err.error;
}
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: `${message ?? t("something_went_wrong_please_try_again")}`,
});
throw error;
}
};
// Handler for role filter updates
const handleRoleFilterUpdate = (role: string) => {
const currentFilters = filtersStore.filters;
const currentRoles = currentFilters?.roles || [];
const updatedRoles = currentRoles.includes(role) ? currentRoles.filter((r) => r !== role) : [...currentRoles, role];
filtersStore.updateFilters({
roles: updatedRoles.length > 0 ? updatedRoles : undefined,
});
};
// derived values
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Members` : undefined;
const appliedRoleFilters = filtersStore.filters?.roles || [];
// if user is not authorized to view this page
if (workspaceUserInfo && !canPerformWorkspaceMemberActions) {
return <NotAuthorizedView section="settings" className="h-auto" />;
}
return (
<SettingsContentWrapper header={<MembersWorkspaceSettingsHeader />} hugging>
<PageHead title={pageTitle} />
<SendWorkspaceInvitationModal
isOpen={inviteModal}
onClose={() => setInviteModal(false)}
onSubmit={handleWorkspaceInvite}
/>
<section
className={cn("size-full", {
"opacity-60": !canPerformWorkspaceMemberActions,
})}
>
<div className="flex items-center justify-between gap-4 pb-4">
<h4 className="flex items-center gap-2.5 text-h3-medium">
{t("workspace_settings.settings.members.title")}
{workspaceMemberIds && workspaceMemberIds.length > 0 && (
<CountChip count={workspaceMemberIds.length} className="m-auto h-5" />
)}
</h4>
<div className="flex items-center gap-2">
<div className="nodedc-settings-field flex min-h-[2.75rem] items-center gap-1.5 px-3.5">
<SearchIcon className="h-3.5 w-3.5 text-placeholder" />
<input
className="w-full max-w-[234px] border-none bg-transparent text-body-xs-regular outline-none placeholder:text-placeholder"
placeholder={`${t("search")}...`}
value={searchQuery}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<MemberListFiltersDropdown
appliedFilters={appliedRoleFilters}
handleUpdate={handleRoleFilterUpdate}
memberType="workspace"
/>
<MembersActivityButton workspaceSlug={workspaceSlug} />
{canPerformWorkspaceAdminActions && (
<Button
variant="ghost"
size="lg"
className="nodedc-settings-primary-button min-w-[11rem]"
onClick={() => setInviteModal(true)}
>
{t("workspace_settings.settings.members.add_member")}
</Button>
)}
<BillingActionsButton canPerformWorkspaceAdminActions={canPerformWorkspaceAdminActions} />
</div>
</div>
<WorkspaceMembersList searchQuery={searchQuery} isAdmin={canPerformWorkspaceAdminActions} />
</section>
</SettingsContentWrapper>
);
});
export default WorkspaceMembersSettingsPage;
export default function WorkspaceMembersSettingsPage() {
return null;
}

View File

@ -1,36 +1,12 @@
/**
* 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";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { PageHead } from "@/components/core/page-title";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { WorkspaceDetails } from "@/components/workspace/settings/workspace-details";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
import { redirect } from "react-router";
// local imports
import { GeneralWorkspaceSettingsHeader } from "./header";
import type { Route } from "./+types/page";
function GeneralWorkspaceSettingsPage() {
// store hooks
const { currentWorkspace } = useWorkspace();
const { t } = useTranslation();
// derived values
const pageTitle = currentWorkspace?.name
? t("workspace_settings.page_label", { workspace: currentWorkspace.name })
: undefined;
return (
<SettingsContentWrapper header={<GeneralWorkspaceSettingsHeader />}>
<PageHead title={pageTitle} />
<WorkspaceDetails />
</SettingsContentWrapper>
);
export function clientLoader({ params }: Route.ClientLoaderArgs) {
const { workspaceSlug } = params;
throw redirect(`/${workspaceSlug}/?workspaceSettings=general`);
}
export default observer(GeneralWorkspaceSettingsPage);
export default function GeneralWorkspaceSettingsPage() {
return null;
}

View File

@ -1,111 +1,12 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWebhook } from "@plane/types";
// ui
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { PageHead } from "@/components/core/page-title";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "@/components/web-hooks";
// hooks
import { useWebhook } from "@/hooks/store/use-webhook";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
import { redirect } from "react-router";
// local imports
import type { Route } from "./+types/page";
import { WebhookDetailsWorkspaceSettingsHeader } from "./header";
function WebhookDetailsPage({ params }: Route.ComponentProps) {
// states
const [deleteWebhookModal, setDeleteWebhookModal] = useState(false);
// router
export function clientLoader({ params }: Route.ClientLoaderArgs) {
const { workspaceSlug, webhookId } = params;
// mobx store
const { currentWebhook, fetchWebhookById, updateWebhook } = useWebhook();
const { currentWorkspace } = useWorkspace();
const { allowPermissions } = useUserPermissions();
// TODO: fix this error
// useEffect(() => {
// if (isCreated !== "true") clearSecretKey();
// }, [clearSecretKey, isCreated]);
// derived values
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Webhook` : undefined;
useSWR(
isAdmin ? `WEBHOOK_DETAILS_${workspaceSlug}_${webhookId}` : null,
isAdmin ? () => fetchWebhookById(workspaceSlug, webhookId) : null
);
const handleUpdateWebhook = async (formData: IWebhook) => {
if (!formData || !formData.id) return;
const payload = {
url: formData.url,
is_active: formData.is_active,
project: formData.project,
cycle: formData.cycle,
module: formData.module,
issue: formData.issue,
issue_comment: formData.issue_comment,
};
try {
await updateWebhook(workspaceSlug, formData.id, payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Webhook updated successfully.",
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error?.error ?? "Something went wrong. Please try again.",
});
}
};
if (!isAdmin)
return (
<>
<PageHead title={pageTitle} />
<div className="mt-10 flex h-full w-full justify-center p-4">
<p className="text-13 text-tertiary">You are not authorized to access this page.</p>
</div>
</>
);
if (!currentWebhook)
return (
<div className="grid h-full w-full place-items-center p-4">
<LogoSpinner />
</div>
);
return (
<SettingsContentWrapper header={<WebhookDetailsWorkspaceSettingsHeader />}>
<PageHead title={pageTitle} />
<DeleteWebhookModal isOpen={deleteWebhookModal} onClose={() => setDeleteWebhookModal(false)} />
<div className="w-full space-y-8 overflow-y-auto">
<div>
<WebhookForm onSubmit={handleUpdateWebhook} data={currentWebhook} />
</div>
{currentWebhook && <WebhookDeleteSection openDeleteModal={() => setDeleteWebhookModal(true)} />}
</div>
</SettingsContentWrapper>
);
throw redirect(`/${workspaceSlug}/?workspaceSettings=webhooks&webhookId=${webhookId}`);
}
export default observer(WebhookDetailsPage);
export default function WebhookDetailsPage() {
return null;
}

View File

@ -1,109 +1,12 @@
/**
* 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";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
// components
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import { SettingsHeading } from "@/components/settings/heading";
import { WebhookSettingsLoader } from "@/components/ui/loader/settings/web-hook";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { WebhooksList, CreateWebhookModal } from "@/components/web-hooks";
import { WebhooksEmptyState } from "@/components/web-hooks/empty-state";
// hooks
import { useWebhook } from "@/hooks/store/use-webhook";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
import { redirect } from "react-router";
// local imports
import type { Route } from "./+types/page";
import { WebhooksWorkspaceSettingsHeader } from "./header";
function WebhooksListPage({ params }: Route.ComponentProps) {
// states
const [showCreateWebhookModal, setShowCreateWebhookModal] = useState(false);
// router
export function clientLoader({ params }: Route.ClientLoaderArgs) {
const { workspaceSlug } = params;
// plane hooks
const { t } = useTranslation();
// mobx store
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { fetchWebhooks, webhooks, clearSecretKey, webhookSecretKey, createWebhook } = useWebhook();
const { currentWorkspace } = useWorkspace();
// derived values
const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
useSWR(
canPerformWorkspaceAdminActions ? `WEBHOOKS_LIST_${workspaceSlug}` : null,
canPerformWorkspaceAdminActions ? () => fetchWebhooks(workspaceSlug) : null
);
const pageTitle = currentWorkspace?.name
? `${currentWorkspace.name} - ${t("workspace_settings.settings.webhooks.title")}`
: undefined;
// clear secret key when modal is closed.
useEffect(() => {
if (!showCreateWebhookModal && webhookSecretKey) clearSecretKey();
}, [showCreateWebhookModal, webhookSecretKey, clearSecretKey]);
if (workspaceUserInfo && !canPerformWorkspaceAdminActions) {
return <NotAuthorizedView section="settings" className="h-auto" />;
}
if (!webhooks) return <WebhookSettingsLoader />;
return (
<SettingsContentWrapper header={<WebhooksWorkspaceSettingsHeader />}>
<PageHead title={pageTitle} />
<div className="w-full">
<CreateWebhookModal
createWebhook={createWebhook}
clearSecretKey={clearSecretKey}
currentWorkspace={currentWorkspace}
isOpen={showCreateWebhookModal}
onClose={() => {
setShowCreateWebhookModal(false);
}}
/>
<SettingsHeading
title={t("workspace_settings.settings.webhooks.title")}
description={t("workspace_settings.settings.webhooks.description")}
control={
<Button
variant="ghost"
size="lg"
className="nodedc-settings-primary-button min-w-[11rem]"
onClick={() => setShowCreateWebhookModal(true)}
>
{t("workspace_settings.settings.webhooks.add_webhook")}
</Button>
}
/>
{Object.keys(webhooks).length > 0 ? (
<div className="mt-4">
<WebhooksList />
</div>
) : (
<div className="flex h-full w-full flex-col">
<div className="flex h-full w-full items-center justify-center">
<div className="py-20">
<WebhooksEmptyState onClick={() => setShowCreateWebhookModal(true)} />
</div>
</div>
</div>
)}
</div>
</SettingsContentWrapper>
);
throw redirect(`/${workspaceSlug}/?workspaceSettings=webhooks`);
}
export default observer(WebhooksListPage);
export default function WebhooksListPage() {
return null;
}

View File

@ -69,6 +69,7 @@ export const AppHeader = observer(function AppHeader(props: AppHeaderProps) {
)}
>
<ExtendedAppHeader header={header} />
<div className="nodedc-bottom-dock-voice-slot" data-nodedc-voice-task-dock-slot />
</Row>
{mobileHeader && mobileHeader}
</div>

View File

@ -132,6 +132,21 @@ export const IssueStructuredContentBlocks = observer(function IssueStructuredCon
/>
</div>
<div className="mb-3">
<input
type="text"
value={block.title}
onChange={(event) => updateBlockDraft(block.id, { title: event.target.value })}
onBlur={() => {
const latestBlock = getLatestBlock(block.id);
if (latestBlock) saveBlocks(draftBlocks.map((item) => (item.id === block.id ? latestBlock : item)));
}}
placeholder="Название чекера"
className="nodedc-modal-input h-11 w-full px-4 text-13 text-primary placeholder:text-placeholder"
disabled={disabled}
/>
</div>
<div className="nodedc-structured-checklist">
{block.items.map((item, index) => (
<div key={item.id} className="nodedc-structured-check-row">

View File

@ -23,6 +23,7 @@ export type TIssueStructuredCheckerItem = {
export type TIssueStructuredCheckerBlock = {
id: string;
type: "checker";
title: string;
items: TIssueStructuredCheckerItem[];
};
@ -49,7 +50,7 @@ const normalizeDetailLayout = (value: unknown): TIssueDetailLayout =>
const sanitizeBlocks = (value: unknown): TIssueStructuredBlock[] => {
if (!Array.isArray(value)) return [];
return value.flatMap((block) => {
return value.flatMap<TIssueStructuredBlock>((block) => {
if (!isRecord(block)) return [];
if (block.type === "text") {
@ -82,6 +83,7 @@ const sanitizeBlocks = (value: unknown): TIssueStructuredBlock[] => {
{
id: typeof block.id === "string" ? block.id : createLocalId(),
type: "checker",
title: typeof block.title === "string" ? block.title : "",
items,
},
];
@ -96,6 +98,7 @@ export const createIssueStructuredBlock = (type: "checker" | "text"): TIssueStru
return {
id: createLocalId(),
type: "checker",
title: "",
items: [{ id: createLocalId(), text: "", checked: false }],
};
}

View File

@ -5,24 +5,35 @@
*/
import { observer } from "mobx-react";
import type { KeyboardEvent } from "react";
import { useTranslation } from "@plane/i18n";
import type { TQuickAddIssueForm } from "../root";
export const KanbanQuickAddIssueForm = observer(function KanbanQuickAddIssueForm(props: TQuickAddIssueForm) {
const { ref, projectDetail, register, onSubmit, isEpic } = props;
const { t } = useTranslation();
const handleNameKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key !== "Enter" || event.shiftKey) return;
event.preventDefault();
event.currentTarget.form?.requestSubmit();
};
return (
<div className="m-1 overflow-hidden rounded-[28px] bg-layer-2 shadow-raised-200">
<form ref={ref} onSubmit={onSubmit} className="flex w-full items-center gap-x-3 p-3">
<form ref={ref} onSubmit={onSubmit} className="flex w-full items-start gap-x-3 p-3">
<div className="w-full">
<h4 className="text-11 leading-5 font-medium text-tertiary">{projectDetail?.identifier ?? "..."}</h4>
<input
<textarea
rows={1}
autoComplete="off"
placeholder={isEpic ? t("epic.title.label") : t("issue.title.label")}
onKeyDown={handleNameKeyDown}
{...register("name", {
required: isEpic ? t("epic.title.required") : t("issue.title.required"),
})}
className="w-full rounded-md bg-transparent px-2 py-1.5 pl-0 text-13 leading-5 font-medium text-secondary outline-none"
className="max-h-32 min-h-8 w-full resize-none overflow-y-auto rounded-md bg-transparent px-2 py-1.5 pl-0 text-13 leading-5 font-medium whitespace-pre-wrap text-secondary outline-none [field-sizing:content]"
/>
</div>
</form>

View File

@ -6,6 +6,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ElementType, MouseEvent, ReactNode } from "react";
import { createPortal } from "react-dom";
import { useParams } from "next/navigation";
import useSWR from "swr";
import {
@ -760,6 +761,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
const [commitResult, setCommitResult] = useState<TVoiceTaskCommitResult | null>(null);
const [hasDraftChanges, setHasDraftChanges] = useState(false);
const [selectedTargetIssue, setSelectedTargetIssue] = useState<TVoiceTaskTargetOption | null>(null);
const [dockSlot, setDockSlot] = useState<Element | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const discardedRecorderRef = useRef<MediaRecorder | null>(null);
@ -787,6 +789,22 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
return UNAVAILABLE_LABELS[preflight.reason ?? "not_configured"];
}, [preflight]);
useEffect(() => {
if (typeof document === "undefined") return;
const updateDockSlot = () => {
setDockSlot(document.querySelector("[data-nodedc-voice-task-dock-slot]"));
};
updateDockSlot();
const observer = new MutationObserver(updateDockSlot);
observer.observe(document.body, { childList: true, subtree: true });
return () => {
observer.disconnect();
};
}, []);
const clearTimer = useCallback(() => {
if (timerRef.current) {
window.clearInterval(timerRef.current);
@ -1171,23 +1189,21 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
return (
<>
<div className="pointer-events-none fixed right-4 bottom-[calc(var(--nodedc-bottom-dock-offset,0px)+1rem)] z-[29]">
<Tooltip tooltipContent={tooltipContent} position="left">
<button
type="button"
className={cn(
"pointer-events-auto flex size-11 items-center justify-center border-0 bg-transparent p-0 shadow-none transition outline-none",
isAvailable
? "text-[rgb(var(--nodedc-accent-rgb))] hover:text-[rgb(var(--nodedc-card-active-rgb))]"
: "cursor-not-allowed text-tertiary"
)}
disabled={!isAvailable}
onClick={openVoiceTasker}
>
<Mic className="size-7" />
</button>
</Tooltip>
</div>
{isAvailable && dockSlot
? createPortal(
<Tooltip tooltipContent={tooltipContent} position="top">
<button
type="button"
className="nodedc-bottom-dock-voice-button"
onClick={openVoiceTasker}
aria-label="Voice Tasker"
>
<Mic className="size-4" />
</button>
</Tooltip>,
dockSlot
)
: null}
<ModalCore
isOpen={isOpen}

View File

@ -9,15 +9,18 @@ import { useParams } from "next/navigation";
// Plane imports
import type { IWebhook } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
// components
import { openWorkspaceWebhookSettingsModal } from "@/components/workspace/settings/workspace-settings-modal.utils";
// hooks
import { useWebhook } from "@/hooks/store/use-webhook";
interface IWebhookListItem {
useModalLink?: boolean;
webhook: IWebhook;
}
export function WebhooksListItem(props: IWebhookListItem) {
const { webhook } = props;
const { useModalLink = false, webhook } = props;
// router
const { workspaceSlug } = useParams();
// store hooks
@ -28,17 +31,33 @@ export function WebhooksListItem(props: IWebhookListItem) {
await updateWebhook(workspaceSlug.toString(), webhook.id, { is_active: !webhook.is_active });
};
const content = (
<>
<h5 className="truncate text-body-sm-medium text-primary">{webhook.url}</h5>
<div className="shrink-0" onClick={(event) => event.stopPropagation()}>
<ToggleSwitch value={webhook.is_active} onChange={handleToggle} />
</div>
</>
);
return (
<div className="nodedc-settings-card px-4 py-3">
<Link
href={`/${workspaceSlug}/settings/webhooks/${webhook?.id}`}
className="flex items-center justify-between gap-4"
>
<h5 className="truncate text-body-sm-medium text-primary">{webhook.url}</h5>
<div className="shrink-0">
<ToggleSwitch value={webhook.is_active} onChange={handleToggle} />
</div>
</Link>
{useModalLink && webhook.id ? (
<button
type="button"
onClick={() => openWorkspaceWebhookSettingsModal(webhook.id, true)}
className="flex w-full items-center justify-between gap-4 text-left"
>
{content}
</button>
) : (
<Link
href={`/${workspaceSlug}/settings/webhooks/${webhook?.id}`}
className="flex items-center justify-between gap-4"
>
{content}
</Link>
)}
</div>
);
}

View File

@ -10,14 +10,18 @@ import { useWebhook } from "@/hooks/store/use-webhook";
// components
import { WebhooksListItem } from "./webhooks-list-item";
export const WebhooksList = observer(function WebhooksList() {
type TWebhooksListProps = {
useModalLinks?: boolean;
};
export const WebhooksList = observer(function WebhooksList({ useModalLinks = false }: TWebhooksListProps) {
// store hooks
const { webhooks } = useWebhook();
return (
<div className="nodedc-settings-card flex size-full flex-col gap-y-3 overflow-y-auto p-3">
{Object.values(webhooks ?? {}).map((webhook) => (
<WebhooksListItem key={webhook.id} webhook={webhook} />
<WebhooksListItem key={webhook.id} webhook={webhook} useModalLink={useModalLinks} />
))}
</div>
);

View File

@ -0,0 +1,43 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils";
// components
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { ExportGuide } from "@/components/exporter/guide";
import { SettingsHeading } from "@/components/settings/heading";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
export function WorkspaceExportsSettingsContent() {
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { t } = useTranslation();
const canPerformWorkspaceMemberActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
if (workspaceUserInfo && !canPerformWorkspaceMemberActions) {
return <NotAuthorizedView section="settings" className="h-auto" />;
}
return (
<div
className={cn("flex w-full flex-col gap-y-6", {
"opacity-60": !canPerformWorkspaceMemberActions,
})}
>
<SettingsHeading
title={t("workspace_settings.settings.exports.heading")}
description={t("workspace_settings.settings.exports.description")}
/>
<ExportGuide />
</div>
);
}

View File

@ -0,0 +1,134 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { SearchIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspaceBulkInviteFormData } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { CountChip } from "@/components/common/count-chip";
import { MemberListFiltersDropdown } from "@/components/project/dropdowns/filters/member-list";
import { WorkspaceMembersList } from "@/components/workspace/settings/members-list";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUserPermissions } from "@/hooks/store/user";
// plane web components
import { BillingActionsButton } from "@/plane-web/components/workspace/billing/billing-actions-button";
import { MembersActivityButton, SendWorkspaceInvitationModal } from "@/plane-web/components/workspace/members";
type TWorkspaceMembersSettingsContentProps = {
workspaceSlug: string;
};
export function WorkspaceMembersSettingsContent({ workspaceSlug }: TWorkspaceMembersSettingsContentProps) {
const [inviteModal, setInviteModal] = useState(false);
const [searchQuery, setSearchQuery] = useState<string>("");
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const {
workspace: { workspaceMemberIds, inviteMembersToWorkspace, filtersStore },
} = useMember();
const { t } = useTranslation();
const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const canPerformWorkspaceMemberActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
const appliedRoleFilters = filtersStore.filters?.roles || [];
const handleWorkspaceInvite = async (data: IWorkspaceBulkInviteFormData) => {
try {
await inviteMembersToWorkspace(workspaceSlug, data);
setInviteModal(false);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: t("workspace_settings.settings.members.invitations_sent_successfully"),
});
} catch (error: unknown) {
const message = error instanceof Error ? (error as Error & { error?: string }).error : undefined;
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: `${message ?? t("something_went_wrong_please_try_again")}`,
});
throw error;
}
};
const handleRoleFilterUpdate = (role: string) => {
const currentRoles = filtersStore.filters?.roles || [];
const updatedRoles = currentRoles.includes(role) ? currentRoles.filter((r) => r !== role) : [...currentRoles, role];
filtersStore.updateFilters({
roles: updatedRoles.length > 0 ? updatedRoles : undefined,
});
};
if (workspaceUserInfo && !canPerformWorkspaceMemberActions) {
return <NotAuthorizedView section="settings" className="h-auto" />;
}
return (
<>
<SendWorkspaceInvitationModal
isOpen={inviteModal}
onClose={() => setInviteModal(false)}
onSubmit={handleWorkspaceInvite}
/>
<section
className={cn("size-full", {
"opacity-60": !canPerformWorkspaceMemberActions,
})}
>
<div className="flex flex-wrap items-center justify-between gap-4 pb-4">
<h4 className="flex items-center gap-2.5 text-h3-medium">
{t("workspace_settings.settings.members.title")}
{workspaceMemberIds && workspaceMemberIds.length > 0 && (
<CountChip count={workspaceMemberIds.length} className="m-auto h-5" />
)}
</h4>
<div className="flex flex-wrap items-center gap-2">
<div className="nodedc-settings-field flex min-h-[2.75rem] items-center gap-1.5 px-3.5">
<SearchIcon className="h-3.5 w-3.5 text-placeholder" />
<input
className="w-full max-w-[234px] border-none bg-transparent text-body-xs-regular outline-none placeholder:text-placeholder"
placeholder={`${t("search")}...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<MemberListFiltersDropdown
appliedFilters={appliedRoleFilters}
handleUpdate={handleRoleFilterUpdate}
memberType="workspace"
/>
<MembersActivityButton workspaceSlug={workspaceSlug} />
{canPerformWorkspaceAdminActions && (
<Button
variant="ghost"
size="lg"
className="nodedc-settings-primary-button min-w-[11rem]"
onClick={() => setInviteModal(true)}
>
{t("workspace_settings.settings.members.add_member")}
</Button>
)}
<BillingActionsButton canPerformWorkspaceAdminActions={canPerformWorkspaceAdminActions} />
</div>
</div>
<WorkspaceMembersList searchQuery={searchQuery} isAdmin={canPerformWorkspaceAdminActions} />
</section>
</>
);
}

View File

@ -10,6 +10,8 @@ import useSWR from "swr";
// plane imports
import type { IWorkspaceStorageProjectSummary } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { SettingsHeading } from "@/components/settings/heading";
// services
import { WorkspaceService } from "@/services/workspace.service";
@ -38,12 +40,12 @@ const StatCard = (props: {
const { title, value, caption, icon: Icon, tone = "default" } = props;
return (
<div className="rounded-[28px] border border-white/5 bg-custom-background-80/85 p-5 shadow-[0_22px_80px_rgba(0,0,0,0.28)]">
<div className="nodedc-settings-card p-5">
<div className="mb-5 flex items-start justify-between gap-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">{title}</div>
<div
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-full bg-custom-background-90 text-secondary",
"flex size-10 shrink-0 items-center justify-center rounded-full bg-white/5 text-secondary",
tone === "accent" && "bg-accent-primary/20 text-accent-primary",
tone === "warning" && "bg-red-500/15 text-red-300"
)}
@ -62,31 +64,52 @@ const ProjectStorageRow = (props: { project: IWorkspaceStorageProjectSummary; ma
const ratio = maxSize > 0 ? Math.max((project.logical_size / maxSize) * 100, project.logical_size > 0 ? 3 : 0) : 0;
return (
<tr className="border-b border-white/6 last:border-0">
<td className="py-4 pr-4 align-middle">
<div className="flex min-w-0 flex-col">
<span className="truncate text-14 font-semibold text-primary">{project.name}</span>
<span className="mt-1 text-11 uppercase tracking-[0.16em] text-tertiary">{project.identifier}</span>
<div className="nodedc-settings-field grid min-w-[62rem] grid-cols-[minmax(14rem,1.35fr)_0.55fr_0.55fr_minmax(15rem,1.45fr)_0.75fr_0.75fr_0.65fr_0.65fr] items-center gap-4 px-4 py-3.5">
<div className="flex min-w-0 flex-col">
<span className="truncate text-14 font-semibold text-primary">{project.name}</span>
<span className="mt-1 text-11 uppercase tracking-[0.16em] text-tertiary">{project.identifier}</span>
</div>
<StorageValue>{formatCount(project.file_count)}</StorageValue>
<StorageValue>{formatCount(project.blob_count)}</StorageValue>
<div className="flex min-w-0 items-center gap-3">
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/6">
<div className="h-full rounded-full bg-accent-primary" style={{ width: `${ratio}%` }} />
</div>
</td>
<td className="px-4 py-4 align-middle text-13 text-secondary">{formatCount(project.file_count)}</td>
<td className="px-4 py-4 align-middle text-13 text-secondary">{formatCount(project.blob_count)}</td>
<td className="px-4 py-4 align-middle">
<div className="flex min-w-[12rem] items-center gap-3">
<div className="h-2 flex-1 overflow-hidden rounded-full bg-custom-background-90">
<div className="h-full rounded-full bg-accent-primary" style={{ width: `${ratio}%` }} />
</div>
<span className="w-20 text-right text-13 font-medium text-primary">{formatBytes(project.logical_size)}</span>
</div>
</td>
<td className="px-4 py-4 align-middle text-13 text-secondary">{formatBytes(project.physical_size)}</td>
<td className="px-4 py-4 align-middle text-13 text-accent-primary">{formatBytes(project.dedup_savings)}</td>
<td className="px-4 py-4 align-middle text-13 text-secondary">{formatCount(project.failed_upload_count)}</td>
<td className="pl-4 py-4 align-middle text-13 text-secondary">{formatCount(project.soft_deleted_count)}</td>
</tr>
<span className="w-20 text-right text-13 font-medium text-primary">{formatBytes(project.logical_size)}</span>
</div>
<StorageValue>{formatBytes(project.physical_size)}</StorageValue>
<StorageValue accent>{formatBytes(project.dedup_savings)}</StorageValue>
<StorageValue warning={project.failed_upload_count > 0}>{formatCount(project.failed_upload_count)}</StorageValue>
<StorageValue>{formatCount(project.soft_deleted_count)}</StorageValue>
</div>
);
};
const StorageValue = (props: { accent?: boolean; children: string; warning?: boolean }) => (
<span
className={cn(
"text-13 font-medium text-secondary",
props.accent && "text-accent-primary",
props.warning && "text-red-300"
)}
>
{props.children}
</span>
);
const ProjectStorageHeader = () => (
<div className="grid min-w-[62rem] grid-cols-[minmax(14rem,1.35fr)_0.55fr_0.55fr_minmax(15rem,1.45fr)_0.75fr_0.75fr_0.65fr_0.65fr] gap-4 px-4 text-[11px] font-semibold uppercase tracking-[0.16em] text-tertiary">
<span>Проект</span>
<span>Файлы</span>
<span>Blob</span>
<span>Логический объем</span>
<span>Физический</span>
<span>Дедуп</span>
<span>Ошибки</span>
<span>Удалено</span>
</div>
);
type TStorageSettingsContentProps = {
workspaceSlug: string;
};
@ -101,24 +124,22 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
const maxProjectSize = Math.max(...projects.map((project) => project.logical_size), 0);
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-semibold tracking-normal text-primary">Хранилище</h1>
<p className="mt-2 max-w-3xl text-14 leading-6 text-secondary">
Контроль объема файлов, дедупликации и кандидатов на очистку по workspace и проектам.
</p>
</div>
<div className="flex w-full flex-col gap-7">
<SettingsHeading
title="Хранилище"
description="Контроль объема файлов, дедупликации и кандидатов на очистку по workspace и проектам."
/>
{isLoading && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-36 animate-pulse rounded-[28px] bg-custom-background-80/80" />
<div key={index} className="nodedc-settings-card h-36 animate-pulse" />
))}
</div>
)}
{error && (
<div className="rounded-[28px] border border-red-500/20 bg-red-500/10 px-5 py-4 text-14 text-red-200">
<div className="nodedc-settings-card bg-red-500/10 px-5 py-4 text-14 text-red-200">
Не удалось загрузить данные хранилища.
</div>
)}
@ -176,37 +197,26 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
/>
</div>
<section className="rounded-[28px] border border-white/5 bg-custom-background-80/85 p-5 shadow-[0_22px_80px_rgba(0,0,0,0.28)]">
<section className="nodedc-settings-card p-5">
<div className="mb-5 flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold tracking-normal text-primary">Проекты</h2>
<p className="mt-1 text-13 text-secondary">Сортировка по логическому объему файлов.</p>
</div>
<div className="rounded-full bg-custom-background-90 px-4 py-2 text-12 font-medium text-secondary">
<div className="nodedc-settings-chip flex min-h-0 items-center px-4 py-2 text-12 font-medium text-secondary">
{formatCount(projects.length)} проектов
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[58rem] border-collapse">
<thead>
<tr className="border-b border-white/8 text-left text-[11px] font-semibold uppercase tracking-[0.16em] text-tertiary">
<th className="pb-3 pr-4">Проект</th>
<th className="px-4 pb-3">Файлы</th>
<th className="px-4 pb-3">Blob</th>
<th className="px-4 pb-3">Логический объем</th>
<th className="px-4 pb-3">Физический</th>
<th className="px-4 pb-3">Дедуп</th>
<th className="px-4 pb-3">Ошибки</th>
<th className="pb-3 pl-4">Удалено</th>
</tr>
</thead>
<tbody>
<div className="flex min-w-[62rem] flex-col gap-2">
<ProjectStorageHeader />
<div className="flex flex-col gap-2">
{projects.map((project) => (
<ProjectStorageRow key={project.id} project={project} maxSize={maxProjectSize} />
))}
</tbody>
</table>
</div>
</div>
</div>
</section>
</>

View File

@ -0,0 +1,173 @@
/**
* 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 useSWR from "swr";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWebhook } from "@plane/types";
// components
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { LogoSpinner } from "@/components/common/logo-spinner";
import { SettingsHeading } from "@/components/settings/heading";
import { WebhookSettingsLoader } from "@/components/ui/loader/settings/web-hook";
import { CreateWebhookModal, DeleteWebhookModal, WebhookDeleteSection, WebhookForm, WebhooksList } from "@/components/web-hooks";
import { WebhooksEmptyState } from "@/components/web-hooks/empty-state";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
import { useWebhook } from "@/hooks/store/use-webhook";
import { useWorkspace } from "@/hooks/store/use-workspace";
type TWorkspaceWebhooksSettingsContentProps = {
selectedWebhookId?: string;
workspaceSlug: string;
};
export function WorkspaceWebhooksSettingsContent({
selectedWebhookId,
workspaceSlug,
}: TWorkspaceWebhooksSettingsContentProps) {
const [showCreateWebhookModal, setShowCreateWebhookModal] = useState(false);
const { t } = useTranslation();
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { fetchWebhooks, webhooks, clearSecretKey, webhookSecretKey, createWebhook } = useWebhook();
const { currentWorkspace } = useWorkspace();
const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
useSWR(
canPerformWorkspaceAdminActions ? `WEBHOOKS_LIST_${workspaceSlug}` : null,
canPerformWorkspaceAdminActions ? () => fetchWebhooks(workspaceSlug) : null
);
useEffect(() => {
if (!showCreateWebhookModal && webhookSecretKey) clearSecretKey();
}, [showCreateWebhookModal, webhookSecretKey, clearSecretKey]);
if (workspaceUserInfo && !canPerformWorkspaceAdminActions) {
return <NotAuthorizedView section="settings" className="h-auto" />;
}
if (selectedWebhookId) {
return <WorkspaceWebhookDetailsSettingsContent webhookId={selectedWebhookId} workspaceSlug={workspaceSlug} />;
}
if (!webhooks) return <WebhookSettingsLoader />;
return (
<div className="w-full">
<CreateWebhookModal
createWebhook={createWebhook}
clearSecretKey={clearSecretKey}
currentWorkspace={currentWorkspace}
isOpen={showCreateWebhookModal}
onClose={() => setShowCreateWebhookModal(false)}
/>
<SettingsHeading
title={t("workspace_settings.settings.webhooks.title")}
description={t("workspace_settings.settings.webhooks.description")}
control={
<Button
variant="ghost"
size="lg"
className="nodedc-settings-primary-button min-w-[11rem]"
onClick={() => setShowCreateWebhookModal(true)}
>
{t("workspace_settings.settings.webhooks.add_webhook")}
</Button>
}
/>
{Object.keys(webhooks).length > 0 ? (
<div className="mt-4">
<WebhooksList useModalLinks />
</div>
) : (
<div className="flex h-full w-full flex-col">
<div className="flex h-full w-full items-center justify-center">
<div className="py-20">
<WebhooksEmptyState onClick={() => setShowCreateWebhookModal(true)} />
</div>
</div>
</div>
)}
</div>
);
}
type TWorkspaceWebhookDetailsSettingsContentProps = {
webhookId: string;
workspaceSlug: string;
};
function WorkspaceWebhookDetailsSettingsContent({ webhookId, workspaceSlug }: TWorkspaceWebhookDetailsSettingsContentProps) {
const [deleteWebhookModal, setDeleteWebhookModal] = useState(false);
const { currentWebhook, fetchWebhookById, updateWebhook } = useWebhook();
const { allowPermissions } = useUserPermissions();
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
useSWR(
isAdmin ? `WEBHOOK_DETAILS_${workspaceSlug}_${webhookId}` : null,
isAdmin ? () => fetchWebhookById(workspaceSlug, webhookId) : null
);
const handleUpdateWebhook = async (formData: IWebhook) => {
if (!formData || !formData.id) return;
const payload = {
url: formData.url,
is_active: formData.is_active,
project: formData.project,
cycle: formData.cycle,
module: formData.module,
issue: formData.issue,
issue_comment: formData.issue_comment,
};
try {
await updateWebhook(workspaceSlug, formData.id, payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Webhook updated successfully.",
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error?.error ?? "Something went wrong. Please try again.",
});
}
};
if (!isAdmin) {
return (
<div className="mt-10 flex h-full w-full justify-center p-4">
<p className="text-13 text-tertiary">You are not authorized to access this page.</p>
</div>
);
}
if (!currentWebhook)
return (
<div className="grid h-full w-full place-items-center p-4">
<LogoSpinner />
</div>
);
return (
<>
<DeleteWebhookModal isOpen={deleteWebhookModal} onClose={() => setDeleteWebhookModal(false)} />
<div className="w-full space-y-8 overflow-y-auto">
<WebhookForm onSubmit={handleUpdateWebhook} data={currentWebhook} />
{currentWebhook && <WebhookDeleteSection openDeleteModal={() => setDeleteWebhookModal(true)} />}
</div>
</>
);
}

View File

@ -11,6 +11,7 @@ import { X } from "lucide-react";
import {
EUserPermissionsLevel,
GROUPED_WORKSPACE_SETTINGS,
WORKSPACE_SETTINGS,
WORKSPACE_SETTINGS_CATEGORIES,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
@ -23,7 +24,10 @@ import { SettingsSidebarItem } from "@/components/settings/sidebar/item";
import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon";
import { WorkspaceSettingsSidebarHeader } from "@/components/settings/workspace/sidebar/header";
import { AIVoiceTaskerSettingsContent } from "@/components/workspace/settings/ai-voice-tasker-settings";
import { WorkspaceExportsSettingsContent } from "@/components/workspace/settings/exports-settings";
import { WorkspaceMembersSettingsContent } from "@/components/workspace/settings/members-settings";
import { StorageSettingsContent } from "@/components/workspace/settings/storage-settings";
import { WorkspaceWebhooksSettingsContent } from "@/components/workspace/settings/webhooks-settings";
import { WorkspaceDetails } from "@/components/workspace/settings/workspace-details";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
@ -32,13 +36,14 @@ import { useWorkspace } from "@/hooks/store/use-workspace";
import {
closeWorkspaceSettingsModal,
getWorkspaceSettingsModalTabFromSearch,
getWorkspaceSettingsWebhookIdFromSearch,
openWorkspaceSettingsModal,
WORKSPACE_SETTINGS_MODAL_EVENT,
type TWorkspaceSettingsModalTab,
} from "./workspace-settings-modal.utils";
const HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["billing-and-plans"]);
const MODAL_TABS = new Set<TWorkspaceSettingsTabs>(["general", "storage", "ai-voice-tasker"]);
const MODAL_TABS = new Set<TWorkspaceSettingsTabs>(["general", "members", "export", "storage", "webhooks", "ai-voice-tasker"]);
const getInitialTab = (): TWorkspaceSettingsModalTab => {
if (typeof window === "undefined") return "general";
@ -54,16 +59,21 @@ const getInitialOpenState = () => {
export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal() {
const [activeTab, setActiveTab] = useState<TWorkspaceSettingsModalTab>(getInitialTab);
const [activeWebhookId, setActiveWebhookId] = useState<string | undefined>(() =>
typeof window === "undefined" ? undefined : getWorkspaceSettingsWebhookIdFromSearch(window.location.search)
);
const [isOpen, setIsOpen] = useState(getInitialOpenState);
// store hooks
const { currentWorkspace } = useWorkspace();
const { allowPermissions } = useUserPermissions();
const { t } = useTranslation();
useEffect(() => {
const syncFromLocation = () => {
const tab = getWorkspaceSettingsModalTabFromSearch(window.location.search);
setIsOpen(Boolean(tab));
if (tab) setActiveTab(tab);
setActiveWebhookId(tab === "webhooks" ? getWorkspaceSettingsWebhookIdFromSearch(window.location.search) : undefined);
};
const handleModalEvent = (event: Event) => {
@ -71,6 +81,9 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
setIsOpen(detail.isOpen);
if (detail.tab) setActiveTab(detail.tab);
setActiveWebhookId(
detail.tab === "webhooks" ? getWorkspaceSettingsWebhookIdFromSearch(window.location.search) : undefined
);
};
window.addEventListener(WORKSPACE_SETTINGS_MODAL_EVENT, handleModalEvent);
@ -104,15 +117,28 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
return <AIVoiceTaskerSettingsContent workspaceSlug={currentWorkspace.slug} />;
}
if (activeTab === "members" && currentWorkspace?.slug) {
return <WorkspaceMembersSettingsContent workspaceSlug={currentWorkspace.slug} />;
}
if (activeTab === "export") {
return <WorkspaceExportsSettingsContent />;
}
if (activeTab === "storage" && currentWorkspace?.slug) {
return <StorageSettingsContent workspaceSlug={currentWorkspace.slug} />;
}
if (activeTab === "webhooks" && currentWorkspace?.slug) {
return <WorkspaceWebhooksSettingsContent selectedWebhookId={activeWebhookId} workspaceSlug={currentWorkspace.slug} />;
}
return <WorkspaceDetails />;
};
const activeTabLabel =
activeTab === "ai-voice-tasker" ? "AI / Voice Tasker" : activeTab === "storage" ? "хранилище" : "основные параметры";
const activeTabLabel = WORKSPACE_SETTINGS[activeTab]?.i18n_label
? t(WORKSPACE_SETTINGS[activeTab].i18n_label)
: "основные параметры";
return (
<ModalCore

View File

@ -1,7 +1,9 @@
export const WORKSPACE_SETTINGS_MODAL_QUERY_KEY = "workspaceSettings";
export const WORKSPACE_SETTINGS_MODAL_EVENT = "nodedc:workspace-settings-modal";
export type TWorkspaceSettingsModalTab = "general" | "storage" | "ai-voice-tasker";
export const WORKSPACE_SETTINGS_WEBHOOK_QUERY_KEY = "webhookId";
export type TWorkspaceSettingsModalTab = "general" | "members" | "export" | "storage" | "webhooks" | "ai-voice-tasker";
type TWorkspaceSettingsModalEventDetail = {
isOpen: boolean;
@ -15,16 +17,39 @@ const dispatchWorkspaceSettingsModalEvent = (detail: TWorkspaceSettingsModalEven
export const getWorkspaceSettingsModalTabFromSearch = (search: string): TWorkspaceSettingsModalTab | undefined => {
const value = new URLSearchParams(search).get(WORKSPACE_SETTINGS_MODAL_QUERY_KEY);
if (value === "general" || value === "storage" || value === "ai-voice-tasker") return value;
if (
value === "general" ||
value === "members" ||
value === "export" ||
value === "storage" ||
value === "webhooks" ||
value === "ai-voice-tasker"
)
return value;
return undefined;
};
export const setWorkspaceSettingsModalSearch = (tab: TWorkspaceSettingsModalTab, replace = false) => {
export const getWorkspaceSettingsWebhookIdFromSearch = (search: string): string | undefined => {
const value = new URLSearchParams(search).get(WORKSPACE_SETTINGS_WEBHOOK_QUERY_KEY);
return value || undefined;
};
export const setWorkspaceSettingsModalSearch = (
tab: TWorkspaceSettingsModalTab,
replace = false,
options?: { webhookId?: string }
) => {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.set(WORKSPACE_SETTINGS_MODAL_QUERY_KEY, tab);
if (tab === "webhooks" && options?.webhookId) {
url.searchParams.set(WORKSPACE_SETTINGS_WEBHOOK_QUERY_KEY, options.webhookId);
} else {
url.searchParams.delete(WORKSPACE_SETTINGS_WEBHOOK_QUERY_KEY);
}
window.history[replace ? "replaceState" : "pushState"](window.history.state, "", url);
};
@ -34,17 +59,26 @@ export const clearWorkspaceSettingsModalSearch = () => {
const url = new URL(window.location.href);
url.searchParams.delete(WORKSPACE_SETTINGS_MODAL_QUERY_KEY);
url.searchParams.delete(WORKSPACE_SETTINGS_WEBHOOK_QUERY_KEY);
window.history.replaceState(window.history.state, "", url);
};
export const openWorkspaceSettingsModal = (tab: TWorkspaceSettingsModalTab = "general", replace = false) => {
export const openWorkspaceSettingsModal = (
tab: TWorkspaceSettingsModalTab = "general",
replace = false,
options?: { webhookId?: string }
) => {
if (typeof window === "undefined") return;
setWorkspaceSettingsModalSearch(tab, replace);
setWorkspaceSettingsModalSearch(tab, replace, options);
dispatchWorkspaceSettingsModalEvent({ isOpen: true, tab });
};
export const openWorkspaceWebhookSettingsModal = (webhookId: string, replace = false) => {
openWorkspaceSettingsModal("webhooks", replace, { webhookId });
};
export const closeWorkspaceSettingsModal = () => {
if (typeof window === "undefined") return;

View File

@ -359,6 +359,34 @@
backdrop-filter: blur(34px);
}
.nodedc-bottom-dock-voice-slot {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
min-width: 0;
}
.nodedc-bottom-dock-voice-button {
display: grid;
height: 2rem;
width: 2rem;
place-items: center;
border: 0 !important;
border-radius: 999px;
background: transparent;
color: rgb(var(--nodedc-accent-rgb));
outline: none !important;
box-shadow: none !important;
transition:
color 160ms ease,
transform 160ms ease;
}
.nodedc-bottom-dock-voice-button:hover {
color: rgb(var(--nodedc-card-active-rgb));
}
.nodedc-bottom-dock-aware-padding {
padding-bottom: calc(var(--nodedc-quick-add-reserve, 2.5rem) + 0.5rem);
}