Compare commits

...

11 Commits

Author SHA1 Message Date
gakshita
427d4888c4 fix: workspace settings internal screens 2025-04-29 18:48:28 +05:30
gakshita
b6b7baa9b8 fix: css + build 2025-04-29 12:55:34 +05:30
gakshita
57560c214d fix: handled no project 2025-04-25 15:55:24 +05:30
gakshita
c82e35ff0c routes 2025-04-25 14:43:54 +05:30
gakshita
b6ef8aa541 chore: project settings + refactoring 2025-04-24 19:11:55 +05:30
gakshita
3493939d9d feat: profile + workspace settings ui 2025-04-24 15:20:26 +05:30
gakshita
71e1de2054 feat: workspce settings + layouting 2025-04-23 20:15:51 +05:30
gakshita
767788bdee feat: workspace settings 2025-04-23 20:15:30 +05:30
gakshita
89a29f75ed fix: backend 2025-04-23 17:05:50 +05:30
sangeethailango
526d7da666 chore: remove unwanted fields 2025-04-23 15:24:56 +05:30
sangeethailango
12bd0b8879 chore: return workspace name and logo in profile settings api 2025-04-23 15:11:07 +05:30
79 changed files with 2330 additions and 414 deletions

View File

@@ -99,11 +99,16 @@ class UserMeSettingsSerializer(BaseSerializer):
workspace_member__member=obj.id,
workspace_member__is_active=True,
).first()
logo_asset_url = workspace.logo_asset.asset_url if workspace.logo_asset is not None else ""
return {
"last_workspace_id": profile.last_workspace_id,
"last_workspace_slug": (
workspace.slug if workspace is not None else ""
),
"last_workspace_name": (
workspace.name if workspace is not None else ""
),
"last_workspace_logo": (logo_asset_url),
"fallback_workspace_id": profile.last_workspace_id,
"fallback_workspace_slug": (
workspace.slug if workspace is not None else ""

View File

@@ -32,3 +32,4 @@ export * from "./dashboard";
export * from "./page";
export * from "./emoji";
export * from "./subscription";
export * from "./settings";

View File

@@ -1,39 +1,53 @@
export const PROFILE_SETTINGS = {
profile: {
key: "profile",
i18n_label: "profile.actions.profile",
href: `/settings/account`,
highlight: (pathname: string) => pathname === "/settings/account/",
},
security: {
key: "security",
i18n_label: "profile.actions.security",
href: `/settings/account/security`,
highlight: (pathname: string) => pathname === "/settings/account/security/",
},
activity: {
key: "activity",
i18n_label: "profile.actions.activity",
href: `/settings/account/activity`,
highlight: (pathname: string) => pathname === "/settings/account/activity/",
},
appearance: {
key: "appearance",
i18n_label: "profile.actions.appearance",
href: `/settings/account/appearance`,
highlight: (pathname: string) => pathname.includes("/settings/account/appearance"),
},
notifications: {
key: "notifications",
i18n_label: "profile.actions.notifications",
href: `/settings/account/notifications`,
highlight: (pathname: string) => pathname === "/settings/account/notifications/",
},
"api-tokens": {
key: "api-tokens",
i18n_label: "profile.actions.api-tokens",
href: `/settings/account/api-tokens`,
highlight: (pathname: string) => pathname === "/settings/account/api-tokens/",
},
};
export const PROFILE_ACTION_LINKS: {
key: string;
i18n_label: string;
href: string;
highlight: (pathname: string) => boolean;
}[] = [
{
key: "profile",
i18n_label: "profile.actions.profile",
href: `/profile`,
highlight: (pathname: string) => pathname === "/profile/",
},
{
key: "security",
i18n_label: "profile.actions.security",
href: `/profile/security`,
highlight: (pathname: string) => pathname === "/profile/security/",
},
{
key: "activity",
i18n_label: "profile.actions.activity",
href: `/profile/activity`,
highlight: (pathname: string) => pathname === "/profile/activity/",
},
{
key: "appearance",
i18n_label: "profile.actions.appearance",
href: `/profile/appearance`,
highlight: (pathname: string) => pathname.includes("/profile/appearance"),
},
{
key: "notifications",
i18n_label: "profile.actions.notifications",
href: `/profile/notifications`,
highlight: (pathname: string) => pathname === "/profile/notifications/",
},
PROFILE_SETTINGS["profile"],
PROFILE_SETTINGS["security"],
PROFILE_SETTINGS["activity"],
PROFILE_SETTINGS["appearance"],
PROFILE_SETTINGS["notifications"],
PROFILE_SETTINGS["api-tokens"],
];
export const PROFILE_VIEWER_TAB = [

View File

@@ -0,0 +1,52 @@
import { PROFILE_SETTINGS } from ".";
import { WORKSPACE_SETTINGS } from "./workspace";
export enum WORKSPACE_SETTINGS_CATEGORY {
ADMINISTRATION = "administration",
FEATURES = "features",
DEVELOPER = "developer",
}
export enum PROFILE_SETTINGS_CATEGORY {
YOUR_PROFILE = "your profile",
DEVELOPER = "developer",
}
export enum PROJECT_SETTINGS_CATEGORY {
PROJECTS = "projects",
}
export const WORKSPACE_SETTINGS_CATEGORIES = [
WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION,
WORKSPACE_SETTINGS_CATEGORY.FEATURES,
WORKSPACE_SETTINGS_CATEGORY.DEVELOPER,
];
export const PROFILE_SETTINGS_CATEGORIES = [
PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE,
PROFILE_SETTINGS_CATEGORY.DEVELOPER,
];
export const PROJECT_SETTINGS_CATEGORIES = [PROJECT_SETTINGS_CATEGORY.PROJECTS];
export const GROUPED_WORKSPACE_SETTINGS = {
[WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION]: [
WORKSPACE_SETTINGS["general"],
WORKSPACE_SETTINGS["members"],
WORKSPACE_SETTINGS["billing-and-plans"],
WORKSPACE_SETTINGS["export"],
],
[WORKSPACE_SETTINGS_CATEGORY.FEATURES]: [],
[WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"]],
};
export const GROUPED_PROFILE_SETTINGS = {
[PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE]: [
PROFILE_SETTINGS["profile"],
PROFILE_SETTINGS["security"],
PROFILE_SETTINGS["activity"],
PROFILE_SETTINGS["appearance"],
PROFILE_SETTINGS["notifications"],
],
[PROFILE_SETTINGS_CATEGORY.DEVELOPER]: [PROFILE_SETTINGS["api-tokens"]],
};

View File

@@ -114,13 +114,6 @@ export const WORKSPACE_SETTINGS = {
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`,
},
"api-tokens": {
key: "api-tokens",
i18n_label: "workspace_settings.settings.api_tokens.title",
href: `/settings/api-tokens`,
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens/`,
},
};
export const WORKSPACE_SETTINGS_ACCESS = Object.fromEntries(
@@ -139,7 +132,6 @@ export const WORKSPACE_SETTINGS_LINKS: {
WORKSPACE_SETTINGS["billing-and-plans"],
WORKSPACE_SETTINGS["export"],
WORKSPACE_SETTINGS["webhooks"],
WORKSPACE_SETTINGS["api-tokens"],
];
export const ROLE = {

View File

@@ -334,6 +334,8 @@
"new_password_must_be_different_from_old_password": "New password must be different from old password",
"edited": "edited",
"bot": "Bot",
"settings_description": "Manage your account, workspace, and project preferences all in one place. Switch between tabs to easily configure.",
"back_to_workspace": "Back to workspace",
"project_view": {
"sort_by": {
@@ -1352,6 +1354,8 @@
"exporting": "Exporting",
"previous_exports": "Previous exports",
"export_separate_files": "Export the data into separate files",
"exporting_projects": "Exporting project",
"format": "Format",
"modal": {
"title": "Export to",
"toasts": {
@@ -1418,29 +1422,29 @@
}
},
"api_tokens": {
"title": "API Tokens",
"add_token": "Add API token",
"title": "Personal Access Tokens",
"add_token": "Add personal access token",
"create_token": "Create token",
"never_expires": "Never expires",
"generate_token": "Generate token",
"generating": "Generating",
"delete": {
"title": "Delete API token",
"title": "Delete personal access token",
"description": "Any application using this token will no longer have the access to Plane data. This action cannot be undone.",
"success": {
"title": "Success!",
"message": "The API token has been successfully deleted"
"message": "The token has been successfully deleted"
},
"error": {
"title": "Error!",
"message": "The API token could not be deleted"
"message": "The token could not be deleted"
}
}
}
},
"empty_state": {
"api_tokens": {
"title": "No API tokens created",
"title": "No personal access tokens created",
"description": "Plane APIs can be used to integrate your data in Plane with any external system. Create a token to get started."
},
"webhooks": {
@@ -1492,7 +1496,8 @@
"security": "Security",
"activity": "Activity",
"appearance": "Appearance",
"notifications": "Notifications"
"notifications": "Notifications",
"api-tokens": "Personal Access Tokens"
},
"tabs": {
"summary": "Summary",

View File

@@ -76,6 +76,8 @@ export interface IUserSettings {
workspace: {
last_workspace_id: string | undefined;
last_workspace_slug: string | undefined;
last_workspace_name: string | undefined;
last_workspace_logo: string | undefined;
fallback_workspace_id: string | undefined;
fallback_workspace_slug: string | undefined;
invites: number | undefined;
@@ -155,14 +157,7 @@ export interface IUserProfileProjectSegregation {
id: string;
pending_issues: number;
}[];
user_data: Pick<
IUser,
| "avatar_url"
| "cover_image_url"
| "display_name"
| "first_name"
| "last_name"
> & {
user_data: Pick<IUser, "avatar_url" | "cover_image_url" | "display_name" | "first_name" | "last_name"> & {
date_joined: Date;
user_timezone: string;
};

View File

@@ -69,7 +69,7 @@ const ProjectCyclesPage = observer(() => {
primaryButton={{
text: t("disabled_project.empty_state.cycle.primary_button.text"),
onClick: () => {
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
router.push(`/${workspaceSlug}/settings/project/${projectId}/features`);
},
disabled: !hasAdminLevelPermission,
}}

View File

@@ -42,7 +42,7 @@ const ProjectInboxPage = observer(() => {
primaryButton={{
text: t("disabled_project.empty_state.inbox.primary_button.text"),
onClick: () => {
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
router.push(`/${workspaceSlug}/settings/project/${projectId}/features`);
},
disabled: !canPerformEmptyStateActions,
}}

View File

@@ -61,7 +61,7 @@ const ProjectModulesPage = observer(() => {
primaryButton={{
text: t("disabled_project.empty_state.module.primary_button.text"),
onClick: () => {
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
router.push(`/${workspaceSlug}/settings/project/${projectId}/features`);
},
disabled: !canPerformEmptyStateActions,
}}

View File

@@ -54,7 +54,7 @@ const ProjectPagesPage = observer(() => {
primaryButton={{
text: t("disabled_project.empty_state.page.primary_button.text"),
onClick: () => {
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
router.push(`/${workspaceSlug}/settings/project/${projectId}/features`);
},
disabled: !canPerformEmptyStateActions,
}}

View File

@@ -68,7 +68,7 @@ const ProjectViewsPage = observer(() => {
primaryButton={{
text: t("disabled_project.empty_state.view.primary_button.text"),
onClick: () => {
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
router.push(`/${workspaceSlug}/settings/project/${projectId}/features`);
},
disabled: !canPerformEmptyStateActions,
}}

View File

@@ -1,48 +0,0 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { WORKSPACE_SETTINGS_LINKS, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// components
import { SidebarNavItem } from "@/components/sidebar";
// hooks
import { useUserPermissions } from "@/hooks/store";
// plane web helpers
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
export const WorkspaceSettingsSidebar = observer(() => {
// router
const { workspaceSlug } = useParams();
const pathname = usePathname();
// mobx store
const { t } = useTranslation();
const { allowPermissions } = useUserPermissions();
return (
<div className="flex w-[280px] flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold text-custom-sidebar-text-400 uppercase">{t("settings")}</span>
<div className="flex w-full flex-col gap-1">
{WORKSPACE_SETTINGS_LINKS.map(
(link) =>
shouldRenderSettingLink(workspaceSlug.toString(), link.key) &&
allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && (
<Link key={link.key} href={`/${workspaceSlug}${link.href}`}>
<SidebarNavItem
key={link.key}
isActive={link.highlight(pathname, `/${workspaceSlug}`)}
className="text-sm font-medium px-4 py-2"
>
{t(link.i18n_label)}
</SidebarNavItem>
</Link>
)
)}
</div>
</div>
</div>
);
});

View File

@@ -1,37 +0,0 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
import { Settings } from "lucide-react";
// ui
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
// hooks
import { useWorkspace } from "@/hooks/store";
export const WorkspaceSettingHeader: FC = observer(() => {
const { currentWorkspace, loader } = useWorkspace();
const { t } = useTranslation();
return (
<Header>
<Header.LeftItem>
<Breadcrumbs isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${currentWorkspace?.slug}/settings`}
label={currentWorkspace?.name ?? "Workspace"}
icon={<Settings className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.BreadcrumbItem type="text" link={<BreadcrumbLink label={t("settings")} />} />
</Breadcrumbs>
</Header.LeftItem>
</Header>
);
});

View File

@@ -0,0 +1,21 @@
"use client";
import { ContentWrapper } from "@/components/core";
import { SettingsHeader } from "@/components/settings";
import { AuthenticationWrapper } from "@/lib/wrappers";
import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return (
<AuthenticationWrapper>
<WorkspaceAuthWrapper>
<main className="relative flex h-screen w-full flex-col overflow-hidden bg-custom-background-100">
{/* Header */}
<SettingsHeader />
{/* Content */}
<ContentWrapper className="px-12 py-page-y flex">{children}</ContentWrapper>
</main>
</WorkspaceAuthWrapper>
</AuthenticationWrapper>
);
}

View File

@@ -3,56 +3,55 @@
import { FC, ReactNode } from "react";
import { observer } from "mobx-react";
// components
import { useParams, usePathname } from "next/navigation";
import { usePathname } from "next/navigation";
import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_ACCESS } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens";
import { AppHeader } from "@/components/core";
// hooks
import { NotAuthorizedView } from "@/components/auth-screens";
import { CommandPalette } from "@/components/command-palette";
import { SettingsContentWrapper } from "@/components/settings";
import { useUserPermissions } from "@/hooks/store";
// plane web constants
// local components
import { WorkspaceSettingHeader } from "../header";
import { MobileWorkspaceSettingsTabs } from "./mobile-header-tabs";
import { WorkspaceSettingsSidebar } from "./sidebar";
export interface IWorkspaceSettingLayout {
children: ReactNode;
}
const pathnameToAccessKey = (pathname: string) => {
const pathArray = pathname.replace(/^\/|\/$/g, "").split("/"); // Regex removes leading and trailing slashes
const workspaceSlug = pathArray[0];
const accessKey = pathArray.slice(1, 3).join("/");
return { workspaceSlug, accessKey: `/${accessKey}` || "" };
};
const WorkspaceSettingLayout: FC<IWorkspaceSettingLayout> = observer((props) => {
const { children } = props;
// store hooks
const { workspaceUserInfo } = useUserPermissions();
// next hooks
const pathname = usePathname();
const [workspaceSlug, suffix, route] = pathname.replace(/^\/|\/$/g, "").split("/"); // Regex removes leading and trailing slashes
// derived values
const { workspaceSlug, accessKey } = pathnameToAccessKey(pathname);
const userWorkspaceRole = workspaceUserInfo?.[workspaceSlug.toString()]?.role;
const isAuthorized =
pathname &&
workspaceSlug &&
userWorkspaceRole &&
WORKSPACE_SETTINGS_ACCESS[route ? `/${suffix}/${route}` : `/${suffix}`]?.includes(
userWorkspaceRole as EUserWorkspaceRoles
);
WORKSPACE_SETTINGS_ACCESS[accessKey]?.includes(userWorkspaceRole as EUserWorkspaceRoles);
return (
<>
<AppHeader header={<WorkspaceSettingHeader />} />
<MobileWorkspaceSettingsTabs />
<CommandPalette />
<div className="inset-y-0 flex flex-row vertical-scrollbar scrollbar-lg h-full w-full overflow-y-auto">
{workspaceUserInfo && !isAuthorized ? (
<NotAuthorizedView section="settings" />
) : (
<>
<div className="px-page-x !pr-0 py-page-y flex-shrink-0 overflow-y-hidden sm:hidden hidden md:block lg:block">
<WorkspaceSettingsSidebar />
</div>
<div className="flex flex-col relative w-full overflow-hidden">
<div className="w-full h-full overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-page-x md:px-9 py-page-y">
{children}
</div>
<div className="flex-shrink-0 overflow-y-hidden sm:hidden hidden md:block lg:block">
<WorkspaceSettingsSidebar workspaceSlug={workspaceSlug} pathname={pathname} />
</div>
<SettingsContentWrapper>{children}</SettingsContentWrapper>
</>
)}
</div>

View File

@@ -0,0 +1,63 @@
import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react";
import {
EUserPermissionsLevel,
GROUPED_WORKSPACE_SETTINGS,
WORKSPACE_SETTINGS_CATEGORIES,
EUserWorkspaceRoles,
} from "@plane/constants";
import { SettingsSidebar } from "@/components/settings";
import { useUserPermissions } from "@/hooks/store/user";
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
type TWorkspaceSettingsSidebarProps = {
workspaceSlug: string;
pathname: string;
};
export const WorkspaceActionIcons = ({
type,
size,
className,
}: {
type: string;
size?: number;
className?: string;
}) => {
const icons = {
general: Building,
members: Users,
export: ArrowUpToLine,
"billing-and-plans": CreditCard,
webhooks: Webhook,
};
if (type === undefined) return null;
const Icon = icons[type as keyof typeof icons];
if (!Icon) return null;
return <Icon size={size} className={className} strokeWidth={2} />;
};
export const WorkspaceSettingsSidebar = (props: TWorkspaceSettingsSidebarProps) => {
const { workspaceSlug, pathname } = props;
// store hooks
const { allowPermissions } = useUserPermissions();
return (
<SettingsSidebar
categories={WORKSPACE_SETTINGS_CATEGORIES}
groupedSettings={GROUPED_WORKSPACE_SETTINGS}
workspaceSlug={workspaceSlug.toString()}
isActive={(data: { href: string }) =>
data.href === "/settings"
? pathname === `/${workspaceSlug}${data.href}/`
: new RegExp(`^/${workspaceSlug}${data.href}/`).test(pathname)
}
shouldRender={(data: { key: string; access?: EUserWorkspaceRoles[] | undefined }) =>
data.access
? shouldRenderSettingLink(workspaceSlug.toString(), data.key) &&
allowPermissions(data.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())
: false
}
actionIcons={WorkspaceActionIcons}
/>
);
};

View File

@@ -0,0 +1,77 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// ui
import { Button } from "@plane/ui";
// components
import { PageHead } from "@/components/core";
import { DetailedEmptyState } from "@/components/empty-state";
import { ProfileActivityListPage, ProfileSettingContentHeader } from "@/components/profile";
// hooks
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
const PER_PAGE = 100;
const ProfileActivityPage = observer(() => {
// states
const [pageCount, setPageCount] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [resultsCount, setResultsCount] = useState(0);
const [isEmpty, setIsEmpty] = useState(false);
// plane hooks
const { t } = useTranslation();
// derived values
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/profile/activity" });
const updateTotalPages = (count: number) => setTotalPages(count);
const updateResultsCount = (count: number) => setResultsCount(count);
const updateEmptyState = (isEmpty: boolean) => setIsEmpty(isEmpty);
const handleLoadMore = () => setPageCount((prev) => prev + 1);
const activityPages: JSX.Element[] = [];
for (let i = 0; i < pageCount; i++)
activityPages.push(
<ProfileActivityListPage
key={i}
cursor={`${PER_PAGE}:${i}:0`}
perPage={PER_PAGE}
updateResultsCount={updateResultsCount}
updateTotalPages={updateTotalPages}
updateEmptyState={updateEmptyState}
/>
);
const isLoadMoreVisible = pageCount < totalPages && resultsCount !== 0;
if (isEmpty) {
return (
<DetailedEmptyState
title={t("profile.empty_state.activity.title")}
description={t("profile.empty_state.activity.description")}
assetPath={resolvedPath}
/>
);
}
return (
<>
<PageHead title="Profile - Activity" />
<ProfileSettingContentHeader title={t("activity")} />
<div className="w-full">{activityPages}</div>
{isLoadMoreVisible && (
<div className="flex w-full items-center justify-center text-xs">
<Button variant="accent-primary" size="sm" onClick={handleLoadMore}>
{t("load_more")}
</Button>
</div>
)}
</>
);
});
export default ProfileActivityPage;

View File

@@ -0,0 +1,88 @@
"use client";
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useTheme } from "next-themes";
// plane imports
import { I_THEME_OPTION, THEME_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IUserTheme } from "@plane/types";
import { setPromiseToast } from "@plane/ui";
// components
import { LogoSpinner } from "@/components/common";
import { CustomThemeSelector, ThemeSwitch, PageHead } from "@/components/core";
import { ProfileSettingContentHeader } from "@/components/profile";
// constants
// helpers
import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper";
// hooks
import { useUserProfile } from "@/hooks/store";
const ProfileAppearancePage = observer(() => {
const { t } = useTranslation();
const { setTheme } = useTheme();
// states
const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null);
// hooks
const { data: userProfile, updateUserTheme } = useUserProfile();
useEffect(() => {
if (userProfile?.theme?.theme) {
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
if (userThemeOption) {
setCurrentTheme(userThemeOption);
}
}
}, [userProfile?.theme?.theme]);
const handleThemeChange = (themeOption: I_THEME_OPTION) => {
applyThemeChange({ theme: themeOption.value });
const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value });
setPromiseToast(updateCurrentUserThemePromise, {
loading: "Updating theme...",
success: {
title: "Success!",
message: () => "Theme updated successfully!",
},
error: {
title: "Error!",
message: () => "Failed to Update the theme",
},
});
};
const applyThemeChange = (theme: Partial<IUserTheme>) => {
setTheme(theme?.theme || "system");
if (theme?.theme === "custom" && theme?.palette) {
applyTheme(theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", false);
} else unsetCustomCssVariables();
};
return (
<>
<PageHead title="Profile - Appearance" />
{userProfile ? (
<>
<ProfileSettingContentHeader title={t("appearance")} />
<div className="gap-4 py-6 sm:gap-16 w-full">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-custom-text-100">{t("theme")}</h4>
<p className="text-sm text-custom-text-200">{t("select_or_customize_your_interface_color_scheme")}</p>
</div>
<div className="col-span-12 sm:col-span-6">
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />
</div>
</div>
{userProfile?.theme?.theme === "custom" && <CustomThemeSelector applyThemeChange={applyThemeChange} />}
</>
) : (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
<LogoSpinner />
</div>
)}
</>
);
});
export default ProfileAppearancePage;

View File

@@ -0,0 +1,29 @@
"use client";
import { ReactNode } from "react";
import { useParams, usePathname } from "next/navigation";
// components
import { CommandPalette } from "@/components/command-palette";
import { SettingsContentWrapper } from "@/components/settings";
import { ProfileSidebar } from "./sidebar";
type Props = {
children: ReactNode;
};
export default function ProfileSettingsLayout(props: Props) {
const { children } = props;
// router
const pathname = usePathname();
const { workspaceSlug } = useParams();
return (
<>
<CommandPalette />
<div className="relative flex h-full w-full">
<ProfileSidebar workspaceSlug={workspaceSlug.toString()} pathname={pathname} />
<SettingsContentWrapper>{children}</SettingsContentWrapper>
</div>
</>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import useSWR from "swr";
// components
import { useTranslation } from "@plane/i18n";
import { PageHead } from "@/components/core";
import { ProfileSettingContentHeader } from "@/components/profile";
import { EmailNotificationForm } from "@/components/profile/notification";
import { EmailSettingsLoader } from "@/components/ui";
// services
import { UserService } from "@/services/user.service";
const userService = new UserService();
export default function ProfileNotificationPage() {
const { t } = useTranslation();
// fetching user email notification settings
const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () =>
userService.currentUserEmailNotificationSettings()
);
if (!data || isLoading) {
return <EmailSettingsLoader />;
}
return (
<>
<PageHead title={`${t("profile.label")} - ${t("notifications")}`} />
<ProfileSettingContentHeader
title={t("email_notifications")}
description={t("stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified")}
/>
<EmailNotificationForm data={data} />
</>
);
}

View File

@@ -0,0 +1,32 @@
"use client";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// components
import { LogoSpinner } from "@/components/common";
import { PageHead } from "@/components/core";
import { ProfileForm } from "@/components/profile";
// hooks
import { useUser } from "@/hooks/store";
const ProfileSettingsPage = observer(() => {
const { t } = useTranslation();
// store hooks
const { data: currentUser, userProfile } = useUser();
if (!currentUser)
return (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
<LogoSpinner />
</div>
);
return (
<>
<PageHead title={`${t("profile.label")} - ${t("general_settings")}`} />
<ProfileForm user={currentUser} profile={userProfile.data} />
</>
);
});
export default ProfileSettingsPage;

View File

@@ -0,0 +1,251 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { Eye, EyeOff } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// ui
import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { PasswordStrengthMeter } from "@/components/account";
import { PageHead } from "@/components/core";
import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile";
// helpers
import { authErrorHandler } from "@/helpers/authentication.helper";
import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper";
// hooks
import { useUser } from "@/hooks/store";
// services
import { AuthService } from "@/services/auth.service";
export interface FormValues {
old_password: string;
new_password: string;
confirm_password: string;
}
const defaultValues: FormValues = {
old_password: "",
new_password: "",
confirm_password: "",
};
const authService = new AuthService();
const defaultShowPassword = {
oldPassword: false,
password: false,
confirmPassword: false,
};
const SecurityPage = observer(() => {
// store
const { data: currentUser, changePassword } = useUser();
// states
const [showPassword, setShowPassword] = useState(defaultShowPassword);
const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false);
const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false);
// use form
const {
control,
handleSubmit,
watch,
formState: { errors, isSubmitting },
reset,
} = useForm<FormValues>({ defaultValues });
// derived values
const oldPassword = watch("old_password");
const password = watch("new_password");
const confirmPassword = watch("confirm_password");
const oldPasswordRequired = !currentUser?.is_password_autoset;
// i18n
const { t } = useTranslation();
const isNewPasswordSameAsOldPassword = oldPassword !== "" && password !== "" && password === oldPassword;
const handleShowPassword = (key: keyof typeof showPassword) =>
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
const handleChangePassword = async (formData: FormValues) => {
const { old_password, new_password } = formData;
try {
const csrfToken = await authService.requestCSRFToken().then((data) => data?.csrf_token);
if (!csrfToken) throw new Error("csrf token not found");
await changePassword(csrfToken, {
...(oldPasswordRequired && { old_password }),
new_password,
});
reset(defaultValues);
setShowPassword(defaultShowPassword);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("auth.common.password.toast.change_password.success.title"),
message: t("auth.common.password.toast.change_password.success.message"),
});
} catch (err: any) {
const errorInfo = authErrorHandler(err.error_code?.toString());
setToast({
type: TOAST_TYPE.ERROR,
title: errorInfo?.title ?? t("auth.common.password.toast.error.title"),
message:
typeof errorInfo?.message === "string" ? errorInfo.message : t("auth.common.password.toast.error.message"),
});
}
};
const isButtonDisabled =
getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID ||
(oldPasswordRequired && oldPassword.trim() === "") ||
password.trim() === "" ||
confirmPassword.trim() === "" ||
password !== confirmPassword ||
password === oldPassword;
const passwordSupport = password.length > 0 &&
getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && (
<PasswordStrengthMeter password={password} isFocused={isPasswordInputFocused} />
);
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
return (
<>
<PageHead title="Profile - Security" />
<ProfileSettingContentHeader title={t("auth.common.password.change_password.label.default")} />
<form onSubmit={handleSubmit(handleChangePassword)} className="flex flex-col gap-8 w-full mt-8">
<div className="flex flex-col gap-10 w-full">
{oldPasswordRequired && (
<div className="space-y-1">
<h4 className="text-sm">{t("auth.common.password.current_password.label")}</h4>
<div className="relative flex items-center rounded-md">
<Controller
control={control}
name="old_password"
rules={{
required: t("common.errors.required"),
}}
render={({ field: { value, onChange } }) => (
<Input
id="old_password"
type={showPassword?.oldPassword ? "text" : "password"}
value={value}
onChange={onChange}
placeholder={t("old_password")}
className="w-full"
hasError={Boolean(errors.old_password)}
/>
)}
/>
{showPassword?.oldPassword ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword("oldPassword")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword("oldPassword")}
/>
)}
</div>
{errors.old_password && <span className="text-xs text-red-500">{errors.old_password.message}</span>}
</div>
)}
<div className="space-y-1">
<h4 className="text-sm">{t("auth.common.password.new_password.label")}</h4>
<div className="relative flex items-center rounded-md">
<Controller
control={control}
name="new_password"
rules={{
required: t("common.errors.required"),
}}
render={({ field: { value, onChange } }) => (
<Input
id="new_password"
type={showPassword?.password ? "text" : "password"}
value={value}
placeholder={t("auth.common.password.new_password.placeholder")}
onChange={onChange}
className="w-full"
hasError={Boolean(errors.new_password)}
onFocus={() => setIsPasswordInputFocused(true)}
onBlur={() => setIsPasswordInputFocused(false)}
/>
)}
/>
{showPassword?.password ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword("password")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword("password")}
/>
)}
</div>
{passwordSupport}
{isNewPasswordSameAsOldPassword && !isPasswordInputFocused && (
<span className="text-xs text-red-500">{t("new_password_must_be_different_from_old_password")}</span>
)}
</div>
<div className="space-y-1">
<h4 className="text-sm">{t("auth.common.password.confirm_password.label")}</h4>
<div className="relative flex items-center rounded-md">
<Controller
control={control}
name="confirm_password"
rules={{
required: t("common.errors.required"),
}}
render={({ field: { value, onChange } }) => (
<Input
id="confirm_password"
type={showPassword?.confirmPassword ? "text" : "password"}
placeholder={t("auth.common.password.confirm_password.placeholder")}
value={value}
onChange={onChange}
className="w-full"
hasError={Boolean(errors.confirm_password)}
onFocus={() => setIsRetryPasswordInputFocused(true)}
onBlur={() => setIsRetryPasswordInputFocused(false)}
/>
)}
/>
{showPassword?.confirmPassword ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword("confirmPassword")}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => handleShowPassword("confirmPassword")}
/>
)}
</div>
{!!confirmPassword && password !== confirmPassword && renderPasswordMatchError && (
<span className="text-sm text-red-500">{t("auth.common.password.errors.match")}</span>
)}
</div>
</div>
<div className="flex items-center justify-between py-2">
<Button variant="primary" type="submit" loading={isSubmitting} disabled={isButtonDisabled}>
{isSubmitting
? `${t("auth.common.password.change_password.label.submitting")}`
: t("auth.common.password.change_password.label.default")}
</Button>
</div>
</form>
</>
);
});
export default SecurityPage;

View File

@@ -0,0 +1,65 @@
import { CircleUser, Activity, Bell, CircleUserRound, KeyRound, Settings2, Blocks } from "lucide-react";
import { GROUPED_PROFILE_SETTINGS, PROFILE_SETTINGS_CATEGORIES } from "@plane/constants";
import { SettingsSidebar } from "@/components/settings";
import { getFileURL } from "@/helpers/file.helper";
import { useUser } from "@/hooks/store/user";
type TProfileSidebarProps = {
workspaceSlug: string;
pathname: string;
};
export const ProjectActionIcons = ({ type, size, className }: { type: string; size?: number; className?: string }) => {
const icons = {
profile: CircleUser,
security: KeyRound,
activity: Activity,
appearance: Settings2,
notifications: Bell,
"api-tokens": Blocks,
};
if (type === undefined) return null;
const Icon = icons[type as keyof typeof icons];
if (!Icon) return null;
return <Icon size={size} className={className} strokeWidth={2} />;
};
export const ProfileSidebar = (props: TProfileSidebarProps) => {
const { workspaceSlug, pathname } = props;
// store hooks
const { data: currentUser } = useUser();
return (
<SettingsSidebar
categories={PROFILE_SETTINGS_CATEGORIES}
groupedSettings={GROUPED_PROFILE_SETTINGS}
workspaceSlug={workspaceSlug.toString()}
isActive={(data: { href: string }) => pathname === `/${workspaceSlug}${data.href}/`}
customHeader={
<div className="flex items-center gap-2">
<div className="flex-shrink-0">
{!currentUser?.avatar_url || currentUser?.avatar_url === "" ? (
<div className="h-8 w-8 rounded-full">
<CircleUserRound className="h-full w-full text-custom-text-200" />
</div>
) : (
<div className="relative h-8 w-8 overflow-hidden">
<img
src={getFileURL(currentUser?.avatar_url)}
className="absolute left-0 top-0 h-full w-full rounded-lg object-cover"
alt={currentUser?.display_name}
/>
</div>
)}
</div>
<div className="w-full overflow-hidden">
<div className="text-base font-medium text-custom-text-200 truncate">{currentUser?.display_name}</div>
<div className="text-sm text-custom-text-300 truncate">{currentUser?.email}</div>
</div>
</div>
}
actionIcons={ProjectActionIcons}
shouldRender
/>
);
};

View File

@@ -0,0 +1,63 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IProject } from "@plane/types";
// ui
import { TOAST_TYPE, setToast } from "@plane/ui";
// components
import { NotAuthorizedView } from "@/components/auth-screens";
import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation";
import { PageHead } from "@/components/core";
// hooks
import { useProject, useUserPermissions } from "@/hooks/store";
const AutomationSettingsPage = observer(() => {
// router
const { workspaceSlug, projectId } = useParams();
// store hooks
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { currentProjectDetails: projectDetails, updateProject } = useProject();
const { t } = useTranslation();
// derived values
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
const handleChange = async (formData: Partial<IProject>) => {
if (!workspaceSlug || !projectId || !projectDetails) return;
await updateProject(workspaceSlug.toString(), projectId.toString(), formData).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong. Please try again.",
});
});
};
// derived values
const pageTitle = projectDetails?.name ? `${projectDetails?.name} - Automations` : undefined;
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView />;
}
return (
<>
<PageHead title={pageTitle} />
<section className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
<div className="flex flex-col items-start border-b border-custom-border-100 pb-3.5">
<h3 className="text-xl font-medium leading-normal">{t("project_settings.automations.label")}</h3>
</div>
<AutoArchiveAutomation handleChange={handleChange} />
<AutoCloseAutomation handleChange={handleChange} />
</section>
</>
);
});
export default AutomationSettingsPage;

View File

@@ -0,0 +1,45 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { EstimateRoot } from "@/components/estimates";
// hooks
import { useProject, useUserPermissions } from "@/hooks/store";
const EstimatesSettingsPage = observer(() => {
const { workspaceSlug, projectId } = useParams();
// store
const { currentProjectDetails } = useProject();
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
// derived values
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Estimates` : undefined;
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
if (!workspaceSlug || !projectId) return <></>;
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView />;
}
return (
<>
<PageHead title={pageTitle} />
<div
className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "pointer-events-none opacity-60"}`}
>
<EstimateRoot
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
isAdmin={canPerformProjectAdminActions}
/>
</div>
</>
);
});
export default EstimatesSettingsPage;

View File

@@ -0,0 +1,43 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { ProjectFeaturesList } from "@/components/project";
// hooks
import { useProject, useUserPermissions } from "@/hooks/store";
const FeaturesSettingsPage = observer(() => {
const { workspaceSlug, projectId } = useParams();
// store
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { currentProjectDetails } = useProject();
// derived values
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Features` : undefined;
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
if (!workspaceSlug || !projectId) return null;
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView />;
}
return (
<>
<PageHead title={pageTitle} />
<section className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
<ProjectFeaturesList
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
isAdmin={canPerformProjectAdminActions}
/>
</section>
</>
);
});
export default FeaturesSettingsPage;

View File

@@ -0,0 +1,57 @@
"use client";
import { useEffect, useRef } 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";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { ProjectSettingsLabelList } from "@/components/labels";
// hooks
import { useProject, useUserPermissions } from "@/hooks/store";
const LabelsSettingsPage = observer(() => {
// store hooks
const { currentProjectDetails } = useProject();
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Labels` : undefined;
const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
// derived values
const canPerformProjectMemberActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
// Enable Auto Scroll for Labels list
useEffect(() => {
const element = scrollableContainerRef.current;
if (!element) return;
return combine(
autoScrollForElements({
element,
})
);
}, [scrollableContainerRef?.current]);
if (workspaceUserInfo && !canPerformProjectMemberActions) {
return <NotAuthorizedView section="settings" isProjectView />;
}
return (
<>
<PageHead title={pageTitle} />
<div ref={scrollableContainerRef} className="h-full w-full gap-10 overflow-y-auto">
<ProjectSettingsLabelList />
</div>
</>
);
});
export default LabelsSettingsPage;

View File

@@ -0,0 +1,40 @@
"use client";
import { observer } from "mobx-react";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { ProjectMemberList, ProjectSettingsMemberDefaults } from "@/components/project";
// hooks
import { useProject, useUserPermissions } from "@/hooks/store";
const MembersSettingsPage = observer(() => {
// store
const { currentProjectDetails } = useProject();
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
// derived values
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined;
const isProjectMemberOrAdmin = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const canPerformProjectMemberActions = isProjectMemberOrAdmin || isWorkspaceAdmin;
if (workspaceUserInfo && !canPerformProjectMemberActions) {
return <NotAuthorizedView section="settings" isProjectView />;
}
return (
<>
<PageHead title={pageTitle} />
<section className={`w-full overflow-y-auto`}>
<ProjectSettingsMemberDefaults />
<ProjectMemberList />
</section>
</>
);
});
export default MembersSettingsPage;

View File

@@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { PageHead } from "@/components/core";
import {
ArchiveRestoreProjectModal,
ArchiveProjectSelection,
DeleteProjectModal,
DeleteProjectSection,
ProjectDetailsForm,
ProjectDetailsFormLoader,
} from "@/components/project";
// hooks
import { useProject, useUserPermissions } from "@/hooks/store";
const ProjectSettingsPage = observer(() => {
// states
const [selectProject, setSelectedProject] = useState<string | null>(null);
const [archiveProject, setArchiveProject] = useState<boolean>(false);
// router
const { workspaceSlug, projectId } = useParams();
// store hooks
const { currentProjectDetails, fetchProjectDetails } = useProject();
const { allowPermissions } = useUserPermissions();
// api call to fetch project details
// TODO: removed this API if not necessary
const { isLoading } = useSWR(
workspaceSlug && projectId ? `PROJECT_DETAILS_${projectId}` : null,
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug.toString(), projectId.toString()) : null
);
// derived values
const isAdmin = allowPermissions(
[EUserPermissions.ADMIN],
EUserPermissionsLevel.PROJECT,
workspaceSlug.toString(),
projectId.toString()
);
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - General Settings` : undefined;
return (
<>
<PageHead title={pageTitle} />
{currentProjectDetails && workspaceSlug && projectId && (
<>
<ArchiveRestoreProjectModal
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
isOpen={archiveProject}
onClose={() => setArchiveProject(false)}
archive
/>
<DeleteProjectModal
project={currentProjectDetails}
isOpen={Boolean(selectProject)}
onClose={() => setSelectedProject(null)}
/>
</>
)}
<div className={`w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>
{currentProjectDetails && workspaceSlug && projectId && !isLoading ? (
<ProjectDetailsForm
project={currentProjectDetails}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
isAdmin={isAdmin}
/>
) : (
<ProjectDetailsFormLoader />
)}
{isAdmin && currentProjectDetails && (
<>
<ArchiveProjectSelection
projectDetails={currentProjectDetails}
handleArchive={() => setArchiveProject(true)}
/>
<DeleteProjectSection
projectDetails={currentProjectDetails}
handleDelete={() => setSelectedProject(currentProjectDetails.id ?? null)}
/>
</>
)}
</div>
</>
);
});
export default ProjectSettingsPage;

View File

@@ -0,0 +1,73 @@
"use client";
import React from "react";
import range from "lodash/range";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { Loader } from "@plane/ui";
// components
import { SidebarNavItem } from "@/components/sidebar";
// hooks
import { useUserPermissions } from "@/hooks/store";
// plane web constants
import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project";
export const ProjectSettingsSidebar = observer(() => {
const { workspaceSlug, projectId } = useParams();
const pathname = usePathname();
// mobx store
const { allowPermissions, projectUserInfo } = useUserPermissions();
const { t } = useTranslation();
// derived values
const currentProjectRole = projectUserInfo?.[workspaceSlug?.toString()]?.[projectId?.toString()]?.role;
if (!currentProjectRole) {
return (
<div className="flex w-[280px] flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold text-custom-sidebar-text-400">SETTINGS</span>
<Loader className="flex w-full flex-col gap-2">
{range(8).map((index) => (
<Loader.Item key={index} height="34px" />
))}
</Loader>
</div>
</div>
);
}
return (
<div className="flex w-[280px] flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold text-custom-sidebar-text-400">SETTINGS</span>
<div className="flex w-full flex-col gap-1">
{PROJECT_SETTINGS_LINKS.map(
(link) =>
allowPermissions(
link.access,
EUserPermissionsLevel.PROJECT,
workspaceSlug.toString(),
projectId.toString()
) && (
<Link key={link.key} href={`/${workspaceSlug}/projects/${projectId}${link.href}`}>
<SidebarNavItem
key={link.key}
isActive={link.highlight(pathname, `/${workspaceSlug}/projects/${projectId}`)}
className="text-sm font-medium px-4 py-2"
>
{t(link.i18n_label)}
</SidebarNavItem>
</Link>
)
)}
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,49 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// components
import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { ProjectStateRoot } from "@/components/project-states";
// hook
import { useProject, useUserPermissions } from "@/hooks/store";
const StatesSettingsPage = observer(() => {
const { workspaceSlug, projectId } = useParams();
// store
const { currentProjectDetails } = useProject();
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { t } = useTranslation();
// derived values
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - States` : undefined;
// derived values
const canPerformProjectMemberActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
if (workspaceUserInfo && !canPerformProjectMemberActions) {
return <NotAuthorizedView section="settings" isProjectView />;
}
return (
<>
<PageHead title={pageTitle} />
<div className="w-full">
<div className="flex items-center border-b border-custom-border-100">
<h3 className="text-xl font-medium">{t("common.states")}</h3>
</div>
{workspaceSlug && projectId && (
<ProjectStateRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
)}
</div>
</>
);
});
export default StatesSettingsPage;

View File

@@ -0,0 +1,46 @@
"use client";
import { ReactNode, useEffect } from "react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
// components
import { CommandPalette } from "@/components/command-palette";
import { SettingsContentWrapper } from "@/components/settings";
import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar";
import { useProject } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
type Props = {
children: ReactNode;
};
const ProjectSettingsLayout = observer((props: Props) => {
const { children } = props;
// router
const router = useAppRouter();
const pathname = usePathname();
const { workspaceSlug, projectId } = useParams();
const { joinedProjectIds } = useProject();
useEffect(() => {
if (projectId) return;
if (joinedProjectIds.length > 0) {
router.push(`/${workspaceSlug}/settings/project/${joinedProjectIds[0]}`);
}
}, [joinedProjectIds, router, workspaceSlug, projectId]);
return (
<>
<CommandPalette />
<ProjectAuthWrapper workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()}>
<div className="relative flex h-full w-full">
{projectId && <ProjectSettingsSidebar workspaceSlug={workspaceSlug.toString()} pathname={pathname} />}
<SettingsContentWrapper>{children}</SettingsContentWrapper>
</div>
</ProjectAuthWrapper>
</>
);
});
export default ProjectSettingsLayout;

View File

@@ -0,0 +1,38 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useTheme } from "next-themes";
import { Button, getButtonStyling } from "@plane/ui";
import { cn } from "@plane/utils";
import { useCommandPalette } from "@/hooks/store";
const ProjectSettingsPage = () => {
// store hooks
const { resolvedTheme } = useTheme();
const { toggleCreateProjectModal } = useCommandPalette();
// derived values
const resolvedPath =
resolvedTheme === "dark"
? "/empty-state/project-settings/no-projects-dark.png"
: "/empty-state/project-settings/no-projects-light.png";
return (
<div className="flex flex-col gap-4 items-center justify-center h-full max-w-[480px] mx-auto">
<Image src={resolvedPath} alt="No projects yet" width={384} height={250} />
<div className="text-lg font-semibold text-custom-text-350">No projects yet</div>
<div className="text-sm text-custom-text-350 text-center">
Projects act as the foundation for goal-driven work. They let you manage your teams, tasks, and everything you
need to get things done.
</div>
<div className="flex gap-2">
<Link href="https://plane.so/" target="_blank" className={cn(getButtonStyling("neutral-primary", "sm"))}>
Learn more about projects
</Link>
<Button size="sm" onClick={() => toggleCreateProjectModal(true)}>
Start your first project
</Button>
</div>
</div>
);
};
export default ProjectSettingsPage;

View File

@@ -44,7 +44,7 @@ export const DeleteWorkspaceSection: FC<TDeleteWorkspace> = observer((props) =>
}
>
<div className="flex flex-col gap-4">
<span className="text-base tracking-tight">
<span className="text-base tracking-tight w-[800px]">
{t("workspace_settings.settings.general.delete_workspace_description")}
</span>
<div>

View File

@@ -4,26 +4,33 @@ import packageJson from "package.json";
// ui
import { Button, Tooltip } from "@plane/ui";
// hooks
import { cn } from "@plane/utils";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local components
import { PaidPlanUpgradeModal } from "../license";
export const WorkspaceEditionBadge = observer(() => {
export const WorkspaceEditionBadge = observer((props: { className?: string; isEditable?: boolean }) => {
const { className, isEditable = true } = props;
const { isMobile } = usePlatformOS();
// states
const [isPaidPlanPurchaseModalOpen, setIsPaidPlanPurchaseModalOpen] = useState(false);
return (
<>
<PaidPlanUpgradeModal
isOpen={isPaidPlanPurchaseModalOpen}
handleClose={() => setIsPaidPlanPurchaseModalOpen(false)}
/>
{isEditable && (
<PaidPlanUpgradeModal
isOpen={isPaidPlanPurchaseModalOpen}
handleClose={() => setIsPaidPlanPurchaseModalOpen(false)}
/>
)}
<Tooltip tooltipContent={`Version: v${packageJson.version}`} isMobile={isMobile}>
<Button
tabIndex={-1}
variant="accent-primary"
className="w-fit min-w-24 cursor-pointer rounded-2xl px-2 py-1 text-center text-sm font-medium outline-none"
className={cn(
"w-fit min-w-24 cursor-pointer rounded-2xl px-2 py-1 text-center text-sm font-medium outline-none",
className
)}
onClick={() => setIsPaidPlanPurchaseModalOpen(true)}
>
Community

View File

@@ -9,57 +9,57 @@ export const PROJECT_SETTINGS = {
general: {
key: "general",
i18n_label: "common.general",
href: `/settings`,
href: ``,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/`,
Icon: SettingIcon,
},
members: {
key: "members",
i18n_label: "members",
href: `/settings/members`,
href: `/members`,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/members/`,
Icon: SettingIcon,
},
features: {
key: "features",
i18n_label: "common.features",
href: `/settings/features`,
href: `/features`,
access: [EUserPermissions.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/features/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/features/`,
Icon: SettingIcon,
},
states: {
key: "states",
i18n_label: "common.states",
href: `/settings/states`,
href: `/states`,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/states/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/states/`,
Icon: SettingIcon,
},
labels: {
key: "labels",
i18n_label: "common.labels",
href: `/settings/labels`,
href: `/labels`,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/labels/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/labels/`,
Icon: SettingIcon,
},
estimates: {
key: "estimates",
i18n_label: "common.estimates",
href: `/settings/estimates`,
href: `/estimates`,
access: [EUserPermissions.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/estimates/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/estimates/`,
Icon: SettingIcon,
},
automations: {
key: "automations",
i18n_label: "project_settings.automations.label",
href: `/settings/automations`,
href: `/automations`,
access: [EUserPermissions.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/automations/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/automations/`,
Icon: SettingIcon,
},
};

View File

@@ -0,0 +1,112 @@
import { Download } from "lucide-react";
import { IExportData } from "@plane/types";
import { getDate, getFileURL, renderFormattedDate } from "@plane/utils";
type RowData = IExportData;
const checkExpiry = (inputDateString: string) => {
const currentDate = new Date();
const expiryDate = getDate(inputDateString);
if (!expiryDate) return false;
expiryDate.setDate(expiryDate.getDate() + 7);
return expiryDate > currentDate;
};
export const useExportColumns = () => {
const columns = [
{
key: "Exported By",
content: "Exported By",
tdRender: (rowData: RowData) => {
const { avatar_url, display_name, email } = rowData.initiated_by_detail;
return (
<div className="flex items-center gap-x-2">
<div>
{avatar_url && avatar_url.trim() !== "" ? (
<span className="relative flex h-4 w-4 items-center justify-center rounded-full capitalize text-white">
<img
src={getFileURL(avatar_url)}
className="absolute left-0 top-0 h-full w-full rounded-full object-cover"
alt={display_name || email}
/>
</span>
) : (
<span className="relative flex h-4 w-4 items-center justify-center rounded-full bg-gray-700 capitalize text-white text-xs">
{(email ?? display_name ?? "?")[0]}
</span>
)}
</div>
<div>{display_name}</div>
</div>
);
},
},
{
key: "Exported On",
content: "Exported On",
tdRender: (rowData: RowData) => <span>{renderFormattedDate(rowData.created_at)}</span>,
},
{
key: "Exported projects",
content: "Exported projects",
tdRender: (rowData: RowData) => <div className="text-sm">{rowData.project.length} project(s)</div>,
},
{
key: "Format",
content: "Format",
tdRender: (rowData: RowData) => (
<span className="text-sm">
{rowData.provider === "csv"
? "CSV"
: rowData.provider === "xlsx"
? "Excel"
: rowData.provider === "json"
? "JSON"
: ""}
</span>
),
},
{
key: "Status",
content: "Status",
tdRender: (rowData: RowData) => (
<span
className={`rounded text-xs px-2 py-1 capitalize ${
rowData.status === "completed"
? "bg-green-500/20 text-green-500"
: rowData.status === "processing"
? "bg-yellow-500/20 text-yellow-500"
: rowData.status === "failed"
? "bg-red-500/20 text-red-500"
: rowData.status === "expired"
? "bg-orange-500/20 text-orange-500"
: "bg-gray-500/20 text-gray-500"
}`}
>
{rowData.status}
</span>
),
},
{
key: "Download",
content: "Download",
tdRender: (rowData: RowData) =>
checkExpiry(rowData.created_at) ? (
<>
{rowData.status == "completed" ? (
<a target="_blank" href={rowData?.url} rel="noopener noreferrer">
<button className="w-full flex items-center gap-1 text-custom-primary-100 font-medium">
<Download className="h-4 w-4" />
<div>Download</div>
</button>
</a>
) : (
"-"
)}
</>
) : (
<div className="text-xs text-red-500">Expired</div>
),
},
];
return columns;
};

View File

@@ -0,0 +1,172 @@
import { useState } from "react";
import { intersection } from "lodash";
import { Controller, useForm } from "react-hook-form";
import { EUserPermissions, EUserPermissionsLevel, EXPORTERS_LIST } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button, CustomSearchSelect, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui";
import { useProject, useUser, useUserPermissions } from "@/hooks/store";
import { ProjectExportService } from "@/services/project/project-export.service";
type Props = {
workspaceSlug: string;
provider: string | null;
mutateServices: () => void;
};
type FormData = {
provider: (typeof EXPORTERS_LIST)[0];
project: string[];
multiple: boolean;
};
const projectExportService = new ProjectExportService();
export const ExportForm = (props: Props) => {
// props
const { workspaceSlug, mutateServices } = props;
// states
const [exportLoading, setExportLoading] = useState(false);
// store hooks
const { allowPermissions } = useUserPermissions();
const { data: user, canPerformAnyCreateAction, projectsWithCreatePermissions } = useUser();
const { workspaceProjectIds, getProjectById } = useProject();
const { t } = useTranslation();
// form
const { handleSubmit, control } = useForm<FormData>({
defaultValues: {
provider: EXPORTERS_LIST[0],
project: [],
multiple: false,
},
});
// derived values
const hasProjects = workspaceProjectIds && workspaceProjectIds.length > 0;
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const wsProjectIdsWithCreatePermisisons = projectsWithCreatePermissions
? intersection(workspaceProjectIds, Object.keys(projectsWithCreatePermissions))
: [];
const options = wsProjectIdsWithCreatePermisisons?.map((projectId) => {
const projectDetails = getProjectById(projectId);
return {
value: projectDetails?.id,
query: `${projectDetails?.name} ${projectDetails?.identifier}`,
content: (
<div className="flex items-center gap-2">
<span className="text-[0.65rem] text-custom-text-200 flex-shrink-0">{projectDetails?.identifier}</span>
<span className="truncate">{projectDetails?.name}</span>
</div>
),
};
});
// handlers
const ExportCSVToMail = async (formData: FormData) => {
console.log(formData);
setExportLoading(true);
if (workspaceSlug && user) {
const payload = {
provider: formData.provider.provider,
project: formData.project,
multiple: formData.project.length > 1,
};
await projectExportService
.csvExport(workspaceSlug as string, payload)
.then(() => {
mutateServices();
setExportLoading(false);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("workspace_settings.settings.exports.modal.toasts.success.title"),
message: t("workspace_settings.settings.exports.modal.toasts.success.message", {
entity:
formData.provider.provider === "csv"
? "CSV"
: formData.provider.provider === "xlsx"
? "Excel"
: formData.provider.provider === "json"
? "JSON"
: "",
}),
});
})
.catch(() => {
setExportLoading(false);
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("workspace_settings.settings.exports.modal.toasts.error.message"),
});
});
}
};
return (
<form onSubmit={handleSubmit(ExportCSVToMail)} className="flex flex-col gap-4 mt-4">
<div className="flex gap-4">
{/* Project Selector */}
<div className="w-1/2">
<div className="text-sm font-medium text-custom-text-200 mb-2">
{t("workspace_settings.settings.exports.exporting_projects")}
</div>
<Controller
control={control}
name="project"
disabled={!isAdmin && (!hasProjects || !canPerformAnyCreateAction)}
render={({ field: { value, onChange } }) => (
<CustomSearchSelect
value={value ?? []}
onChange={(val: string[]) => onChange(val)}
options={options}
input
label={
value && value.length > 0
? value
.map((projectId) => {
const projectDetails = getProjectById(projectId);
return projectDetails?.identifier;
})
.join(", ")
: "All projects"
}
optionsClassName="max-w-48 sm:max-w-[532px]"
placement="bottom-end"
multiple
/>
)}
/>
</div>
{/* Format Selector */}
<div className="w-1/2">
<div className="text-sm font-medium text-custom-text-200 mb-2">
{t("workspace_settings.settings.exports.format")}
</div>
<Controller
control={control}
name="provider"
disabled={!isAdmin && (!hasProjects || !canPerformAnyCreateAction)}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={t(value.i18n_title)}
optionsClassName="max-w-48 sm:max-w-[532px]"
placement="bottom-end"
buttonClassName="py-2 text-sm"
>
{EXPORTERS_LIST.map((service) => (
<CustomSelect.Option key={service.provider} className="flex items-center gap-2" value={service}>
<span className="truncate">{t(service.i18n_title)}</span>
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
</div>
<div className="flex items-center justify-between ">
<Button variant="primary" type="submit" loading={exportLoading}>
{exportLoading ? `${t("workspace_settings.settings.exports.exporting")}...` : t("export")}
</Button>
</div>
</form>
);
};

View File

@@ -1,221 +1,38 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation";
import useSWR, { mutate } from "swr";
// icons
import { MoveLeft, MoveRight, RefreshCw } from "lucide-react";
// plane imports
import { EXPORTERS_LIST, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/ui";
// components
import { DetailedEmptyState } from "@/components/empty-state";
import { Exporter, SingleExport } from "@/components/exporter";
import { ImportExportSettingsLoader } from "@/components/ui";
// constants
import { mutate } from "swr";
import { EXPORT_SERVICES_LIST } from "@/constants/fetch-keys";
// hooks
import { useProject, useUser, useUserPermissions } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
// services images
import CSVLogo from "@/public/services/csv.svg";
import ExcelLogo from "@/public/services/excel.svg";
import JSONLogo from "@/public/services/json.svg";
// services
import { IntegrationService } from "@/services/integrations";
const integrationService = new IntegrationService();
const getExporterLogo = (provider: string) => {
switch (provider) {
case "csv":
return CSVLogo;
case "excel":
return ExcelLogo;
case "xlsx":
return ExcelLogo;
case "json":
return JSONLogo;
default:
return "";
}
};
import { ExportForm } from "./export-form";
import { PrevExports } from "./prev-exports";
const IntegrationGuide = observer(() => {
// states
const [refreshing, setRefreshing] = useState(false);
const per_page = 10;
const [cursor, setCursor] = useState<string | undefined>(`10:0:0`);
// router
const router = useAppRouter();
const { workspaceSlug } = useParams();
const searchParams = useSearchParams();
const provider = searchParams.get("provider");
// plane hooks
const { t } = useTranslation();
// store hooks
const { data: currentUser, canPerformAnyCreateAction } = useUser();
const { allowPermissions } = useUserPermissions();
const { workspaceProjectIds } = useProject();
// derived values
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/exports" });
const { data: exporterServices } = useSWR(
workspaceSlug && cursor ? EXPORT_SERVICES_LIST(workspaceSlug as string, cursor, `${per_page}`) : null,
workspaceSlug && cursor
? () => integrationService.getExportsServicesList(workspaceSlug as string, cursor, per_page)
: null
);
const handleRefresh = () => {
setRefreshing(true);
mutate(EXPORT_SERVICES_LIST(workspaceSlug as string, `${cursor}`, `${per_page}`)).then(() => setRefreshing(false));
};
const handleCsvClose = () => {
router.replace(`/${workspaceSlug?.toString()}/settings/exports`);
};
const hasProjects = workspaceProjectIds && workspaceProjectIds.length > 0;
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
useEffect(() => {
const interval = setInterval(() => {
if (exporterServices?.results?.some((service) => service.status === "processing")) {
handleRefresh();
} else {
clearInterval(interval);
}
}, 3000);
return () => clearInterval(interval);
}, [exporterServices]);
// state
const per_page = 10;
const [cursor, setCursor] = useState<string | undefined>(`10:0:0`);
return (
<>
<div className="h-full w-full">
<>
<div>
{EXPORTERS_LIST.map((service) => (
<div
key={service.provider}
className="flex items-center justify-between gap-2 border-b border-custom-border-100 bg-custom-background-100 py-6"
>
<div className="flex w-full items-start justify-between gap-4">
<div className="item-center flex gap-2.5">
<div className="relative h-10 w-10 flex-shrink-0">
<Image
src={getExporterLogo(service?.provider)}
layout="fill"
objectFit="cover"
alt={`${t(service.i18n_title)} Logo`}
/>
</div>
<div>
<h3 className="flex items-center gap-4 text-sm font-medium">{t(service.i18n_title)}</h3>
<p className="text-sm tracking-tight text-custom-text-200">{t(service.i18n_description)}</p>
</div>
</div>
<div className="flex-shrink-0">
<Link href={`/${workspaceSlug}/settings/exports?provider=${service.provider}`}>
<span>
<Button
variant="primary"
className="capitalize"
disabled={!isAdmin && (!hasProjects || !canPerformAnyCreateAction)}
>
{t(service.type)}
</Button>
</span>
</Link>
</div>
</div>
</div>
))}
</div>
<div>
<div className="flex items-center justify-between border-b border-custom-border-100 pb-3.5 pt-7">
<div className="flex items-center gap-2">
<h3 className="flex gap-2 text-xl font-medium">
{t("workspace_settings.settings.exports.previous_exports")}
</h3>
<button
type="button"
className="flex flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 px-1.5 py-1 text-xs outline-none"
onClick={handleRefresh}
>
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{" "}
{refreshing ? `${t("refreshing")}...` : t("refresh_status")}
</button>
</div>
<div className="flex items-center gap-2 text-xs">
<button
disabled={!exporterServices?.prev_page_results}
onClick={() => exporterServices?.prev_page_results && setCursor(exporterServices?.prev_cursor)}
className={`flex items-center rounded border border-custom-primary-100 px-1 text-custom-primary-100 ${
exporterServices?.prev_page_results
? "cursor-pointer hover:bg-custom-primary-100 hover:text-white"
: "cursor-not-allowed opacity-75"
}`}
>
<MoveLeft className="h-4 w-4" />
<div className="pr-1">{t("prev")}</div>
</button>
<button
disabled={!exporterServices?.next_page_results}
onClick={() => exporterServices?.next_page_results && setCursor(exporterServices?.next_cursor)}
className={`flex items-center rounded border border-custom-primary-100 px-1 text-custom-primary-100 ${
exporterServices?.next_page_results
? "cursor-pointer hover:bg-custom-primary-100 hover:text-white"
: "cursor-not-allowed opacity-75"
}`}
>
<div className="pl-1">{t("next")}</div>
<MoveRight className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex flex-col">
{exporterServices && exporterServices?.results ? (
exporterServices?.results?.length > 0 ? (
<div>
<div className="divide-y divide-custom-border-200">
{exporterServices?.results.map((service) => (
<SingleExport key={service.id} service={service} refreshing={refreshing} />
))}
</div>
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<DetailedEmptyState
title={t("workspace_settings.empty_state.exports.title")}
description={t("workspace_settings.empty_state.exports.description")}
assetPath={resolvedPath}
/>
</div>
)
) : (
<ImportExportSettingsLoader />
)}
</div>
</div>
</>
{provider && (
<Exporter
isOpen
handleClose={() => handleCsvClose()}
data={null}
user={currentUser || null}
<ExportForm
workspaceSlug={workspaceSlug as string}
provider={provider}
mutateServices={() => mutate(EXPORT_SERVICES_LIST(workspaceSlug as string, `${cursor}`, `${per_page}`))}
/>
)}
<PrevExports
workspaceSlug={workspaceSlug as string}
cursor={cursor}
per_page={per_page}
setCursor={setCursor}
/>
</>
</div>
</>
);

View File

@@ -0,0 +1,135 @@
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import useSWR, { mutate } from "swr";
import { MoveLeft, MoveRight, RefreshCw } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { IExportData } from "@plane/types";
import { Table } from "@plane/ui";
import { EXPORT_SERVICES_LIST } from "@/constants/fetch-keys";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { IntegrationService } from "@/services/integrations";
import { DetailedEmptyState } from "../empty-state";
import { ImportExportSettingsLoader } from "../ui";
import { useExportColumns } from "./column";
const integrationService = new IntegrationService();
type Props = {
workspaceSlug: string;
cursor: string | undefined;
per_page: number;
setCursor: (cursor: string) => void;
};
type RowData = IExportData;
export const PrevExports = observer((props: Props) => {
// props
const { workspaceSlug, cursor, per_page, setCursor } = props;
// state
const [refreshing, setRefreshing] = useState(false);
// hooks
const { t } = useTranslation();
const columns = useExportColumns();
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/exports" });
const { data: exporterServices } = useSWR(
workspaceSlug && cursor ? EXPORT_SERVICES_LIST(workspaceSlug as string, cursor, `${per_page}`) : null,
workspaceSlug && cursor
? () => integrationService.getExportsServicesList(workspaceSlug as string, cursor, per_page)
: null
);
const handleRefresh = () => {
setRefreshing(true);
mutate(EXPORT_SERVICES_LIST(workspaceSlug as string, `${cursor}`, `${per_page}`)).then(() => setRefreshing(false));
};
useEffect(() => {
const interval = setInterval(() => {
if (exporterServices?.results?.some((service) => service.status === "processing")) {
handleRefresh();
} else {
clearInterval(interval);
}
}, 3000);
return () => clearInterval(interval);
}, [exporterServices]);
return (
<div>
<div className="flex items-center justify-between border-b border-custom-border-100 pb-3.5 pt-7">
<div className="flex items-center gap-2">
<h3 className="flex gap-2 text-xl font-medium">
{t("workspace_settings.settings.exports.previous_exports")}
</h3>
<button
type="button"
className="flex flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 px-1.5 py-1 text-xs outline-none"
onClick={handleRefresh}
>
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{" "}
{refreshing ? `${t("refreshing")}...` : t("refresh_status")}
</button>
</div>
<div className="flex items-center gap-2 text-xs">
<button
disabled={!exporterServices?.prev_page_results}
onClick={() => exporterServices?.prev_page_results && setCursor(exporterServices?.prev_cursor)}
className={`flex items-center rounded border border-custom-primary-100 px-1 text-custom-primary-100 ${
exporterServices?.prev_page_results
? "cursor-pointer hover:bg-custom-primary-100 hover:text-white"
: "cursor-not-allowed opacity-75"
}`}
>
<MoveLeft className="h-4 w-4" />
<div className="pr-1">{t("prev")}</div>
</button>
<button
disabled={!exporterServices?.next_page_results}
onClick={() => exporterServices?.next_page_results && setCursor(exporterServices?.next_cursor)}
className={`flex items-center rounded border border-custom-primary-100 px-1 text-custom-primary-100 ${
exporterServices?.next_page_results
? "cursor-pointer hover:bg-custom-primary-100 hover:text-white"
: "cursor-not-allowed opacity-75"
}`}
>
<div className="pl-1">{t("next")}</div>
<MoveRight className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex flex-col">
{exporterServices && exporterServices?.results ? (
exporterServices?.results?.length > 0 ? (
<div>
<div className="divide-y divide-custom-border-200">
<Table
columns={columns}
data={exporterServices?.results ?? []}
keyExtractor={(rowData: RowData) => rowData?.id ?? ""}
tHeadClassName="border-b border-custom-border-100"
thClassName="text-left font-medium divide-x-0 text-custom-text-400"
tBodyClassName="divide-y-0"
tBodyTrClassName="divide-x-0 p-4 h-[50px] text-custom-text-200"
tHeadTrClassName="divide-x-0"
/>
</div>
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<DetailedEmptyState
title={t("workspace_settings.empty_state.exports.title")}
description={t("workspace_settings.empty_state.exports.description")}
assetPath={resolvedPath}
/>
</div>
)
) : (
<ImportExportSettingsLoader />
)}
</div>
</div>
);
});

View File

@@ -115,7 +115,7 @@ export const NoProjectsEmptyState = observer(() => {
flag: "visited_profile",
cta: {
text: "home.empty.personalize_account.cta",
link: "/profile",
link: `/${workspaceSlug}/settings/account`,
disabled: false,
},
},

View File

@@ -72,7 +72,7 @@ export const ProjectArchivedEmptyState: React.FC = observer(() => {
assetPath={archivedIssuesResolvedPath}
primaryButton={{
text: t("project_issues.empty_state.no_archived_issues.primary_button.text"),
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/settings/automations`),
onClick: () => router.push(`/${workspaceSlug}/settings/project/${projectId}/automations`),
disabled: !canPerformEmptyStateActions,
}}
/>

View File

@@ -163,7 +163,7 @@ export const ProfileForm = observer((props: TProfileFormProps) => {
/>
)}
/>
<form onSubmit={handleSubmit(onSubmit)}>
<form onSubmit={handleSubmit(onSubmit)} className="w-full overflow-y-scroll">
<div className="flex w-full flex-col gap-6">
<div className="relative h-44 w-full">
<img

View File

@@ -9,7 +9,7 @@ import { ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
// services
import { UserService } from "@/services/user.service";
// types
interface IEmailNotificationFormProps {
interface IEmailNotificationFormProps {
data: IUserEmailNotificationSettings;
}
@@ -20,10 +20,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
const { data } = props;
const { t } = useTranslation();
// form data
const {
control,
reset,
} = useForm<IUserEmailNotificationSettings>({
const { control, reset } = useForm<IUserEmailNotificationSettings>({
defaultValues: {
...data,
},
@@ -55,10 +52,9 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
return (
<>
<div className="pt-6 text-lg font-medium text-custom-text-100">{t("notify_me_when")}:</div>
{/* Notification Settings */}
<div className="flex flex-col py-2">
<div className="flex gap-2 items-center pt-6">
<div className="flex gap-2 items-center pt-2">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">{t("property_changes")}</div>
<div className="text-sm font-normal text-custom-text-300">{t("property_changes_description")}</div>
@@ -83,9 +79,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
<div className="flex gap-2 items-center pt-6 pb-2">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">{t("state_change")}</div>
<div className="text-sm font-normal text-custom-text-300">
{t("state_change_description")}
</div>
<div className="text-sm font-normal text-custom-text-300">{t("state_change_description")}</div>
</div>
<div className="shrink-0">
<Controller
@@ -129,9 +123,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
<div className="flex gap-2 items-center pt-6">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">{t("comments")}</div>
<div className="text-sm font-normal text-custom-text-300">
{t("comments_description")}
</div>
<div className="text-sm font-normal text-custom-text-300">{t("comments_description")}</div>
</div>
<div className="shrink-0">
<Controller
@@ -153,9 +145,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
<div className="flex gap-2 items-center pt-6">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">{t("mentions")}</div>
<div className="text-sm font-normal text-custom-text-300">
{t("mentions_description")}
</div>
<div className="text-sm font-normal text-custom-text-300">{t("mentions_description")}</div>
</div>
<div className="shrink-0">
<Controller

View File

@@ -9,7 +9,7 @@ type Props = {
export const ProfileSettingContentHeader: FC<Props> = (props) => {
const { title, description } = props;
return (
<div className="flex flex-col gap-1 py-4 border-b border-custom-border-100">
<div className="flex flex-col gap-1 pb-4 border-b border-custom-border-100 w-full">
<div className="text-xl font-medium text-custom-text-100">{title}</div>
{description && <div className="text-sm font-normal text-custom-text-300">{description}</div>}
</div>

View File

@@ -37,7 +37,7 @@ export const ProfileSidebar: FC<TProfileSidebar> = observer((props) => {
// refs
const ref = useRef<HTMLDivElement>(null);
// router
const { userId } = useParams();
const { userId, workspaceSlug } = useParams();
// store hooks
const { data: currentUser } = useUser();
const { profileSidebarCollapsed, toggleProfileSidebar } = useAppTheme();
@@ -94,7 +94,7 @@ export const ProfileSidebar: FC<TProfileSidebar> = observer((props) => {
<div className="relative h-[110px]">
{currentUser?.id === userId && (
<div className="absolute right-3.5 top-3.5 grid h-5 w-5 place-items-center rounded bg-white">
<Link href="/profile">
<Link href={`/${workspaceSlug}/settings/account`}>
<span className="grid place-items-center text-black">
<Pencil className="h-3 w-3" />
</span>

View File

@@ -35,7 +35,7 @@ export const ArchiveProjectSelection: React.FC<IArchiveProject> = (props) => {
>
<Disclosure.Panel>
<div className="flex flex-col gap-8 pt-4">
<span className="text-sm tracking-tight">
<span className="text-sm tracking-tight w-[800px]">
Archiving a project will unlist your project from your side navigation although you will still be able
to access it from your projects page. You can restore the project or delete it whenever you want.
</span>

View File

@@ -36,7 +36,7 @@ export const DeleteProjectSection: React.FC<IDeleteProjectSection> = (props) =>
>
<Disclosure.Panel>
<div className="flex flex-col gap-8 pt-4">
<span className="text-sm tracking-tight">
<span className="text-sm tracking-tight w-[800px]">
When deleting a project, all of the data and resources within that project will be permanently removed
and cannot be recovered.
</span>

View File

@@ -0,0 +1,5 @@
import { ReactNode } from "react";
export const SettingsContentWrapper = ({ children }: { children: ReactNode }) => (
<div className="relative flex flex-col min-w-[60%] items-center mx-auto overflow-y-scroll px-12">{children}</div>
);

View File

@@ -0,0 +1,65 @@
"use client";
import { observer } from "mobx-react";
import Link from "next/link";
import { ChevronLeftIcon } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { getButtonStyling } from "@plane/ui/src/button";
import { cn } from "@plane/utils";
import { useUserSettings } from "@/hooks/store";
import SettingsTabs from "./tabs";
import WorkspaceLogo from "./workspace-logo";
export const SettingsHeader = observer(() => {
// hooks
const { data: currentUserSettings } = useUserSettings();
const { t } = useTranslation();
// redirect url for normal mode
const redirectWorkspaceSlug =
currentUserSettings?.workspace?.last_workspace_slug ||
currentUserSettings?.workspace?.fallback_workspace_slug ||
"";
return (
<div className="bg-custom-background-90 px-12 py-8">
{/* Breadcrumb */}
<Link
href={`/${redirectWorkspaceSlug}`}
className="group flex items-center gap-2 text-custom-text-300 mb-4 border border-transparent hover:bg-custom-background-100 hover:border-custom-border-200 w-fit pr-2 rounded-lg "
>
<button
className={cn(
getButtonStyling("neutral-primary", "sm"),
"rounded-lg p-1 hover:bg-custom-background-100 hover:border-custom-border-200",
"group-hover:bg-custom-background-100 group-hover:border-transparent"
)}
>
<ChevronLeftIcon className="h-4 w-4 my-auto" />
</button>
<div className="text-sm my-auto font-semibold">{t("back_to_workspace")}</div>
{/* Last workspace */}
<div className="flex items-center gap-1">
<WorkspaceLogo
workspace={{
logo_url: currentUserSettings?.workspace?.last_workspace_logo || "",
name: currentUserSettings?.workspace?.last_workspace_name || "",
}}
size="sm"
className="my-auto"
/>
<div className="text-xs my-auto text-custom-text-100 font-semibold">
{currentUserSettings?.workspace?.last_workspace_name}
</div>
</div>
</Link>
<div className="flex flex-col gap-2">
{/* Description */}
<div className="text-custom-text-100 font-semibold text-2xl">{t("settings")}</div>
<div className="text-custom-text-300 text-base">{t("settings_description")}</div>
{/* Actions */}
<SettingsTabs />
</div>
</div>
);
});

View File

@@ -0,0 +1,3 @@
export * from "./header";
export * from "./sidebar";
export * from "./content-wrapper";

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,73 @@
import { range } from "lodash";
import { observer } from "mobx-react";
import Link from "next/link";
import { usePathname, useParams } from "next/navigation";
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Loader } from "@plane/ui";
import { cn } from "@/helpers/common.helper";
import { useProject, useUserPermissions } from "@/hooks/store";
import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project";
export const NavItemChildren = observer((props: { projectId: string }) => {
const { projectId } = props;
const { workspaceSlug } = useParams();
const pathname = usePathname();
// mobx store
const { getProjectById } = useProject();
const { allowPermissions } = useUserPermissions();
const { t } = useTranslation();
// derived values
const currentProject = getProjectById(projectId);
if (!currentProject) {
return (
<div className="flex w-[280px] flex-col gap-6">
<div className="flex flex-col gap-2">
<Loader className="flex w-full flex-col gap-2">
{range(8).map((index) => (
<Loader.Item key={index} height="34px" />
))}
</Loader>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<div className="flex w-full flex-col gap-1">
{PROJECT_SETTINGS_LINKS.map((link) => {
const isActive = link.highlight(pathname, `/${workspaceSlug}/settings/project/${projectId}`);
return (
allowPermissions(
link.access,
EUserPermissionsLevel.PROJECT,
workspaceSlug.toString(),
projectId.toString()
) && (
<Link key={link.key} href={`/${workspaceSlug}/settings/project/${projectId}${link.href}`}>
<div
className={cn(
"cursor-pointer relative group w-full flex items-center justify-between gap-1.5 rounded p-1 px-1.5 outline-none",
{
"text-custom-primary-200 bg-custom-primary-100/10": isActive,
"text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-90 active:bg-custom-sidebar-background-90":
!isActive,
},
"text-sm font-medium"
)}
>
{t(link.i18n_label)}
</div>
</Link>
)
);
})}
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,47 @@
import { observer } from "mobx-react";
import { PROJECT_SETTINGS_CATEGORIES, PROJECT_SETTINGS_CATEGORY } from "@plane/constants";
import { Logo } from "@/components/common";
import { getUserRole } from "@/helpers/user.helper";
import { useProject } from "@/hooks/store/use-project";
import { SettingsSidebar } from "../..";
import { NavItemChildren } from "./nav-item-children";
type TProjectSettingsSidebarProps = {
workspaceSlug: string;
pathname: string;
};
export const ProjectSettingsSidebar = observer((props: TProjectSettingsSidebarProps) => {
const { workspaceSlug } = props;
// store hooks
const { joinedProjectIds, projectMap } = useProject();
const groupedProject = joinedProjectIds.map((projectId) => ({
key: projectId,
i18n_label: projectMap[projectId].name,
href: `/settings/project/${projectId}`,
icon: <Logo logo={projectMap[projectId].logo_props} />,
}));
return (
<SettingsSidebar
categories={PROJECT_SETTINGS_CATEGORIES}
groupedSettings={{
[PROJECT_SETTINGS_CATEGORY.PROJECTS]: groupedProject,
}}
workspaceSlug={workspaceSlug.toString()}
isActive={false}
appendItemsToTitle={(key: string) => {
const role = projectMap[key].member_role;
return (
<div className="text-xs font-medium text-custom-text-200 capitalize bg-custom-background-90 rounded-md px-1 py-0.5">
{role ? getUserRole(role)?.toLowerCase() : "Guest"}
</div>
);
}}
shouldRender
renderChildren={(key: string) => <NavItemChildren projectId={key} />}
/>
);
});

View File

@@ -0,0 +1,38 @@
import { getUserRole } from "@/helpers/user.helper";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { WorkspaceEditionBadge } from "@/plane-web/components/workspace/edition-badge";
import WorkspaceLogo from "../workspace-logo";
export const SettingsSidebarHeader = (props: { customHeader?: React.ReactNode }) => {
const { customHeader } = props;
const { currentWorkspace } = useWorkspace();
return customHeader
? customHeader
: currentWorkspace && (
<div className="flex w-full gap-3 items-center justify-between">
<div className="flex w-full gap-3 items-center overflow-hidden">
<WorkspaceLogo
workspace={{
logo_url: currentWorkspace.logo_url || "",
name: currentWorkspace.name,
}}
size="md"
/>
<div className="w-full overflow-hidden">
<div className="text-base font-medium text-custom-text-200 truncate text-ellipsis ">
{currentWorkspace.name}
</div>
<div className="text-sm text-custom-text-300 capitalize">
{getUserRole(currentWorkspace.role)?.toLowerCase() || "guest"}
</div>
</div>
</div>
<div className="flex-shrink-0">
<WorkspaceEditionBadge
isEditable={false}
className="text-xs rounded-md min-w-fit px-1 py-0.5 flex-shrink-0"
/>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,88 @@
import React, { useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Disclosure } from "@headlessui/react";
import { EUserWorkspaceRoles } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { cn } from "@/helpers/common.helper";
export type TSettingItem = {
key: string;
i18n_label: string;
href: string;
access?: EUserWorkspaceRoles[];
icon?: React.ReactNode;
};
export type TSettingsSidebarNavItemProps = {
workspaceSlug: string;
setting: TSettingItem;
isActive: boolean | ((data: { href: string }) => boolean);
actionIcons?: (props: { type: string; size?: number; className?: string }) => React.ReactNode;
appendItemsToTitle?: (key: string) => React.ReactNode;
renderChildren?: (key: string) => React.ReactNode;
};
const SettingsSidebarNavItem = (props: TSettingsSidebarNavItemProps) => {
const { workspaceSlug, setting, isActive, actionIcons, appendItemsToTitle, renderChildren } = props;
// router
const { projectId } = useParams();
// i18n
const { t } = useTranslation();
// state
const [isExpanded, setIsExpanded] = useState(projectId === setting.key);
// derived
const buttonClass = cn(
"flex w-full items-center px-2 py-1.5 rounded text-custom-text-200 justify-between",
"hover:bg-custom-primary-100/10",
{
"text-custom-primary-200 bg-custom-primary-100/10": typeof isActive === "function" ? isActive(setting) : isActive,
"hover:bg-custom-sidebar-background-90 active:bg-custom-sidebar-background-90":
typeof isActive === "function" ? !isActive(setting) : !isActive,
}
);
const titleElement = (
<>
<div className="flex items-center gap-1.5 overflow-hidden">
{setting.icon ? setting.icon : actionIcons && actionIcons({ type: setting.key, size: 16 })}
<div className="text-sm font-medium truncate">{t(setting.i18n_label)}</div>
</div>
{appendItemsToTitle?.(setting.key)}
</>
);
return (
<Disclosure as="div" className="flex flex-col w-full" defaultOpen={isExpanded} key={setting.key}>
<Disclosure.Button
as="button"
type="button"
className={cn(
"group w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400"
)}
onClick={() => setIsExpanded(!isExpanded)}
>
{renderChildren ? (
<div className={buttonClass}>{titleElement}</div>
) : (
<Link href={`/${workspaceSlug}/${setting.href}`} className={buttonClass}>
{titleElement}
</Link>
)}
</Disclosure.Button>
{/* Nested Navigation */}
{isExpanded && (
<Disclosure.Panel
as="div"
className={cn("flex flex-col gap-0.5", {
"space-y-0 ml-0": isExpanded,
})}
static
>
<div className="ml-4 border-l border-custom-border-200 pl-2 my-0.5">{renderChildren?.(setting.key)}</div>
</Disclosure.Panel>
)}
</Disclosure>
);
};
export default SettingsSidebarNavItem;

View File

@@ -0,0 +1,64 @@
import { useTranslation } from "@plane/i18n";
import { SettingsSidebarHeader } from "./header";
import SettingsSidebarNavItem, { TSettingItem } from "./nav-item";
interface SettingsSidebarProps {
customHeader?: React.ReactNode;
categories: string[];
groupedSettings: {
[key: string]: TSettingItem[];
};
workspaceSlug: string;
isActive: boolean | ((data: { href: string }) => boolean);
shouldRender: boolean | ((setting: TSettingItem) => boolean);
actionIcons?: (props: { type: string; size?: number; className?: string }) => React.ReactNode;
appendItemsToTitle?: (key: string) => React.ReactNode;
renderChildren?: (key: string) => React.ReactNode;
}
export const SettingsSidebar = (props: SettingsSidebarProps) => {
const {
customHeader,
categories,
groupedSettings,
workspaceSlug,
isActive,
shouldRender,
actionIcons,
appendItemsToTitle,
renderChildren,
} = props;
const { t } = useTranslation();
return (
<div className="flex w-[220px] flex-col gap-2 h-full">
{/* Header */}
<SettingsSidebarHeader customHeader={customHeader} />
{/* Navigation */}
<div className="divide-y divide-custom-border-100 overflow-x-hidden scrollbar-sm h-full w-full overflow-y-auto vertical-scrollbar">
{categories.map((category) => (
<div key={category} className="py-3">
<span className="text-sm font-semibold text-custom-text-400 capitalize mb-2">{t(category)}</span>
{groupedSettings[category].length > 0 && (
<div className="relative flex flex-col gap-0.5 overflow-y-scroll h-full mt-2">
{groupedSettings[category].map(
(setting) =>
(typeof shouldRender === "function" ? shouldRender(setting) : shouldRender) && (
<SettingsSidebarNavItem
key={setting.key}
setting={setting}
workspaceSlug={workspaceSlug}
isActive={isActive}
appendItemsToTitle={appendItemsToTitle}
renderChildren={renderChildren}
actionIcons={actionIcons}
/>
)
)}
</div>
)}
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,63 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { cn } from "@plane/utils";
import { useProject } from "@/hooks/store";
const TABS = {
account: {
key: "account",
label: "Account",
href: `/settings/account/`,
},
workspace: {
key: "workspace",
label: "Workspace",
href: `/settings/`,
},
projects: {
key: "projects",
label: "Projects",
href: `/settings/project/`,
},
};
const SettingsTabs = observer(() => {
// router
const pathname = usePathname();
const { workspaceSlug } = useParams();
// store hooks
const { joinedProjectIds } = useProject();
const currentTab = pathname.includes(TABS.projects.href)
? TABS.projects
: pathname.includes(TABS.account.href)
? TABS.account
: TABS.workspace;
return (
<div className="flex w-fit min-w-fit items-center justify-between gap-1.5 rounded-md text-sm p-0.5 bg-custom-background-80/60 mt-2">
{Object.values(TABS).map((tab) => {
const isActive = currentTab?.key === tab.key;
const href = tab.key === TABS.projects.key ? `${tab.href}${joinedProjectIds[0] || ""}` : tab.href;
return (
<Link
key={tab.key}
href={`/${workspaceSlug}${href}`}
className={cn(
"flex items-center justify-center p-1 min-w-fit w-full font-medium outline-none focus:outline-none cursor-pointer transition-all rounded text-custom-text-400 ",
{
"bg-custom-background-100 text-custom-text-300 shadow-sm": isActive,
"hover:text-custom-text-300 hover:bg-custom-background-80/60": !isActive,
}
)}
>
<div className="text-xs font-semibold p-1">{tab.label}</div>
</Link>
);
})}
</div>
);
});
export default SettingsTabs;

View File

@@ -0,0 +1,40 @@
import { cn } from "@/helpers/common.helper";
import { getFileURL } from "@/helpers/file.helper";
interface IWorkspaceLogoProps {
workspace: {
logo_url: string;
name: string;
};
className?: string;
size?: "sm" | "md" | "lg";
}
const WorkspaceLogo = (props: IWorkspaceLogoProps) => {
const { workspace, className, size = "md" } = props;
const sizeClass = size === "sm" ? "h-4 w-4 text-[9px]" : size === "md" ? "h-8 w-8 text-sm" : "h-10 w-10 text-base";
return (
<div className={cn("flex gap-3 items-center", className)}>
{workspace.logo_url && workspace.logo_url !== "" ? (
<div className={cn("relative my-auto flex rounded", sizeClass)}>
<img
src={getFileURL(workspace.logo_url)}
className={cn("absolute left-0 top-0 h-full w-full rounded-md object-cover", sizeClass)}
alt="Workspace Logo"
/>
</div>
) : (
<div
className={cn(
"relative flex items-center justify-center rounded bg-gray-700 uppercase text-white my-auto",
sizeClass
)}
>
{workspace?.name?.charAt(0) ?? "N"}
</div>
)}
</div>
);
};
export default WorkspaceLogo;

View File

@@ -2,6 +2,7 @@
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import { AlertTriangle } from "lucide-react";
// types
@@ -30,6 +31,7 @@ export const DeleteWorkspaceForm: React.FC<Props> = observer((props) => {
const { data, onClose } = props;
// router
const router = useAppRouter();
const { workspaceSlug } = useParams();
// store hooks
const { captureWorkspaceEvent } = useEventTracker();
const { deleteWorkspace } = useWorkspace();
@@ -60,7 +62,7 @@ export const DeleteWorkspaceForm: React.FC<Props> = observer((props) => {
await deleteWorkspace(data.slug)
.then(() => {
handleClose();
router.push("/profile");
router.push(`/${workspaceSlug}/settings/account`);
captureWorkspaceEvent({
eventName: WORKSPACE_DELETED,
payload: {

View File

@@ -45,7 +45,7 @@ export const WorkspaceMembersListItem: FC<Props> = observer((props) => {
state: "SUCCESS",
element: "Workspace settings members page",
});
router.push("/profile");
router.push(`/${workspaceSlug}/settings/account`);
})
.catch((err: any) =>
setToast({

View File

@@ -3,6 +3,7 @@
import { Fragment, Ref, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { usePopper } from "react-popper";
// icons
import { ChevronDown, CirclePlus, LogOut, Mails, Settings } from "lucide-react";
@@ -26,7 +27,7 @@ import SidebarDropdownItem from "./dropdown-item";
export const SidebarDropdown = observer(() => {
const { t } = useTranslation();
const { workspaceSlug } = useParams();
// store hooks
const { sidebarCollapsed, toggleSidebar } = useAppTheme();
const { data: currentUser } = useUser();
@@ -217,7 +218,7 @@ export const SidebarDropdown = observer(() => {
>
<div className="flex flex-col gap-2.5 pb-2">
<span className="px-2 text-custom-sidebar-text-200">{currentUser?.email}</span>
<Link href="/profile">
<Link href={`/${workspaceSlug}/settings/account`}>
<Menu.Item as="div">
<span className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80">
<Settings className="h-4 w-4 stroke-[1.5]" />

View File

@@ -353,7 +353,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem>
<Link href={`/${workspaceSlug}/projects/${project?.id}/settings`}>
<Link href={`/${workspaceSlug}/settings/project/${project?.id}`}>
<div className="flex items-center justify-start gap-2">
<Settings className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("settings")}</span>

View File

@@ -178,7 +178,7 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
<Button>Go Home</Button>
</Link>
)}
<Link href="/profile">
<Link href={`/${workspaceSlug}/settings/account`}>
<Button variant="neutral-primary">Visit Profile</Button>
</Link>
</div>

View File

@@ -35,6 +35,8 @@ export class UserSettingsStore implements IUserSettingsStore {
workspace: {
last_workspace_id: undefined,
last_workspace_slug: undefined,
last_workspace_name: undefined,
last_workspace_logo: undefined,
fallback_workspace_id: undefined,
fallback_workspace_slug: undefined,
invites: undefined,

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB