diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx index d92cd73..4f6506a 100644 --- a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx @@ -25,13 +25,13 @@ import { CycleAppliedFiltersList } from "@/components/cycles/applied-filters"; import { CyclesView } from "@/components/cycles/cycles-view"; import { CycleCreateUpdateModal } from "@/components/cycles/modal"; import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; +import { openProjectSettingsModal } from "@/components/project/settings/project-settings-modal.utils"; import { CycleModuleListLayoutLoader } from "@/components/ui/loader/cycle-module-list-loader"; // hooks import { useCycle } from "@/hooks/store/use-cycle"; import { useCycleFilter } from "@/hooks/store/use-cycle-filter"; import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; -import { useAppRouter } from "@/hooks/use-app-router"; import type { Route } from "./+types/page"; function ProjectCyclesPage({ params }: Route.ComponentProps) { @@ -40,8 +40,6 @@ function ProjectCyclesPage({ params }: Route.ComponentProps) { // store hooks const { currentProjectCycleIds, loader } = useCycle(); const { getProjectById, currentProjectDetails } = useProject(); - // router - const router = useAppRouter(); const { workspaceSlug, projectId } = params; // theme hook const { resolvedTheme } = useTheme(); @@ -81,7 +79,7 @@ function ProjectCyclesPage({ params }: Route.ComponentProps) { primaryButton={{ text: t("disabled_project.empty_state.cycle.primary_button.text"), onClick: () => { - router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + openProjectSettingsModal(projectId, "features_cycles"); }, disabled: !hasAdminLevelPermission, }} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx index c6be164..092362d 100644 --- a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx @@ -18,15 +18,13 @@ import lightIntakeAsset from "@/app/assets/empty-state/disabled-feature/intake-l import { PageHead } from "@/components/core/page-title"; import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; import { InboxIssueRoot } from "@/components/inbox"; +import { openProjectSettingsModal } from "@/components/project/settings/project-settings-modal.utils"; // hooks import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; -import { useAppRouter } from "@/hooks/use-app-router"; import type { Route } from "./+types/page"; function ProjectInboxPage({ params }: Route.ComponentProps) { - /// router - const router = useAppRouter(); const { workspaceSlug, projectId } = params; const searchParams = useSearchParams(); const navigationTab = searchParams.get("currentTab"); @@ -53,7 +51,7 @@ function ProjectInboxPage({ params }: Route.ComponentProps) { primaryButton={{ text: t("disabled_project.empty_state.inbox.primary_button.text"), onClick: () => { - router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + openProjectSettingsModal(projectId, "features_intake"); }, disabled: !canPerformEmptyStateActions, }} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx index 12e0be1..a64357c 100644 --- a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx @@ -20,17 +20,15 @@ import lightModulesAsset from "@/app/assets/empty-state/disabled-feature/modules import { PageHead } from "@/components/core/page-title"; import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; import { ModuleAppliedFiltersList, ModulesListView } from "@/components/modules"; +import { openProjectSettingsModal } from "@/components/project/settings/project-settings-modal.utils"; // hooks import { useModuleFilter } from "@/hooks/store/use-module-filter"; import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; -import { useAppRouter } from "@/hooks/use-app-router"; import type { Route } from "./+types/page"; function ProjectModulesPage({ params }: Route.ComponentProps) { - // router - const router = useAppRouter(); - const { workspaceSlug, projectId } = params; + const { projectId } = params; // theme hook const { resolvedTheme } = useTheme(); // plane hooks @@ -74,7 +72,7 @@ function ProjectModulesPage({ params }: Route.ComponentProps) { primaryButton={{ text: t("disabled_project.empty_state.module.primary_button.text"), onClick: () => { - router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + openProjectSettingsModal(projectId, "features_modules"); }, disabled: !canPerformEmptyStateActions, }} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx index 4071f4d..c4e5a94 100644 --- a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx @@ -20,10 +20,10 @@ import { PageHead } from "@/components/core/page-title"; import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; import { PagesListRoot } from "@/components/pages/list/root"; import { PagesListView } from "@/components/pages/pages-list-view"; +import { openProjectSettingsModal } from "@/components/project/settings/project-settings-modal.utils"; // hooks import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; -import { useAppRouter } from "@/hooks/use-app-router"; // plane web hooks import { EPageStoreType } from "@/plane-web/hooks/store"; import type { Route } from "./+types/page"; @@ -36,7 +36,6 @@ const getPageType = (pageType?: string | null): TPageNavigationTabs => { function ProjectPagesPage({ params }: Route.ComponentProps) { // router - const router = useAppRouter(); const searchParams = useSearchParams(); const type = searchParams.get("type"); const { workspaceSlug, projectId } = params; @@ -65,7 +64,7 @@ function ProjectPagesPage({ params }: Route.ComponentProps) { primaryButton={{ text: t("disabled_project.empty_state.page.primary_button.text"), onClick: () => { - router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + openProjectSettingsModal(projectId, "features_pages"); }, disabled: !canPerformEmptyStateActions, }} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx index af05eab..c827c57 100644 --- a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx @@ -20,19 +20,17 @@ import lightViewsAsset from "@/app/assets/empty-state/disabled-feature/views-lig // components import { PageHead } from "@/components/core/page-title"; import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; +import { openProjectSettingsModal } from "@/components/project/settings/project-settings-modal.utils"; import { ViewAppliedFiltersList } from "@/components/views/applied-filters"; import { ProjectViewsList } from "@/components/views/views-list"; // hooks import { useProject } from "@/hooks/store/use-project"; import { useProjectView } from "@/hooks/store/use-project-view"; import { useUserPermissions } from "@/hooks/store/user"; -import { useAppRouter } from "@/hooks/use-app-router"; import type { Route } from "./+types/page"; function ProjectViewsPage({ params }: Route.ComponentProps) { - // router - const router = useAppRouter(); - const { workspaceSlug, projectId } = params; + const { projectId } = params; // theme hook const { resolvedTheme } = useTheme(); // plane hooks @@ -77,7 +75,7 @@ function ProjectViewsPage({ params }: Route.ComponentProps) { primaryButton={{ text: t("disabled_project.empty_state.view.primary_button.text"), onClick: () => { - router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + openProjectSettingsModal(projectId, "features_views"); }, disabled: !canPerformEmptyStateActions, }} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx index 6ebe9df..a209871 100644 --- a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx @@ -6,8 +6,12 @@ import { observer } from "mobx-react"; import { usePathname } from "next/navigation"; -import { Outlet } from "react-router"; +import { Outlet, redirect } from "react-router"; // components +import { + buildProjectSettingsModalUrl, + getProjectSettingsModalTabFromPath, +} from "@/components/project/settings/project-settings-modal.utils"; import { getProjectActivePath } from "@/components/settings/helper"; import { SettingsMobileNav } from "@/components/settings/mobile/nav"; // layouts @@ -16,6 +20,13 @@ import { ProjectAuthWrapper } from "@/layouts/auth-layout/project-wrapper"; import type { Route } from "./+types/layout"; import { ProjectSettingsSidebarRoot } from "@/components/settings/project/sidebar"; +export const clientLoader = ({ params, request }: Route.ClientLoaderArgs) => { + const { workspaceSlug, projectId } = params; + const tab = getProjectSettingsModalTabFromPath(new URL(request.url).pathname); + + throw redirect(buildProjectSettingsModalUrl(workspaceSlug, projectId, tab)); +}; + function ProjectDetailSettingsLayout({ params }: Route.ComponentProps) { const { workspaceSlug, projectId } = params; // router diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx index 86229fe..bbdf2d4 100644 --- a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx @@ -7,6 +7,7 @@ import { useEffect } from "react"; import { observer } from "mobx-react"; import { Outlet } from "react-router"; +import { buildProjectSettingsModalUrl } from "@/components/project/settings/project-settings-modal.utils"; // hooks import { useProject } from "@/hooks/store/use-project"; import { useAppRouter } from "@/hooks/use-app-router"; @@ -23,7 +24,7 @@ function ProjectSettingsLayout({ params }: Route.ComponentProps) { useEffect(() => { if (projectId) return; if (joinedProjectIds.length > 0) { - router.push(`/${workspaceSlug}/settings/projects/${joinedProjectIds[0]}`); + router.push(buildProjectSettingsModalUrl(workspaceSlug, joinedProjectIds[0])); } }, [joinedProjectIds, router, workspaceSlug, projectId]); diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/layout.tsx index 655b4dd..25fed7a 100644 --- a/plane-src/apps/web/app/(all)/[workspaceSlug]/layout.tsx +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/layout.tsx @@ -10,6 +10,7 @@ import { WorkspaceContentWrapper } from "@/plane-web/components/workspace/conten import { AppRailVisibilityProvider } from "@/plane-web/hooks/app-rail"; import { GlobalModals } from "@/plane-web/components/common/modal/global"; import { WorkspaceAuthWrapper } from "@/layouts/auth-layout/workspace-wrapper"; +import { ProjectSettingsModal } from "@/components/project/settings/project-settings-modal"; import { WorkspaceSettingsModal } from "@/components/workspace/settings/workspace-settings-modal"; import { WorkspaceNotificationsModal } from "@/components/workspace-notifications/notifications-modal"; import type { Route } from "./+types/layout"; @@ -24,6 +25,7 @@ export default function WorkspaceLayout(props: Route.ComponentProps) { + diff --git a/plane-src/apps/web/app/routes/redirects/core/project-settings.tsx b/plane-src/apps/web/app/routes/redirects/core/project-settings.tsx index db74d57..505e175 100644 --- a/plane-src/apps/web/app/routes/redirects/core/project-settings.tsx +++ b/plane-src/apps/web/app/routes/redirects/core/project-settings.tsx @@ -5,13 +5,18 @@ */ import { redirect } from "react-router"; +import { + buildProjectSettingsModalUrl, + getProjectSettingsModalTabFromPath, +} from "@/components/project/settings/project-settings-modal.utils"; import type { Route } from "./+types/project-settings"; export const clientLoader = ({ params }: Route.ClientLoaderArgs) => { const { workspaceSlug, projectId } = params; const splat = params["*"] || ""; - const destination = `/${workspaceSlug}/settings/projects/${projectId}${splat ? `/${splat}` : ""}/`; - throw redirect(destination); + const tab = getProjectSettingsModalTabFromPath(splat); + + throw redirect(buildProjectSettingsModalUrl(workspaceSlug, projectId, tab)); }; export default function ProjectSettings() { diff --git a/plane-src/apps/web/core/components/issues/issue-layouts/empty-states/archived-issues.tsx b/plane-src/apps/web/core/components/issues/issue-layouts/empty-states/archived-issues.tsx index 8ae22d8..20c31a9 100644 --- a/plane-src/apps/web/core/components/issues/issue-layouts/empty-states/archived-issues.tsx +++ b/plane-src/apps/web/core/components/issues/issue-layouts/empty-states/archived-issues.tsx @@ -11,16 +11,13 @@ import { EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { EmptyStateDetailed } from "@plane/propel/empty-state"; import { EIssuesStoreType, EUserProjectRoles } from "@plane/types"; +import { openProjectSettingsModal } from "@/components/project/settings/project-settings-modal.utils"; // hooks import { useUserPermissions } from "@/hooks/store/user"; import { useWorkItemFilterInstance } from "@/hooks/store/work-item-filters/use-work-item-filter-instance"; -import { useAppRouter } from "@/hooks/use-app-router"; export const ProjectArchivedEmptyState = observer(function ProjectArchivedEmptyState() { - // router - const router = useAppRouter(); - const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams(); - const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined; + const { projectId: routerProjectId } = useParams(); const projectId = routerProjectId ? routerProjectId.toString() : undefined; // plane hooks const { t } = useTranslation(); @@ -57,7 +54,9 @@ export const ProjectArchivedEmptyState = observer(function ProjectArchivedEmptyS actions={[ { label: t("workspace_empty_state.archive_work_items.cta_primary"), - onClick: () => router.push(`/${workspaceSlug}/settings/projects/${projectId}/automations`), + onClick: () => { + if (projectId) openProjectSettingsModal(projectId, "automations"); + }, disabled: !canPerformEmptyStateActions, variant: "primary", }, diff --git a/plane-src/apps/web/core/components/navigation/project-actions-menu.tsx b/plane-src/apps/web/core/components/navigation/project-actions-menu.tsx index df01cd5..dc4ff0a 100644 --- a/plane-src/apps/web/core/components/navigation/project-actions-menu.tsx +++ b/plane-src/apps/web/core/components/navigation/project-actions-menu.tsx @@ -13,6 +13,7 @@ import { useTranslation } from "@plane/i18n"; import { LinkIcon } from "@plane/propel/icons"; import type { TContextMenuItem } from "@plane/ui"; import { ActionDropdown } from "@plane/ui"; +import { openProjectSettingsModal } from "@/components/project/settings/project-settings-modal.utils"; type Props = { workspaceSlug: string; @@ -75,7 +76,7 @@ export function ProjectActionsMenu({ title: t("settings"), icon: Settings, action: () => { - navigate(`/${workspaceSlug}/settings/projects/${project?.id}`); + openProjectSettingsModal(project.id); }, }, ...(!isAuthorized diff --git a/plane-src/apps/web/core/components/project/card.tsx b/plane-src/apps/web/core/components/project/card.tsx index c9286d1..1cbca53 100644 --- a/plane-src/apps/web/core/components/project/card.tsx +++ b/plane-src/apps/web/core/components/project/card.tsx @@ -26,10 +26,13 @@ import { copyUrlToClipboard, cn, getFileURL, renderFormattedDate } from "@plane/ import { useMember } from "@/hooks/store/use-member"; import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; -import { useAppRouter } from "@/hooks/use-app-router"; import { usePlatformOS } from "@/hooks/use-platform-os"; // local imports import { CoverImage } from "@/components/common/cover-image"; +import { + buildProjectSettingsModalUrl, + openProjectSettingsModal, +} from "@/components/project/settings/project-settings-modal.utils"; import { DeleteProjectModal } from "./delete-project-modal"; import { JoinProjectModal } from "./join-project-modal"; import { ArchiveRestoreProjectModal } from "./archive-restore-modal"; @@ -47,7 +50,6 @@ export const ProjectCard = observer(function ProjectCard(props: Props) { // refs const projectCardRef = useRef(null); // router - const router = useAppRouter(); const { workspaceSlug } = useParams(); // store hooks const { getUserDetails } = useMember(); @@ -125,7 +127,7 @@ export const ProjectCard = observer(function ProjectCard(props: Props) { const MENU_ITEMS: TContextMenuItem[] = [ { key: "settings", - action: () => router.push(`/${workspaceSlug}/settings/projects/${project.id}`), + action: () => openProjectSettingsModal(project.id), title: "Settings", icon: Settings, shouldRender: !isArchived && (hasAdminRole || hasMemberRole), @@ -339,9 +341,11 @@ export const ProjectCard = observer(function ProjectCard(props: Props) { { + e.preventDefault(); e.stopPropagation(); + openProjectSettingsModal(project.id); }} - href={`/${workspaceSlug}/settings/projects/${project.id}`} + href={workspaceSlug ? buildProjectSettingsModalUrl(workspaceSlug.toString(), project.id) : "#"} > diff --git a/plane-src/apps/web/core/components/project/settings/project-settings-modal.tsx b/plane-src/apps/web/core/components/project/settings/project-settings-modal.tsx new file mode 100644 index 0000000..bbe416e --- /dev/null +++ b/plane-src/apps/web/core/components/project/settings/project-settings-modal.tsx @@ -0,0 +1,539 @@ +import { useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; +import { observer } from "mobx-react"; +import { X } from "lucide-react"; +import useSWR from "swr"; +// plane imports +import { + EUserPermissions, + EUserPermissionsLevel, + GROUPED_PROJECT_SETTINGS, + PROJECT_SETTINGS, + PROJECT_SETTINGS_CATEGORIES, +} from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { ScrollArea } from "@plane/propel/scrollarea"; +import { setToast, TOAST_TYPE } from "@plane/propel/toast"; +import type { IProject, TProjectSettingsTabs } from "@plane/types"; +import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; +// components +import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { EstimateRoot } from "@/components/estimates"; +import { ProjectSettingsLabelList } from "@/components/labels"; +import { ProjectDetailsForm } from "@/components/project/form"; +import { ProjectDetailsFormLoader } from "@/components/project/form-loader"; +import { ProjectMemberList } from "@/components/project/member-list"; +import { ProjectSettingsMemberDefaults } from "@/components/project/project-settings-member-defaults"; +import { GeneralProjectSettingsControlSection } from "@/components/project/settings/control-section"; +import { ProjectStateRoot } from "@/components/project-states"; +import { SettingsSidebarItem } from "@/components/settings/sidebar/item"; +import { SettingsHeading } from "@/components/settings/heading"; +import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item"; +import { ProjectSettingsSidebarHeader } from "@/components/settings/project/sidebar/header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +// layouts +import { ProjectAuthWrapper } from "@/layouts/auth-layout/project-wrapper"; +// plane web imports +import { CustomAutomationsRoot } from "@/plane-web/components/automations/root"; +import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces/teamspace-list"; +// services +import { WorkspaceService } from "@/services/workspace.service"; +// local imports +import { + closeProjectSettingsModal, + getProjectSettingsModalProjectIdFromSearch, + getProjectSettingsModalTabFromSearch, + openProjectSettingsModal, + PROJECT_SETTINGS_MODAL_EVENT, + type TProjectSettingsModalTab, +} from "./project-settings-modal.utils"; + +const PROJECT_CATEGORY_I18N_KEYS = { + general: "project_settings_categories.general", + features: "project_settings_categories.features", + "work-structure": "project_settings_categories.work_structure", + execution: "project_settings_categories.execution", +} as const; + +const PROJECT_FEATURE_SETTINGS: Record< + Extract< + TProjectSettingsModalTab, + "features_cycles" | "features_modules" | "features_views" | "features_pages" | "features_intake" + >, + { + descriptionKey: string; + featureProperty: keyof IProject; + titleKey: string; + toggleDescriptionKey: string; + toggleTitleKey: string; + } +> = { + features_cycles: { + descriptionKey: "project_settings.features.cycles.description", + featureProperty: "cycle_view", + titleKey: "project_settings.features.cycles.title", + toggleDescriptionKey: "project_settings.features.cycles.toggle_description", + toggleTitleKey: "project_settings.features.cycles.toggle_title", + }, + features_modules: { + descriptionKey: "project_settings.features.modules.description", + featureProperty: "module_view", + titleKey: "project_settings.features.modules.title", + toggleDescriptionKey: "project_settings.features.modules.toggle_description", + toggleTitleKey: "project_settings.features.modules.toggle_title", + }, + features_views: { + descriptionKey: "project_settings.features.views.description", + featureProperty: "issue_views_view", + titleKey: "project_settings.features.views.title", + toggleDescriptionKey: "project_settings.features.views.toggle_description", + toggleTitleKey: "project_settings.features.views.toggle_title", + }, + features_pages: { + descriptionKey: "project_settings.features.pages.description", + featureProperty: "page_view", + titleKey: "project_settings.features.pages.title", + toggleDescriptionKey: "project_settings.features.pages.toggle_description", + toggleTitleKey: "project_settings.features.pages.toggle_title", + }, + features_intake: { + descriptionKey: "project_settings.features.intake.description", + featureProperty: "inbox_view", + titleKey: "project_settings.features.intake.title", + toggleDescriptionKey: "project_settings.features.intake.toggle_description", + toggleTitleKey: "project_settings.features.intake.toggle_title", + }, +}; + +const workspaceService = new WorkspaceService(); + +const getInitialTab = (): TProjectSettingsModalTab => { + if (typeof window === "undefined") return "general"; + + return getProjectSettingsModalTabFromSearch(window.location.search) ?? "general"; +}; + +const getInitialProjectId = () => { + if (typeof window === "undefined") return undefined; + + return getProjectSettingsModalProjectIdFromSearch(window.location.search); +}; + +const getInitialOpenState = () => { + if (typeof window === "undefined") return false; + + return Boolean(getProjectSettingsModalTabFromSearch(window.location.search) && getInitialProjectId()); +}; + +export const ProjectSettingsModal = observer(function ProjectSettingsModal() { + const [activeTab, setActiveTab] = useState(getInitialTab); + const [activeProjectId, setActiveProjectId] = useState(getInitialProjectId); + const [isOpen, setIsOpen] = useState(getInitialOpenState); + // store hooks + const { getProjectById } = useProject(); + const { currentWorkspace } = useWorkspace(); + const { t } = useTranslation(); + // derived values + const projectDetails = getProjectById(activeProjectId); + const workspaceSlug = currentWorkspace?.slug; + const activeTabLabel = PROJECT_SETTINGS[activeTab]?.i18n_label + ? t(PROJECT_SETTINGS[activeTab].i18n_label) + : "основные параметры"; + + useEffect(() => { + const syncFromLocation = () => { + const tab = getProjectSettingsModalTabFromSearch(window.location.search); + const projectId = getProjectSettingsModalProjectIdFromSearch(window.location.search); + + setIsOpen(Boolean(tab && projectId)); + if (tab) setActiveTab(tab); + setActiveProjectId(projectId); + }; + + const handleModalEvent = (event: Event) => { + const detail = (event as CustomEvent<{ isOpen: boolean; projectId?: string; tab?: TProjectSettingsModalTab }>) + .detail; + + setIsOpen(detail.isOpen); + if (detail.tab) setActiveTab(detail.tab); + if (detail.projectId) setActiveProjectId(detail.projectId); + }; + + window.addEventListener(PROJECT_SETTINGS_MODAL_EVENT, handleModalEvent); + window.addEventListener("popstate", syncFromLocation); + + syncFromLocation(); + + return () => { + window.removeEventListener(PROJECT_SETTINGS_MODAL_EVENT, handleModalEvent); + window.removeEventListener("popstate", syncFromLocation); + }; + }, []); + + const handleClose = () => { + closeProjectSettingsModal(); + }; + + const handleSelectItem = (itemKey: TProjectSettingsTabs) => { + if (!activeProjectId) return; + + openProjectSettingsModal(activeProjectId, itemKey, true); + }; + + return ( + + {workspaceSlug && activeProjectId ? ( + +
+
+ + +
+ +
+
+
+
Настройки проекта
+
+ {projectDetails?.name ?? "Project"} / {activeTabLabel} +
+
+ +
+ + +
+ +
+
+
+
+
+ ) : null} +
+ ); +}); + +type TProjectModalSidebarProps = { + activeTab: TProjectSettingsModalTab; + onSelectItem: (itemKey: TProjectSettingsTabs) => void; + projectId: string; + workspaceSlug: string; +}; + +const ProjectModalSidebar = observer(function ProjectModalSidebar(props: TProjectModalSidebarProps) { + const { activeTab, onSelectItem, projectId, workspaceSlug } = props; + // store hooks + const { allowPermissions } = useUserPermissions(); + const { t } = useTranslation(); + const { data: nodedcWorkspacePolicy } = useSWR(`NODEDC_WORKSPACE_POLICY_${workspaceSlug}`, () => + workspaceService.getNodeDCWorkspacePolicy(workspaceSlug) + ); + const shouldHideMemberSettings = !nodedcWorkspacePolicy || nodedcWorkspacePolicy.managed_by === "launcher"; + + return ( + +
+ {PROJECT_SETTINGS_CATEGORIES.map((category) => { + const accessibleItems = GROUPED_PROJECT_SETTINGS[category].filter( + (item) => + (!shouldHideMemberSettings || item.key !== "members") && + allowPermissions(item.access, EUserPermissionsLevel.PROJECT, workspaceSlug, projectId) + ); + + if (accessibleItems.length === 0) return null; + + return ( +
+
+ {t(PROJECT_CATEGORY_I18N_KEYS[category])} +
+
+ {accessibleItems.map((item) => ( + onSelectItem(item.key)} + isActive={item.key === activeTab} + icon={PROJECT_SETTINGS_ICONS[item.key]} + label={t(item.i18n_label)} + /> + ))} +
+
+ ); + })} +
+
+ ); +}); + +type TProjectSettingsModalContentProps = { + activeTab: TProjectSettingsModalTab; + projectId: string; + workspaceSlug: string; +}; + +const ProjectSettingsModalContent = observer(function ProjectSettingsModalContent( + props: TProjectSettingsModalContentProps +) { + const { activeTab, projectId, workspaceSlug } = props; + // store hooks + const { getProjectById, updateProject } = useProject(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { t } = useTranslation(); + const { data: nodedcWorkspacePolicy } = useSWR(`NODEDC_WORKSPACE_POLICY_${workspaceSlug}`, () => + workspaceService.getNodeDCWorkspacePolicy(workspaceSlug) + ); + // derived values + const projectDetails = getProjectById(projectId); + const pageTitle = projectDetails?.name ? `${projectDetails.name} - Settings` : undefined; + const canPerformProjectAdminActions = allowPermissions( + [EUserPermissions.ADMIN], + EUserPermissionsLevel.PROJECT, + workspaceSlug, + projectId + ); + const canPerformProjectMemberActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT, + workspaceSlug, + projectId + ); + const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE, workspaceSlug); + const canPerformProjectMemberListActions = canPerformProjectMemberActions || isWorkspaceAdmin; + + if (activeTab === "general") { + return ( + <> + +
+ {projectDetails ? ( + + ) : ( + + )} + {canPerformProjectAdminActions && } +
+ + ); + } + + if (activeTab === "members") { + if (workspaceUserInfo && !canPerformProjectMemberListActions) { + return ; + } + + if (nodedcWorkspacePolicy?.managed_by === "launcher") { + return ( + <> + + +
+

+ NODE.DC managed project +

+
+

Участники проекта управляются в Launcher.

+

+ Этот workspace подключен к enterprise-контуру NODE.DC. Project-level доступы назначаются в Launcher, + поэтому локальное управление участниками проекта в Task Manager заблокировано. +

+
+
+ + ); + } + + return ( + <> + + + + + + + ); + } + + if (activeTab in PROJECT_FEATURE_SETTINGS) { + const featureSettings = PROJECT_FEATURE_SETTINGS[activeTab as keyof typeof PROJECT_FEATURE_SETTINGS]; + + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + + return ( + <> + +
+ +
+ +
+
+ + ); + } + + if (activeTab === "states") { + if (workspaceUserInfo && !canPerformProjectMemberActions) { + return ; + } + + return ( + <> + +
+ +
+ +
+
+ + ); + } + + if (activeTab === "labels") { + if (workspaceUserInfo && !canPerformProjectMemberActions) { + return ; + } + + return ; + } + + if (activeTab === "estimates") { + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + + return ( + <> + +
+ +
+ + ); + } + + if (activeTab === "automations") { + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + + const handleChange = async (formData: Partial) => { + if (!projectDetails) return; + + try { + await updateProject(workspaceSlug, projectId, formData); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Something went wrong. Please try again.", + }); + } + }; + + return ( + <> + +
+ +
+ + +
+
+ + + ); + } + + return null; +}); + +function ProjectLabelsSettingsContent(props: { pageTitle?: string }) { + const { pageTitle } = props; + const scrollableContainerRef = useRef(null); + + useEffect(() => { + const element = scrollableContainerRef.current; + + if (!element) return; + + return combine( + autoScrollForElements({ + element, + }) + ); + }, []); + + return ( + <> + +
+ +
+ + ); +} diff --git a/plane-src/apps/web/core/components/project/settings/project-settings-modal.utils.ts b/plane-src/apps/web/core/components/project/settings/project-settings-modal.utils.ts new file mode 100644 index 0000000..ec54ff1 --- /dev/null +++ b/plane-src/apps/web/core/components/project/settings/project-settings-modal.utils.ts @@ -0,0 +1,121 @@ +import type { TProjectSettingsTabs } from "@plane/types"; + +export const PROJECT_SETTINGS_MODAL_QUERY_KEY = "projectSettings"; +export const PROJECT_SETTINGS_PROJECT_QUERY_KEY = "projectId"; +export const PROJECT_SETTINGS_MODAL_EVENT = "nodedc:project-settings-modal"; + +export type TProjectSettingsModalTab = TProjectSettingsTabs; + +type TProjectSettingsModalEventDetail = { + isOpen: boolean; + projectId?: string; + tab?: TProjectSettingsModalTab; +}; + +const PROJECT_SETTINGS_MODAL_TABS = new Set([ + "general", + "members", + "features_cycles", + "features_modules", + "features_views", + "features_pages", + "features_intake", + "states", + "labels", + "estimates", + "automations", +]); + +const PROJECT_SETTINGS_PATH_TO_TAB: Array<[string, TProjectSettingsModalTab]> = [ + ["features/cycles", "features_cycles"], + ["features/modules", "features_modules"], + ["features/views", "features_views"], + ["features/pages", "features_pages"], + ["features/intake", "features_intake"], + ["features", "features_cycles"], + ["members", "members"], + ["states", "states"], + ["labels", "labels"], + ["estimates", "estimates"], + ["automations", "automations"], +]; + +const dispatchProjectSettingsModalEvent = (detail: TProjectSettingsModalEventDetail) => { + window.dispatchEvent(new CustomEvent(PROJECT_SETTINGS_MODAL_EVENT, { detail })); +}; + +export const getProjectSettingsModalTabFromSearch = (search: string): TProjectSettingsModalTab | undefined => { + const value = new URLSearchParams(search).get(PROJECT_SETTINGS_MODAL_QUERY_KEY) as TProjectSettingsModalTab | null; + + return value && PROJECT_SETTINGS_MODAL_TABS.has(value) ? value : undefined; +}; + +export const getProjectSettingsModalProjectIdFromSearch = (search: string): string | undefined => { + const value = new URLSearchParams(search).get(PROJECT_SETTINGS_PROJECT_QUERY_KEY); + + return value || undefined; +}; + +export const getProjectSettingsModalTabFromPath = (path: string | undefined): TProjectSettingsModalTab => { + const normalizedPath = (path ?? "").replace(/^\/+|\/+$/g, ""); + if (!normalizedPath) return "general"; + + const matchedTab = PROJECT_SETTINGS_PATH_TO_TAB.find(([suffix]) => normalizedPath.endsWith(suffix)); + return matchedTab?.[1] ?? "general"; +}; + +export const buildProjectSettingsModalSearch = (projectId: string, tab: TProjectSettingsModalTab = "general") => { + const searchParams = new URLSearchParams(); + searchParams.set(PROJECT_SETTINGS_MODAL_QUERY_KEY, tab); + searchParams.set(PROJECT_SETTINGS_PROJECT_QUERY_KEY, projectId); + + return searchParams.toString(); +}; + +export const buildProjectSettingsModalUrl = ( + workspaceSlug: string, + projectId: string, + tab: TProjectSettingsModalTab = "general" +) => `/${workspaceSlug}/?${buildProjectSettingsModalSearch(projectId, tab)}`; + +export const setProjectSettingsModalSearch = ( + projectId: string, + tab: TProjectSettingsModalTab = "general", + replace = false +) => { + if (typeof window === "undefined") return; + + const url = new URL(window.location.href); + url.searchParams.set(PROJECT_SETTINGS_MODAL_QUERY_KEY, tab); + url.searchParams.set(PROJECT_SETTINGS_PROJECT_QUERY_KEY, projectId); + + window.history[replace ? "replaceState" : "pushState"](window.history.state, "", url); +}; + +export const clearProjectSettingsModalSearch = () => { + if (typeof window === "undefined") return; + + const url = new URL(window.location.href); + url.searchParams.delete(PROJECT_SETTINGS_MODAL_QUERY_KEY); + url.searchParams.delete(PROJECT_SETTINGS_PROJECT_QUERY_KEY); + + window.history.replaceState(window.history.state, "", url); +}; + +export const openProjectSettingsModal = ( + projectId: string, + tab: TProjectSettingsModalTab = "general", + replace = false +) => { + if (typeof window === "undefined") return; + + setProjectSettingsModalSearch(projectId, tab, replace); + dispatchProjectSettingsModalEvent({ isOpen: true, projectId, tab }); +}; + +export const closeProjectSettingsModal = () => { + if (typeof window === "undefined") return; + + clearProjectSettingsModalSearch(); + dispatchProjectSettingsModalEvent({ isOpen: false }); +}; diff --git a/plane-src/apps/web/core/components/settings/project/sidebar/header.tsx b/plane-src/apps/web/core/components/settings/project/sidebar/header.tsx index 07ec4b3..9204379 100644 --- a/plane-src/apps/web/core/components/settings/project/sidebar/header.tsx +++ b/plane-src/apps/web/core/components/settings/project/sidebar/header.tsx @@ -18,11 +18,12 @@ import { useProject } from "@/hooks/store/use-project"; import { useWorkspace } from "@/hooks/store/use-workspace"; type Props = { + onBack?: () => void; projectId: string; }; export const ProjectSettingsSidebarHeader = observer(function ProjectSettingsSidebarHeader(props: Props) { - const { projectId } = props; + const { onBack, projectId } = props; // router const router = useAppRouter(); // store hooks @@ -47,7 +48,14 @@ export const ProjectSettingsSidebarHeader = observer(function ProjectSettingsSid size="base" icon={ArrowLeft} className="nodedc-toolbar-icon-button" - onClick={() => router.push(`/${currentWorkspace?.slug}/projects/${projectId}/issues/`)} + onClick={() => { + if (onBack) { + onBack(); + return; + } + + router.push(`/${currentWorkspace?.slug}/projects/${projectId}/issues/`); + }} />

{t("project_settings_label")}

@@ -57,7 +65,9 @@ export const ProjectSettingsSidebarHeader = observer(function ProjectSettingsSid

{projectDetails?.name}

-

{t(ROLE_DETAILS[currentProjectRole].i18n_title)}

+

+ {t(ROLE_DETAILS[currentProjectRole].i18n_title)} +

diff --git a/plane-src/apps/web/core/components/workspace/sidebar/projects-list-item.tsx b/plane-src/apps/web/core/components/workspace/sidebar/projects-list-item.tsx index c295ec9..4e843b1 100644 --- a/plane-src/apps/web/core/components/workspace/sidebar/projects-list-item.tsx +++ b/plane-src/apps/web/core/components/workspace/sidebar/projects-list-item.tsx @@ -32,6 +32,7 @@ import { DEFAULT_TAB_KEY, getTabUrl } from "@/components/navigation/tab-navigati import { useTabPreferences } from "@/components/navigation/use-tab-preferences"; import { LeaveProjectModal } from "@/components/project/leave-project-modal"; import { PublishProjectModal } from "@/components/project/publish-project/modal"; +import { openProjectSettingsModal } from "@/components/project/settings/project-settings-modal.utils"; // hooks import { useAppTheme } from "@/hooks/store/use-app-theme"; import { useCommandPalette } from "@/hooks/store/use-command-palette"; @@ -175,7 +176,7 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem label: t("settings"), icon: , onClick: () => { - router.push(`/${workspaceSlug}/settings/projects/${project?.id}`); + if (project?.id) openProjectSettingsModal(project.id); }, }, !isAuthorized