[WEB-3998] feat: settings page revamp (#6959)

* chore: return workspace name and logo in profile settings api

* chore: remove unwanted fields

* fix: backend

* feat: workspace settings

* feat: workspce settings + layouting

* feat: profile + workspace settings ui

* chore: project settings + refactoring

* routes

* fix: handled no project

* fix: css + build

* feat: profile settings internal screens upgrade

* fix: workspace settings internal screens

* fix: external scrolling allowed

* fix: css

* fix: css

* fix: css

* fix: preferences settings

* fix: css

* fix: mobile interface

* fix: profile redirections

* fix: dark theme

* fix: css

* fix: css

* feat: scroll

* fix: refactor

* fix: bug fixes

* fix: refactor

* fix: css

* fix: routes

* fix: first day of the week

* fix: scrolling

* fix: refactoring

* fix: project -> projects

* fix: refactoring

* fix: refactor

* fix: no authorized view consistency

* fix: folder structure

* fix: revert

* fix: handled redirections

* fix: scroll

* fix: deleted old routes

* fix: empty states

* fix: headings

* fix: settings description

* fix: build

---------

Co-authored-by: gakshita <akshitagoyal1516@gmail.com>
Co-authored-by: Akshita Goyal <36129505+gakshita@users.noreply.github.com>
This commit is contained in:
Sangeetha 2025-05-30 18:47:33 +05:30 committed by GitHub
parent 445c819fbd
commit 41c2aefad4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
112 changed files with 2789 additions and 975 deletions

View file

@ -110,11 +110,16 @@ class UserMeSettingsSerializer(BaseSerializer):
workspace_member__member=obj.id, workspace_member__member=obj.id,
workspace_member__is_active=True, workspace_member__is_active=True,
).first() ).first()
logo_asset_url = workspace.logo_asset.asset_url if workspace.logo_asset is not None else ""
return { return {
"last_workspace_id": profile.last_workspace_id, "last_workspace_id": profile.last_workspace_id,
"last_workspace_slug": ( "last_workspace_slug": (
workspace.slug if workspace is not None else "" 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_id": profile.last_workspace_id,
"fallback_workspace_slug": ( "fallback_workspace_slug": (
workspace.slug if workspace is not None else "" workspace.slug if workspace is not None else ""

View file

@ -3,6 +3,7 @@ import csv
import io import io
import os import os
from datetime import date from datetime import date
import uuid
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.db import IntegrityError from django.db import IntegrityError
@ -35,6 +36,7 @@ from plane.db.models import (
Workspace, Workspace,
WorkspaceMember, WorkspaceMember,
WorkspaceTheme, WorkspaceTheme,
Profile
) )
from plane.app.permissions import ROLE, allow_permission from plane.app.permissions import ROLE, allow_permission
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
@ -157,8 +159,19 @@ class WorkSpaceViewSet(BaseViewSet):
def partial_update(self, request, *args, **kwargs): def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs) return super().partial_update(request, *args, **kwargs)
def remove_last_workspace_ids_from_user_settings(self, id: uuid.UUID) -> None:
"""
Remove the last workspace id from the user settings
"""
Profile.objects.filter(last_workspace_id=id).update(last_workspace_id=None)
return
@allow_permission([ROLE.ADMIN], level="WORKSPACE") @allow_permission([ROLE.ADMIN], level="WORKSPACE")
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
# Get the workspace
workspace = self.get_object()
self.remove_last_workspace_ids_from_user_settings(workspace.id)
return super().destroy(request, *args, **kwargs) return super().destroy(request, *args, **kwargs)

View file

@ -32,5 +32,6 @@ export * from "./dashboard";
export * from "./page"; export * from "./page";
export * from "./emoji"; export * from "./emoji";
export * from "./subscription"; export * from "./subscription";
export * from "./settings";
export * from "./icon"; export * from "./icon";
export * from "./analytics-v2"; export * from "./analytics-v2";

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/",
},
preferences: {
key: "preferences",
i18n_label: "profile.actions.preferences",
href: `/settings/account/preferences`,
highlight: (pathname: string) => pathname === "/settings/account/preferences",
},
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: { export const PROFILE_ACTION_LINKS: {
key: string; key: string;
i18n_label: string; i18n_label: string;
href: string; href: string;
highlight: (pathname: string) => boolean; highlight: (pathname: string) => boolean;
}[] = [ }[] = [
{ PROFILE_SETTINGS["profile"],
key: "profile", PROFILE_SETTINGS["security"],
i18n_label: "profile.actions.profile", PROFILE_SETTINGS["activity"],
href: `/profile`, PROFILE_SETTINGS["preferences"],
highlight: (pathname: string) => pathname === "/profile/", PROFILE_SETTINGS["notifications"],
}, PROFILE_SETTINGS["api-tokens"],
{
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/",
},
]; ];
export const PROFILE_VIEWER_TAB = [ export const PROFILE_VIEWER_TAB = [
@ -72,6 +86,23 @@ export const PROFILE_ADMINS_TAB = [
}, },
]; ];
export const PREFERENCE_OPTIONS: {
id: string;
title: string;
description: string;
}[] = [
{
id: "theme",
title: "theme",
description: "select_or_customize_your_interface_color_scheme",
},
{
id: "start_of_week",
title: "First day of the week",
description: "This will change how all calendars in your app look.",
},
];
/** /**
* @description The start of the week for the user * @description The start of the week for the user
* @enum {number} * @enum {number}

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["preferences"],
PROFILE_SETTINGS["notifications"],
PROFILE_SETTINGS["security"],
PROFILE_SETTINGS["activity"],
],
[PROFILE_SETTINGS_CATEGORY.DEVELOPER]: [PROFILE_SETTINGS["api-tokens"]],
};

View file

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

View file

@ -43,7 +43,8 @@
"your_account": "Your account", "your_account": "Your account",
"security": "Security", "security": "Security",
"activity": "Activity", "activity": "Activity",
"appearance": "Appearance", "preferences": "Preferences",
"language_and_time": "Language & Time",
"notifications": "Notifications", "notifications": "Notifications",
"workspaces": "Workspaces", "workspaces": "Workspaces",
"create_workspace": "Create workspace", "create_workspace": "Create workspace",
@ -56,6 +57,10 @@
"something_went_wrong_please_try_again": "Something went wrong. Please try again.", "something_went_wrong_please_try_again": "Something went wrong. Please try again.",
"load_more": "Load more", "load_more": "Load more",
"select_or_customize_your_interface_color_scheme": "Select or customize your interface color scheme.", "select_or_customize_your_interface_color_scheme": "Select or customize your interface color scheme.",
"timezone_setting": "Current timezone setting.",
"language_setting": "Choose the language used in the user interface.",
"settings_moved_to_preferences": "Timezone & Language settings have been moved to preferences.",
"go_to_preferences": "Go to preferences",
"theme": "Theme", "theme": "Theme",
"system_preference": "System preference", "system_preference": "System preference",
"light": "Light", "light": "Light",
@ -334,6 +339,8 @@
"new_password_must_be_different_from_old_password": "New password must be different from old password", "new_password_must_be_different_from_old_password": "New password must be different from old password",
"edited": "edited", "edited": "edited",
"bot": "Bot", "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": { "project_view": {
"sort_by": { "sort_by": {
"created_at": "Created at", "created_at": "Created at",
@ -1301,6 +1308,28 @@
} }
} }
}, },
"account_settings": {
"profile":{},
"preferences":{
"heading": "Preferences",
"description": "Customize your app experience the way you work"
},
"notifications":{
"heading": "Email notifications",
"description": "Stay in the loop on Work items you are subscribed to. Enable this to get notified."
},
"security":{
"heading": "Security"
},
"api_tokens":{
"heading": "Personal Access Tokens",
"description": "Generate secure API tokens to integrate your data with external systems and applications."
},
"activity":{
"heading": "Activity",
"description": "Track your recent actions and changes across all projects and work items."
}
},
"workspace_settings": { "workspace_settings": {
"label": "Workspace settings", "label": "Workspace settings",
"page_label": "{workspace} - General settings", "page_label": "{workspace} - General settings",
@ -1367,16 +1396,22 @@
} }
}, },
"billing_and_plans": { "billing_and_plans": {
"heading": "Billing & Plans",
"description":"Choose your plan, manage subscriptions, and easily upgrade as your needs grow.",
"title": "Billing & Plans", "title": "Billing & Plans",
"current_plan": "Current plan", "current_plan": "Current plan",
"free_plan": "You are currently using the free plan", "free_plan": "You are currently using the free plan",
"view_plans": "View plans" "view_plans": "View plans"
}, },
"exports": { "exports": {
"heading": "Exports",
"description": "Export your project data in various formats and access your export history with download links.",
"title": "Exports", "title": "Exports",
"exporting": "Exporting", "exporting": "Exporting",
"previous_exports": "Previous exports", "previous_exports": "Previous exports",
"export_separate_files": "Export the data into separate files", "export_separate_files": "Export the data into separate files",
"exporting_projects": "Exporting project",
"format": "Format",
"modal": { "modal": {
"title": "Export to", "title": "Export to",
"toasts": { "toasts": {
@ -1392,6 +1427,8 @@
} }
}, },
"webhooks": { "webhooks": {
"heading": "Webhooks",
"description": "Automate notifications to external services when project events occur.",
"title": "Webhooks", "title": "Webhooks",
"add_webhook": "Add webhook", "add_webhook": "Add webhook",
"modal": { "modal": {
@ -1443,29 +1480,29 @@
} }
}, },
"api_tokens": { "api_tokens": {
"title": "API Tokens", "title": "Personal Access Tokens",
"add_token": "Add API token", "add_token": "Add personal access token",
"create_token": "Create token", "create_token": "Create token",
"never_expires": "Never expires", "never_expires": "Never expires",
"generate_token": "Generate token", "generate_token": "Generate token",
"generating": "Generating", "generating": "Generating",
"delete": { "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.", "description": "Any application using this token will no longer have the access to Plane data. This action cannot be undone.",
"success": { "success": {
"title": "Success!", "title": "Success!",
"message": "The API token has been successfully deleted" "message": "The token has been successfully deleted"
}, },
"error": { "error": {
"title": "Error!", "title": "Error!",
"message": "The API token could not be deleted" "message": "The token could not be deleted"
} }
} }
} }
}, },
"empty_state": { "empty_state": {
"api_tokens": { "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." "description": "Plane APIs can be used to integrate your data in Plane with any external system. Create a token to get started."
}, },
"webhooks": { "webhooks": {
@ -1515,8 +1552,9 @@
"profile": "Profile", "profile": "Profile",
"security": "Security", "security": "Security",
"activity": "Activity", "activity": "Activity",
"appearance": "Appearance", "preferences": "Preferences",
"notifications": "Notifications" "notifications": "Notifications",
"api-tokens": "Personal Access Tokens"
}, },
"tabs": { "tabs": {
"summary": "Summary", "summary": "Summary",
@ -1578,6 +1616,8 @@
} }
}, },
"states": { "states": {
"heading": "States",
"description": "Define and customize workflow states to track the progress of your work items.",
"describe_this_state_for_your_members": "Describe this state for your members.", "describe_this_state_for_your_members": "Describe this state for your members.",
"empty_state": { "empty_state": {
"title": "No states available for the {groupKey} group", "title": "No states available for the {groupKey} group",
@ -1585,6 +1625,8 @@
} }
}, },
"labels": { "labels": {
"heading": "Labels",
"description": "Create custom labels to categorize and organize your work items",
"label_title": "Label title", "label_title": "Label title",
"label_title_is_required": "Label title is required", "label_title_is_required": "Label title is required",
"label_max_char": "Label name should not exceed 255 characters", "label_max_char": "Label name should not exceed 255 characters",
@ -1593,9 +1635,11 @@
} }
}, },
"estimates": { "estimates": {
"heading": "Estimates",
"description": "Set up estimation systems to track and communicate the effort required for each work item.",
"label": "Estimates", "label": "Estimates",
"title": "Enable estimates for my project", "title": "Enable estimates for my project",
"description": "They help you in communicating complexity and workload of the team.", "enable_description": "They help you in communicating complexity and workload of the team.",
"no_estimate": "No estimate", "no_estimate": "No estimate",
"new": "New estimate system", "new": "New estimate system",
"create": { "create": {
@ -1677,6 +1721,8 @@
}, },
"automations": { "automations": {
"label": "Automations", "label": "Automations",
"heading": "Automations",
"description": "Configure automated actions to streamline your project management workflow and reduce manual tasks.",
"auto-archive": { "auto-archive": {
"title": "Auto-archive closed work items", "title": "Auto-archive closed work items",
"description": "Plane will auto archive work items that have been completed or canceled.", "description": "Plane will auto archive work items that have been completed or canceled.",

View file

@ -79,6 +79,8 @@ export interface IUserSettings {
workspace: { workspace: {
last_workspace_id: string | undefined; last_workspace_id: string | undefined;
last_workspace_slug: string | undefined; last_workspace_slug: string | undefined;
last_workspace_name: string | undefined;
last_workspace_logo: string | undefined;
fallback_workspace_id: string | undefined; fallback_workspace_id: string | undefined;
fallback_workspace_slug: string | undefined; fallback_workspace_slug: string | undefined;
invites: number | undefined; invites: number | undefined;

View file

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

View file

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

View file

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

View file

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

View file

@ -1,33 +0,0 @@
"use client";
import { FC, ReactNode } from "react";
// components
import { AppHeader } from "@/components/core";
// local components
import { ProjectSettingHeader } from "../header";
import { ProjectSettingsSidebar } from "./sidebar";
export interface IProjectSettingLayout {
children: ReactNode;
}
const ProjectSettingLayout: FC<IProjectSettingLayout> = (props) => {
const { children } = props;
return (
<>
<AppHeader header={<ProjectSettingHeader />} />
<div className="inset-y-0 flex flex-row vertical-scrollbar scrollbar-lg h-full w-full overflow-y-auto">
<div className="px-page-x !pr-0 py-page-y flex-shrink-0 overflow-y-hidden sm:hidden hidden md:block lg:block">
<ProjectSettingsSidebar />
</div>
<div className="flex flex-col relative w-full overflow-hidden">
<div className="h-full w-full overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-page-x md:px-9 py-page-y">
{children}
</div>
</div>
</div>
</>
);
};
export default ProjectSettingLayout;

View file

@ -1,73 +0,0 @@
"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

@ -1,79 +0,0 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// ui
import { Settings } from "lucide-react";
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs, CustomMenu, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
// hooks
import { useProject, useUserPermissions } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project";
export const ProjectSettingHeader: FC = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = useParams();
// store hooks
const { allowPermissions } = useUserPermissions();
const { loader } = useProject();
const { t } = useTranslation();
return (
<Header>
<Header.LeftItem>
<div>
<div className="z-50">
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<ProjectBreadcrumb />
<div className="hidden sm:hidden md:block lg:block">
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink label="Settings" icon={<Settings className="h-4 w-4 text-custom-text-300" />} />
}
/>
</div>
</Breadcrumbs>
</div>
</div>
<CustomMenu
className="flex-shrink-0 block sm:block md:hidden lg:hidden"
maxHeight="lg"
customButton={
<span className="text-xs px-1.5 py-1 border rounded-md text-custom-text-200 border-custom-border-300">
Settings
</span>
}
placement="bottom-start"
closeOnSelect
>
{PROJECT_SETTINGS_LINKS.map(
(item) =>
allowPermissions(
item.access,
EUserPermissionsLevel.PROJECT,
workspaceSlug.toString(),
projectId.toString()
) && (
<CustomMenu.MenuItem
key={item.key}
onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}${item.href}`)}
>
{t(item.i18n_label)}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</Header.LeftItem>
</Header>
);
});

View file

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

View file

@ -1,63 +0,0 @@
"use client";
import { FC, ReactNode } from "react";
import { observer } from "mobx-react";
// components
import { useParams, 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 { 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 WorkspaceSettingLayout: FC<IWorkspaceSettingLayout> = observer((props) => {
const { children } = props;
const { workspaceUserInfo } = useUserPermissions();
const pathname = usePathname();
const [workspaceSlug, suffix, route] = pathname.replace(/^\/|\/$/g, "").split("/"); // Regex removes leading and trailing slashes
// derived values
const userWorkspaceRole = workspaceUserInfo?.[workspaceSlug.toString()]?.role;
const isAuthorized =
pathname &&
workspaceSlug &&
userWorkspaceRole &&
WORKSPACE_SETTINGS_ACCESS[route ? `/${suffix}/${route}` : `/${suffix}`]?.includes(
userWorkspaceRole as EUserWorkspaceRoles
);
return (
<>
<AppHeader header={<WorkspaceSettingHeader />} />
<MobileWorkspaceSettingsTabs />
<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>
</>
)}
</div>
</>
);
});
export default WorkspaceSettingLayout;

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,25 @@
"use client";
import { CommandPalette } from "@/components/command-palette";
import { ContentWrapper } from "@/components/core";
import { SettingsContentLayout, 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>
<CommandPalette />
<main className="relative flex h-screen w-full flex-col overflow-hidden bg-custom-background-100">
{/* Header */}
<SettingsHeader />
{/* Content */}
<ContentWrapper className="px-4 md:pl-12 md:py-page-y md:flex w-full">
<SettingsContentLayout>{children}</SettingsContentLayout>
</ContentWrapper>
</main>
</WorkspaceAuthWrapper>
</AuthenticationWrapper>
);
}

View file

@ -6,6 +6,7 @@ import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens"; import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
// hooks // hooks
import { SettingsContentWrapper } from "@/components/settings";
import { useUserPermissions, useWorkspace } from "@/hooks/store"; import { useUserPermissions, useWorkspace } from "@/hooks/store";
// plane web components // plane web components
import { BillingRoot } from "@/plane-web/components/workspace"; import { BillingRoot } from "@/plane-web/components/workspace";
@ -19,14 +20,14 @@ const BillingSettingsPage = observer(() => {
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Billing & Plans` : undefined; const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Billing & Plans` : undefined;
if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { if (workspaceUserInfo && !canPerformWorkspaceAdminActions) {
return <NotAuthorizedView section="settings" />; return <NotAuthorizedView section="settings" className="h-auto" />;
} }
return ( return (
<> <SettingsContentWrapper size="lg">
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<BillingRoot /> <BillingRoot />
</> </SettingsContentWrapper>
); );
}); });

View file

@ -8,6 +8,7 @@ import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import ExportGuide from "@/components/exporter/guide"; import ExportGuide from "@/components/exporter/guide";
// helpers // helpers
import { SettingsContentWrapper, SettingsHeading } from "@/components/settings";
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
// hooks // hooks
import { useUserPermissions, useWorkspace } from "@/hooks/store"; import { useUserPermissions, useWorkspace } from "@/hooks/store";
@ -29,23 +30,24 @@ const ExportsPage = observer(() => {
// if user is not authorized to view this page // if user is not authorized to view this page
if (workspaceUserInfo && !canPerformWorkspaceMemberActions) { if (workspaceUserInfo && !canPerformWorkspaceMemberActions) {
return <NotAuthorizedView section="settings" />; return <NotAuthorizedView section="settings" className="h-auto" />;
} }
return ( return (
<> <SettingsContentWrapper size="lg">
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<div <div
className={cn("w-full overflow-y-auto", { className={cn("w-full", {
"opacity-60": !canPerformWorkspaceMemberActions, "opacity-60": !canPerformWorkspaceMemberActions,
})} })}
> >
<div className="flex items-center border-b border-custom-border-100 pb-3.5"> <SettingsHeading
<h3 className="text-xl font-medium">{t("workspace_settings.settings.exports.title")}</h3> title={t("workspace_settings.settings.exports.heading")}
</div> description={t("workspace_settings.settings.exports.description")}
/>
<ExportGuide /> <ExportGuide />
</div> </div>
</> </SettingsContentWrapper>
); );
}); });

View file

@ -3,40 +3,32 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// components // components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import IntegrationGuide from "@/components/integration/guide"; import IntegrationGuide from "@/components/integration/guide";
// hooks // hooks
import { SettingsContentWrapper, SettingsHeading } from "@/components/settings";
import { useUserPermissions, useWorkspace } from "@/hooks/store"; import { useUserPermissions, useWorkspace } from "@/hooks/store";
const ImportsPage = observer(() => { const ImportsPage = observer(() => {
// router
// store hooks // store hooks
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { allowPermissions } = useUserPermissions(); const { allowPermissions } = useUserPermissions();
// derived values // derived values
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Imports` : undefined; const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Imports` : undefined;
if (!isAdmin) if (!isAdmin) return <NotAuthorizedView section="settings" className="h-auto" />;
return (
<>
<PageHead title={pageTitle} />
<div className="mt-10 flex h-full w-full justify-center p-4">
<p className="text-sm text-custom-text-300">You are not authorized to access this page.</p>
</div>
</>
);
return ( return (
<> <SettingsContentWrapper size="lg">
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<section className="w-full overflow-y-auto"> <section className="w-full">
<div className="flex items-center border-b border-custom-border-100 pb-3.5"> <SettingsHeading title="Imports" />
<h3 className="text-xl font-medium">Imports</h3>
</div>
<IntegrationGuide /> <IntegrationGuide />
</section> </section>
</> </SettingsContentWrapper>
); );
}); });

View file

@ -4,8 +4,10 @@ import { useParams } from "next/navigation";
import useSWR from "swr"; import useSWR from "swr";
// components // components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { SingleIntegrationCard } from "@/components/integration"; import { SingleIntegrationCard } from "@/components/integration";
import { SettingsContentWrapper } from "@/components/settings";
import { IntegrationAndImportExportBanner, IntegrationsSettingsLoader } from "@/components/ui"; import { IntegrationAndImportExportBanner, IntegrationsSettingsLoader } from "@/components/ui";
// constants // constants
import { APP_INTEGRATIONS } from "@/constants/fetch-keys"; import { APP_INTEGRATIONS } from "@/constants/fetch-keys";
@ -26,23 +28,14 @@ const WorkspaceIntegrationsPage = observer(() => {
// derived values // derived values
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Integrations` : undefined; const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Integrations` : undefined;
if (!isAdmin)
return (
<>
<PageHead title={pageTitle} />
<div className="mt-10 flex h-full w-full justify-center">
<p className="text-sm text-custom-text-300">You are not authorized to access this page.</p>
</div>
</>
);
const { data: appIntegrations } = useSWR(workspaceSlug && isAdmin ? APP_INTEGRATIONS : null, () => const { data: appIntegrations } = useSWR(workspaceSlug && isAdmin ? APP_INTEGRATIONS : null, () =>
workspaceSlug && isAdmin ? integrationService.getAppIntegrationsList() : null workspaceSlug && isAdmin ? integrationService.getAppIntegrationsList() : null
); );
if (!isAdmin) return <NotAuthorizedView section="settings" className="h-auto" />;
return ( return (
<> <SettingsContentWrapper size="lg">
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<section className="w-full overflow-y-auto"> <section className="w-full overflow-y-auto">
<IntegrationAndImportExportBanner bannerName="Integrations" /> <IntegrationAndImportExportBanner bannerName="Integrations" />
@ -56,7 +49,7 @@ const WorkspaceIntegrationsPage = observer(() => {
)} )}
</div> </div>
</section> </section>
</> </SettingsContentWrapper>
); );
}); });

View file

@ -0,0 +1,58 @@
"use client";
import { FC, ReactNode } from "react";
import { observer } from "mobx-react";
// components
import { usePathname } from "next/navigation";
import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_ACCESS } from "@plane/constants";
// hooks
import { NotAuthorizedView } from "@/components/auth-screens";
import { CommandPalette } from "@/components/command-palette";
import { SettingsMobileNav } from "@/components/settings";
import { getWorkspaceActivePath, pathnameToAccessKey } from "@/components/settings/helper";
import { useUserPermissions } from "@/hooks/store";
// local components
import { WorkspaceSettingsSidebar } from "./sidebar";
export interface IWorkspaceSettingLayout {
children: ReactNode;
}
const WorkspaceSettingLayout: FC<IWorkspaceSettingLayout> = observer((props) => {
const { children } = props;
// store hooks
const { workspaceUserInfo } = useUserPermissions();
// next hooks
const pathname = usePathname();
// derived values
const { workspaceSlug, accessKey } = pathnameToAccessKey(pathname);
const userWorkspaceRole = workspaceUserInfo?.[workspaceSlug.toString()]?.role;
const isAuthorized: boolean | string =
pathname &&
workspaceSlug &&
userWorkspaceRole &&
WORKSPACE_SETTINGS_ACCESS[accessKey]?.includes(userWorkspaceRole as EUserWorkspaceRoles);
return (
<>
<CommandPalette />
<SettingsMobileNav
hamburgerContent={WorkspaceSettingsSidebar}
activePath={getWorkspaceActivePath(pathname) || ""}
/>
<div className="inset-y-0 flex flex-row w-full">
{workspaceUserInfo && !isAuthorized ? (
<NotAuthorizedView section="settings" className="h-auto" />
) : (
<div className="relative flex h-full w-full">
<div className="hidden md:block">{<WorkspaceSettingsSidebar />}</div>
{children}
</div>
)}
</div>
</>
);
});
export default WorkspaceSettingLayout;

View file

@ -14,6 +14,7 @@ import { Button, TOAST_TYPE, setToast } from "@plane/ui";
import { NotAuthorizedView } from "@/components/auth-screens"; import { NotAuthorizedView } from "@/components/auth-screens";
import { CountChip } from "@/components/common"; import { CountChip } from "@/components/common";
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { SettingsContentWrapper } from "@/components/settings";
import { WorkspaceMembersList } from "@/components/workspace"; import { WorkspaceMembersList } from "@/components/workspace";
// helpers // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
@ -95,11 +96,11 @@ const WorkspaceMembersSettingsPage = observer(() => {
// if user is not authorized to view this page // if user is not authorized to view this page
if (workspaceUserInfo && !canPerformWorkspaceMemberActions) { if (workspaceUserInfo && !canPerformWorkspaceMemberActions) {
return <NotAuthorizedView section="settings" />; return <NotAuthorizedView section="settings" className="h-auto" />;
} }
return ( return (
<> <SettingsContentWrapper size="lg">
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<SendWorkspaceInvitationModal <SendWorkspaceInvitationModal
isOpen={inviteModal} isOpen={inviteModal}
@ -107,7 +108,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
onSubmit={handleWorkspaceInvite} onSubmit={handleWorkspaceInvite}
/> />
<section <section
className={cn("w-full h-full overflow-y-auto", { className={cn("w-full h-full", {
"opacity-60": !canPerformWorkspaceMemberActions, "opacity-60": !canPerformWorkspaceMemberActions,
})} })}
> >
@ -137,7 +138,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
</div> </div>
<WorkspaceMembersList searchQuery={searchQuery} isAdmin={canPerformWorkspaceAdminActions} /> <WorkspaceMembersList searchQuery={searchQuery} isAdmin={canPerformWorkspaceAdminActions} />
</section> </section>
</> </SettingsContentWrapper>
); );
}); });

View file

@ -4,6 +4,7 @@ import { observer } from "mobx-react";
// components // components
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { SettingsContentWrapper } from "@/components/settings";
import { WorkspaceDetails } from "@/components/workspace"; import { WorkspaceDetails } from "@/components/workspace";
// hooks // hooks
import { useWorkspace } from "@/hooks/store"; import { useWorkspace } from "@/hooks/store";
@ -18,10 +19,10 @@ const WorkspaceSettingsPage = observer(() => {
: undefined; : undefined;
return ( return (
<> <SettingsContentWrapper>
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<WorkspaceDetails /> <WorkspaceDetails />
</> </SettingsContentWrapper>
); );
}); });

View file

@ -0,0 +1,73 @@
import { useParams, usePathname } from "next/navigation";
import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react";
import {
EUserPermissionsLevel,
GROUPED_WORKSPACE_SETTINGS,
WORKSPACE_SETTINGS_CATEGORIES,
EUserWorkspaceRoles,
EUserPermissions,
WORKSPACE_SETTINGS_CATEGORY,
} from "@plane/constants";
import { SettingsSidebar } from "@/components/settings";
import { useUserPermissions } from "@/hooks/store/user";
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
const ICONS = {
general: Building,
members: Users,
export: ArrowUpToLine,
"billing-and-plans": CreditCard,
webhooks: Webhook,
};
export const WorkspaceActionIcons = ({
type,
size,
className,
}: {
type: string;
size?: number;
className?: string;
}) => {
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} />;
};
type TWorkspaceSettingsSidebarProps = {
isMobile?: boolean;
};
export const WorkspaceSettingsSidebar = (props: TWorkspaceSettingsSidebarProps) => {
const { isMobile = false } = props;
// router
const pathname = usePathname();
const { workspaceSlug } = useParams(); // store hooks
const { allowPermissions } = useUserPermissions();
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
return (
<SettingsSidebar
isMobile={isMobile}
categories={WORKSPACE_SETTINGS_CATEGORIES.filter(
(category) =>
isAdmin || ![WORKSPACE_SETTINGS_CATEGORY.FEATURES, WORKSPACE_SETTINGS_CATEGORY.DEVELOPER].includes(category)
)}
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

@ -11,6 +11,7 @@ import { TOAST_TYPE, setToast } from "@plane/ui";
// components // components
import { LogoSpinner } from "@/components/common"; import { LogoSpinner } from "@/components/common";
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { SettingsContentWrapper } from "@/components/settings";
import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "@/components/web-hooks"; import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "@/components/web-hooks";
// hooks // hooks
import { useUserPermissions, useWebhook, useWorkspace } from "@/hooks/store"; import { useUserPermissions, useWebhook, useWorkspace } from "@/hooks/store";
@ -87,7 +88,7 @@ const WebhookDetailsPage = observer(() => {
); );
return ( return (
<> <SettingsContentWrapper>
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<DeleteWebhookModal isOpen={deleteWebhookModal} onClose={() => setDeleteWebhookModal(false)} /> <DeleteWebhookModal isOpen={deleteWebhookModal} onClose={() => setDeleteWebhookModal(false)} />
<div className="w-full space-y-8 overflow-y-auto"> <div className="w-full space-y-8 overflow-y-auto">
@ -96,7 +97,7 @@ const WebhookDetailsPage = observer(() => {
</div> </div>
{currentWebhook && <WebhookDeleteSection openDeleteModal={() => setDeleteWebhookModal(true)} />} {currentWebhook && <WebhookDeleteSection openDeleteModal={() => setDeleteWebhookModal(true)} />}
</div> </div>
</> </SettingsContentWrapper>
); );
}); });

View file

@ -7,11 +7,11 @@ import useSWR from "swr";
// plane imports // plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/ui";
// components // components
import { NotAuthorizedView } from "@/components/auth-screens"; import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { DetailedEmptyState } from "@/components/empty-state"; import { DetailedEmptyState } from "@/components/empty-state";
import { SettingsContentWrapper, SettingsHeading } from "@/components/settings";
import { WebhookSettingsLoader } from "@/components/ui"; import { WebhookSettingsLoader } from "@/components/ui";
import { WebhooksList, CreateWebhookModal } from "@/components/web-hooks"; import { WebhooksList, CreateWebhookModal } from "@/components/web-hooks";
// hooks // hooks
@ -48,15 +48,15 @@ const WebhooksListPage = observer(() => {
}, [showCreateWebhookModal, webhookSecretKey, clearSecretKey]); }, [showCreateWebhookModal, webhookSecretKey, clearSecretKey]);
if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { if (workspaceUserInfo && !canPerformWorkspaceAdminActions) {
return <NotAuthorizedView section="settings" />; return <NotAuthorizedView section="settings" className="h-auto" />;
} }
if (!webhooks) return <WebhookSettingsLoader />; if (!webhooks) return <WebhookSettingsLoader />;
return ( return (
<> <SettingsContentWrapper>
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<div className="w-full overflow-y-auto"> <div className="w-full">
<CreateWebhookModal <CreateWebhookModal
createWebhook={createWebhook} createWebhook={createWebhook}
clearSecretKey={clearSecretKey} clearSecretKey={clearSecretKey}
@ -66,35 +66,37 @@ const WebhooksListPage = observer(() => {
setShowCreateWebhookModal(false); setShowCreateWebhookModal(false);
}} }}
/> />
<SettingsHeading
title={t("workspace_settings.settings.webhooks.title")}
description={t("workspace_settings.settings.webhooks.description")}
button={{
label: t("workspace_settings.settings.webhooks.add_webhook"),
onClick: () => setShowCreateWebhookModal(true),
}}
/>
{Object.keys(webhooks).length > 0 ? ( {Object.keys(webhooks).length > 0 ? (
<div className="flex h-full w-full flex-col"> <div className="flex h-full w-full flex-col">
<div className="flex items-center justify-between gap-4 border-b border-custom-border-200 pb-3.5">
<div className="text-xl font-medium">{t("workspace_settings.settings.webhooks.title")}</div>
<Button variant="primary" size="sm" onClick={() => setShowCreateWebhookModal(true)}>
{t("workspace_settings.settings.webhooks.add_webhook")}
</Button>
</div>
<WebhooksList /> <WebhooksList />
</div> </div>
) : ( ) : (
<div className="flex h-full w-full flex-col"> <div className="flex h-full w-full flex-col">
<div className="flex items-center justify-between gap-4 border-b border-custom-border-200 pb-3.5">
<div className="text-xl font-medium">{t("workspace_settings.settings.webhooks.title")}</div>
<Button variant="primary" size="sm" onClick={() => setShowCreateWebhookModal(true)}>
{t("workspace_settings.settings.webhooks.add_webhook")}
</Button>
</div>
<div className="h-full w-full flex items-center justify-center"> <div className="h-full w-full flex items-center justify-center">
<DetailedEmptyState <DetailedEmptyState
className="!px-0 py-5"
title={t("workspace_settings.empty_state.webhooks.title")} title={t("workspace_settings.empty_state.webhooks.title")}
description={t("workspace_settings.empty_state.webhooks.description")} description={t("workspace_settings.empty_state.webhooks.description")}
assetPath={resolvedPath} assetPath={resolvedPath}
size="md"
primaryButton={{
text: t("workspace_settings.settings.webhooks.add_webhook"),
onClick: () => setShowCreateWebhookModal(true),
}}
/> />
</div> </div>
</div> </div>
)} )}
</div> </div>
</> </SettingsContentWrapper>
); );
}); });

View file

@ -0,0 +1,89 @@
"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 } from "@/components/profile";
// hooks
import { SettingsHeading } from "@/components/settings";
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 (
<div className="flex h-full w-full flex-col">
<SettingsHeading
title={t("account_settings.activity.heading")}
description={t("account_settings.activity.description")}
/>
<DetailedEmptyState
title={t("profile.empty_state.activity.title")}
description={t("profile.empty_state.activity.description")}
assetPath={resolvedPath}
className="w-full !px-0 justify-center mx-auto min-h-fit"
size="md"
/>
</div>
);
}
return (
<>
<PageHead title="Profile - Activity" />
<SettingsHeading
title={t("account_settings.activity.heading")}
description={t("account_settings.activity.description")}
/>
<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

@ -7,12 +7,12 @@ import useSWR from "swr";
// plane imports // plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/ui";
// component // component
import { ApiTokenListItem, CreateApiTokenModal } from "@/components/api-token"; import { ApiTokenListItem, CreateApiTokenModal } from "@/components/api-token";
import { NotAuthorizedView } from "@/components/auth-screens"; import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { DetailedEmptyState } from "@/components/empty-state"; import { DetailedEmptyState } from "@/components/empty-state";
import { SettingsHeading } from "@/components/settings";
import { APITokenSettingsLoader } from "@/components/ui"; import { APITokenSettingsLoader } from "@/components/ui";
import { API_TOKENS_LIST } from "@/constants/fetch-keys"; import { API_TOKENS_LIST } from "@/constants/fetch-keys";
// store hooks // store hooks
@ -48,7 +48,7 @@ const ApiTokensPage = observer(() => {
: undefined; : undefined;
if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { if (workspaceUserInfo && !canPerformWorkspaceAdminActions) {
return <NotAuthorizedView section="settings" />; return <NotAuthorizedView section="settings" className="h-auto" />;
} }
if (!tokens) { if (!tokens) {
@ -56,18 +56,20 @@ const ApiTokensPage = observer(() => {
} }
return ( return (
<> <div className="w-full">
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<CreateApiTokenModal isOpen={isCreateTokenModalOpen} onClose={() => setIsCreateTokenModalOpen(false)} /> <CreateApiTokenModal isOpen={isCreateTokenModalOpen} onClose={() => setIsCreateTokenModalOpen(false)} />
<section className="w-full overflow-y-auto"> <section className="w-full">
{tokens.length > 0 ? ( {tokens.length > 0 ? (
<> <>
<div className="flex items-center justify-between border-b border-custom-border-200 pb-3.5"> <SettingsHeading
<h3 className="text-xl font-medium">{t("workspace_settings.settings.api_tokens.title")}</h3> title={t("account_settings.api_tokens.heading")}
<Button variant="primary" onClick={() => setIsCreateTokenModalOpen(true)}> description={t("account_settings.api_tokens.description")}
{t("workspace_settings.settings.api_tokens.add_token")} button={{
</Button> label: t("workspace_settings.settings.api_tokens.add_token"),
</div> onClick: () => setIsCreateTokenModalOpen(true),
}}
/>
<div> <div>
{tokens.map((token) => ( {tokens.map((token) => (
<ApiTokenListItem key={token.id} token={token} /> <ApiTokenListItem key={token.id} token={token} />
@ -76,23 +78,31 @@ const ApiTokensPage = observer(() => {
</> </>
) : ( ) : (
<div className="flex h-full w-full flex-col"> <div className="flex h-full w-full flex-col">
<div className="flex items-center justify-between gap-4 border-b border-custom-border-200 pb-3.5"> <SettingsHeading
<h3 className="text-xl font-medium">{t("workspace_settings.settings.api_tokens.title")}</h3> title={t("account_settings.api_tokens.heading")}
<Button variant="primary" onClick={() => setIsCreateTokenModalOpen(true)}> description={t("account_settings.api_tokens.description")}
{t("workspace_settings.settings.api_tokens.add_token")} button={{
</Button> label: t("workspace_settings.settings.api_tokens.add_token"),
</div> onClick: () => setIsCreateTokenModalOpen(true),
}}
/>
<div className="h-full w-full flex items-center justify-center"> <div className="h-full w-full flex items-center justify-center">
<DetailedEmptyState <DetailedEmptyState
title={t("workspace_settings.empty_state.api_tokens.title")} title={t("workspace_settings.empty_state.api_tokens.title")}
description={t("workspace_settings.empty_state.api_tokens.description")} description={t("workspace_settings.empty_state.api_tokens.description")}
assetPath={resolvedPath} assetPath={resolvedPath}
className="w-full !px-0 justify-center mx-auto"
size="md"
primaryButton={{
text: t("workspace_settings.settings.api_tokens.add_token"),
onClick: () => setIsCreateTokenModalOpen(true),
}}
/> />
</div> </div>
</div> </div>
)} )}
</section> </section>
</> </div>
); );
}); });

View file

@ -0,0 +1,33 @@
"use client";
import { ReactNode } from "react";
// components
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { SettingsContentWrapper, SettingsMobileNav } from "@/components/settings";
import { getProfileActivePath } from "@/components/settings/helper";
import { ProfileSidebar } from "./sidebar";
type Props = {
children: ReactNode;
};
const ProfileSettingsLayout = observer((props: Props) => {
const { children } = props;
// router
const pathname = usePathname();
return (
<>
<SettingsMobileNav hamburgerContent={ProfileSidebar} activePath={getProfileActivePath(pathname) || ""} />
<div className="relative flex h-full w-full">
<div className="hidden md:block">
<ProfileSidebar />
</div>
<SettingsContentWrapper>{children}</SettingsContentWrapper>
</div>
</>
);
});
export default ProfileSettingsLayout;

View file

@ -0,0 +1,37 @@
"use client";
import useSWR from "swr";
// components
import { useTranslation } from "@plane/i18n";
import { PageHead } from "@/components/core";
import { EmailNotificationForm } from "@/components/profile/notification";
import { SettingsHeading } from "@/components/settings";
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")}`} />
<SettingsHeading
title={t("account_settings.notifications.heading")}
description={t("account_settings.notifications.description")}
/>
<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,46 @@
"use client";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// components
import { LogoSpinner } from "@/components/common";
import { PageHead } from "@/components/core";
import { PreferencesList } from "@/components/preferences/list";
import { LanguageTimezone, ProfileSettingContentHeader } from "@/components/profile";
// hooks
import { SettingsHeading } from "@/components/settings";
import { useUserProfile } from "@/hooks/store";
const ProfileAppearancePage = observer(() => {
const { t } = useTranslation();
// hooks
const { data: userProfile } = useUserProfile();
return (
<>
<PageHead title={`${t("profile.label")} - ${t("preferences")}`} />
{userProfile ? (
<>
<div className="flex flex-col gap-4 w-full">
<div>
<SettingsHeading
title={t("account_settings.preferences.heading")}
description={t("account_settings.preferences.description")}
/>
<PreferencesList />
</div>
<div>
<ProfileSettingContentHeader title={t("language_and_time")} />
<LanguageTimezone />
</div>
</div>
</>
) : (
<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,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 } 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,82 @@
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { CircleUser, Activity, Bell, CircleUserRound, KeyRound, Settings2, Blocks, Lock } from "lucide-react";
import {
EUserPermissions,
EUserPermissionsLevel,
GROUPED_PROFILE_SETTINGS,
PROFILE_SETTINGS_CATEGORIES,
PROFILE_SETTINGS_CATEGORY,
} from "@plane/constants";
import { SettingsSidebar } from "@/components/settings";
import { getFileURL } from "@/helpers/file.helper";
import { useUser, useUserPermissions } from "@/hooks/store/user";
const ICONS = {
profile: CircleUser,
security: Lock,
activity: Activity,
preferences: Settings2,
notifications: Bell,
"api-tokens": KeyRound,
connections: Blocks,
};
export const ProjectActionIcons = ({ type, size, className }: { type: string; size?: number; className?: string }) => {
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} />;
};
type TProfileSidebarProps = {
isMobile?: boolean;
};
export const ProfileSidebar = observer((props: TProfileSidebarProps) => {
const { isMobile = false } = props;
// router
const pathname = usePathname();
const { workspaceSlug } = useParams();
// store hooks
const { data: currentUser } = useUser();
const { allowPermissions } = useUserPermissions();
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
return (
<SettingsSidebar
isMobile={isMobile}
categories={PROFILE_SETTINGS_CATEGORIES.filter(
(category) => isAdmin || category !== PROFILE_SETTINGS_CATEGORY.DEVELOPER
)}
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

@ -13,6 +13,7 @@ import { NotAuthorizedView } from "@/components/auth-screens";
import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation"; import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation";
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
// hooks // hooks
import { SettingsContentWrapper, SettingsHeading } from "@/components/settings";
import { useProject, useUserPermissions } from "@/hooks/store"; import { useProject, useUserPermissions } from "@/hooks/store";
const AutomationSettingsPage = observer(() => { const AutomationSettingsPage = observer(() => {
@ -43,20 +44,21 @@ const AutomationSettingsPage = observer(() => {
const pageTitle = projectDetails?.name ? `${projectDetails?.name} - Automations` : undefined; const pageTitle = projectDetails?.name ? `${projectDetails?.name} - Automations` : undefined;
if (workspaceUserInfo && !canPerformProjectAdminActions) { if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView />; return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
} }
return ( return (
<> <SettingsContentWrapper>
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<section className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "opacity-60"}`}> <section className={`w-full ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
<div className="flex flex-col items-start border-b border-custom-border-100 pb-3.5"> <SettingsHeading
<h3 className="text-xl font-medium leading-normal">{t("project_settings.automations.label")}</h3> title={t("project_settings.automations.heading")}
</div> description={t("project_settings.automations.description")}
/>
<AutoArchiveAutomation handleChange={handleChange} /> <AutoArchiveAutomation handleChange={handleChange} />
<AutoCloseAutomation handleChange={handleChange} /> <AutoCloseAutomation handleChange={handleChange} />
</section> </section>
</> </SettingsContentWrapper>
); );
}); });

View file

@ -8,6 +8,7 @@ import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { EstimateRoot } from "@/components/estimates"; import { EstimateRoot } from "@/components/estimates";
// hooks // hooks
import { SettingsContentWrapper } from "@/components/settings";
import { useProject, useUserPermissions } from "@/hooks/store"; import { useProject, useUserPermissions } from "@/hooks/store";
const EstimatesSettingsPage = observer(() => { const EstimatesSettingsPage = observer(() => {
@ -23,22 +24,20 @@ const EstimatesSettingsPage = observer(() => {
if (!workspaceSlug || !projectId) return <></>; if (!workspaceSlug || !projectId) return <></>;
if (workspaceUserInfo && !canPerformProjectAdminActions) { if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView />; return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
} }
return ( return (
<> <SettingsContentWrapper>
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<div <div className={`w-full ${canPerformProjectAdminActions ? "" : "pointer-events-none opacity-60"}`}>
className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "pointer-events-none opacity-60"}`}
>
<EstimateRoot <EstimateRoot
workspaceSlug={workspaceSlug?.toString()} workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()} projectId={projectId?.toString()}
isAdmin={canPerformProjectAdminActions} isAdmin={canPerformProjectAdminActions}
/> />
</div> </div>
</> </SettingsContentWrapper>
); );
}); });

View file

@ -8,6 +8,7 @@ import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { ProjectFeaturesList } from "@/components/project"; import { ProjectFeaturesList } from "@/components/project";
// hooks // hooks
import { SettingsContentWrapper } from "@/components/settings";
import { useProject, useUserPermissions } from "@/hooks/store"; import { useProject, useUserPermissions } from "@/hooks/store";
const FeaturesSettingsPage = observer(() => { const FeaturesSettingsPage = observer(() => {
@ -23,20 +24,20 @@ const FeaturesSettingsPage = observer(() => {
if (!workspaceSlug || !projectId) return null; if (!workspaceSlug || !projectId) return null;
if (workspaceUserInfo && !canPerformProjectAdminActions) { if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView />; return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
} }
return ( return (
<> <SettingsContentWrapper>
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<section className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "opacity-60"}`}> <section className={`w-full ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
<ProjectFeaturesList <ProjectFeaturesList
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()} projectId={projectId.toString()}
isAdmin={canPerformProjectAdminActions} isAdmin={canPerformProjectAdminActions}
/> />
</section> </section>
</> </SettingsContentWrapper>
); );
}); });

View file

@ -10,6 +10,7 @@ import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { ProjectSettingsLabelList } from "@/components/labels"; import { ProjectSettingsLabelList } from "@/components/labels";
// hooks // hooks
import { SettingsContentWrapper } from "@/components/settings";
import { useProject, useUserPermissions } from "@/hooks/store"; import { useProject, useUserPermissions } from "@/hooks/store";
const LabelsSettingsPage = observer(() => { const LabelsSettingsPage = observer(() => {
@ -38,19 +39,19 @@ const LabelsSettingsPage = observer(() => {
element, element,
}) })
); );
}, [scrollableContainerRef?.current]); }, []);
if (workspaceUserInfo && !canPerformProjectMemberActions) { if (workspaceUserInfo && !canPerformProjectMemberActions) {
return <NotAuthorizedView section="settings" isProjectView />; return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
} }
return ( return (
<> <SettingsContentWrapper>
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<div ref={scrollableContainerRef} className="h-full w-full gap-10 overflow-y-auto"> <div ref={scrollableContainerRef} className="h-full w-full gap-10">
<ProjectSettingsLabelList /> <ProjectSettingsLabelList />
</div> </div>
</> </SettingsContentWrapper>
); );
}); });

View file

@ -7,6 +7,7 @@ import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { ProjectMemberList, ProjectSettingsMemberDefaults } from "@/components/project"; import { ProjectMemberList, ProjectSettingsMemberDefaults } from "@/components/project";
// hooks // hooks
import { SettingsContentWrapper } from "@/components/settings";
import { useProject, useUserPermissions } from "@/hooks/store"; import { useProject, useUserPermissions } from "@/hooks/store";
const MembersSettingsPage = observer(() => { const MembersSettingsPage = observer(() => {
@ -23,17 +24,17 @@ const MembersSettingsPage = observer(() => {
const canPerformProjectMemberActions = isProjectMemberOrAdmin || isWorkspaceAdmin; const canPerformProjectMemberActions = isProjectMemberOrAdmin || isWorkspaceAdmin;
if (workspaceUserInfo && !canPerformProjectMemberActions) { if (workspaceUserInfo && !canPerformProjectMemberActions) {
return <NotAuthorizedView section="settings" isProjectView />; return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
} }
return ( return (
<> <SettingsContentWrapper size="lg">
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<section className={`w-full overflow-y-auto`}> <section className={`w-full`}>
<ProjectSettingsMemberDefaults /> <ProjectSettingsMemberDefaults />
<ProjectMemberList /> <ProjectMemberList />
</section> </section>
</> </SettingsContentWrapper>
); );
}); });

View file

@ -16,9 +16,9 @@ import {
ProjectDetailsFormLoader, ProjectDetailsFormLoader,
} from "@/components/project"; } from "@/components/project";
// hooks // hooks
import { SettingsContentWrapper } from "@/components/settings";
import { useProject, useUserPermissions } from "@/hooks/store"; import { useProject, useUserPermissions } from "@/hooks/store";
const ProjectSettingsPage = observer(() => {
const GeneralSettingsPage = observer(() => {
// states // states
const [selectProject, setSelectedProject] = useState<string | null>(null); const [selectProject, setSelectedProject] = useState<string | null>(null);
const [archiveProject, setArchiveProject] = useState<boolean>(false); const [archiveProject, setArchiveProject] = useState<boolean>(false);
@ -45,7 +45,7 @@ const GeneralSettingsPage = observer(() => {
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - General Settings` : undefined; const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - General Settings` : undefined;
return ( return (
<> <SettingsContentWrapper>
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
{currentProjectDetails && workspaceSlug && projectId && ( {currentProjectDetails && workspaceSlug && projectId && (
<> <>
@ -64,7 +64,7 @@ const GeneralSettingsPage = observer(() => {
</> </>
)} )}
<div className={`w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}> <div className={`w-full ${isAdmin ? "" : "opacity-60"}`}>
{currentProjectDetails && workspaceSlug && projectId && !isLoading ? ( {currentProjectDetails && workspaceSlug && projectId && !isLoading ? (
<ProjectDetailsForm <ProjectDetailsForm
project={currentProjectDetails} project={currentProjectDetails}
@ -89,8 +89,8 @@ const GeneralSettingsPage = observer(() => {
</> </>
)} )}
</div> </div>
</> </SettingsContentWrapper>
); );
}); });
export default GeneralSettingsPage; export default ProjectSettingsPage;

View file

@ -9,6 +9,7 @@ import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
import { ProjectStateRoot } from "@/components/project-states"; import { ProjectStateRoot } from "@/components/project-states";
// hook // hook
import { SettingsContentWrapper, SettingsHeading } from "@/components/settings";
import { useProject, useUserPermissions } from "@/hooks/store"; import { useProject, useUserPermissions } from "@/hooks/store";
const StatesSettingsPage = observer(() => { const StatesSettingsPage = observer(() => {
@ -28,19 +29,22 @@ const StatesSettingsPage = observer(() => {
); );
if (workspaceUserInfo && !canPerformProjectMemberActions) { if (workspaceUserInfo && !canPerformProjectMemberActions) {
return <NotAuthorizedView section="settings" isProjectView />; return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
} }
return ( return (
<> <SettingsContentWrapper>
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<div className="flex items-center border-b border-custom-border-100"> <div className="w-full">
<h3 className="text-xl font-medium">{t("common.states")}</h3> <SettingsHeading
title={t("project_settings.states.heading")}
description={t("project_settings.states.description")}
/>
{workspaceSlug && projectId && (
<ProjectStateRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
)}
</div> </div>
{workspaceSlug && projectId && ( </SettingsContentWrapper>
<ProjectStateRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
)}
</>
); );
}); });

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 { SettingsMobileNav } from "@/components/settings";
import { getProjectActivePath } from "@/components/settings/helper";
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/projects/${joinedProjectIds[0]}`);
}
}, [joinedProjectIds, router, workspaceSlug, projectId]);
return (
<>
<SettingsMobileNav hamburgerContent={ProjectSettingsSidebar} activePath={getProjectActivePath(pathname) || ""} />
<ProjectAuthWrapper workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()}>
<div className="relative flex h-full w-full">
<div className="hidden md:block">{projectId && <ProjectSettingsSidebar />}</div>
{children}
</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

@ -11,7 +11,7 @@ import { setPromiseToast } from "@plane/ui";
// components // components
import { LogoSpinner } from "@/components/common"; import { LogoSpinner } from "@/components/common";
import { ThemeSwitch, PageHead, CustomThemeSelector } from "@/components/core"; import { ThemeSwitch, PageHead, CustomThemeSelector } from "@/components/core";
import { ProfileSettingContentHeader, ProfileSettingContentWrapper, StartOfWeekPreference } from "@/components/profile"; import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile";
// helpers // helpers
import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper"; import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper";
// hooks // hooks
@ -75,7 +75,6 @@ const ProfileAppearancePage = observer(() => {
</div> </div>
</div> </div>
{userProfile?.theme?.theme === "custom" && <CustomThemeSelector applyThemeChange={applyThemeChange} />} {userProfile?.theme?.theme === "custom" && <CustomThemeSelector applyThemeChange={applyThemeChange} />}
<StartOfWeekPreference />
</ProfileSettingContentWrapper> </ProfileSettingContentWrapper>
) : ( ) : (
<div className="grid h-full w-full place-items-center px-4 sm:px-0"> <div className="grid h-full w-full place-items-center px-4 sm:px-0">

View file

@ -0,0 +1,7 @@
import { StartOfWeekPreference } from "@/components/profile/start-of-week-preference";
import { ThemeSwitcher } from "./theme-switcher";
export const PREFERENCE_COMPONENTS = {
theme: ThemeSwitcher,
start_of_week: StartOfWeekPreference,
};

View file

@ -0,0 +1,105 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { observer } from "mobx-react";
import { useTheme } from "next-themes";
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 { CustomThemeSelector, ThemeSwitch } from "@/components/core";
// helpers
import { PreferencesSection } from "@/components/preferences/section";
import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper";
// hooks
import { useUserProfile } from "@/hooks/store";
export const ThemeSwitcher = observer(
(props: {
option: {
id: string;
title: string;
description: string;
};
}) => {
// hooks
const { setTheme } = useTheme();
const { data: userProfile, updateUserTheme } = useUserProfile();
// states
const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null);
const { t } = useTranslation();
// initialize theme
useEffect(() => {
if (!userProfile?.theme?.theme) return;
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile.theme.theme);
if (userThemeOption) {
setCurrentTheme(userThemeOption);
}
}, [userProfile?.theme?.theme]);
// handlers
const applyThemeChange = useCallback(
(theme: Partial<IUserTheme>) => {
const themeValue = theme?.theme || "system";
setTheme(themeValue);
if (theme?.theme === "custom" && theme?.palette) {
const defaultPalette = "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5";
const palette = theme.palette !== ",,,," ? theme.palette : defaultPalette;
applyTheme(palette, false);
} else {
unsetCustomCssVariables();
}
},
[setTheme]
);
const handleThemeChange = useCallback(
async (themeOption: I_THEME_OPTION) => {
try {
applyThemeChange({ theme: themeOption.value });
const updatePromise = updateUserTheme({ theme: themeOption.value });
setPromiseToast(updatePromise, {
loading: "Updating theme...",
success: {
title: "Success!",
message: () => "Theme updated successfully!",
},
error: {
title: "Error!",
message: () => "Failed to update the theme",
},
});
} catch (error) {
console.error("Error updating theme:", error);
}
},
[applyThemeChange, updateUserTheme]
);
if (!userProfile) return null;
return (
<>
<PreferencesSection
title={t(props.option.title)}
description={t(props.option.description)}
control={
<div className="">
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />
</div>
}
/>
{userProfile.theme?.theme === "custom" && <CustomThemeSelector applyThemeChange={applyThemeChange} />}
</>
);
}
);

View file

@ -6,9 +6,11 @@ import {
EProductSubscriptionEnum, EProductSubscriptionEnum,
SUBSCRIPTION_WITH_BILLING_FREQUENCY, SUBSCRIPTION_WITH_BILLING_FREQUENCY,
} from "@plane/constants"; } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TBillingFrequency, TProductBillingFrequency } from "@plane/types"; import { TBillingFrequency, TProductBillingFrequency } from "@plane/types";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// components // components
import { SettingsHeading } from "@/components/settings";
import { getSubscriptionTextColor } from "@/components/workspace/billing/subscription"; import { getSubscriptionTextColor } from "@/components/workspace/billing/subscription";
// local imports // local imports
import { PlansComparison } from "./comparison/root"; import { PlansComparison } from "./comparison/root";
@ -20,6 +22,7 @@ export const BillingRoot = observer(() => {
const [productBillingFrequency, setProductBillingFrequency] = useState<TProductBillingFrequency>( const [productBillingFrequency, setProductBillingFrequency] = useState<TProductBillingFrequency>(
DEFAULT_PRODUCT_BILLING_FREQUENCY DEFAULT_PRODUCT_BILLING_FREQUENCY
); );
const { t } = useTranslation();
/** /**
* Retrieves the billing frequency for a given subscription type * Retrieves the billing frequency for a given subscription type
@ -56,11 +59,10 @@ export const BillingRoot = observer(() => {
return ( return (
<section className="relative size-full flex flex-col overflow-y-auto scrollbar-hide"> <section className="relative size-full flex flex-col overflow-y-auto scrollbar-hide">
<div> <SettingsHeading
<div className="flex items-center"> title={t("workspace_settings.settings.billing_and_plans.heading")}
<h3 className="text-xl font-medium flex gap-4">Billing and plans</h3> description={t("workspace_settings.settings.billing_and_plans.description")}
</div> />
</div>
<div <div
className={cn( className={cn(

View file

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

View file

@ -12,17 +12,18 @@ type Props = {
actionButton?: React.ReactNode; actionButton?: React.ReactNode;
section?: "settings" | "general"; section?: "settings" | "general";
isProjectView?: boolean; isProjectView?: boolean;
className?: string;
}; };
export const NotAuthorizedView: React.FC<Props> = observer((props) => { export const NotAuthorizedView: React.FC<Props> = observer((props) => {
const { actionButton, section = "general", isProjectView = false } = props; const { actionButton, section = "general", isProjectView = false, className } = props;
// assets // assets
const settingAsset = isProjectView ? ProjectNotAuthorizedImg : WorkspaceNotAuthorizedImg; const settingAsset = isProjectView ? ProjectNotAuthorizedImg : WorkspaceNotAuthorizedImg;
const asset = section === "settings" ? settingAsset : Unauthorized; const asset = section === "settings" ? settingAsset : Unauthorized;
return ( return (
<DefaultLayout> <DefaultLayout className={className}>
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-custom-background-100 text-center"> <div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-custom-background-100 text-center">
<div className="h-44 w-72"> <div className="h-44 w-72">
<Image src={asset} height="176" width="288" alt="ProjectSettingImg" /> <Image src={asset} height="176" width="288" alt="ProjectSettingImg" />

View file

@ -8,7 +8,7 @@ import { Transition, Dialog } from "@headlessui/react";
// plane imports // plane imports
import { ACCEPTED_AVATAR_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE, MAX_FILE_SIZE } from "@plane/constants"; import { ACCEPTED_AVATAR_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE, MAX_FILE_SIZE } from "@plane/constants";
import { EFileAssetType } from "@plane/types/src/enums"; import { EFileAssetType } from "@plane/types/src/enums";
import { Button } from "@plane/ui"; import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// helpers // helpers
import { getAssetIdFromUrl, getFileURL } from "@/helpers/file.helper"; import { getAssetIdFromUrl, getFileURL } from "@/helpers/file.helper";
import { checkURLValidity } from "@/helpers/string.helper"; import { checkURLValidity } from "@/helpers/string.helper";
@ -71,9 +71,13 @@ export const WorkspaceImageUploadModal: React.FC<Props> = observer((props) => {
); );
updateWorkspaceLogo(workspaceSlug.toString(), asset_url); updateWorkspaceLogo(workspaceSlug.toString(), asset_url);
onSuccess(asset_url); onSuccess(asset_url);
} catch (error) { } catch (error: any) {
console.log("error", error); console.log("error", error);
throw new Error("Error in uploading file."); setToast({
type: TOAST_TYPE.ERROR,
title: "Error",
message: error.error || "Something went wrong",
});
} finally { } finally {
setIsImageUploading(false); setIsImageUploading(false);
} }

View file

@ -1,13 +1,10 @@
"use client"; "use client";
import { FC } from "react"; import { FC } from "react";
import Image from "next/image";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/ui";
// public images // public images
import EstimateEmptyDarkImage from "@/public/empty-state/estimates/dark.svg"; import { DetailedEmptyState } from "../empty-state";
import EstimateEmptyLightImage from "@/public/empty-state/estimates/light.svg";
type TEstimateEmptyScreen = { type TEstimateEmptyScreen = {
onButtonClick: () => void; onButtonClick: () => void;
@ -20,28 +17,17 @@ export const EstimateEmptyScreen: FC<TEstimateEmptyScreen> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const emptyScreenImage = resolvedTheme === "light" ? EstimateEmptyLightImage : EstimateEmptyDarkImage; const resolvedPath = `/empty-state/project-settings/estimates-${resolvedTheme === "light" ? "light" : "dark"}.png`;
return ( return (
<div className="relative flex flex-col justify-center items-center text-center gap-8 border border-custom-border-300 rounded bg-custom-background-90 py-10"> <DetailedEmptyState
<div className="flex-shrink-0 w-[120px] h-[120px] overflow-hidden relative flex justify-center items-center"> title={t("project_settings.empty_state.estimates.title")}
<Image description={t("project_settings.empty_state.estimates.description")}
src={emptyScreenImage} assetPath={resolvedPath}
alt="Empty estimate image" className="w-full !px-0 !py-4"
width={100} primaryButton={{
height={100} text: t("project_settings.empty_state.estimates.primary_button"),
className="object-contain w-full h-full" onClick: onButtonClick,
/> }}
</div> />
<div className="space-y-1.5">
<h3 className="text-xl font-semibold text-custom-text-100">
{t("project_settings.empty_state.estimates.title")}
</h3>
<p className="text-sm text-custom-text-300">{t("project_settings.empty_state.estimates.description")}</p>
</div>
<div>
<Button onClick={onButtonClick}>{t("project_settings.empty_state.estimates.primary_button")}</Button>
</div>
</div>
); );
}; };

View file

@ -15,6 +15,7 @@ import {
import { useProject, useProjectEstimates } from "@/hooks/store"; import { useProject, useProjectEstimates } from "@/hooks/store";
// plane web components // plane web components
import { UpdateEstimateModal } from "@/plane-web/components/estimates"; import { UpdateEstimateModal } from "@/plane-web/components/estimates";
import { SettingsHeading } from "../settings";
type TEstimateRoot = { type TEstimateRoot = {
workspaceSlug: string; workspaceSlug: string;
@ -46,9 +47,11 @@ export const EstimateRoot: FC<TEstimateRoot> = observer((props) => {
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{/* header */} {/* header */}
<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("common.estimates")}</h3> <SettingsHeading
</div> title={t("project_settings.estimates.heading")}
description={t("project_settings.estimates.description")}
/>
{/* current active estimate section */} {/* current active estimate section */}
{currentActiveEstimateId ? ( {currentActiveEstimateId ? (
@ -57,7 +60,7 @@ export const EstimateRoot: FC<TEstimateRoot> = observer((props) => {
<div className="relative border-b border-custom-border-200 pb-4 flex justify-between items-center gap-3"> <div className="relative border-b border-custom-border-200 pb-4 flex justify-between items-center gap-3">
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-lg font-medium text-custom-text-100">{t("project_settings.estimates.title")}</h3> <h3 className="text-lg font-medium text-custom-text-100">{t("project_settings.estimates.title")}</h3>
<p className="text-sm text-custom-text-200">{t("project_settings.estimates.description")}</p> <p className="text-sm text-custom-text-200">{t("project_settings.estimates.enable_description")}</p>
</div> </div>
<EstimateDisableSwitch workspaceSlug={workspaceSlug} projectId={projectId} isAdmin={isAdmin} /> <EstimateDisableSwitch workspaceSlug={workspaceSlug} projectId={projectId} isAdmin={isAdmin} />
</div> </div>

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"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Image from "next/image";
import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation"; import { useParams, useSearchParams } from "next/navigation";
import useSWR, { mutate } from "swr"; import { 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 { EXPORT_SERVICES_LIST } from "@/constants/fetch-keys"; import { EXPORT_SERVICES_LIST } from "@/constants/fetch-keys";
// hooks import { ExportForm } from "./export-form";
import { useProject, useUser, useUserPermissions } from "@/hooks/store"; import { PrevExports } from "./prev-exports";
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 "";
}
};
const IntegrationGuide = observer(() => { const IntegrationGuide = observer(() => {
// states
const [refreshing, setRefreshing] = useState(false);
const per_page = 10;
const [cursor, setCursor] = useState<string | undefined>(`10:0:0`);
// router // router
const router = useAppRouter();
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const provider = searchParams.get("provider"); const provider = searchParams.get("provider");
// plane hooks // state
const { t } = useTranslation(); const per_page = 10;
// store hooks const [cursor, setCursor] = useState<string | undefined>(`10:0:0`);
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]);
return ( return (
<> <>
<div className="h-full w-full"> <div className="h-full w-full">
<> <>
<div> <ExportForm
{EXPORTERS_LIST.map((service) => ( workspaceSlug={workspaceSlug as string}
<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}
provider={provider} provider={provider}
mutateServices={() => mutate(EXPORT_SERVICES_LIST(workspaceSlug as string, `${cursor}`, `${per_page}`))} 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> </div>
</> </>
); );

View file

@ -0,0 +1,137 @@
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-[40px] 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}
className="w-full !px-0"
size="sm"
/>
</div>
)
) : (
<ImportExportSettingsLoader />
)}
</div>
</div>
);
});

View file

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

View file

@ -72,7 +72,7 @@ export const ProjectArchivedEmptyState: React.FC = observer(() => {
assetPath={archivedIssuesResolvedPath} assetPath={archivedIssuesResolvedPath}
primaryButton={{ primaryButton={{
text: t("project_issues.empty_state.no_archived_issues.primary_button.text"), 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/projects/${projectId}/automations`),
disabled: !canPerformEmptyStateActions, disabled: !canPerformEmptyStateActions,
}} }}
/> />

View file

@ -19,6 +19,7 @@ import {
// hooks // hooks
import { useLabel, useUserPermissions } from "@/hooks/store"; import { useLabel, useUserPermissions } from "@/hooks/store";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { SettingsHeading } from "../settings";
// plane web imports // plane web imports
export const ProjectSettingsLabelList: React.FC = observer(() => { export const ProjectSettingsLabelList: React.FC = observer(() => {
@ -75,14 +76,16 @@ export const ProjectSettingsLabelList: React.FC = observer(() => {
data={selectDeleteLabel ?? null} data={selectDeleteLabel ?? null}
onClose={() => setSelectDeleteLabel(null)} onClose={() => setSelectDeleteLabel(null)}
/> />
<div className="flex items-center justify-between border-b border-custom-border-100 pb-3.5"> <SettingsHeading
<h3 className="text-xl font-medium">Labels</h3> title={t("project_settings.labels.heading")}
{isEditable && ( description={t("project_settings.labels.description")}
<Button variant="primary" onClick={newLabel} size="sm"> button={{
{t("common.add_label")} label: t("common.add_label"),
</Button> onClick: newLabel,
)} }}
</div> showButton={isEditable}
/>
<div className="w-full py-2"> <div className="w-full py-2">
{showLabelForm && ( {showLabelForm && (
<div className="my-2 w-full rounded border border-custom-border-200 px-3.5 py-2"> <div className="my-2 w-full rounded border border-custom-border-200 px-3.5 py-2">
@ -106,6 +109,7 @@ export const ProjectSettingsLabelList: React.FC = observer(() => {
title={t("project_settings.empty_state.labels.title")} title={t("project_settings.empty_state.labels.title")}
description={t("project_settings.empty_state.labels.description")} description={t("project_settings.empty_state.labels.description")}
assetPath={resolvedPath} assetPath={resolvedPath}
className="w-full !px-0 !py-4"
/> />
</div> </div>
) : ( ) : (

View file

@ -0,0 +1,11 @@
import { PREFERENCE_OPTIONS } from "@plane/constants";
import { PREFERENCE_COMPONENTS } from "@/plane-web/components/preferences/config";
export const PreferencesList = () => (
<div className="py-6 space-y-6">
{PREFERENCE_OPTIONS.map((option) => {
const Component = PREFERENCE_COMPONENTS[option.id as keyof typeof PREFERENCE_COMPONENTS];
return <Component key={option.id} option={option} />;
})}
</div>
);

View file

@ -0,0 +1,15 @@
interface SettingsSectionProps {
title: string;
description: string;
control: React.ReactNode;
}
export const PreferencesSection = ({ title, description, control }: SettingsSectionProps) => (
<div className="flex w-full justify-between gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-base font-medium text-custom-text-100">{title}</h4>
<p className="text-sm text-custom-text-200">{description}</p>
</div>
<div className="col-span-12 sm:col-span-6 my-auto">{control}</div>
</div>
);

View file

@ -81,8 +81,8 @@ export const ProfileActivityListPage: React.FC<Props> = observer((props) => {
</div> </div>
)} )}
<span className="ring-6 flex h-6 w-6 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white"> <span className="ring-6 flex h-6 w-6 p-2 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white">
<MessageSquare className="h-6 w-6 !text-2xl text-custom-text-200" aria-hidden="true" /> <MessageSquare className="!text-2xl text-custom-text-200" aria-hidden="true" />
</span> </span>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">

View file

@ -1,18 +1,20 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { ChevronDown, CircleUserRound } from "lucide-react"; import { ChevronDown, CircleUserRound, InfoIcon } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
// plane imports // plane imports
import { USER_ROLES } from "@plane/constants"; import { USER_ROLES } from "@plane/constants";
import { useTranslation, SUPPORTED_LANGUAGES } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import type { IUser, TUserProfile } from "@plane/types"; import type { IUser, TUserProfile } from "@plane/types";
import { Button, CustomSelect, Input, TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui"; import { Button, CustomSelect, Input, TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui";
// components // components
import { getButtonStyling } from "@plane/ui/src/button";
import { cn } from "@plane/utils";
import { DeactivateAccountModal } from "@/components/account"; import { DeactivateAccountModal } from "@/components/account";
import { ImagePickerPopover, UserImageUploadModal } from "@/components/core"; import { ImagePickerPopover, UserImageUploadModal } from "@/components/core";
import { TimezoneSelect } from "@/components/global";
// constants
// helpers // helpers
import { getFileURL } from "@/helpers/file.helper"; import { getFileURL } from "@/helpers/file.helper";
// hooks // hooks
@ -39,6 +41,7 @@ export type TProfileFormProps = {
export const ProfileForm = observer((props: TProfileFormProps) => { export const ProfileForm = observer((props: TProfileFormProps) => {
const { user, profile } = props; const { user, profile } = props;
const { workspaceSlug } = useParams();
// states // states
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false); const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
@ -73,12 +76,6 @@ export const ProfileForm = observer((props: TProfileFormProps) => {
const { data: currentUser, updateCurrentUser } = useUser(); const { data: currentUser, updateCurrentUser } = useUser();
const { updateUserProfile } = useUserProfile(); const { updateUserProfile } = useUserProfile();
const getLanguageLabel = (value: string) => {
const selectedLanguage = SUPPORTED_LANGUAGES.find((l) => l.value === value);
if (!selectedLanguage) return value;
return selectedLanguage.label;
};
const handleProfilePictureDelete = async (url: string | null | undefined) => { const handleProfilePictureDelete = async (url: string | null | undefined) => {
if (!url) return; if (!url) return;
await updateCurrentUser({ await updateCurrentUser({
@ -111,17 +108,16 @@ export const ProfileForm = observer((props: TProfileFormProps) => {
last_name: formData.last_name, last_name: formData.last_name,
avatar_url: formData.avatar_url, avatar_url: formData.avatar_url,
display_name: formData?.display_name, display_name: formData?.display_name,
user_timezone: formData.user_timezone,
}; };
// if unsplash or a pre-defined image is uploaded, delete the old uploaded asset // if unsplash or a pre-defined image is uploaded, delete the old uploaded asset
if (formData.cover_image_url?.startsWith("http")) { if (formData.cover_image_url?.startsWith("http")) {
userPayload.cover_image_url = formData.cover_image_url;
userPayload.cover_image = formData.cover_image_url; userPayload.cover_image = formData.cover_image_url;
userPayload.cover_image_asset = null; userPayload.cover_image_asset = null;
} }
const profilePayload: Partial<TUserProfile> = { const profilePayload: Partial<TUserProfile> = {
role: formData.role, role: formData.role,
language: formData.language,
}; };
const updateCurrentUserDetail = updateCurrentUser(userPayload).finally(() => setIsLoading(false)); const updateCurrentUserDetail = updateCurrentUser(userPayload).finally(() => setIsLoading(false));
@ -163,7 +159,17 @@ export const ProfileForm = observer((props: TProfileFormProps) => {
/> />
)} )}
/> />
<form onSubmit={handleSubmit(onSubmit)}> <div className="w-full flex text-custom-primary-200 bg-custom-primary-100/10 rounded-md p-2 gap-2 items-center mb-4">
<InfoIcon className="h-4 w-4 flex-shrink-0" />
<div className="text-sm font-medium flex-1">{t("settings_moved_to_preferences")}</div>
<Link
href={`/${workspaceSlug}/settings/account/preferences`}
className={cn(getButtonStyling("neutral-primary", "sm"))}
>
{t("go_to_preferences")}
</Link>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="w-full">
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="relative h-44 w-full"> <div className="relative h-44 w-full">
<img <img
@ -368,59 +374,6 @@ export const ProfileForm = observer((props: TProfileFormProps) => {
</div> </div>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-x-6 gap-y-4">
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">
{t("timezone")}&nbsp;
<span className="text-red-500">*</span>
</h4>
<Controller
name="user_timezone"
control={control}
rules={{ required: "Please select a timezone" }}
render={({ field: { value, onChange } }) => (
<TimezoneSelect
value={value}
onChange={(value: string) => {
onChange(value);
}}
error={Boolean(errors.user_timezone)}
/>
)}
/>
{errors.user_timezone && <span className="text-xs text-red-500">{errors.user_timezone.message}</span>}
</div>
<div className="flex flex-col gap-1">
<div className="flex gap-2 items-center">
<h4 className="text-sm font-medium text-custom-text-200">{t("language")} </h4>
<div className="w-fit cursor-pointer rounded-2xl text-custom-primary-200 bg-custom-primary-100/20 text-center font-medium outline-none text-xs px-2">
Alpha
</div>
</div>
<Controller
control={control}
name="language"
rules={{ required: "Please select a language" }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
label={value ? getLanguageLabel(value) : "Select a language"}
onChange={onChange}
buttonClassName={errors.language ? "border-red-500" : "border-none"}
className="rounded-md border-[0.5px] !border-custom-border-200"
optionsClassName="w-full"
input
>
{SUPPORTED_LANGUAGES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
</div>
<div className="flex items-center justify-between pt-6 pb-8"> <div className="flex items-center justify-between pt-6 pb-8">
<Button variant="primary" type="submit" loading={isLoading}> <Button variant="primary" type="submit" loading={isLoading}>
{isLoading ? t("saving") : t("save_changes")} {isLoading ? t("saving") : t("save_changes")}
@ -429,7 +382,7 @@ export const ProfileForm = observer((props: TProfileFormProps) => {
</div> </div>
</div> </div>
</form> </form>
<Disclosure as="div" className="border-t border-custom-border-100"> <Disclosure as="div" className="border-t border-custom-border-100 w-full">
{({ open }) => ( {({ open }) => (
<> <>
<Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between py-4"> <Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between py-4">

View file

@ -6,4 +6,5 @@ export * from "./time";
export * from "./profile-setting-content-wrapper"; export * from "./profile-setting-content-wrapper";
export * from "./profile-setting-content-header"; export * from "./profile-setting-content-header";
export * from "./form"; export * from "./form";
export * from "./preferences/language-timezone";
export * from "./start-of-week-preference"; export * from "./start-of-week-preference";

View file

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

View file

@ -0,0 +1,100 @@
import { observer } from "mobx-react";
import { SUPPORTED_LANGUAGES, useTranslation } from "@plane/i18n";
import { CustomSelect, TOAST_TYPE, setToast } from "@plane/ui";
import { TimezoneSelect } from "@/components/global";
import { useUser, useUserProfile } from "@/hooks/store";
export const LanguageTimezone = observer(() => {
// store hooks
const {
data: user,
updateCurrentUser,
userProfile: { data: profile },
} = useUser();
const { updateUserProfile } = useUserProfile();
const { t } = useTranslation();
const handleTimezoneChange = (value: string) => {
updateCurrentUser({ user_timezone: value })
.then(() => {
setToast({
title: "Success!",
message: "Timezone updated successfully",
type: TOAST_TYPE.SUCCESS,
});
})
.catch(() => {
setToast({
title: "Error!",
message: "Failed to update timezone",
type: TOAST_TYPE.ERROR,
});
});
};
const handleLanguageChange = (value: string) => {
updateUserProfile({ language: value })
.then(() => {
setToast({
title: "Success!",
message: "Language updated successfully",
type: TOAST_TYPE.SUCCESS,
});
})
.catch(() => {
setToast({
title: "Error!",
message: "Failed to update language",
type: TOAST_TYPE.ERROR,
});
});
};
const getLanguageLabel = (value: string) => {
const selectedLanguage = SUPPORTED_LANGUAGES.find((l) => l.value === value);
if (!selectedLanguage) return value;
return selectedLanguage.label;
};
return (
<div className="py-6">
<div className="flex flex-col gap-x-6 gap-y-6">
<div className="flex flex-col gap-1">
<div className="flex gap-4 sm:gap-16 w-full justify-between">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-base font-medium text-custom-text-100"> {t("timezone")}&nbsp;</h4>
<p className="text-sm text-custom-text-200">{t("timezone_setting")}</p>
</div>
<div className="col-span-12 sm:col-span-6 my-auto">
<TimezoneSelect value={user?.user_timezone || "Asia/Kolkata"} onChange={handleTimezoneChange} />
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<div className="flex gap-4 sm:gap-16 w-full justify-between">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-base font-medium text-custom-text-100"> {t("language")}&nbsp;</h4>
<p className="text-sm text-custom-text-200">{t("language_setting")}</p>
</div>
<div className="col-span-12 sm:col-span-6 my-auto">
<CustomSelect
value={profile?.language}
label={profile?.language ? getLanguageLabel(profile?.language) : "Select a language"}
onChange={handleLanguageChange}
buttonClassName={"border-none"}
className="rounded-md border !border-custom-border-200"
optionsClassName="w-full"
input
>
{SUPPORTED_LANGUAGES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
</div>
</div>
</div>
</div>
</div>
);
});

View file

@ -9,7 +9,7 @@ type Props = {
export const ProfileSettingContentHeader: FC<Props> = (props) => { export const ProfileSettingContentHeader: FC<Props> = (props) => {
const { title, description } = props; const { title, description } = props;
return ( 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> <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>} {description && <div className="text-sm font-normal text-custom-text-300">{description}</div>}
</div> </div>

View file

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

View file

@ -7,49 +7,50 @@ import { EStartOfTheWeek, START_OF_THE_WEEK_OPTIONS } from "@plane/constants";
import { CustomSelect, setToast, TOAST_TYPE } from "@plane/ui"; import { CustomSelect, setToast, TOAST_TYPE } from "@plane/ui";
// hooks // hooks
import { useUserProfile } from "@/hooks/store"; import { useUserProfile } from "@/hooks/store";
import { PreferencesSection } from "../preferences/section";
const getStartOfWeekLabel = (startOfWeek: EStartOfTheWeek) => const getStartOfWeekLabel = (startOfWeek: EStartOfTheWeek) =>
START_OF_THE_WEEK_OPTIONS.find((option) => option.value === startOfWeek)?.label; START_OF_THE_WEEK_OPTIONS.find((option) => option.value === startOfWeek)?.label;
export const StartOfWeekPreference = observer(() => { export const StartOfWeekPreference = observer((props: { option: { title: string; description: string } }) => {
// hooks // hooks
const { data: userProfile, updateUserProfile } = useUserProfile(); const { data: userProfile, updateUserProfile } = useUserProfile();
return ( return (
<div className="grid grid-cols-12 gap-4 py-6 sm:gap-16"> <PreferencesSection
<div className="col-span-12 sm:col-span-6"> title={props.option.title}
<h4 className="text-lg font-semibold text-custom-text-100">First day of the week</h4> description={props.option.description}
<p className="text-sm text-custom-text-200">This will change how all calendars in your app look.</p> control={
</div> <div className="">
<div className="col-span-12 sm:col-span-6"> <CustomSelect
<CustomSelect value={userProfile.start_of_the_week}
value={userProfile.start_of_the_week} label={getStartOfWeekLabel(userProfile.start_of_the_week)}
label={getStartOfWeekLabel(userProfile.start_of_the_week)} onChange={(val: number) => {
onChange={(val: number) => { updateUserProfile({ start_of_the_week: val })
updateUserProfile({ start_of_the_week: val }) .then(() => {
.then(() => { setToast({
setToast({ type: TOAST_TYPE.SUCCESS,
type: TOAST_TYPE.SUCCESS, title: "Success",
title: "Success", message: "First day of the week updated successfully",
message: "First day of the week updated successfully", });
})
.catch(() => {
setToast({ type: TOAST_TYPE.ERROR, title: "Update failed", message: "Please try again later." });
}); });
}) }}
.catch(() => { input
setToast({ type: TOAST_TYPE.ERROR, title: "Update failed", message: "Please try again later." }); maxHeight="lg"
}); >
}} <>
input {START_OF_THE_WEEK_OPTIONS.map((day) => (
maxHeight="lg" <CustomSelect.Option key={day.value} value={day.value}>
> {day.label}
<> </CustomSelect.Option>
{START_OF_THE_WEEK_OPTIONS.map((day) => ( ))}
<CustomSelect.Option key={day.value} value={day.value}> </>
{day.label} </CustomSelect>
</CustomSelect.Option> </div>
))} }
</> />
</CustomSelect>
</div>
</div>
); );
}); });

View file

@ -127,7 +127,7 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
const MENU_ITEMS: TContextMenuItem[] = [ const MENU_ITEMS: TContextMenuItem[] = [
{ {
key: "settings", key: "settings",
action: () => router.push(`/${workspaceSlug}/projects/${project.id}/settings`, {}, { showProgressBar: false }), action: () => router.push(`/${workspaceSlug}/settings/projects/${project.id}`, {}, { showProgressBar: false }),
title: "Settings", title: "Settings",
icon: Settings, icon: Settings,
shouldRender: !isArchived && (hasAdminRole || hasMemberRole), shouldRender: !isArchived && (hasAdminRole || hasMemberRole),
@ -344,7 +344,7 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
href={`/${workspaceSlug}/projects/${project.id}/settings`} href={`/${workspaceSlug}/settings/projects/${project.id}`}
> >
<Settings className="h-3.5 w-3.5" /> <Settings className="h-3.5 w-3.5" />
</Link> </Link>

View file

@ -78,9 +78,9 @@ export const ProjectMemberListItem: React.FC<Props> = observer((props) => {
data={(memberDetails?.filter((member): member is IProjectMemberDetails => member !== null) ?? []) as any} data={(memberDetails?.filter((member): member is IProjectMemberDetails => member !== null) ?? []) as any}
keyExtractor={(rowData) => rowData?.member.id ?? ""} keyExtractor={(rowData) => rowData?.member.id ?? ""}
tHeadClassName="border-b border-custom-border-100" tHeadClassName="border-b border-custom-border-100"
thClassName="text-left font-medium divide-x-0" thClassName="text-left font-medium divide-x-0 text-custom-text-400"
tBodyClassName="divide-y-0" tBodyClassName="divide-y-0"
tBodyTrClassName="divide-x-0" tBodyTrClassName="divide-x-0 p-4 h-[40px] text-custom-text-200"
tHeadTrClassName="divide-x-0" tHeadTrClassName="divide-x-0"
/> />
</> </>

View file

@ -13,6 +13,7 @@ import { ProjectMemberListItem, SendProjectInvitationModal } from "@/components/
// ui // ui
import { MembersSettingsLoader } from "@/components/ui"; import { MembersSettingsLoader } from "@/components/ui";
import { useEventTracker, useMember, useUserPermissions } from "@/hooks/store"; import { useEventTracker, useMember, useUserPermissions } from "@/hooks/store";
import { SettingsHeading } from "../settings";
export const ProjectMemberList: React.FC = observer(() => { export const ProjectMemberList: React.FC = observer(() => {
// router // router
@ -48,31 +49,35 @@ export const ProjectMemberList: React.FC = observer(() => {
return ( return (
<> <>
<SendProjectInvitationModal isOpen={inviteModal} onClose={() => setInviteModal(false)} /> <SendProjectInvitationModal isOpen={inviteModal} onClose={() => setInviteModal(false)} />
<SettingsHeading
title={t("members")}
appendToRight={
<div className="flex gap-2">
<div className="ml-auto flex items-center justify-start gap-1 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5">
<Search className="h-3.5 w-3.5" />
<input
className="w-full max-w-[234px] border-none bg-transparent text-sm focus:outline-none placeholder:text-custom-text-400"
placeholder="Search"
value={searchQuery}
autoFocus
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{isAdmin && (
<Button
variant="primary"
onClick={() => {
setTrackElement("PROJECT_SETTINGS_MEMBERS_PAGE_HEADER");
setInviteModal(true);
}}
>
{t("add_member")}
</Button>
)}
</div>
}
/>
<div className="flex items-center justify-between gap-4 border-b border-custom-border-100 py-3.5 overflow-x-hidden">
<h4 className="text-xl font-medium">{t("members")}</h4>
<div className="ml-auto flex items-center justify-start gap-1 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5">
<Search className="h-3.5 w-3.5" />
<input
className="w-full max-w-[234px] border-none bg-transparent text-sm focus:outline-none placeholder:text-custom-text-400"
placeholder="Search"
value={searchQuery}
autoFocus
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{isAdmin && (
<Button
variant="primary"
onClick={() => {
setTrackElement("PROJECT_SETTINGS_MEMBERS_PAGE_HEADER");
setInviteModal(true);
}}
>
{t("add_member")}
</Button>
)}
</div>
{!projectMemberIds ? ( {!projectMemberIds ? (
<MembersSettingsLoader /> <MembersSettingsLoader />
) : ( ) : (

View file

@ -6,6 +6,7 @@ import { useTranslation } from "@plane/i18n";
import { IProject } from "@plane/types"; import { IProject } from "@plane/types";
import { ToggleSwitch, Tooltip, setPromiseToast } from "@plane/ui"; import { ToggleSwitch, Tooltip, setPromiseToast } from "@plane/ui";
// hooks // hooks
import { SettingsHeading } from "@/components/settings";
import { useEventTracker, useProject, useUser } from "@/hooks/store"; import { useEventTracker, useProject, useUser } from "@/hooks/store";
// plane web components // plane web components
import { UpgradeBadge } from "@/plane-web/components/workspace"; import { UpgradeBadge } from "@/plane-web/components/workspace";
@ -61,14 +62,11 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
<div className="space-y-6"> <div className="space-y-6">
{Object.entries(PROJECT_FEATURES_LIST).map(([featureSectionKey, feature]) => ( {Object.entries(PROJECT_FEATURES_LIST).map(([featureSectionKey, feature]) => (
<div key={featureSectionKey} className=""> <div key={featureSectionKey} className="">
<div className="flex flex-col justify-center pb-2 border-b border-custom-border-100"> <SettingsHeading title={t(feature.key)} description={t(`${feature.key}_description`)} />
<h3 className="text-xl font-medium">{t(feature.key)}</h3>
<h4 className="text-sm leading-5 text-custom-text-200">{t(`${feature.key}_description`)}</h4>
</div>
{Object.entries(feature.featureList).map(([featureItemKey, featureItem]) => ( {Object.entries(feature.featureList).map(([featureItemKey, featureItem]) => (
<div <div
key={featureItemKey} key={featureItemKey}
className="gap-x-8 gap-y-2 border-b border-custom-border-100 bg-custom-background-100 pb-2 pt-4" className="gap-x-8 gap-y-2 border-b border-custom-border-100 bg-custom-background-100 py-4"
> >
<div key={featureItemKey} className="flex items-center justify-between"> <div key={featureItemKey} className="flex items-center justify-between">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">

View file

@ -45,10 +45,10 @@ export const NameColumn: React.FC<NameProps> = (props) => {
{({}) => ( {({}) => (
<div className="relative group"> <div className="relative group">
<div className="flex items-center gap-x-4 gap-y-2 w-72 justify-between"> <div className="flex items-center gap-x-4 gap-y-2 w-72 justify-between">
<div className="flex items-center gap-x-4 gap-y-2 flex-1"> <div className="flex items-center gap-x-2 gap-y-2 flex-1">
{avatar_url && avatar_url.trim() !== "" ? ( {avatar_url && avatar_url.trim() !== "" ? (
<Link href={`/${workspaceSlug}/profile/${id}`}> <Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex h-6 w-6 items-center justify-center rounded-full p-4 capitalize text-white"> <span className="relative flex h-4 w-4 items-center justify-center rounded-full capitalize text-white">
<img <img
src={getFileURL(avatar_url)} src={getFileURL(avatar_url)}
className="absolute left-0 top-0 h-full w-full rounded-full object-cover" className="absolute left-0 top-0 h-full w-full rounded-full object-cover"
@ -58,7 +58,7 @@ export const NameColumn: React.FC<NameProps> = (props) => {
</Link> </Link>
) : ( ) : (
<Link href={`/${workspaceSlug}/profile/${id}`}> <Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-gray-700 p-4 capitalize text-white"> <span className="relative flex h-4 w-4 text-xs items-center justify-center rounded-full bg-gray-700 capitalize text-white">
{(email ?? display_name ?? "?")[0]} {(email ?? display_name ?? "?")[0]}
</span> </span>
</Link> </Link>

View file

@ -0,0 +1,22 @@
import { ReactNode } from "react";
import { observer } from "mobx-react";
import { cn } from "@plane/utils";
type TProps = {
children: ReactNode;
size?: "lg" | "md";
};
export const SettingsContentWrapper = observer((props: TProps) => {
const { children, size = "md" } = props;
return (
<div
className={cn("flex flex-col w-full items-center mx-auto py-4 md:py-0", {
"p-4 max-w-[800px] 2xl:max-w-[1000px]": size === "md",
"md:px-16": size === "lg",
})}
>
<div className="pb-20 w-full">{children}</div>
</div>
);
});

View file

@ -0,0 +1,84 @@
"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, useWorkspace } from "@/hooks/store";
import { WorkspaceLogo } from "../workspace";
import SettingsTabs from "./tabs";
export const SettingsHeader = observer(() => {
// hooks
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
const { isScrolled } = useUserSettings();
// redirect url for normal mode
return (
<div
className={cn(
"bg-custom-background-90 px-4 py-4 gap-2 md:px-12 md:py-8 transition-all duration-300 ease-in-out relative",
{
"!pt-4 flex md:flex-col": isScrolled,
}
)}
>
<Link
href={`/${currentWorkspace?.slug}`}
className={cn(
getButtonStyling("neutral-primary", "sm"),
"md:absolute left-2 top-9 group flex gap-2 text-custom-text-300 mb-4 border border-transparent w-fit rounded-lg ",
"h-6 w-6 rounded-lg p-1 bg-custom-background-100 border-custom-border-200 ",
isScrolled ? "-mt-1 " : "hidden p-0 overflow-hidden items-center pr-2 border-none"
)}
>
<ChevronLeftIcon className={cn("h-4 w-4", !isScrolled ? "my-auto h-0" : "")} />
</Link>
{/* Breadcrumb */}
<Link
href={`/${currentWorkspace?.slug}`}
className={cn(
"group flex gap-2 text-custom-text-300 mb-4 border border-transparent w-fit rounded-lg",
!isScrolled ? "hover:bg-custom-background-100 hover:border-custom-border-200 items-center pr-2 " : " h-0 m-0"
)}
>
<button
className={cn(
getButtonStyling("neutral-primary", "sm"),
"h-6 w-6 rounded-lg p-1 hover:bg-custom-background-100 hover:border-custom-border-200",
"group-hover:bg-custom-background-100 group-hover:border-transparent",
{ "h-0 hidden": isScrolled }
)}
>
<ChevronLeftIcon className={cn("h-4 w-4", !isScrolled ? "my-auto" : "")} />
</button>
<div
className={cn("flex gap-2 h-full w-full transition-[height] duration-300 ease-in-out", {
"h-0 w-0 overflow-hidden": isScrolled,
})}
>
<div className="text-sm my-auto font-semibold text-custom-text-200">{t("back_to_workspace")}</div>
{/* Last workspace */}
<div className="flex items-center gap-1">
<WorkspaceLogo
name={currentWorkspace?.name || ""}
logo={currentWorkspace?.logo_url || ""}
classNames="my-auto size-4 text-xs"
/>
<div className="text-xs my-auto text-custom-text-100 font-semibold">{currentWorkspace?.name}</div>
</div>
</div>
</Link>
<div className="flex flex-col gap-2">
{/* Description */}
<div className="text-custom-text-100 font-semibold text-2xl">{t("settings")}</div>
{!isScrolled && <div className="text-custom-text-200 text-base">{t("settings_description")}</div>}
{/* Actions */}
<SettingsTabs />
</div>
</div>
);
});

View file

@ -0,0 +1,29 @@
import { Button } from "@plane/ui";
type Props = {
title: string | React.ReactNode;
description?: string;
appendToRight?: React.ReactNode;
showButton?: boolean;
button?: {
label: string;
onClick: () => void;
};
};
export const SettingsHeading = ({ title, description, button, appendToRight, showButton = true }: Props) => (
<div className="flex items-center justify-between border-b border-custom-border-100 pb-3.5">
<div className="flex flex-col items-start gap-1">
{typeof title === "string" ? <h3 className="text-xl font-medium">{title}</h3> : title}
{description && <div className="text-sm text-custom-text-300">{description}</div>}
</div>
{button && showButton && (
<Button variant="primary" onClick={button.onClick} size="sm">
{button.label}
</Button>
)}
{appendToRight}
</div>
);
export default SettingsHeading;

View file

@ -0,0 +1,56 @@
import { GROUPED_PROFILE_SETTINGS, GROUPED_WORKSPACE_SETTINGS } from "@plane/constants";
import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants";
const hrefToLabelMap = (options: Record<string, Array<{ href: string; i18n_label: string; [key: string]: any }>>) =>
Object.values(options)
.flat()
.reduce(
(acc, setting) => {
acc[setting.href] = setting.i18n_label;
return acc;
},
{} as Record<string, string>
);
const workspaceHrefToLabelMap = hrefToLabelMap(GROUPED_WORKSPACE_SETTINGS);
const profiletHrefToLabelMap = hrefToLabelMap(GROUPED_PROFILE_SETTINGS);
const projectHrefToLabelMap = PROJECT_SETTINGS_LINKS.reduce(
(acc, setting) => {
acc[setting.href] = setting.i18n_label;
return acc;
},
{} as Record<string, string>
);
export 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}` || "" };
};
export const getWorkspaceActivePath = (pathname: string) => {
const parts = pathname.split("/").filter(Boolean);
const settingsIndex = parts.indexOf("settings");
if (settingsIndex === -1) return null;
const subPath = "/" + parts.slice(settingsIndex, settingsIndex + 2).join("/");
return workspaceHrefToLabelMap[subPath];
};
export const getProfileActivePath = (pathname: string) => {
const parts = pathname.split("/").filter(Boolean);
const settingsIndex = parts.indexOf("settings");
if (settingsIndex === -1) return null;
const subPath = "/" + parts.slice(settingsIndex, settingsIndex + 3).join("/");
return profiletHrefToLabelMap[subPath];
};
export const getProjectActivePath = (pathname: string) => {
const parts = pathname.split("/").filter(Boolean);
const settingsIndex = parts.indexOf("settings");
if (settingsIndex === -1) return null;
const subPath = parts.slice(settingsIndex + 3, settingsIndex + 4).join("/");
return subPath ? projectHrefToLabelMap["/" + subPath] : projectHrefToLabelMap[subPath];
};

View file

@ -0,0 +1,6 @@
export * from "./header";
export * from "./sidebar";
export * from "./content-wrapper";
export * from "./mobile";
export * from "./heading";
export * from "./layout";

View file

@ -0,0 +1,46 @@
import { useEffect, useRef } from "react";
import throttle from "lodash/throttle";
import { observer } from "mobx-react";
import { useUserSettings } from "@/hooks/store";
export const SettingsContentLayout = observer(({ children }: { children: React.ReactNode }) => {
// refs
const ref = useRef<HTMLDivElement>(null);
const scrolledRef = useRef(false);
// store hooks
const { toggleIsScrolled, isScrolled } = useUserSettings();
useEffect(() => {
toggleIsScrolled(false);
const container = ref.current;
if (!container) return;
const handleScroll = () => {
const scrollTop = container.scrollTop;
if (container.scrollHeight > container.clientHeight || scrolledRef.current) {
const _isScrolled = scrollTop > 0;
toggleIsScrolled(_isScrolled);
}
};
// Throttle the scroll handler to improve performance
// Set trailing to true to ensure the last call runs after the delay
const throttledHandleScroll = throttle(handleScroll, 150);
container.addEventListener("scroll", throttledHandleScroll);
return () => {
container.removeEventListener("scroll", throttledHandleScroll);
// Cancel any pending throttled invocations when unmounting
throttledHandleScroll.cancel();
};
}, []);
useEffect(() => {
scrolledRef.current = isScrolled;
}, [isScrolled]);
return (
<div className="w-full h-full min-h-full overflow-y-scroll " ref={ref}>
{children}
</div>
);
});

View file

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

View file

@ -0,0 +1,46 @@
import { useRef } from "react";
import { observer } from "mobx-react";
import { ChevronRight, Menu } from "lucide-react";
import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { useUserSettings } from "@/hooks/store";
type Props = {
hamburgerContent: React.ComponentType<{ isMobile: boolean }>;
activePath: string;
};
export const SettingsMobileNav = observer((props: Props) => {
const { hamburgerContent: HamburgerContent, activePath } = props;
// refs
const sidebarRef = useRef<HTMLDivElement>(null);
// store hooks
const { sidebarCollapsed, toggleSidebar } = useUserSettings();
const { t } = useTranslation();
useOutsideClickDetector(sidebarRef, () => {
if (!sidebarCollapsed) toggleSidebar(true);
});
return (
<div className="md:hidden">
<div className="border-b border-custom-border-100 py-3 flex items-center gap-4">
<div ref={sidebarRef} className="relative w-fit">
{!sidebarCollapsed && <HamburgerContent isMobile />}
<button
type="button"
className="z-50 group flex-shrink-0 size-6 grid place-items-center rounded border border-custom-border-200 transition-all bg-custom-background md:hidden"
onClick={() => toggleSidebar()}
>
<Menu className="size-3.5 text-custom-text-200 transition-all group-hover:text-custom-text-100" />
</button>
</div>
{/* path */}
<div className="flex items-center gap-2">
<ChevronRight className="size-4 text-custom-text-300" />
<span className="text-sm font-medium text-custom-text-200">{t(activePath)}</span>
</div>
</div>
</div>
);
});

View file

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

View file

@ -0,0 +1,78 @@
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, useUserSettings } 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();
const { toggleSidebar } = useUserSettings();
// 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/projects/${projectId}`);
return (
allowPermissions(
link.access,
EUserPermissionsLevel.PROJECT,
workspaceSlug?.toString() ?? "",
projectId?.toString() ?? ""
) && (
<Link
key={link.key}
href={`/${workspaceSlug}/settings/projects/${projectId}${link.href}`}
onClick={() => toggleSidebar(true)}
>
<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,49 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
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 = {
isMobile?: boolean;
};
export const ProjectSettingsSidebar = observer((props: TProjectSettingsSidebarProps) => {
const { isMobile = false } = props;
const { workspaceSlug } = useParams();
// store hooks
const { joinedProjectIds, projectMap } = useProject();
const groupedProject = joinedProjectIds.map((projectId) => ({
key: projectId,
i18n_label: projectMap[projectId].name,
href: `/settings/projects/${projectId}`,
icon: <Logo logo={projectMap[projectId].logo_props} />,
}));
return (
<SettingsSidebar
isMobile={isMobile}
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,33 @@
import { WorkspaceLogo } from "@/components/workspace";
import { getUserRole } from "@/helpers/user.helper";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { SubscriptionPill } from "@/plane-web/components/common";
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 pr-2">
<div className="flex w-full gap-3 items-center overflow-hidden">
<WorkspaceLogo
logo={currentWorkspace.logo_url ?? ""}
name={currentWorkspace.name ?? ""}
classNames="size-8 border border-custom-border-200"
/>
<div className="w-full overflow-hidden">
<div className="text-base font-medium text-custom-text-200 truncate text-ellipsis ">
{currentWorkspace.name ?? "Workspace"}
</div>
<div className="text-sm text-custom-text-300 capitalize">
{getUserRole(currentWorkspace.role)?.toLowerCase() || "guest"}
</div>
</div>
</div>
<div className="flex-shrink-0">
<SubscriptionPill workspace={currentWorkspace} />
</div>
</div>
);
};

View file

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

View file

@ -0,0 +1,93 @@
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";
import { useUserSettings } from "@/hooks/store";
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);
// hooks
const { toggleSidebar } = useUserSettings();
// 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, className: "w-4 h-4" })}
<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} onClick={() => toggleSidebar(true)}>
{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,75 @@
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils";
import { SettingsSidebarHeader } from "./header";
import SettingsSidebarNavItem, { TSettingItem } from "./nav-item";
interface SettingsSidebarProps {
isMobile?: boolean;
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 = observer((props: SettingsSidebarProps) => {
const {
isMobile = false,
customHeader,
categories,
groupedSettings,
workspaceSlug,
isActive,
shouldRender,
actionIcons,
appendItemsToTitle,
renderChildren,
} = props;
// hooks
const { t } = useTranslation();
return (
<div
className={cn("flex w-[250px] flex-col gap-2 flex-shrink-0 pb-5", {
"absolute left-0 top-[42px] z-50 h-fit max-h-[400px] overflow-scroll bg-custom-background-100 border border-custom-border-100 rounded shadow-sm p-4":
isMobile,
})}
>
{/* Header */}
<SettingsSidebarHeader customHeader={customHeader} />
{/* Navigation */}
<div className="divide-y divide-custom-border-100 overflow-x-hidden w-full">
{categories.map((category) => (
<div key={category} className="py-3 h-full">
<span className="text-sm font-semibold text-custom-text-350 capitalize mb-2">{t(category)}</span>
{groupedSettings[category].length > 0 && (
<div className="relative flex flex-col gap-0.5 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/projects/`,
},
};
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 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-200 ",
{
"bg-custom-background-100 text-custom-text-100 shadow-sm": isActive,
"hover:text-custom-text-100 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

@ -1,21 +1,24 @@
import range from "lodash/range"; import range from "lodash/range";
import { useTranslation } from "@plane/i18n";
export const APITokenSettingsLoader = () => ( export const APITokenSettingsLoader = () => {
<section className="w-full overflow-y-auto py-8 pr-9"> const { t } = useTranslation();
<div className="mb-2 flex items-center justify-between border-b border-custom-border-200 py-3.5"> return (
<h3 className="text-xl font-medium">API tokens</h3> <section className="w-full overflow-y-auto">
<span className="h-8 w-28 bg-custom-background-80 rounded" /> <div className="mb-2 flex items-center justify-between border-b border-custom-border-200 pb-3.5">
</div> <h3 className="text-xl font-medium">{t("workspace_settings.settings.api_tokens.title")}</h3>
<div className="divide-y-[0.5px] divide-custom-border-200"> <span className="h-8 w-28 bg-custom-background-80 rounded" />
{range(2).map((i) => ( </div>
<div key={i} className="flex flex-col gap-2 px-4 py-3"> <div className="divide-y-[0.5px] divide-custom-border-200">
<div className="flex items-center gap-2"> {range(2).map((i) => (
<span className="h-5 w-28 bg-custom-background-80 rounded" /> <div key={i} className="flex flex-col gap-2 py-3">
<span className="h-5 w-16 bg-custom-background-80 rounded" /> <div className="flex items-center gap-2">
<span className="h-5 w-28 bg-custom-background-80 rounded" />
<span className="h-5 w-16 bg-custom-background-80 rounded" />
</div>
<span className="h-5 w-36 bg-custom-background-80 rounded" />
</div> </div>
<span className="h-5 w-36 bg-custom-background-80 rounded" /> ))}
</div> </div>
))} </section>
</div> );
</section> };
);

View file

@ -13,7 +13,7 @@ import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
// constants // constants
// hooks // hooks
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
import { useEventTracker, useWorkspace } from "@/hooks/store"; import { useEventTracker, useUserSettings, useWorkspace } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
type Props = { type Props = {
@ -34,6 +34,8 @@ export const DeleteWorkspaceForm: React.FC<Props> = observer((props) => {
const { captureWorkspaceEvent } = useEventTracker(); const { captureWorkspaceEvent } = useEventTracker();
const { deleteWorkspace } = useWorkspace(); const { deleteWorkspace } = useWorkspace();
const { t } = useTranslation(); const { t } = useTranslation();
const { getWorkspaceRedirectionUrl } = useWorkspace();
const { fetchCurrentUserSettings } = useUserSettings();
// form info // form info
const { const {
control, control,
@ -58,9 +60,10 @@ export const DeleteWorkspaceForm: React.FC<Props> = observer((props) => {
if (!data || !canDelete) return; if (!data || !canDelete) return;
await deleteWorkspace(data.slug) await deleteWorkspace(data.slug)
.then(() => { .then(async () => {
await fetchCurrentUserSettings();
handleClose(); handleClose();
router.push("/profile"); router.push(getWorkspaceRedirectionUrl());
captureWorkspaceEvent({ captureWorkspaceEvent({
eventName: WORKSPACE_DELETED, eventName: WORKSPACE_DELETED,
payload: { payload: {

View file

@ -43,10 +43,10 @@ export const NameColumn: React.FC<NameProps> = (props) => {
{({}) => ( {({}) => (
<div className="relative group"> <div className="relative group">
<div className="flex items-center gap-x-4 gap-y-2 w-72 justify-between"> <div className="flex items-center gap-x-4 gap-y-2 w-72 justify-between">
<div className="flex items-center gap-x-4 gap-y-2 flex-1"> <div className="flex items-center gap-x-2 gap-y-2 flex-1">
{avatar_url && avatar_url.trim() !== "" ? ( {avatar_url && avatar_url.trim() !== "" ? (
<Link href={`/${workspaceSlug}/profile/${id}`}> <Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex h-6 w-6 items-center justify-center rounded-full p-4 capitalize text-white"> <span className="relative flex h-6 w-6 items-center justify-center rounded-full capitalize text-white">
<img <img
src={getFileURL(avatar_url)} src={getFileURL(avatar_url)}
className="absolute left-0 top-0 h-full w-full rounded-full object-cover" className="absolute left-0 top-0 h-full w-full rounded-full object-cover"
@ -56,7 +56,7 @@ export const NameColumn: React.FC<NameProps> = (props) => {
</Link> </Link>
) : ( ) : (
<Link href={`/${workspaceSlug}/profile/${id}`}> <Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-gray-700 p-4 capitalize text-white"> <span className="relative flex h-4 w-4 text-xs items-center justify-center rounded-full bg-gray-700 capitalize text-white">
{(email ?? display_name ?? "?")[0]} {(email ?? display_name ?? "?")[0]}
</span> </span>
</Link> </Link>

View file

@ -13,7 +13,7 @@ import { MembersLayoutLoader } from "@/components/ui/loader/layouts/members-layo
import { ConfirmWorkspaceMemberRemove } from "@/components/workspace"; import { ConfirmWorkspaceMemberRemove } from "@/components/workspace";
// constants // constants
// hooks // hooks
import { useEventTracker, useMember, useUser, useUserPermissions } from "@/hooks/store"; import { useEventTracker, useMember, useUser, useUserPermissions, useUserSettings, useWorkspace } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
import { useMemberColumns } from "@/plane-web/components/workspace/settings/useMemberColumns"; import { useMemberColumns } from "@/plane-web/components/workspace/settings/useMemberColumns";
@ -33,6 +33,8 @@ export const WorkspaceMembersListItem: FC<Props> = observer((props) => {
} = useMember(); } = useMember();
const { leaveWorkspace } = useUserPermissions(); const { leaveWorkspace } = useUserPermissions();
const { captureEvent } = useEventTracker(); const { captureEvent } = useEventTracker();
const { getWorkspaceRedirectionUrl } = useWorkspace();
const { fetchCurrentUserSettings } = useUserSettings();
const { t } = useTranslation(); const { t } = useTranslation();
// derived values // derived values
@ -40,12 +42,13 @@ export const WorkspaceMembersListItem: FC<Props> = observer((props) => {
if (!workspaceSlug || !currentUser) return; if (!workspaceSlug || !currentUser) return;
await leaveWorkspace(workspaceSlug.toString()) await leaveWorkspace(workspaceSlug.toString())
.then(() => { .then(async () => {
await fetchCurrentUserSettings();
router.push(getWorkspaceRedirectionUrl());
captureEvent(WORKSPACE_MEMBER_LEAVE, { captureEvent(WORKSPACE_MEMBER_LEAVE, {
state: "SUCCESS", state: "SUCCESS",
element: "Workspace settings members page", element: "Workspace settings members page",
}); });
router.push("/profile");
}) })
.catch((err: any) => .catch((err: any) =>
setToast({ setToast({
@ -101,9 +104,9 @@ export const WorkspaceMembersListItem: FC<Props> = observer((props) => {
data={(memberDetails?.filter((member): member is IWorkspaceMember => member !== null) ?? []) as any} data={(memberDetails?.filter((member): member is IWorkspaceMember => member !== null) ?? []) as any}
keyExtractor={(rowData) => rowData?.member.id ?? ""} keyExtractor={(rowData) => rowData?.member.id ?? ""}
tHeadClassName="border-b border-custom-border-100" tHeadClassName="border-b border-custom-border-100"
thClassName="text-left font-medium divide-x-0" thClassName="text-left font-medium divide-x-0 text-custom-text-400"
tBodyClassName="divide-y-0" tBodyClassName="divide-y-0"
tBodyTrClassName="divide-x-0" tBodyTrClassName="divide-x-0 p-4 h-[40px] text-custom-text-200"
tHeadTrClassName="divide-x-0" tHeadTrClassName="divide-x-0"
/> />
</div> </div>

View file

@ -155,7 +155,7 @@ export const WorkspaceDetails: FC = observer(() => {
/> />
)} )}
/> />
<div className={`w-full overflow-y-auto md:pr-9 pr-4 ${isAdmin ? "" : "opacity-60"}`}> <div className={`w-full md:pr-9 pr-4 ${isAdmin ? "" : "opacity-60"}`}>
<div className="flex gap-5 border-b border-custom-border-100 pb-7 items-start"> <div className="flex gap-5 border-b border-custom-border-100 pb-7 items-start">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)} disabled={!isAdmin}> <button type="button" onClick={() => setIsImageUploadModalOpen(true)} disabled={!isAdmin}>

Some files were not shown because too many files have changed in this diff Show more