[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:
parent
445c819fbd
commit
41c2aefad4
112 changed files with 2789 additions and 975 deletions
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
52
packages/constants/src/settings.ts
Normal file
52
packages/constants/src/settings.ts
Normal 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"]],
|
||||||
|
};
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
2
packages/types/src/users.d.ts
vendored
2
packages/types/src/users.d.ts
vendored
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -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,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
25
web/app/(all)/[workspaceSlug]/(settings)/layout.tsx
Normal file
25
web/app/(all)/[workspaceSlug]/(settings)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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()} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
7
web/ce/components/preferences/config.ts
Normal file
7
web/ce/components/preferences/config.ts
Normal 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,
|
||||||
|
};
|
||||||
105
web/ce/components/preferences/theme-switcher.tsx
Normal file
105
web/ce/components/preferences/theme-switcher.tsx
Normal 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} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
112
web/core/components/exporter/column.tsx
Normal file
112
web/core/components/exporter/column.tsx
Normal 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;
|
||||||
|
};
|
||||||
172
web/core/components/exporter/export-form.tsx
Normal file
172
web/core/components/exporter/export-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
137
web/core/components/exporter/prev-exports.tsx
Normal file
137
web/core/components/exporter/prev-exports.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
11
web/core/components/preferences/list.tsx
Normal file
11
web/core/components/preferences/list.tsx
Normal 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>
|
||||||
|
);
|
||||||
15
web/core/components/preferences/section.tsx
Normal file
15
web/core/components/preferences/section.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
|
||||||
<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">
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
100
web/core/components/profile/preferences/language-timezone.tsx
Normal file
100
web/core/components/profile/preferences/language-timezone.tsx
Normal 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")} </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")} </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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
22
web/core/components/settings/content-wrapper.tsx
Normal file
22
web/core/components/settings/content-wrapper.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
84
web/core/components/settings/header.tsx
Normal file
84
web/core/components/settings/header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
29
web/core/components/settings/heading.tsx
Normal file
29
web/core/components/settings/heading.tsx
Normal 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;
|
||||||
56
web/core/components/settings/helper.ts
Normal file
56
web/core/components/settings/helper.ts
Normal 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];
|
||||||
|
};
|
||||||
6
web/core/components/settings/index.ts
Normal file
6
web/core/components/settings/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export * from "./header";
|
||||||
|
export * from "./sidebar";
|
||||||
|
export * from "./content-wrapper";
|
||||||
|
export * from "./mobile";
|
||||||
|
export * from "./heading";
|
||||||
|
export * from "./layout";
|
||||||
46
web/core/components/settings/layout.tsx
Normal file
46
web/core/components/settings/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
1
web/core/components/settings/mobile/index.ts
Normal file
1
web/core/components/settings/mobile/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./nav";
|
||||||
46
web/core/components/settings/mobile/nav.tsx
Normal file
46
web/core/components/settings/mobile/nav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
1
web/core/components/settings/project/sidebar/index.ts
Normal file
1
web/core/components/settings/project/sidebar/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./root";
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
49
web/core/components/settings/project/sidebar/root.tsx
Normal file
49
web/core/components/settings/project/sidebar/root.tsx
Normal 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} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
33
web/core/components/settings/sidebar/header.tsx
Normal file
33
web/core/components/settings/sidebar/header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
web/core/components/settings/sidebar/index.ts
Normal file
1
web/core/components/settings/sidebar/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./root";
|
||||||
93
web/core/components/settings/sidebar/nav-item.tsx
Normal file
93
web/core/components/settings/sidebar/nav-item.tsx
Normal 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;
|
||||||
75
web/core/components/settings/sidebar/root.tsx
Normal file
75
web/core/components/settings/sidebar/root.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
63
web/core/components/settings/tabs.tsx
Normal file
63
web/core/components/settings/tabs.tsx
Normal 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;
|
||||||
|
|
@ -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>
|
};
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue