UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: единый модальный режим настроек воркспейса

This commit is contained in:
DCCONSTRUCTIONS
2026-04-27 20:05:01 +03:00
parent c3d2d78724
commit 3231ee9b55
14 changed files with 503 additions and 524 deletions
@@ -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;
}