diff --git a/packages/i18n/.eslintignore b/packages/i18n/.eslintignore new file mode 100644 index 00000000000..6019047c3e5 --- /dev/null +++ b/packages/i18n/.eslintignore @@ -0,0 +1,3 @@ +build/* +dist/* +out/* \ No newline at end of file diff --git a/packages/i18n/.eslintrc.js b/packages/i18n/.eslintrc.js new file mode 100644 index 00000000000..558b8f76ed4 --- /dev/null +++ b/packages/i18n/.eslintrc.js @@ -0,0 +1,9 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ["@plane/eslint-config/library.js"], + parser: "@typescript-eslint/parser", + parserOptions: { + project: true, + }, +}; diff --git a/packages/i18n/.prettierignore b/packages/i18n/.prettierignore new file mode 100644 index 00000000000..d5be669c5e0 --- /dev/null +++ b/packages/i18n/.prettierignore @@ -0,0 +1,4 @@ +.turbo +out/ +dist/ +build/ \ No newline at end of file diff --git a/packages/i18n/.prettierrc b/packages/i18n/.prettierrc new file mode 100644 index 00000000000..87d988f1b26 --- /dev/null +++ b/packages/i18n/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/packages/i18n/package.json b/packages/i18n/package.json new file mode 100644 index 00000000000..0a4d0562797 --- /dev/null +++ b/packages/i18n/package.json @@ -0,0 +1,20 @@ +{ + "name": "@plane/i18n", + "version": "0.24.1", + "description": "I18n shared across multiple apps internally", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "lint": "eslint src --ext .ts,.tsx", + "lint:errors": "eslint src --ext .ts,.tsx --quiet" + }, + "dependencies": { + "@plane/utils": "*" + }, + "devDependencies": { + "@plane/eslint-config": "*", + "@types/node": "^22.5.4", + "typescript": "^5.3.3" + } +} diff --git a/packages/i18n/src/components/index.tsx b/packages/i18n/src/components/index.tsx new file mode 100644 index 00000000000..e94246d081a --- /dev/null +++ b/packages/i18n/src/components/index.tsx @@ -0,0 +1,29 @@ +import React, { createContext, useEffect } from "react"; +import { observer } from "mobx-react"; +import { TranslationStore } from "./store"; +import { Language, languages } from "../config"; + +// Create the store instance +const translationStore = new TranslationStore(); + +// Create Context +export const TranslationContext = createContext(translationStore); + +export const TranslationProvider = observer(({ children }: any) => { + // Handle storage events for cross-tab synchronization + useEffect(() => { + const handleStorageChange = (event: StorageEvent) => { + if (event.key === "userLanguage" && event.newValue) { + const newLang = event.newValue as Language; + if (languages.includes(newLang)) { + translationStore.setLanguage(newLang); + } + } + }; + + window.addEventListener("storage", handleStorageChange); + return () => window.removeEventListener("storage", handleStorageChange); + }, []); + + return {children}; +}); diff --git a/packages/i18n/src/components/store.ts b/packages/i18n/src/components/store.ts new file mode 100644 index 00000000000..bf1934a6535 --- /dev/null +++ b/packages/i18n/src/components/store.ts @@ -0,0 +1,38 @@ +import { makeObservable, observable } from "mobx"; +import { Language, fallbackLng, languages, translations } from "../config"; + +export class TranslationStore { + currentLocale: Language = fallbackLng; + + constructor() { + makeObservable(this, { + currentLocale: observable.ref, + }); + this.initializeLanguage(); + } + + get availableLanguages() { + return languages; + } + + t(key: string) { + return translations[this.currentLocale]?.[key] || translations[fallbackLng][key] || key; + } + + setLanguage(lng: Language) { + localStorage.setItem("userLanguage", lng); + this.currentLocale = lng; + } + + initializeLanguage() { + if (typeof window === "undefined") return; + const savedLocale = localStorage.getItem("userLanguage") as Language; + if (savedLocale && languages.includes(savedLocale)) { + this.setLanguage(savedLocale); + } else { + const browserLang = navigator.language.split("-")[0] as Language; + const newLocale = languages.includes(browserLang as Language) ? (browserLang as Language) : fallbackLng; + this.setLanguage(newLocale); + } + } +} diff --git a/packages/i18n/src/config/index.ts b/packages/i18n/src/config/index.ts new file mode 100644 index 00000000000..3f55d8cf6f0 --- /dev/null +++ b/packages/i18n/src/config/index.ts @@ -0,0 +1,39 @@ +import en from "../locales/en/translations.json"; +import fr from "../locales/fr/translations.json"; +import es from "../locales/es/translations.json"; +import ja from "../locales/ja/translations.json"; + +export type Language = (typeof languages)[number]; +export type Translations = { + [key: string]: { + [key: string]: string; + }; +}; + +export const fallbackLng = "en"; +export const languages = ["en", "fr", "es", "ja"] as const; +export const translations: Translations = { + en, + fr, + es, + ja, +}; + +export const SUPPORTED_LANGUAGES = [ + { + label: "English", + value: "en", + }, + { + label: "French", + value: "fr", + }, + { + label: "Spanish", + value: "es", + }, + { + label: "Japanese", + value: "ja", + }, +]; diff --git a/packages/i18n/src/hooks/index.ts b/packages/i18n/src/hooks/index.ts new file mode 100644 index 00000000000..fb4e297e216 --- /dev/null +++ b/packages/i18n/src/hooks/index.ts @@ -0,0 +1 @@ +export * from "./use-translation"; diff --git a/packages/i18n/src/hooks/use-translation.ts b/packages/i18n/src/hooks/use-translation.ts new file mode 100644 index 00000000000..f947d1d5eb5 --- /dev/null +++ b/packages/i18n/src/hooks/use-translation.ts @@ -0,0 +1,17 @@ +import { useContext } from "react"; +import { TranslationContext } from "../components"; +import { Language } from "../config"; + +export function useTranslation() { + const store = useContext(TranslationContext); + if (!store) { + throw new Error("useTranslation must be used within a TranslationProvider"); + } + + return { + t: (key: string) => store.t(key), + currentLocale: store.currentLocale, + changeLanguage: (lng: Language) => store.setLanguage(lng), + languages: store.availableLanguages, + }; +} diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts new file mode 100644 index 00000000000..639ef4b59a4 --- /dev/null +++ b/packages/i18n/src/index.ts @@ -0,0 +1,3 @@ +export * from "./config"; +export * from "./components"; +export * from "./hooks"; diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json new file mode 100644 index 00000000000..98b5377a087 --- /dev/null +++ b/packages/i18n/src/locales/en/translations.json @@ -0,0 +1,49 @@ +{ + "submit": "Submit", + "cancel": "Cancel", + "loading": "Loading", + "error": "Error", + "success": "Success", + "warning": "Warning", + "info": "Info", + "close": "Close", + "yes": "Yes", + "no": "No", + "ok": "OK", + "name": "Name", + "description": "Description", + "search": "Search", + "add_member": "Add member", + "remove_member": "Remove member", + "add_members": "Add members", + "remove_members": "Remove members", + "add": "Add", + "remove": "Remove", + "add_new": "Add new", + "remove_selected": "Remove selected", + "first_name": "First name", + "last_name": "Last name", + "email": "Email", + "display_name": "Display name", + "role": "Role", + "timezone": "Timezone", + "avatar": "Avatar", + "cover_image": "Cover image", + "password": "Password", + "change_cover": "Change cover", + "language": "Language", + "saving": "Saving...", + "save_changes": "Save changes", + "deactivate_account": "Deactivate account", + "deactivate_account_description": "When deactivating an account, all of the data and resources within that account will be permanently removed and cannot be recovered.", + "profile_settings": "Profile settings", + "your_account": "Your account", + "profile": "Profile", + "security": "Security", + "activity": "Activity", + "appearance": "Appearance", + "notifications": "Notifications", + "workspaces": "Workspaces", + "create_workspace": "Create workspace", + "invitations": "Invitations" +} diff --git a/packages/i18n/src/locales/es/translations.json b/packages/i18n/src/locales/es/translations.json new file mode 100644 index 00000000000..47d98d2d0a9 --- /dev/null +++ b/packages/i18n/src/locales/es/translations.json @@ -0,0 +1,49 @@ +{ + "submit": "Enviar", + "cancel": "Cancelar", + "loading": "Cargando", + "error": "Error", + "success": "Éxito", + "warning": "Advertencia", + "info": "Información", + "close": "Cerrar", + "yes": "Sí", + "no": "No", + "ok": "OK", + "name": "Nombre", + "description": "Descripción", + "search": "Buscar", + "add_member": "Agregar miembro", + "remove_member": "Eliminar miembro", + "add_members": "Agregar miembros", + "remove_members": "Eliminar miembros", + "add": "Agregar", + "remove": "Eliminar", + "add_new": "Agregar nuevo", + "remove_selected": "Eliminar seleccionados", + "first_name": "Nombre", + "last_name": "Apellido", + "email": "Correo electrónico", + "display_name": "Nombre para mostrar", + "role": "Rol", + "timezone": "Zona horaria", + "avatar": "Avatar", + "cover_image": "Imagen de portada", + "password": "Contraseña", + "change_cover": "Cambiar portada", + "language": "Idioma", + "saving": "Guardando...", + "save_changes": "Guardar cambios", + "deactivate_account": "Desactivar cuenta", + "deactivate_account_description": "Al desactivar una cuenta, todos los datos y recursos dentro de esa cuenta se eliminarán permanentemente y no se podrán recuperar.", + "profile_settings": "Configuración de perfil", + "your_account": "Tu cuenta", + "profile": "Perfil", + "security": "Seguridad", + "activity": "Actividad", + "appearance": "Apariencia", + "notifications": "Notificaciones", + "workspaces": "Workspaces", + "create_workspace": "Crear workspace", + "invitations": "Invitaciones" +} diff --git a/packages/i18n/src/locales/fr/translations.json b/packages/i18n/src/locales/fr/translations.json new file mode 100644 index 00000000000..5acc0f457c9 --- /dev/null +++ b/packages/i18n/src/locales/fr/translations.json @@ -0,0 +1,49 @@ +{ + "submit": "Soumettre", + "cancel": "Annuler", + "loading": "Chargement", + "error": "Erreur", + "success": "Succès", + "warning": "Avertissement", + "info": "Info", + "close": "Fermer", + "yes": "Oui", + "no": "Non", + "ok": "OK", + "name": "Nom", + "description": "Description", + "search": "Rechercher", + "add_member": "Ajouter un membre", + "remove_member": "Supprimer un membre", + "add_members": "Ajouter des membres", + "remove_members": "Supprimer des membres", + "add": "Ajouter", + "remove": "Supprimer", + "add_new": "Ajouter nouveau", + "remove_selected": "Supprimer la sélection", + "first_name": "Prénom", + "last_name": "Nom de famille", + "email": "Email", + "display_name": "Nom d'affichage", + "role": "Rôle", + "timezone": "Fuseau horaire", + "avatar": "Avatar", + "cover_image": "Image de couverture", + "password": "Mot de passe", + "change_cover": "Modifier la couverture", + "language": "Langue", + "saving": "Enregistrement...", + "save_changes": "Enregistrer les modifications", + "deactivate_account": "Désactiver le compte", + "deactivate_account_description": "Lors de la désactivation d'un compte, toutes les données et ressources de ce compte seront définitivement supprimées et ne pourront pas être récupérées.", + "profile_settings": "Paramètres du profil", + "your_account": "Votre compte", + "profile": "Profil", + "security": " Sécurité", + "activity": "Activité", + "appearance": "Apparence", + "notifications": "Notifications", + "workspaces": "Workspaces", + "create_workspace": "Créer un workspace", + "invitations": "Invitations" +} diff --git a/packages/i18n/src/locales/ja/translations.json b/packages/i18n/src/locales/ja/translations.json new file mode 100644 index 00000000000..8a12214d019 --- /dev/null +++ b/packages/i18n/src/locales/ja/translations.json @@ -0,0 +1,49 @@ +{ + "submit": "送信", + "cancel": "キャンセル", + "loading": "読み込み中", + "error": "エラー", + "success": "成功", + "warning": "警告", + "info": "情報", + "close": "閉じる", + "yes": "はい", + "no": "いいえ", + "ok": "OK", + "name": "名前", + "description": "説明", + "search": "検索", + "add_member": "メンバーを追加", + "remove_member": "メンバーを削除", + "add_members": "メンバーを追加", + "remove_members": "メンバーを削除", + "add": "追加", + "remove": "削除", + "add_new": "新規追加", + "remove_selected": "選択項目を削除", + "first_name": "名", + "last_name": "姓", + "email": "メールアドレス", + "display_name": "表示名", + "role": "役割", + "timezone": "タイムゾーン", + "avatar": "アバター", + "cover_image": "カバー画像", + "password": "パスワード", + "change_cover": "カバーを変更", + "language": "言語", + "saving": "保存中...", + "save_changes": "変更を保存", + "deactivate_account": "アカウントを無効化", + "deactivate_account_description": "アカウントを無効化すると、そのアカウント内のすべてのデータとリソースが完全に削除され、復元することはできません。", + "profile_settings": "プロフィール設定", + "your_account": "アカウント", + "profile": "プロフィール", + "security": "セキュリティ", + "activity": "アクティビティ", + "appearance": "アピアンス", + "notifications": "通知", + "workspaces": "ワークスペース", + "create_workspace": "ワークスペースを作成", + "invitations": "招待" +} diff --git a/packages/i18n/tsconfig.json b/packages/i18n/tsconfig.json new file mode 100644 index 00000000000..6599e6e82aa --- /dev/null +++ b/packages/i18n/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@plane/typescript-config/react-library.json", + "compilerOptions": { + "jsx": "react", + "lib": ["esnext", "dom"], + "resolveJsonModule": true + }, + "include": ["./src"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index 452bc23c238..c562e7c246b 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -25,7 +25,6 @@ export interface IUser extends IUserLite { is_password_autoset: boolean; is_tour_completed: boolean; mobile_number: string | null; - role: string | null; last_workspace_id: string; user_timezone: string; username: string; @@ -62,6 +61,7 @@ export type TUserProfile = { billing_address_country: string | undefined; billing_address: string | undefined; has_billing_address: boolean; + language: string; created_at: Date | string; updated_at: Date | string; }; diff --git a/packages/ui/src/modals/constants.ts b/packages/ui/src/modals/constants.ts index fe72ef7aea1..e1eccd94129 100644 --- a/packages/ui/src/modals/constants.ts +++ b/packages/ui/src/modals/constants.ts @@ -13,4 +13,5 @@ export enum EModalWidth { XXXXL = "sm:max-w-4xl", VXL = "sm:max-w-5xl", VIXL = "sm:max-w-6xl", + VIIXL = "sm:max-w-7xl", } diff --git a/web/app/[workspaceSlug]/(projects)/layout.tsx b/web/app/[workspaceSlug]/(projects)/layout.tsx index f8fe0f8f9e0..340ec57d0d0 100644 --- a/web/app/[workspaceSlug]/(projects)/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/layout.tsx @@ -1,8 +1,9 @@ "use client"; import { CommandPalette } from "@/components/command-palette"; -import { WorkspaceAuthWrapper } from "@/layouts/auth-layout"; import { AuthenticationWrapper } from "@/lib/wrappers"; +// plane web components +import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper"; import { AppSidebar } from "./sidebar"; export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx index ca3189a1fc7..c75f93d3e3b 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx @@ -2,7 +2,6 @@ import { FC } from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // ui import { Breadcrumbs, Button, ContrastIcon, Header } from "@plane/ui"; // components @@ -17,7 +16,6 @@ import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/u export const CyclesListHeader: FC = observer(() => { // router const router = useAppRouter(); - const { workspaceSlug } = useParams(); // store hooks const { toggleCreateCycleModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx index 6da84e56a7a..f8ed7104f11 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx @@ -1,7 +1,6 @@ "use client"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // ui import { Breadcrumbs, Button, DiceIcon, Header } from "@plane/ui"; // components @@ -16,7 +15,6 @@ import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/u export const ModulesListHeader: React.FC = observer(() => { // router const router = useAppRouter(); - const { workspaceSlug } = useParams(); // store hooks const { toggleCreateModuleModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx index 3cb73a33ec0..edc2fcf65f1 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx @@ -1,7 +1,6 @@ "use client"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; import { Layers } from "lucide-react"; // ui import { Breadcrumbs, Button, Header } from "@plane/ui"; @@ -13,7 +12,6 @@ import { useCommandPalette, useProject } from "@/hooks/store"; export const ProjectViewsHeader = observer(() => { // router - const { workspaceSlug } = useParams(); // store hooks const { toggleCreateViewModal } = useCommandPalette(); const { currentProjectDetails, loader } = useProject(); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx index fc2ec0075f3..cdd4f708077 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx @@ -1,8 +1,8 @@ "use client"; import { ReactNode } from "react"; -// layouts -import { ProjectAuthWrapper } from "@/layouts/auth-layout"; +// plane web layouts +import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper"; const ProjectDetailLayout = ({ children }: { children: ReactNode }) => ( {children} diff --git a/web/app/onboarding/page.tsx b/web/app/onboarding/page.tsx index 6d915dfcbce..34415645cd9 100644 --- a/web/app/onboarding/page.tsx +++ b/web/app/onboarding/page.tsx @@ -44,7 +44,7 @@ const OnboardingPage = observer(() => { const workspacesList = Object.values(workspaces ?? {}); // fetching workspaces list const { isLoading: workspaceListLoader } = useSWR(USER_WORKSPACES_LIST, () => { - user?.id && fetchWorkspaces(); + if (user?.id) fetchWorkspaces(); }); // fetching user workspace invitations const { isLoading: invitationsLoader, data: invitations } = useSWR( diff --git a/web/app/profile/page.tsx b/web/app/profile/page.tsx index 1dd9702a36b..1a4470ea331 100644 --- a/web/app/profile/page.tsx +++ b/web/app/profile/page.tsx @@ -1,140 +1,16 @@ "use client"; -import React, { useEffect, useState } from "react"; import { observer } from "mobx-react"; -import { Controller, useForm } from "react-hook-form"; -import { ChevronDown, CircleUserRound } from "lucide-react"; -import { Disclosure, Transition } from "@headlessui/react"; -import type { IUser } from "@plane/types"; -import { - Button, - CustomSelect, - CustomSearchSelect, - Input, - TOAST_TYPE, - setPromiseToast, - setToast, - Tooltip, -} from "@plane/ui"; // components -import { DeactivateAccountModal } from "@/components/account"; import { LogoSpinner } from "@/components/common"; -import { ImagePickerPopover, UserImageUploadModal, PageHead } from "@/components/core"; -import { ProfileSettingContentWrapper } from "@/components/profile"; -// constants -import { TIME_ZONES, TTimezone } from "@/constants/timezones"; -import { USER_ROLES } from "@/constants/workspace"; -// helpers -import { getFileURL } from "@/helpers/file.helper"; +import { PageHead } from "@/components/core"; +import { ProfileSettingContentWrapper, ProfileForm } from "@/components/profile"; // hooks import { useUser } from "@/hooks/store"; -const defaultValues: Partial = { - avatar_url: "", - cover_image_url: "", - first_name: "", - last_name: "", - display_name: "", - email: "", - role: "Product / Project Manager", - user_timezone: "Asia/Kolkata", -}; - const ProfileSettingsPage = observer(() => { - // states - const [isLoading, setIsLoading] = useState(false); - const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false); - const [deactivateAccountModal, setDeactivateAccountModal] = useState(false); - // form info - const { - handleSubmit, - reset, - watch, - control, - setValue, - formState: { errors }, - } = useForm({ defaultValues }); - // derived values - const userAvatar = watch("avatar_url"); - const userCover = watch("cover_image_url"); // store hooks - const { data: currentUser, updateCurrentUser } = useUser(); - - useEffect(() => { - reset({ ...defaultValues, ...currentUser }); - }, [currentUser, reset]); - - const onSubmit = async (formData: IUser) => { - setIsLoading(true); - const payload: Partial = { - first_name: formData.first_name, - last_name: formData.last_name, - avatar_url: formData.avatar_url, - role: formData.role, - 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 (formData.cover_image_url?.startsWith("http")) { - payload.cover_image = formData.cover_image_url; - payload.cover_image_asset = null; - } - - const updateCurrentUserDetail = updateCurrentUser(payload).finally(() => setIsLoading(false)); - setPromiseToast(updateCurrentUserDetail, { - loading: "Updating...", - success: { - title: "Success!", - message: () => `Profile updated successfully.`, - }, - error: { - title: "Error!", - message: () => `There was some error in updating your profile. Please try again.`, - }, - }); - }; - - const handleDelete = async (url: string | null | undefined) => { - if (!url) return; - - await updateCurrentUser({ - avatar_url: "", - }) - .then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Profile picture deleted successfully.", - }); - setValue("avatar_url", ""); - }) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "There was some error in deleting your profile picture. Please try again.", - }); - }) - .finally(() => { - setIsImageUploadModalOpen(false); - }); - }; - - const getTimeZoneLabel = (timezone: TTimezone | undefined) => { - if (!timezone) return undefined; - return ( -
- {timezone.gmtOffset} - {timezone.name} -
- ); - }; - - const timeZoneOptions = TIME_ZONES.map((timeZone) => ({ - value: timeZone.value, - query: timeZone.name + " " + timeZone.gmtOffset + " " + timeZone.value, - content: getTimeZoneLabel(timeZone), - })); + const { data: currentUser, userProfile } = useUser(); if (!currentUser) return ( @@ -147,312 +23,7 @@ const ProfileSettingsPage = observer(() => { <> - ( - setIsImageUploadModalOpen(false)} - handleRemove={async () => await handleDelete(currentUser?.avatar_url)} - onSuccess={(url) => { - onChange(url); - handleSubmit(onSubmit)(); - setIsImageUploadModalOpen(false); - }} - value={value && value.trim() !== "" ? value : null} - /> - )} - /> - setDeactivateAccountModal(false)} /> -
-
-
- {currentUser?.first_name -
-
-
- -
-
-
-
- ( - onChange(imageUrl)} - control={control} - value={value ?? "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"} - isProfileCover - /> - )} - /> -
-
-
-
-
- {`${watch("first_name")} ${watch("last_name")}`} -
- {watch("email")} -
-
-
-
-
-

- First name* -

- ( - - )} - /> - {errors.first_name && {errors.first_name.message}} -
-
-

Last name

- ( - - )} - /> -
-
-

- Display name* -

- { - if (value.trim().length < 1) return "Display name can't be empty."; - if (value.split(" ").length > 1) return "Display name can't have two consecutive spaces."; - if (value.replace(/\s/g, "").length < 1) - return "Display name must be at least 1 character long."; - if (value.replace(/\s/g, "").length > 20) - return "Display name must be less than 20 characters long."; - return true; - }, - }} - render={({ field: { value, onChange, ref } }) => ( - - )} - /> - {errors?.display_name && ( - {errors?.display_name?.message} - )} -
-
-

- Email* -

- ( - - )} - /> -
-
-

- Role* -

- ( - - {USER_ROLES.map((item) => ( - - {item.label} - - ))} - - )} - /> - {errors.role && Please select a role} -
-
-
-
-
-
-

- Timezone* -

- ( - t.value === value)) ?? value) - : "Select a timezone" - } - options={timeZoneOptions} - onChange={onChange} - buttonClassName={errors.user_timezone ? "border-red-500" : ""} - className="rounded-md border-[0.5px] !border-custom-border-200" - optionsClassName="w-72" - input - /> - )} - /> - {errors.user_timezone && {errors.user_timezone.message}} -
- -
-

Language

- {}} - className="rounded-md bg-custom-background-90" - input - disabled - /> -
-
-
-
- -
-
-
-
- - {({ open }) => ( - <> - - Deactivate account - - - - -
- - When deactivating an account, all of the data and resources within that account will be - permanently removed and cannot be recovered. - -
- -
-
-
-
- - )} -
+
); diff --git a/web/app/profile/sidebar.tsx b/web/app/profile/sidebar.tsx index 479ef21f515..2f456415760 100644 --- a/web/app/profile/sidebar.tsx +++ b/web/app/profile/sidebar.tsx @@ -6,9 +6,9 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; // icons import { ChevronLeft, LogOut, MoveLeft, Plus, UserPlus } from "lucide-react"; -// plane helpers +// plane imports import { useOutsideClickDetector } from "@plane/hooks"; -// ui +import { useTranslation } from "@plane/i18n"; import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; // components import { SidebarNavItem } from "@/components/sidebar"; @@ -23,7 +23,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os"; const WORKSPACE_ACTION_LINKS = [ { - key: "create-workspace", + key: "create_workspace", Icon: Plus, label: "Create workspace", href: "/create-workspace", @@ -47,6 +47,7 @@ export const ProfileLayoutSidebar = observer(() => { const { data: currentUserSettings } = useUserSettings(); const { workspaces } = useWorkspace(); const { isMobile } = usePlatformOS(); + const { t } = useTranslation(); const workspacesList = Object.values(workspaces ?? {}); @@ -117,13 +118,13 @@ export const ProfileLayoutSidebar = observer(() => { {!sidebarCollapsed && ( -

Profile settings

+

{t("profile_settings")}

)}
{!sidebarCollapsed && ( -
Your account
+
{t("your_account")}
)}
{PROFILE_ACTION_LINKS.map((link) => { @@ -132,7 +133,7 @@ export const ProfileLayoutSidebar = observer(() => { return ( { >
- {!sidebarCollapsed &&

{link.label}

} + {!sidebarCollapsed &&

{t(link.key)}

}
@@ -156,7 +157,7 @@ export const ProfileLayoutSidebar = observer(() => {
{!sidebarCollapsed && ( -
Workspaces
+
{t("workspaces")}
)} {workspacesList && workspacesList.length > 0 && (
{ {WORKSPACE_ACTION_LINKS.map((link) => ( { }`} > {} - {!sidebarCollapsed && link.label} + {!sidebarCollapsed && t(link.key)}
diff --git a/web/app/provider.tsx b/web/app/provider.tsx index dba975a6373..34526ffd14c 100644 --- a/web/app/provider.tsx +++ b/web/app/provider.tsx @@ -4,7 +4,8 @@ import { FC, ReactNode } from "react"; import dynamic from "next/dynamic"; import { useTheme, ThemeProvider } from "next-themes"; import { SWRConfig } from "swr"; -// ui +// Plane Imports +import { TranslationProvider } from "@plane/i18n"; import { Toast } from "@plane/ui"; // constants import { SWR_CONFIG } from "@/constants/swr-config"; @@ -41,15 +42,17 @@ export const AppProvider: FC = (props) => { - - - - - {children} - - - - + + + + + + {children} + + + + + diff --git a/web/ce/components/de-dupe/de-dupe-button.tsx b/web/ce/components/de-dupe/de-dupe-button.tsx index eaa4e3b7c8f..a37e6eb4a39 100644 --- a/web/ce/components/de-dupe/de-dupe-button.tsx +++ b/web/ce/components/de-dupe/de-dupe-button.tsx @@ -9,7 +9,4 @@ type TDeDupeButtonRoot = { label: string; }; -export const DeDupeButtonRoot: FC = (props) => { - const { workspaceSlug, isDuplicateModalOpen, label, handleOnClick } = props; - return <>; -}; +export const DeDupeButtonRoot: FC = () => <>; diff --git a/web/ce/layouts/project-wrapper.tsx b/web/ce/layouts/project-wrapper.tsx new file mode 100644 index 00000000000..a9223210994 --- /dev/null +++ b/web/ce/layouts/project-wrapper.tsx @@ -0,0 +1,15 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +// layouts +import { ProjectAuthWrapper as CoreProjectAuthWrapper } from "@/layouts/auth-layout"; + +export type IProjectAuthWrapper = { + children: React.ReactNode; +}; + +export const ProjectAuthWrapper: FC = observer((props) => { + // props + const { children } = props; + + return {children}; +}); diff --git a/web/ce/layouts/workspace-wrapper.tsx b/web/ce/layouts/workspace-wrapper.tsx new file mode 100644 index 00000000000..fcde83e7f32 --- /dev/null +++ b/web/ce/layouts/workspace-wrapper.tsx @@ -0,0 +1,15 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +// layouts +import { WorkspaceAuthWrapper as CoreWorkspaceAuthWrapper } from "@/layouts/auth-layout"; + +export type IWorkspaceAuthWrapper = { + children: React.ReactNode; +}; + +export const WorkspaceAuthWrapper: FC = observer((props) => { + // props + const { children } = props; + + return {children}; +}); diff --git a/web/core/components/issues/issue-detail/label/root.tsx b/web/core/components/issues/issue-detail/label/root.tsx index f71e9ba3ce2..f31defafd44 100644 --- a/web/core/components/issues/issue-detail/label/root.tsx +++ b/web/core/components/issues/issue-detail/label/root.tsx @@ -7,13 +7,12 @@ import { IIssueLabel, TIssue, TIssueServiceType } from "@plane/types"; // components import { TOAST_TYPE, setToast } from "@plane/ui"; // hooks -import { useIssueDetail, useLabel, useProjectInbox, useUserPermissions } from "@/hooks/store"; +import { useIssueDetail, useLabel, useProjectInbox } from "@/hooks/store"; // ui // types -import { LabelList, LabelCreate, IssueLabelSelectRoot } from "./"; +import { LabelList, IssueLabelSelectRoot } from "./"; // TODO: Fix this import statement, as core should not import from ee // eslint-disable-next-line import/order -import { EUserPermissions, EUserPermissionsLevel } from "ee/constants/user-permissions"; export type TIssueLabel = { workspaceSlug: string; @@ -47,9 +46,7 @@ export const IssueLabel: FC = observer((props) => { issue: { getIssueById }, } = useIssueDetail(issueServiceType); const { getIssueInboxByIssueId } = useProjectInbox(); - const { allowPermissions } = useUserPermissions(); - const canCreateLabel = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); const issue = isInboxIssue ? getIssueInboxByIssueId(issueId)?.issue : getIssueById(issueId); const labelOperations: TLabelOperations = useMemo( @@ -113,16 +110,6 @@ export const IssueLabel: FC = observer((props) => { labelOperations={labelOperations} /> )} - - {!disabled && canCreateLabel && ( - - )}
); }); diff --git a/web/core/components/issues/issue-detail/label/select/label-select.tsx b/web/core/components/issues/issue-detail/label/select/label-select.tsx index 7fd700398c1..523a2453c69 100644 --- a/web/core/components/issues/issue-detail/label/select/label-select.tsx +++ b/web/core/components/issues/issue-detail/label/select/label-select.tsx @@ -1,33 +1,40 @@ import { Fragment, useState } from "react"; import { observer } from "mobx-react"; import { usePopper } from "react-popper"; -import { Check, Search, Tag } from "lucide-react"; +import { Check, Loader, Search, Tag } from "lucide-react"; import { Combobox } from "@headlessui/react"; // helpers +import { IIssueLabel } from "@plane/types"; +import { getRandomLabelColor } from "@/constants/label"; import { getTabIndex } from "@/helpers/tab-indices.helper"; // hooks -import { useLabel } from "@/hooks/store"; +import { useLabel, useUserPermissions } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; -// components - +//constants +import { EUserPermissions, EUserPermissionsLevel } from "ee/constants/user-permissions"; export interface IIssueLabelSelect { workspaceSlug: string; projectId: string; issueId: string; values: string[]; onSelect: (_labelIds: string[]) => void; + onAddLabel: (workspaceSlug: string, projectId: string, data: Partial) => Promise; } export const IssueLabelSelect: React.FC = observer((props) => { - const { workspaceSlug, projectId, issueId, values, onSelect } = props; + const { workspaceSlug, projectId, issueId, values, onSelect, onAddLabel } = props; // store hooks const { isMobile } = usePlatformOS(); const { fetchProjectLabels, getProjectLabels } = useLabel(); + const { allowPermissions } = useUserPermissions(); // states const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); const [isLoading, setIsLoading] = useState(false); const [query, setQuery] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const canCreateLabel = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); const projectLabels = getProjectLabels(projectId); @@ -83,11 +90,25 @@ export const IssueLabelSelect: React.FC = observer((props) =>
); - const searchInputKeyDown = (e: React.KeyboardEvent) => { + const searchInputKeyDown = async (e: React.KeyboardEvent) => { if (query !== "" && e.key === "Escape") { e.stopPropagation(); setQuery(""); } + + if (query !== "" && e.key === "Enter") { + e.stopPropagation(); + e.preventDefault(); + await handleAddLabel(query); + } + }; + + const handleAddLabel = async (labelName: string) => { + setSubmitting(true); + const label = await onAddLabel(workspaceSlug, projectId, { name: labelName, color: getRandomLabelColor() }); + onSelect([...values, label.id]); + setQuery(""); + setSubmitting(false); }; if (!issueId || !values) return <>; @@ -159,10 +180,19 @@ export const IssueLabelSelect: React.FC = observer((props) => )} )) + ) : submitting ? ( + + ) : canCreateLabel ? ( +

{ + handleAddLabel(query); + }} + className="text-left text-custom-text-200 cursor-pointer" + > + + Add "{query}" to labels +

) : ( - -

No matching results

-
+

No matching results.

)} diff --git a/web/core/components/issues/issue-detail/label/select/root.tsx b/web/core/components/issues/issue-detail/label/select/root.tsx index 00f96522b1c..a57a58742c2 100644 --- a/web/core/components/issues/issue-detail/label/select/root.tsx +++ b/web/core/components/issues/issue-detail/label/select/root.tsx @@ -26,6 +26,7 @@ export const IssueLabelSelectRoot: FC = (props) => { issueId={issueId} values={values} onSelect={handleLabel} + onAddLabel={labelOperations.createLabel} /> ); }; diff --git a/web/core/components/issues/issue-layouts/kanban/block.tsx b/web/core/components/issues/issue-layouts/kanban/block.tsx index d8f4307e855..5fbee1c0a73 100644 --- a/web/core/components/issues/issue-layouts/kanban/block.tsx +++ b/web/core/components/issues/issue-layouts/kanban/block.tsx @@ -6,6 +6,7 @@ import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-d import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane helpers +import { EIssueServiceType } from "@plane/constants"; import { useOutsideClickDetector } from "@plane/hooks"; // types import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types"; @@ -26,7 +27,6 @@ import { IssueIdentifier } from "@/plane-web/components/issues"; import { TRenderQuickActions } from "../list/list-view-types"; import { IssueProperties } from "../properties/all-properties"; import { getIssueBlockId } from "../utils"; -import { EIssueServiceType } from "@plane/constants"; interface IssueBlockProps { issueId: string; diff --git a/web/core/components/issues/issue-layouts/properties/labels.tsx b/web/core/components/issues/issue-layouts/properties/labels.tsx index 823137cb88f..a2f18c72ff2 100644 --- a/web/core/components/issues/issue-layouts/properties/labels.tsx +++ b/web/core/components/issues/issue-layouts/properties/labels.tsx @@ -1,11 +1,11 @@ "use client"; -import { Fragment, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Placement } from "@popperjs/core"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { usePopper } from "react-popper"; -import { Check, ChevronDown, Search, Tags } from "lucide-react"; +import { Check, ChevronDown, Loader, Search, Tags } from "lucide-react"; import { Combobox } from "@headlessui/react"; // plane helpers import { useOutsideClickDetector } from "@plane/hooks"; @@ -14,9 +14,12 @@ import { IIssueLabel } from "@plane/types"; // ui import { ComboDropDown, Tooltip } from "@plane/ui"; // hooks -import { useLabel } from "@/hooks/store"; +import { getRandomLabelColor } from "@/constants/label"; +import { useLabel, useUserPermissions } from "@/hooks/store"; import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down"; import { usePlatformOS } from "@/hooks/use-platform-os"; +// constants +import { EUserPermissions, EUserPermissionsLevel } from "ee/constants/user-permissions"; export interface IIssuePropertyLabels { projectId: string | null; @@ -62,6 +65,7 @@ export const IssuePropertyLabels: React.FC = observer((pro // states const [query, setQuery] = useState(""); const [isOpen, setIsOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); // refs const dropdownRef = useRef(null); const inputRef = useRef(null); @@ -70,9 +74,12 @@ export const IssuePropertyLabels: React.FC = observer((pro const [popperElement, setPopperElement] = useState(null); const [isLoading, setIsLoading] = useState(false); // store hooks - const { fetchProjectLabels, getProjectLabels } = useLabel(); + const { fetchProjectLabels, getProjectLabels, createLabel } = useLabel(); const { isMobile } = usePlatformOS(); const storeLabels = getProjectLabels(projectId); + const { allowPermissions } = useUserPermissions(); + + const canCreateLabel = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); const onOpen = () => { if (!storeLabels && workspaceSlug && projectId) @@ -102,11 +109,17 @@ export const IssuePropertyLabels: React.FC = observer((pro useOutsideClickDetector(dropdownRef, handleClose); - const searchInputKeyDown = (e: React.KeyboardEvent) => { + const searchInputKeyDown = async (e: React.KeyboardEvent) => { if (query !== "" && e.key === "Escape") { e.stopPropagation(); setQuery(""); } + + if (query !== "" && e.key === "Enter") { + e.stopPropagation(); + e.preventDefault(); + await handleAddLabel(query); + } }; useEffect(() => { @@ -249,6 +262,15 @@ export const IssuePropertyLabels: React.FC = observer((pro ); + const handleAddLabel = async (labelName: string) => { + if (!projectId) return; + setSubmitting(true); + const label = await createLabel(workspaceSlug, projectId, { name: labelName, color: getRandomLabelColor() }); + onChange([...value, label.id]); + setQuery(""); + setSubmitting(false); + }; + return ( = observer((pro )} )) + ) : submitting ? ( + + ) : canCreateLabel ? ( +

{ + handleAddLabel(query); + }} + className="text-left text-custom-text-200 cursor-pointer" + > + + Add "{query}" to labels +

) : ( - -

No matching results

-
+

No matching results.

)} diff --git a/web/core/components/profile/form.tsx b/web/core/components/profile/form.tsx new file mode 100644 index 00000000000..ae2679c6b77 --- /dev/null +++ b/web/core/components/profile/form.tsx @@ -0,0 +1,490 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import { Controller, useForm } from "react-hook-form"; +import { ChevronDown, CircleUserRound } from "lucide-react"; +import { Disclosure, Transition } from "@headlessui/react"; +import { useTranslation, SUPPORTED_LANGUAGES } from "@plane/i18n"; +import type { IUser, TUserProfile } from "@plane/types"; +import { + Button, + CustomSelect, + CustomSearchSelect, + Input, + TOAST_TYPE, + setPromiseToast, + setToast, + Tooltip, +} from "@plane/ui"; +// components +import { DeactivateAccountModal } from "@/components/account"; +import { ImagePickerPopover, UserImageUploadModal } from "@/components/core"; +// constants +import { TIME_ZONES, TTimezone } from "@/constants/timezones"; +import { USER_ROLES } from "@/constants/workspace"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; +// hooks +import { useUser, useUserProfile } from "@/hooks/store"; + +type TUserProfileForm = { + avatar_url: string; + cover_image: string; + cover_image_asset: any; + cover_image_url: string; + first_name: string; + last_name: string; + display_name: string; + email: string; + role: string; + language: string; + user_timezone: string; +}; + +export type TProfileFormProps = { + user: IUser; + profile: TUserProfile; +}; + +export const ProfileForm = observer((props: TProfileFormProps) => { + const { user, profile } = props; + // states + const [isLoading, setIsLoading] = useState(false); + const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false); + const [deactivateAccountModal, setDeactivateAccountModal] = useState(false); + // language support + const { t } = useTranslation(); + // form info + const { + handleSubmit, + watch, + control, + setValue, + formState: { errors }, + } = useForm({ + defaultValues: { + avatar_url: user.avatar_url || "", + cover_image_asset: null, + cover_image_url: user.cover_image_url || "", + first_name: user.first_name || "", + last_name: user.last_name || "", + display_name: user.display_name || "", + email: user.email || "", + role: profile.role || "Product / Project Manager", + language: profile.language || "en", + user_timezone: "Asia/Kolkata", + }, + }); + // derived values + const userAvatar = watch("avatar_url"); + const userCover = watch("cover_image_url"); + // store hooks + const { data: currentUser, updateCurrentUser } = useUser(); + const { updateUserProfile } = useUserProfile(); + + const getLanguageLabel = (value: string) => { + const selectedLanguage = SUPPORTED_LANGUAGES.find((l) => l.value === value); + if (!selectedLanguage) return value; + return selectedLanguage.label; + }; + + const getTimeZoneLabel = (timezone: TTimezone | undefined) => { + if (!timezone) return undefined; + return ( +
+ {timezone.gmtOffset} + {timezone.name} +
+ ); + }; + + const timeZoneOptions = TIME_ZONES.map((timeZone) => ({ + value: timeZone.value, + query: timeZone.name + " " + timeZone.gmtOffset + " " + timeZone.value, + content: getTimeZoneLabel(timeZone), + })); + + const handleProfilePictureDelete = async (url: string | null | undefined) => { + if (!url) return; + await updateCurrentUser({ + avatar_url: "", + }) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Profile picture deleted successfully.", + }); + setValue("avatar_url", ""); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "There was some error in deleting your profile picture. Please try again.", + }); + }) + .finally(() => { + setIsImageUploadModalOpen(false); + }); + }; + + const onSubmit = async (formData: TUserProfileForm) => { + setIsLoading(true); + const userPayload: Partial = { + first_name: formData.first_name, + last_name: formData.last_name, + avatar_url: formData.avatar_url, + 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 (formData.cover_image_url?.startsWith("http")) { + userPayload.cover_image = formData.cover_image_url; + userPayload.cover_image_asset = null; + } + + const profilePayload: Partial = { + role: formData.role, + language: formData.language, + }; + + const updateCurrentUserDetail = updateCurrentUser(userPayload).finally(() => setIsLoading(false)); + const updateCurrentUserProfile = updateUserProfile(profilePayload).finally(() => setIsLoading(false)); + + const promises = [updateCurrentUserDetail, updateCurrentUserProfile]; + const updateUserAndProfile = Promise.all(promises); + + setPromiseToast(updateUserAndProfile, { + loading: "Updating...", + success: { + title: "Success!", + message: () => `Profile updated successfully.`, + }, + error: { + title: "Error!", + message: () => `There was some error in updating your profile. Please try again.`, + }, + }); + }; + + return ( + <> + setDeactivateAccountModal(false)} /> + ( + setIsImageUploadModalOpen(false)} + handleRemove={async () => await handleProfilePictureDelete(currentUser?.avatar_url)} + onSuccess={(url) => { + onChange(url); + handleSubmit(onSubmit)(); + setIsImageUploadModalOpen(false); + }} + value={value && value.trim() !== "" ? value : null} + /> + )} + /> +
+
+
+ {currentUser?.first_name +
+
+
+ +
+
+
+
+ ( + onChange(imageUrl)} + control={control} + value={value ?? "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"} + isProfileCover + /> + )} + /> +
+
+
+
+
+ {`${watch("first_name")} ${watch("last_name")}`} +
+ {watch("email")} +
+
+
+
+
+

+ {t("first_name")}  + * +

+ ( + + )} + /> + {errors.first_name && {errors.first_name.message}} +
+
+

{t("last_name")}

+ ( + + )} + /> +
+
+

+ {t("display_name")}  + * +

+ { + if (value.trim().length < 1) return "Display name can't be empty."; + if (value.split(" ").length > 1) return "Display name can't have two consecutive spaces."; + if (value.replace(/\s/g, "").length < 1) return "Display name must be at least 1 character long."; + if (value.replace(/\s/g, "").length > 20) + return "Display name must be less than 20 characters long."; + return true; + }, + }} + render={({ field: { value, onChange, ref } }) => ( + + )} + /> + {errors?.display_name && {errors?.display_name?.message}} +
+
+

+ {t("email")}  + * +

+ ( + + )} + /> +
+
+

+ {t("role")}  + * +

+ ( + + {USER_ROLES.map((item) => ( + + {item.label} + + ))} + + )} + /> + {errors.role && Please select a role} +
+
+
+
+
+
+

+ {t("timezone")}  + * +

+ ( + t.value === value)) ?? value) + : "Select a timezone" + } + options={timeZoneOptions} + onChange={onChange} + buttonClassName={errors.user_timezone ? "border-red-500" : ""} + className="rounded-md border-[0.5px] !border-custom-border-200" + optionsClassName="w-72" + input + /> + )} + /> + {errors.user_timezone && {errors.user_timezone.message}} +
+
+

{t("language")}

+ ( + + {SUPPORTED_LANGUAGES.map((item) => ( + + {item.label} + + ))} + + )} + /> +
+
+
+ +
+
+
+
+ + {({ open }) => ( + <> + + {t("deactivate_account")} + + + + +
+ {t("deactivate_account_description")} +
+ +
+
+
+
+ + )} +
+ + ); +}); diff --git a/web/core/components/profile/index.ts b/web/core/components/profile/index.ts index d0d33af96ae..e5495aba1f5 100644 --- a/web/core/components/profile/index.ts +++ b/web/core/components/profile/index.ts @@ -3,5 +3,6 @@ export * from "./overview"; export * from "./profile-issues-filter"; export * from "./sidebar"; export * from "./time"; -export * from "./profile-setting-content-wrapper" -export * from "./profile-setting-content-header" \ No newline at end of file +export * from "./profile-setting-content-wrapper"; +export * from "./profile-setting-content-header"; +export * from "./form"; diff --git a/web/core/components/workspace/settings/invitations-list-item.tsx b/web/core/components/workspace/settings/invitations-list-item.tsx index 09ca653f349..bbbbcd2670f 100644 --- a/web/core/components/workspace/settings/invitations-list-item.tsx +++ b/web/core/components/workspace/settings/invitations-list-item.tsx @@ -120,11 +120,11 @@ export const WorkspaceInvitationsListItem: FC = observer((props) => { updateMemberInvitation(workspaceSlug.toString(), invitationDetails.id, { role: value, - }).catch(() => { + }).catch((error) => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: "An error occurred while updating member role. Please try again.", + message: error?.error || "An error occurred while updating member role. Please try again.", }); }); }} diff --git a/web/core/layouts/auth-layout/project-wrapper.tsx b/web/core/layouts/auth-layout/project-wrapper.tsx index 4cca052573a..dcba2a8fef2 100644 --- a/web/core/layouts/auth-layout/project-wrapper.tsx +++ b/web/core/layouts/auth-layout/project-wrapper.tsx @@ -34,12 +34,14 @@ import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/u interface IProjectAuthWrapper { children: ReactNode; + isLoading?: boolean; } export const ProjectAuthWrapper: FC = observer((props) => { - const { children } = props; - // store - // const { fetchInboxes } = useInbox(); + const { children, isLoading: isParentLoading = false } = props; + // router + const { workspaceSlug, projectId } = useParams(); + // store hooks const { toggleCreateProjectModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); const { fetchUserProjectInfo, allowPermissions, projectUserInfo } = useUserPermissions(); @@ -54,14 +56,20 @@ export const ProjectAuthWrapper: FC = observer((props) => { const { fetchProjectStates, fetchProjectStateTransitions } = useProjectState(); const { fetchProjectLabels } = useLabel(); const { getProjectEstimates } = useProjectEstimates(); - // router - const { workspaceSlug, projectId } = useParams(); - + // derived values + const projectExists = projectId ? getProjectById(projectId.toString()) : null; const projectMemberInfo = projectUserInfo?.[workspaceSlug?.toString()]?.[projectId?.toString()]; + const hasPermissionToCurrentProject = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + EUserPermissionsLevel.PROJECT, + workspaceSlug.toString(), + projectId?.toString() + ); // Initialize module timeline chart useEffect(() => { initGantt(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useSWR( @@ -143,17 +151,8 @@ export const ProjectAuthWrapper: FC = observer((props) => { { revalidateIfStale: false, revalidateOnFocus: false } ); - // derived values - const projectExists = projectId ? getProjectById(projectId.toString()) : null; - const hasPermissionToCurrentProject = allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], - EUserPermissionsLevel.PROJECT, - workspaceSlug.toString(), - projectId?.toString() - ); - // check if the project member apis is loading - if (!projectMemberInfo && projectId && hasPermissionToCurrentProject === null) + if (isParentLoading || (!projectMemberInfo && projectId && hasPermissionToCurrentProject === null)) return (
diff --git a/web/core/layouts/auth-layout/workspace-wrapper.tsx b/web/core/layouts/auth-layout/workspace-wrapper.tsx index 2dd658704e3..ab39dd76147 100644 --- a/web/core/layouts/auth-layout/workspace-wrapper.tsx +++ b/web/core/layouts/auth-layout/workspace-wrapper.tsx @@ -8,11 +8,12 @@ import { useParams } from "next/navigation"; import { useTheme } from "next-themes"; import useSWR from "swr"; import useSWRImmutable from "swr/immutable"; - +// ui import { LogOut } from "lucide-react"; -// hooks import { Button, setToast, TOAST_TYPE, Tooltip } from "@plane/ui"; +// components import { LogoSpinner } from "@/components/common"; +// hooks import { useMember, useProject, useUser, useUserPermissions, useWorkspace } from "@/hooks/store"; import { useFavorite } from "@/hooks/store/use-favorite"; import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -25,12 +26,13 @@ import PlaneBlackLogo from "@/public/plane-logos/black-horizontal-with-blue-logo import PlaneWhiteLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; import WorkSpaceNotAvailable from "@/public/workspace/workspace-not-available.png"; -export interface IWorkspaceAuthWrapper { +interface IWorkspaceAuthWrapper { children: ReactNode; + isLoading?: boolean; } export const WorkspaceAuthWrapper: FC = observer((props) => { - const { children } = props; + const { children, isLoading: isParentLoading = false } = props; // router params const { workspaceSlug } = useParams(); // next themes @@ -51,11 +53,11 @@ export const WorkspaceAuthWrapper: FC = observer((props) [EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.WORKSPACE ); - const planeLogo = resolvedTheme === "dark" ? PlaneWhiteLogo : PlaneBlackLogo; const allWorkspaces = workspaces ? Object.values(workspaces) : undefined; const currentWorkspace = (allWorkspaces && allWorkspaces.find((workspace) => workspace?.slug === workspaceSlug)) || undefined; + const currentWorkspaceInfo = workspaceSlug && workspaceInfoBySlug(workspaceSlug.toString()); // fetching user workspace information useSWR( @@ -116,11 +118,8 @@ export const WorkspaceAuthWrapper: FC = observer((props) ); }; - // derived values - const currentWorkspaceInfo = workspaceSlug && workspaceInfoBySlug(workspaceSlug.toString()); - // if list of workspaces are not there then we have to render the spinner - if (allWorkspaces === undefined || loader || isDBInitializing) { + if (isParentLoading || allWorkspaces === undefined || loader || isDBInitializing) { return (
diff --git a/web/core/lib/wrappers/store-wrapper.tsx b/web/core/lib/wrappers/store-wrapper.tsx index fa60c354e3c..eb8f7325ba1 100644 --- a/web/core/lib/wrappers/store-wrapper.tsx +++ b/web/core/lib/wrappers/store-wrapper.tsx @@ -1,7 +1,8 @@ -import { ReactNode, useEffect, FC, useState } from "react"; +import { ReactNode, useEffect, FC } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { useTheme } from "next-themes"; +import { useTranslation, Language } from "@plane/i18n"; // helpers import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper"; // hooks @@ -21,6 +22,7 @@ const StoreWrapper: FC = observer((props) => { const { setQuery } = useRouterParams(); const { sidebarCollapsed, toggleSidebar } = useAppTheme(); const { data: userProfile } = useUserProfile(); + const { changeLanguage } = useTranslation(); /** * Sidebar collapsed fetching from local storage @@ -28,7 +30,6 @@ const StoreWrapper: FC = observer((props) => { useEffect(() => { const localValue = localStorage && localStorage.getItem("app_sidebar_collapsed"); const localBoolValue = localValue ? (localValue === "true" ? true : false) : false; - if (localValue && sidebarCollapsed === undefined) toggleSidebar(localBoolValue); }, [sidebarCollapsed, setTheme, toggleSidebar]); @@ -37,7 +38,6 @@ const StoreWrapper: FC = observer((props) => { */ useEffect(() => { if (!userProfile?.theme?.theme) return; - const currentTheme = userProfile?.theme?.theme || "system"; const currentThemePalette = userProfile?.theme?.palette; if (currentTheme) { @@ -51,6 +51,11 @@ const StoreWrapper: FC = observer((props) => { } }, [userProfile?.theme?.theme, userProfile?.theme?.palette, setTheme]); + useEffect(() => { + if (!userProfile?.language) return; + changeLanguage(userProfile?.language as Language); + }, [userProfile?.language, changeLanguage]); + useEffect(() => { if (!params) return; setQuery(params); diff --git a/web/ee/layouts/project-wrapper.tsx b/web/ee/layouts/project-wrapper.tsx new file mode 100644 index 00000000000..911665c9786 --- /dev/null +++ b/web/ee/layouts/project-wrapper.tsx @@ -0,0 +1 @@ +export * from "ce/layouts/project-wrapper"; diff --git a/web/ee/layouts/workspace-wrapper.tsx b/web/ee/layouts/workspace-wrapper.tsx new file mode 100644 index 00000000000..186266fc1ad --- /dev/null +++ b/web/ee/layouts/workspace-wrapper.tsx @@ -0,0 +1 @@ +export * from "ce/layouts/workspace-wrapper"; diff --git a/web/next.config.js b/web/next.config.js index 14ba0d73579..5f54b4b8cdf 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -29,6 +29,7 @@ const nextConfig = { images: { unoptimized: true, }, + transpilePackages: ["@plane/i18n"], // webpack: (config, { isServer }) => { // if (!isServer) { // // Ensure that all imports of 'yjs' resolve to the same instance diff --git a/web/package.json b/web/package.json index cd0a5ddcca5..d37ae8aaf97 100644 --- a/web/package.json +++ b/web/package.json @@ -31,6 +31,7 @@ "@plane/constants": "*", "@plane/editor": "*", "@plane/hooks": "*", + "@plane/i18n": "*", "@plane/types": "*", "@plane/ui": "*", "@plane/utils": "*",