Compare commits
5 Commits
c3d2d78724
...
9243924f78
| Author | SHA1 | Date |
|---|---|---|
|
|
9243924f78 | |
|
|
efa357c260 | |
|
|
6da9818826 | |
|
|
b4f9c58eb5 | |
|
|
3231ee9b55 |
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue