[WEB-2870]feat: language support (#6215)

* fix: adding language support package

* fix: language support implementation using mobx

* fix: adding more languages for support

* fix: profile settings translations

* feat: added language support for sidebar and user settings

* feat: added language support for deactivation modal

* fix: added project sync after transfer issues (#6200)

* code refactor and improvement (#6203)

* chore: package code refactoring

* chore: component restructuring and refactor

* chore: comment create improvement

* refactor: enhance workspace and project wrapper modularity (#6207)

* [WEB-2678]feat: added functionality to add labels directly from dropdown (#6211)

* enhancement:added functionality to add features directly from dropdown

* fix: fixed import order

* fix: fixed lint errors

* chore: added common component for project activity (#6212)

* chore: added common component for project activity

* fix: added enum

* fix: added enum for initiatives

* - Do not clear temp files that are locked. (#6214)

- Handle edge cases in sync workspace

* fix: labels empty state for drop down (#6216)

* refactor: remove cn helper function from the editor package (#6217)

* * feat: added language support to issue create modal in sidebar
* fix: project activity type

* * fix: added missing translations
* fix: modified translation for plurals

* fix: fixed spanish translation

* dev: language type error in space user profile types

* fix: type fixes

* chore: added alpha tag

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
Co-authored-by: Akshita Goyal <36129505+gakshita@users.noreply.github.com>
Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: gurusinath <gurusainath007@gmail.com>
This commit is contained in:
Vamsi Krishna 2025-01-03 14:16:26 +05:30 committed by GitHub
parent ade0aa1643
commit 873e4330bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 2588 additions and 873 deletions

View file

@ -3,6 +3,7 @@
import React, { useState } from "react";
import { Trash2 } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react";
import { useTranslation } from "@plane/i18n";
// ui
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// hooks
@ -18,6 +19,7 @@ export const DeactivateAccountModal: React.FC<Props> = (props) => {
const router = useAppRouter();
const { isOpen, onClose } = props;
// hooks
const { t } = useTranslation();
const { deactivateAccount, signOut } = useUser();
// states
@ -90,11 +92,10 @@ export const DeactivateAccountModal: React.FC<Props> = (props) => {
</div>
<div>
<Dialog.Title as="h3" className="my-4 text-2xl font-medium leading-6 text-custom-text-100">
Deactivate your account?
{t("deactivate_your_account")}
</Dialog.Title>
<p className="mt-6 list-disc pr-4 text-base font-normal text-custom-text-200">
Once deactivated, you can{"'"}t be assigned issues and be billed for your workspace.To
reactivate your account, you will need an invite to a workspace at this email address.
{t("deactivate_your_account_description")}
</p>
</div>
</div>
@ -102,10 +103,10 @@ export const DeactivateAccountModal: React.FC<Props> = (props) => {
</div>
<div className="mb-2 flex items-center justify-end gap-2 p-4 sm:px-6">
<Button variant="neutral-primary" onClick={onClose}>
Cancel
{t("cancel")}
</Button>
<Button variant="danger" onClick={handleDeleteAccount}>
{isDeactivating ? "Deactivating..." : "Confirm"}
{isDeactivating ? t("deactivating") : t("confirm")}
</Button>
</div>
</Dialog.Panel>

View file

@ -1,6 +1,7 @@
"use client";
import { FC, useMemo } from "react";
import { useTranslation } from "@plane/i18n";
// import { CircleCheck } from "lucide-react";
// helpers
import { cn } from "@/helpers/common.helper";
@ -17,6 +18,7 @@ type TPasswordStrengthMeter = {
export const PasswordStrengthMeter: FC<TPasswordStrengthMeter> = (props) => {
const { password, isFocused = false } = props;
const { t } = useTranslation();
// derived values
const strength = useMemo(() => getPasswordStrength(password), [password]);
const strengthBars = useMemo(() => {
@ -24,40 +26,40 @@ export const PasswordStrengthMeter: FC<TPasswordStrengthMeter> = (props) => {
case E_PASSWORD_STRENGTH.EMPTY: {
return {
bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Please enter your password.",
text: t("please_enter_your_password"),
textColor: "text-custom-text-100",
};
}
case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID: {
return {
bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Password length should me more than 8 characters.",
text: t("password_length_should_me_more_than_8_characters"),
textColor: "text-red-500",
};
}
case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID: {
return {
bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Password is weak.",
text: t("password_is_weak"),
textColor: "text-red-500",
};
}
case E_PASSWORD_STRENGTH.STRENGTH_VALID: {
return {
bars: [`bg-green-500`, `bg-green-500`, `bg-green-500`],
text: "Password is strong.",
text: t("password_is_strong"),
textColor: "text-green-500",
};
}
default: {
return {
bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Please enter your password.",
text: t("please_enter_your_password"),
textColor: "text-custom-text-100",
};
}
}
}, [strength]);
}, [strength,t]);
const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true;

View file

@ -1,29 +1,16 @@
"use client";
import { useMemo } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
// types
import { useTranslation } from "@plane/i18n";
import { IUserTheme } from "@plane/types";
// ui
import { Button, InputColorPicker, setPromiseToast } from "@plane/ui";
// hooks
import { useUserProfile } from "@/hooks/store";
const inputRules = {
minLength: {
value: 7,
message: "Enter a valid hex code of 6 characters",
},
maxLength: {
value: 7,
message: "Enter a valid hex code of 6 characters",
},
pattern: {
value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
message: "Enter a valid hex code of 6 characters",
},
};
type TCustomThemeSelector = {
applyThemeChange: (theme: Partial<IUserTheme>) => void;
};
@ -32,7 +19,7 @@ export const CustomThemeSelector: React.FC<TCustomThemeSelector> = observer((pro
const { applyThemeChange } = props;
// hooks
const { data: userProfile, updateUserTheme } = useUserProfile();
const { t } = useTranslation();
const {
control,
formState: { errors, isSubmitting },
@ -51,6 +38,24 @@ export const CustomThemeSelector: React.FC<TCustomThemeSelector> = observer((pro
},
});
const inputRules = useMemo(
() => ({
minLength: {
value: 7,
message: t("enter_a_valid_hex_code_of_6_characters"),
},
maxLength: {
value: 7,
message: t("enter_a_valid_hex_code_of_6_characters"),
},
pattern: {
value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
message: t("enter_a_valid_hex_code_of_6_characters"),
},
}),
[t] // Empty dependency array since these rules never change
);
const handleUpdateTheme = async (formData: Partial<IUserTheme>) => {
const payload: IUserTheme = {
background: formData.background,
@ -66,14 +71,14 @@ export const CustomThemeSelector: React.FC<TCustomThemeSelector> = observer((pro
const updateCurrentUserThemePromise = updateUserTheme(payload);
setPromiseToast(updateCurrentUserThemePromise, {
loading: "Updating theme...",
loading: t("updating_theme"),
success: {
title: "Success!",
message: () => "Theme updated successfully!",
title: t("success"),
message: () => t("theme_updated_successfully"),
},
error: {
title: "Error!",
message: () => "Failed to Update the theme",
title: t("error"),
message: () => t("failed_to_update_the_theme"),
},
});
@ -91,16 +96,16 @@ export const CustomThemeSelector: React.FC<TCustomThemeSelector> = observer((pro
return (
<form onSubmit={handleSubmit(handleUpdateTheme)}>
<div className="space-y-5">
<h3 className="text-lg font-semibold text-custom-text-100">Customize your theme</h3>
<h3 className="text-lg font-semibold text-custom-text-100">{t("customize_your_theme")}</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 md:grid-cols-3">
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">Background color</h3>
<h3 className="text-left text-sm font-medium text-custom-text-200">{t("background_color")}</h3>
<div className="w-full">
<Controller
control={control}
name="background"
rules={{ ...inputRules, required: "Background color is required" }}
rules={{ ...inputRules, required: t("background_color_is_required") }}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="background"
@ -121,12 +126,12 @@ export const CustomThemeSelector: React.FC<TCustomThemeSelector> = observer((pro
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">Text color</h3>
<h3 className="text-left text-sm font-medium text-custom-text-200">{t("text_color")}</h3>
<div className="w-full">
<Controller
control={control}
name="text"
rules={{ ...inputRules, required: "Text color is required" }}
rules={{ ...inputRules, required: t("text_color_is_required") }}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="text"
@ -147,12 +152,12 @@ export const CustomThemeSelector: React.FC<TCustomThemeSelector> = observer((pro
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">Primary(Theme) color</h3>
<h3 className="text-left text-sm font-medium text-custom-text-200">{t("primary_color")}</h3>
<div className="w-full">
<Controller
control={control}
name="primary"
rules={{ ...inputRules, required: "Primary color is required" }}
rules={{ ...inputRules, required: t("primary_color_is_required") }}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="primary"
@ -173,12 +178,12 @@ export const CustomThemeSelector: React.FC<TCustomThemeSelector> = observer((pro
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">Sidebar background color</h3>
<h3 className="text-left text-sm font-medium text-custom-text-200">{t("sidebar_background_color")}</h3>
<div className="w-full">
<Controller
control={control}
name="sidebarBackground"
rules={{ ...inputRules, required: "Sidebar background color is required" }}
rules={{ ...inputRules, required: t("sidebar_background_color_is_required") }}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="sidebarBackground"
@ -201,12 +206,12 @@ export const CustomThemeSelector: React.FC<TCustomThemeSelector> = observer((pro
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">Sidebar text color</h3>
<h3 className="text-left text-sm font-medium text-custom-text-200">{t("sidebar_text_color")}</h3>
<div className="w-full">
<Controller
control={control}
name="sidebarText"
rules={{ ...inputRules, required: "Sidebar text color is required" }}
rules={{ ...inputRules, required: t("sidebar_text_color_is_required") }}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="sidebarText"
@ -230,7 +235,7 @@ export const CustomThemeSelector: React.FC<TCustomThemeSelector> = observer((pro
</div>
<div className="mt-5 flex justify-end gap-2">
<Button variant="primary" type="submit" loading={isSubmitting}>
{isSubmitting ? "Creating Theme..." : "Set Theme"}
{isSubmitting ? t("creating_theme") : t("set_theme")}
</Button>
</div>
</form>

View file

@ -1,6 +1,7 @@
"use client";
import { FC } from "react";
import { useTranslation } from "@plane/i18n";
// constants
import { CustomSelect } from "@plane/ui";
import { THEME_OPTIONS, I_THEME_OPTION } from "@/constants/themes";
@ -13,7 +14,7 @@ type Props = {
export const ThemeSwitch: FC<Props> = (props) => {
const { value, onChange } = props;
const { t } = useTranslation();
return (
<CustomSelect
value={value}
@ -40,10 +41,10 @@ export const ThemeSwitch: FC<Props> = (props) => {
}}
/>
</div>
{value.label}
{t(value.key)}
</div>
) : (
"Select your theme"
t("select_your_theme")
)
}
onChange={onChange}
@ -72,7 +73,7 @@ export const ThemeSwitch: FC<Props> = (props) => {
}}
/>
</div>
{themeOption.label}
{t(themeOption.key)}
</div>
</CustomSelect.Option>
))}

View file

@ -1,6 +1,7 @@
import { useRef, useState } from "react";
import { observer } from "mobx-react";
import { ChevronDown, LucideIcon } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// ui
import { ComboDropDown } from "@plane/ui";
// helpers
@ -26,6 +27,7 @@ type Props = {
} & MemberDropdownProps;
export const MemberDropdown: React.FC<Props> = observer((props) => {
const { t } = useTranslation();
const {
button,
buttonClassName,
@ -40,7 +42,7 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
multiple,
onChange,
onClose,
placeholder = "Members",
placeholder = t("members"),
tooltipContent,
placement,
projectId,
@ -86,7 +88,7 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
if (value.length === 1) {
return getUserDetails(value[0])?.display_name || placeholder;
} else {
return showUserDetails ? `${value.length} members` : "";
return showUserDetails ? `${value.length} ${t("members").toLocaleLowerCase()}` : "";
}
} else {
return placeholder;
@ -131,7 +133,9 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
className={cn("text-xs", buttonClassName)}
isActive={isOpen}
tooltipHeading={placeholder}
tooltipContent={tooltipContent ?? `${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`}
tooltipContent={
tooltipContent ?? `${value?.length ?? 0} ${value?.length !== 1 ? t("assignees") : t("assignee")}`
}
showTooltip={showTooltip}
variant={buttonVariant}
renderToolTipByDefault={renderByDefault}

View file

@ -8,6 +8,7 @@ import { createPortal } from "react-dom";
import { usePopper } from "react-popper";
import { Check, Search } from "lucide-react";
import { Combobox } from "@headlessui/react";
import { useTranslation } from "@plane/i18n";
// plane ui
import { Avatar } from "@plane/ui";
// helpers
@ -34,6 +35,7 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
// refs
const inputRef = useRef<HTMLInputElement | null>(null);
// store hooks
const { t } = useTranslation();
const { workspaceSlug } = useParams();
const {
getUserDetails,
@ -85,7 +87,7 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
content: (
<div className="flex items-center gap-2">
<Avatar name={userDetails?.display_name} src={getFileURL(userDetails?.avatar_url ?? "")} />
<span className="flex-grow truncate">{currentUser?.id === userId ? "You" : userDetails?.display_name}</span>
<span className="flex-grow truncate">{currentUser?.id === userId ? t("you") : userDetails?.display_name}</span>
</div>
),
};
@ -115,7 +117,7 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
placeholder={t("search")}
displayValue={(assigned: any) => assigned?.name}
onKeyDown={searchInputKeyDown}
/>
@ -142,10 +144,10 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
</Combobox.Option>
))
) : (
<p className="px-1.5 py-1 italic text-custom-text-400">No matching results</p>
<p className="px-1.5 py-1 italic text-custom-text-400">{t("no_matching_results")}</p>
)
) : (
<p className="px-1.5 py-1 italic text-custom-text-400">Loading...</p>
<p className="px-1.5 py-1 italic text-custom-text-400">{t("loading")}</p>
)}
</div>
</div>

View file

@ -5,6 +5,7 @@ import { useTheme } from "next-themes";
import { usePopper } from "react-popper";
import { Check, ChevronDown, Search, SignalHigh } from "lucide-react";
import { Combobox } from "@headlessui/react";
import { useTranslation } from "@plane/i18n";
// types
import { TIssuePriorities } from "@plane/types";
// ui
@ -71,11 +72,12 @@ const BorderButton = (props: ButtonProps) => {
};
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
return (
<Tooltip
tooltipHeading="Priority"
tooltipContent={priorityDetails?.title ?? "None"}
tooltipHeading={t("priority")}
tooltipContent={t(priorityDetails?.key ?? "none")}
disabled={!showTooltip}
isMobile={isMobile}
renderByDefault={renderToolTipByDefault}
@ -119,7 +121,7 @@ const BorderButton = (props: ButtonProps) => {
) : (
<SignalHigh className="size-3" />
))}
{!hideText && <span className="flex-grow truncate">{priorityDetails?.title ?? placeholder}</span>}
{!hideText && <span className="flex-grow truncate">{t(priorityDetails?.key ?? "priority") ?? placeholder}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
@ -153,11 +155,12 @@ const BackgroundButton = (props: ButtonProps) => {
};
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
return (
<Tooltip
tooltipHeading="Priority"
tooltipContent={priorityDetails?.title ?? "None"}
tooltipHeading={t("priority")}
tooltipContent={t(priorityDetails?.key ?? "none")}
disabled={!showTooltip}
isMobile={isMobile}
renderByDefault={renderToolTipByDefault}
@ -201,7 +204,7 @@ const BackgroundButton = (props: ButtonProps) => {
) : (
<SignalHigh className="size-3" />
))}
{!hideText && <span className="flex-grow truncate">{priorityDetails?.title ?? placeholder}</span>}
{!hideText && <span className="flex-grow truncate">{t(priorityDetails?.key ?? "priority") ?? placeholder}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
@ -236,11 +239,12 @@ const TransparentButton = (props: ButtonProps) => {
};
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
return (
<Tooltip
tooltipHeading="Priority"
tooltipContent={priorityDetails?.title ?? "None"}
tooltipHeading={t("priority")}
tooltipContent={t(priorityDetails?.key ?? "none")}
disabled={!showTooltip}
isMobile={isMobile}
renderByDefault={renderToolTipByDefault}
@ -285,7 +289,7 @@ const TransparentButton = (props: ButtonProps) => {
) : (
<SignalHigh className="size-3" />
))}
{!hideText && <span className="flex-grow truncate">{priorityDetails?.title ?? placeholder}</span>}
{!hideText && <span className="flex-grow truncate">{t(priorityDetails?.key ?? "priority") ?? placeholder}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
@ -336,6 +340,8 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
},
],
});
//hooks
const { t } = useTranslation();
// next-themes
// TODO: remove this after new theming implementation
const { resolvedTheme } = useTheme();
@ -346,7 +352,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
content: (
<div className="flex items-center gap-2">
<PriorityIcon priority={priority.key} size={14} withContainer />
<span className="flex-grow truncate">{priority.title}</span>
<span className="flex-grow truncate">{t(priority.key)}</span>
</div>
),
}));
@ -456,7 +462,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
placeholder={t("search")}
displayValue={(assigned: any) => assigned?.name}
onKeyDown={searchInputKeyDown}
/>
@ -482,7 +488,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
</Combobox.Option>
))
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
<p className="text-custom-text-400 italic py-1 px-1.5">{t("no_matching_results")}</p>
)}
</div>
</div>

View file

@ -4,6 +4,7 @@ import { usePopper } from "react-popper";
import { Briefcase, Check, ChevronDown, Search } from "lucide-react";
import { Combobox } from "@headlessui/react";
// ui
import { useTranslation } from "@plane/i18n";
import { ComboDropDown } from "@plane/ui";
// components
import { Logo } from "@/components/common";
@ -86,7 +87,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
});
// store hooks
const { joinedProjectIds, getProjectById } = useProject();
const { t } = useTranslation();
const options = joinedProjectIds?.map((projectId) => {
const projectDetails = getProjectById(projectId);
if (renderCondition && projectDetails && !renderCondition(projectDetails)) return;
@ -238,7 +239,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
placeholder={t("search")}
displayValue={(assigned: any) => assigned?.name}
onKeyDown={searchInputKeyDown}
/>
@ -268,10 +269,10 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
);
})
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
<p className="text-custom-text-400 italic py-1 px-1.5">{t("no_matching_results")}</p>
)
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
<p className="text-custom-text-400 italic py-1 px-1.5">{t("loading")}</p>
)}
</div>
</div>

View file

@ -6,6 +6,7 @@ import { useParams } from "next/navigation";
import { usePopper } from "react-popper";
import { ChevronDown, Search } from "lucide-react";
import { Combobox } from "@headlessui/react";
import { useTranslation } from "@plane/i18n";
// ui
import { ComboDropDown, Spinner, StateGroupIcon } from "@plane/ui";
// helpers
@ -82,6 +83,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
],
});
// store hooks
const { t } = useTranslation();
const { workspaceSlug } = useParams();
const { fetchProjectStates, getProjectStates, getStateById } = useProjectState();
const statesList = stateIds
@ -160,8 +162,8 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
<DropdownButton
className={buttonClassName}
isActive={isOpen}
tooltipHeading="State"
tooltipContent={selectedState?.name ?? "State"}
tooltipHeading={t("state")}
tooltipContent={selectedState?.name ?? t("state")}
showTooltip={showTooltip}
variant={buttonVariant}
renderToolTipByDefault={renderByDefault}
@ -178,7 +180,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
/>
)}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate">{selectedState?.name ?? "State"}</span>
<span className="flex-grow truncate">{selectedState?.name ?? t("state")}</span>
)}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
@ -239,10 +241,10 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
/>
))
) : (
<p className="px-1.5 py-1 italic text-custom-text-400">No matches found</p>
<p className="px-1.5 py-1 italic text-custom-text-400">{t("no_matching_results")}</p>
)
) : (
<p className="px-1.5 py-1 italic text-custom-text-400">Loading...</p>
<p className="px-1.5 py-1 italic text-custom-text-400">{t("loading")}</p>
)}
</div>
</div>

View file

@ -1,4 +1,5 @@
import Image from "next/image";
import { useTranslation } from "@plane/i18n";
// ui
import { getButtonStyling } from "@plane/ui";
// helpers
@ -6,38 +7,40 @@ import { cn } from "@/helpers/common.helper";
// assets
import PlaneLogo from "@/public/plane-logos/blue-without-text.png";
export const ProductUpdatesFooter = () => (
<div className="flex items-center justify-between flex-shrink-0 gap-4 m-6 mb-4">
<div className="flex items-center gap-2">
<a
export const ProductUpdatesFooter = () => {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between flex-shrink-0 gap-4 m-6 mb-4">
<div className="flex items-center gap-2">
<a
href="https://go.plane.so/p-docs"
target="_blank"
className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none"
>
Docs
</a>
<svg viewBox="0 0 2 2" className="h-0.5 w-0.5 fill-current">
<circle cx={1} cy={1} r={1} />
>
{t("docs")}
</a>
<svg viewBox="0 0 2 2" className="h-0.5 w-0.5 fill-current">
<circle cx={1} cy={1} r={1} />
</svg>
<a
href="https://go.plane.so/p-changelog"
target="_blank"
className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none"
>
Full changelog
</a>
<svg viewBox="0 0 2 2" className="h-0.5 w-0.5 fill-current">
<circle cx={1} cy={1} r={1} />
>
{t("full_changelog")}
</a>
<svg viewBox="0 0 2 2" className="h-0.5 w-0.5 fill-current">
<circle cx={1} cy={1} r={1} />
</svg>
<a
href="mailto:support@plane.so"
target="_blank"
className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none"
>
Support
</a>
<svg viewBox="0 0 2 2" className="h-0.5 w-0.5 fill-current">
<circle cx={1} cy={1} r={1} />
>
{t("support")}
</a>
<svg viewBox="0 0 2 2" className="h-0.5 w-0.5 fill-current">
<circle cx={1} cy={1} r={1} />
</svg>
<a
href="https://go.plane.so/p-discord"
@ -55,8 +58,9 @@ export const ProductUpdatesFooter = () => (
"flex gap-1.5 items-center text-center font-medium hover:underline underline-offset-2 outline-none"
)}
>
<Image src={PlaneLogo} alt="Plane" width={12} height={12} />
Powered by Plane Pages
</a>
</div>
);
<Image src={PlaneLogo} alt="Plane" width={12} height={12} />
{t("powered_by_plane_pages")}
</a>
</div>
);
};

View file

@ -1,5 +1,6 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
import { useTranslation } from "@plane/i18n";
// ui
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// components
@ -16,7 +17,7 @@ export type ProductUpdatesModalProps = {
export const ProductUpdatesModal: FC<ProductUpdatesModalProps> = observer((props) => {
const { isOpen, handleClose } = props;
const { t } = useTranslation();
const { config } = useInstance();
return (
@ -27,17 +28,17 @@ export const ProductUpdatesModal: FC<ProductUpdatesModalProps> = observer((props
<iframe src={config?.instance_changelog_url} className="w-full h-full" />
) : (
<div className="flex flex-col items-center justify-center w-full h-full mb-8">
<div className="text-lg font-medium">We are having trouble fetching the updates.</div>
<div className="text-lg font-medium">{t("we_are_having_trouble_fetching_the_updates")}</div>
<div className="text-sm text-custom-text-200">
Please visit{" "}
{t("please_visit")}
<a
href="https://go.plane.so/p-changelog"
target="_blank"
className="text-sm text-custom-primary-100 font-medium hover:text-custom-primary-200 underline underline-offset-1 outline-none"
>
our changelogs
{t("our_changelogs")}
</a>{" "}
for the latest updates.
{t("for_the_latest_updates")}.
</div>
</div>
)}

View file

@ -3,6 +3,7 @@
import React, { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { useTranslation } from "@plane/i18n";
// types
import { EIssuesStoreType } from "@plane/constants";
import type { TBaseIssue, TIssue } from "@plane/types";
@ -54,6 +55,7 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
const [uploadedAssetIds, setUploadedAssetIds] = useState<string[]>([]);
const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false);
// store hooks
const { t } = useTranslation();
const { captureIssueEvent } = useEventTracker();
const { workspaceSlug, projectId: routerProjectId, cycleId, moduleId } = useParams();
const { projectsWithCreatePermissions } = useUser();
@ -218,8 +220,8 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: `${is_draft_issue ? "Draft created." : "Issue created successfully."} `,
title: t("success"),
message: `${is_draft_issue ? t("draft_created") : t("issue_created_successfully")} `,
actionItems: !is_draft_issue && response?.project_id && (
<CreateIssueToastActionItems
workspaceSlug={workspaceSlug.toString()}
@ -241,8 +243,8 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: `${is_draft_issue ? "Draft issue" : "Issue"} could not be created. Please try again.`,
title: t("error"),
message: t(is_draft_issue ? "draft_creation_failed" : "issue_creation_failed"),
});
captureIssueEvent({
eventName: ISSUE_CREATED,
@ -287,8 +289,8 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Issue updated successfully.",
title: t("success"),
message: t("issue_updated_successfully"),
});
captureIssueEvent({
eventName: ISSUE_UPDATED,
@ -300,8 +302,8 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
console.error(error);
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Issue could not be updated. Please try again.",
title: t("error"),
message: t("issue_could_not_be_updated"),
});
captureIssueEvent({
eventName: ISSUE_UPDATED,

View file

@ -4,6 +4,7 @@ import React, { useState } from "react";
import { observer } from "mobx-react";
import { Control, Controller } from "react-hook-form";
import { LayoutPanelTop } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// types
import { ISearchIssueResponse, TIssue } from "@plane/types";
// ui
@ -65,6 +66,7 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
// states
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
// store hooks
const { t } = useTranslation();
const { areEstimateEnabledByProjectId } = useProjectEstimates();
const { getProjectById } = useProject();
const { isMobile } = usePlatformOS();
@ -133,7 +135,7 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
}}
buttonVariant={value?.length > 0 ? "transparent-without-text" : "border-with-text"}
buttonClassName={value?.length > 0 ? "hover:bg-transparent" : ""}
placeholder="Assignees"
placeholder={t("assignees")}
multiple
tabIndex={getIndex("assignee_ids")}
/>
@ -172,7 +174,7 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
}}
buttonVariant="border-with-text"
maxDate={maxDate ?? undefined}
placeholder="Start date"
placeholder={t("start_date")}
tabIndex={getIndex("start_date")}
/>
</div>
@ -191,7 +193,7 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
}}
buttonVariant="border-with-text"
minDate={minDate ?? undefined}
placeholder="Due date"
placeholder={t("due_date")}
tabIndex={getIndex("target_date")}
/>
</div>
@ -209,7 +211,7 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
onChange(cycleId);
handleFormChange();
}}
placeholder="Cycle"
placeholder={t("cycle")}
value={value}
buttonVariant="border-with-text"
tabIndex={getIndex("cycle_id")}
@ -231,7 +233,7 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
onChange(moduleIds);
handleFormChange();
}}
placeholder="Modules"
placeholder={t("modules")}
buttonVariant="border-with-text"
tabIndex={getIndex("module_ids")}
multiple
@ -256,7 +258,7 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
projectId={projectId}
buttonVariant="border-with-text"
tabIndex={getIndex("estimate_point")}
placeholder="Estimate"
placeholder={t("estimate")}
/>
</div>
)}
@ -288,7 +290,7 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
>
<>
<CustomMenu.MenuItem className="!p-1" onClick={() => setParentIssueListModalOpen(true)}>
Change parent issue
{t("change_parent_issue")}
</CustomMenu.MenuItem>
<Controller
control={control}
@ -301,7 +303,7 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
handleFormChange();
}}
>
Remove parent issue
{t("remove_parent_issue")}
</CustomMenu.MenuItem>
)}
/>
@ -314,7 +316,7 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
onClick={() => setParentIssueListModalOpen(true)}
>
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
<span className="whitespace-nowrap">Add parent</span>
<span className="whitespace-nowrap">{t("add_parent")}</span>
</button>
)}
</div>

View file

@ -4,6 +4,7 @@ import React from "react";
import { observer } from "mobx-react";
import { Control, Controller, FieldErrors } from "react-hook-form";
// types
import { useTranslation } from "@plane/i18n";
import { TIssue } from "@plane/types";
// ui
import { Input } from "@plane/ui";
@ -25,12 +26,13 @@ export const IssueTitleInput: React.FC<TIssueTitleInputProps> = observer((props)
const { control, issueTitleRef, errors, handleFormChange } = props;
// store hooks
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile);
const validateWhitespace = (value: string) => {
if (value.trim() === "") {
return "Title is required";
return t("title_is_required");
}
return undefined;
};
@ -41,10 +43,10 @@ export const IssueTitleInput: React.FC<TIssueTitleInputProps> = observer((props)
name="name"
rules={{
validate: validateWhitespace,
required: "Title is required",
required: t("title_is_required"),
maxLength: {
value: 255,
message: "Title should be less than 255 characters",
message: t("title_should_be_less_than_255_characters"),
},
}}
render={({ field: { value, onChange, ref } }) => (
@ -59,7 +61,7 @@ export const IssueTitleInput: React.FC<TIssueTitleInputProps> = observer((props)
}}
ref={issueTitleRef || ref}
hasError={Boolean(errors.name)}
placeholder="Title"
placeholder={t("title")}
className="w-full text-base"
tabIndex={getIndex("name")}
autoFocus

View file

@ -7,6 +7,8 @@ import { useForm } from "react-hook-form";
// editor
import { EIssuesStoreType } from "@plane/constants";
import { EditorRefApi } from "@plane/editor";
// i18n
import { useTranslation } from "@plane/i18n";
// types
import type { TIssue, ISearchIssueResponse, TWorkspaceDraftIssue } from "@plane/types";
// hooks
@ -77,6 +79,7 @@ export interface IssueFormProps {
}
export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
const { t } = useTranslation();
const {
data,
issueTitleRef,
@ -89,10 +92,10 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
onCreateMoreToggleChange,
isDraft,
moveToIssue = false,
modalTitle,
modalTitle = `${data?.id ? t("update") : isDraft ? t("create_a_draft") : t("create_new_issue")}`,
primaryButtonText = {
default: `${data?.id ? "Update" : isDraft ? "Save to Drafts" : "Save"}`,
loading: `${data?.id ? "Updating" : "Saving"}`,
default: `${data?.id ? t("update") : isDraft ? t("save_to_drafts") : t("save")}`,
loading: `${data?.id ? t("updating") : t("saving")}`,
},
isDuplicateModalOpen,
handleDuplicateIssueModal,
@ -198,8 +201,8 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
if (!editorRef.current?.isEditorReadyToDiscard()) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Editor is not ready to discard changes.",
title: t("error"),
message: t("editor_is_not_ready_to_discard_changes"),
});
return;
}
@ -391,7 +394,11 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
<DeDupeButtonRoot
workspaceSlug={workspaceSlug?.toString()}
isDuplicateModalOpen={isDuplicateModalOpen}
label={`${duplicateIssues.length} duplicate issue${duplicateIssues.length > 1 ? "s" : ""} found!`}
label={
duplicateIssues.length === 1
? `${duplicateIssues.length} ${t("duplicate_issue_found")}`
: `${duplicateIssues.length} ${t("duplicate_issues_found")}`
}
handleOnClick={() => handleDuplicateIssueModal(!isDuplicateModalOpen)}
/>
)}
@ -491,7 +498,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
role="button"
>
<ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" />
<span className="text-xs">Create more</span>
<span className="text-xs">{t("create_more")}</span>
</div>
)}
<div className="flex items-center gap-2">
@ -511,7 +518,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
}}
tabIndex={getIndex("discard_button")}
>
Discard
{t("discard")}
</Button>
<Button
variant={moveToIssue ? "neutral-primary" : "primary"}

View file

@ -4,8 +4,8 @@ import { useParams } from "next/navigation";
import { usePopper } from "react-popper";
import { Check, Component, Plus, Search, Tag } from "lucide-react";
import { Combobox } from "@headlessui/react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
// components
import { IssueLabelsList } from "@/components/ui";
// helpers
@ -39,6 +39,7 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
createLabelEnabled = false,
buttonClassName,
} = props;
const { t } = useTranslation();
// router
const { workspaceSlug } = useParams();
// store hooks
@ -131,7 +132,7 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
) : (
<div className="h-full flex items-center justify-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1 text-xs hover:bg-custom-background-80">
<Tag className="h-3 w-3 flex-shrink-0" />
<span>Labels</span>
<span>{t("labels")}</span>
</div>
)}
</button>
@ -152,7 +153,7 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
onChange={(event) => setQuery(event.target.value)}
placeholder="Search"
placeholder={t("search")}
displayValue={(assigned: any) => assigned?.name}
/>
</div>
@ -232,10 +233,10 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
);
})
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
<p className="text-custom-text-400 italic py-1 px-1.5">{t("no_matching_results")}</p>
)
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
<p className="text-custom-text-400 italic py-1 px-1.5">{t("loading")}</p>
)}
{createLabelEnabled && (
<button
@ -244,7 +245,7 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
onClick={() => setIsOpen(true)}
>
<Plus className="h-3 w-3" aria-hidden="true" />
<span className="whitespace-nowrap">Create new label</span>
<span className="whitespace-nowrap">{t("create_new_label")}</span>
</button>
)}
</div>

View file

@ -0,0 +1,463 @@
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, Input, TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui";
// components
import { DeactivateAccountModal } from "@/components/account";
import { ImagePickerPopover, UserImageUploadModal } from "@/components/core";
import { TimezoneSelect } from "@/components/global";
// constants
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<TUserProfileForm>({
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 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<IUser> = {
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<TUserProfile> = {
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 (
<>
<DeactivateAccountModal isOpen={deactivateAccountModal} onClose={() => setDeactivateAccountModal(false)} />
<Controller
control={control}
name="avatar_url"
render={({ field: { onChange, value } }) => (
<UserImageUploadModal
isOpen={isImageUploadModalOpen}
onClose={() => setIsImageUploadModalOpen(false)}
handleRemove={async () => await handleProfilePictureDelete(currentUser?.avatar_url)}
onSuccess={(url) => {
onChange(url);
handleSubmit(onSubmit)();
setIsImageUploadModalOpen(false);
}}
value={value && value.trim() !== "" ? value : null}
/>
)}
/>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex w-full flex-col gap-6">
<div className="relative h-44 w-full">
<img
src={userCover ? getFileURL(userCover) : "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"}
className="h-44 w-full rounded-lg object-cover"
alt={currentUser?.first_name ?? "Cover image"}
/>
<div className="absolute -bottom-6 left-6 flex items-end justify-between">
<div className="flex gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-custom-background-90">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)}>
{!userAvatar || userAvatar === "" ? (
<div className="h-16 w-16 rounded-md bg-custom-background-80 p-2">
<CircleUserRound className="h-full w-full text-custom-text-200" />
</div>
) : (
<div className="relative h-16 w-16 overflow-hidden">
<img
src={getFileURL(userAvatar)}
className="absolute left-0 top-0 h-full w-full rounded-lg object-cover"
onClick={() => setIsImageUploadModalOpen(true)}
alt={currentUser?.display_name}
role="button"
/>
</div>
)}
</button>
</div>
</div>
</div>
<div className="absolute bottom-3 right-3 flex">
<Controller
control={control}
name="cover_image_url"
render={({ field: { value, onChange } }) => (
<ImagePickerPopover
label={t("change_cover")}
onChange={(imageUrl) => onChange(imageUrl)}
control={control}
value={value ?? "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"}
isProfileCover
/>
)}
/>
</div>
</div>
<div className="item-center mt-6 flex justify-between">
<div className="flex flex-col">
<div className="item-center flex text-lg font-medium text-custom-text-200">
<span>{`${watch("first_name")} ${watch("last_name")}`}</span>
</div>
<span className="text-sm text-custom-text-300 tracking-tight">{watch("email")}</span>
</div>
</div>
<div className="flex flex-col gap-2">
<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("first_name")}&nbsp;
<span className="text-red-500">*</span>
</h4>
<Controller
control={control}
name="first_name"
rules={{
required: "Please enter first name",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="first_name"
name="first_name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.first_name)}
placeholder="Enter your first name"
className={`w-full rounded-md ${errors.first_name ? "border-red-500" : ""}`}
maxLength={24}
autoComplete="on"
/>
)}
/>
{errors.first_name && <span className="text-xs text-red-500">{errors.first_name.message}</span>}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">{t("last_name")}</h4>
<Controller
control={control}
name="last_name"
render={({ field: { value, onChange, ref } }) => (
<Input
id="last_name"
name="last_name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.last_name)}
placeholder="Enter your last name"
className="w-full rounded-md"
maxLength={24}
autoComplete="on"
/>
)}
/>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">
{t("display_name")}&nbsp;
<span className="text-red-500">*</span>
</h4>
<Controller
control={control}
name="display_name"
rules={{
required: "Display name is required.",
validate: (value) => {
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 } }) => (
<Input
id="display_name"
name="display_name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors?.display_name)}
placeholder="Enter your display name"
className={`w-full ${errors?.display_name ? "border-red-500" : ""}`}
maxLength={24}
/>
)}
/>
{errors?.display_name && <span className="text-xs text-red-500">{errors?.display_name?.message}</span>}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">
{t("email")}&nbsp;
<span className="text-red-500">*</span>
</h4>
<Controller
control={control}
name="email"
rules={{
required: "Email is required.",
}}
render={({ field: { value, ref } }) => (
<Input
id="email"
name="email"
type="email"
value={value}
ref={ref}
hasError={Boolean(errors.email)}
placeholder="Enter your email"
className={`w-full cursor-not-allowed rounded-md !bg-custom-background-90 ${
errors.email ? "border-red-500" : ""
}`}
autoComplete="on"
disabled
/>
)}
/>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">
{t("role")}&nbsp;
<span className="text-red-500">*</span>
</h4>
<Controller
name="role"
control={control}
rules={{ required: "Role is required." }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={value ? value.toString() : "Select your role"}
buttonClassName={errors.role ? "border-red-500" : "border-none"}
className="rounded-md border-[0.5px] !border-custom-border-200"
optionsClassName="w-full"
input
>
{USER_ROLES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
{errors.role && <span className="text-xs text-red-500">Please select a role</span>}
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-x-6 gap-y-4">
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">
{t("timezone")}&nbsp;
<span className="text-red-500">*</span>
</h4>
<Controller
name="user_timezone"
control={control}
rules={{ required: "Please select a timezone" }}
render={({ field: { value, onChange } }) => (
<TimezoneSelect
value={value}
onChange={(value: string) => {
onChange(value);
}}
error={Boolean(errors.user_timezone)}
/>
)}
/>
{errors.user_timezone && <span className="text-xs text-red-500">{errors.user_timezone.message}</span>}
</div>
<div className="flex flex-col gap-1">
<div className="flex gap-2 items-center">
<h4 className="text-sm font-medium text-custom-text-200">{t("language")} </h4>
<div className="w-fit cursor-pointer rounded-2xl text-custom-primary-200 bg-custom-primary-100/20 text-center font-medium outline-none text-xs px-2">
Alpha
</div>
</div>
<Controller
control={control}
name="language"
rules={{ required: "Please select a language" }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
label={value ? getLanguageLabel(value) : "Select a language"}
onChange={onChange}
buttonClassName={errors.language ? "border-red-500" : "border-none"}
className="rounded-md border-[0.5px] !border-custom-border-200"
optionsClassName="w-full"
input
>
{SUPPORTED_LANGUAGES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
</div>
<div className="flex items-center justify-between pt-6 pb-8">
<Button variant="primary" type="submit" loading={isLoading}>
{isLoading ? t("saving...") : t("save_changes")}
</Button>
</div>
</div>
</div>
</form>
<Disclosure as="div" className="border-t border-custom-border-100">
{({ open }) => (
<>
<Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between py-4">
<span className="text-lg font-medium tracking-tight">{t("deactivate_account")}</span>
<ChevronDown className={`h-5 w-5 transition-all ${open ? "rotate-180" : ""}`} />
</Disclosure.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="flex flex-col gap-8">
<span className="text-sm tracking-tight">{t("deactivate_account_description")}</span>
<div>
<Button variant="danger" onClick={() => setDeactivateAccountModal(true)}>
{t("deactivate_account")}
</Button>
</div>
</div>
</Disclosure.Panel>
</Transition>
</>
)}
</Disclosure>
</>
);
});

View file

@ -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"
export * from "./profile-setting-content-wrapper";
export * from "./profile-setting-content-header";
export * from "./form";

View file

@ -2,14 +2,14 @@
import React, { FC, useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "@plane/i18n";
import { IUserEmailNotificationSettings } from "@plane/types";
// ui
import { ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
// services
import { UserService } from "@/services/user.service";
// types
interface IEmailNotificationFormProps {
interface IEmailNotificationFormProps {
data: IUserEmailNotificationSettings;
}
@ -18,6 +18,7 @@ const userService = new UserService();
export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) => {
const { data } = props;
const { t } = useTranslation();
// form data
const {
control,
@ -34,16 +35,16 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
[key]: value,
});
setToast({
title: "Success!",
title: t("success"),
type: TOAST_TYPE.SUCCESS,
message: "Email notification setting updated successfully",
message: t("email_notification_setting_updated_successfully"),
});
} catch (err) {
console.error(err);
setToast({
title: "Error!",
title: t("error"),
type: TOAST_TYPE.ERROR,
message: "Failed to update email notification setting",
message: t("failed_to_update_email_notification_setting"),
});
}
};
@ -54,15 +55,13 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
return (
<>
<div className="pt-6 text-lg font-medium text-custom-text-100">Notify me when:</div>
<div className="pt-6 text-lg font-medium text-custom-text-100">{t("notify_me_when")}:</div>
{/* Notification Settings */}
<div className="flex flex-col py-2">
<div className="flex gap-2 items-center pt-6">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">Property changes</div>
<div className="text-sm font-normal text-custom-text-300">
Notify me when issue&apos;s properties like assignees, priority, estimates or anything else 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>
<div className="shrink-0">
<Controller
@ -83,9 +82,9 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
</div>
<div className="flex gap-2 items-center pt-6 pb-2">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">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">
Notify me when the issues moves to a different state
{t("state_change_description")}
</div>
</div>
<div className="shrink-0">
@ -107,8 +106,8 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
</div>
<div className="flex gap-2 items-center border-0 border-l-[3px] border-custom-border-300 pl-3">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">Issue completed</div>
<div className="text-sm font-normal text-custom-text-300">Notify me only when an issue is completed</div>
<div className="pb-1 text-base font-medium text-custom-text-100">{t("issue_completed")}</div>
<div className="text-sm font-normal text-custom-text-300">{t("issue_completed_description")}</div>
</div>
<div className="shrink-0">
<Controller
@ -129,9 +128,9 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
</div>
<div className="flex gap-2 items-center pt-6">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">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">
Notify me when someone leaves a comment on the issue
{t("comments_description")}
</div>
</div>
<div className="shrink-0">
@ -153,9 +152,9 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
</div>
<div className="flex gap-2 items-center pt-6">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">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">
Notify me only when someone mentions me in the comments or description
{t("mentions_description")}
</div>
</div>
<div className="shrink-0">

View file

@ -1,7 +1,8 @@
import { ChangeEvent } from "react";
import { Controller, useFormContext, UseFormSetValue } from "react-hook-form";
import { Info } from "lucide-react";
// plane ui
import { useTranslation } from "@plane/i18n";
// ui
import { Input, TextArea, Tooltip } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
@ -27,6 +28,7 @@ const ProjectCommonAttributes: React.FC<Props> = (props) => {
} = useFormContext<TProject>();
const { getIndex } = getTabIndex(ETabIndices.PROJECT_CREATE, isMobile);
const { t } = useTranslation();
const handleNameChange = (onChange: (...event: any[]) => void) => (e: ChangeEvent<HTMLInputElement>) => {
if (!isChangeInIdentifierRequired) {
@ -51,10 +53,10 @@ const ProjectCommonAttributes: React.FC<Props> = (props) => {
control={control}
name="name"
rules={{
required: "Name is required",
required: t("name_is_required"),
maxLength: {
value: 255,
message: "Title should be less than 255 characters",
message: t("title_should_be_less_than_255_characters"),
},
}}
render={({ field: { value, onChange } }) => (
@ -65,7 +67,7 @@ const ProjectCommonAttributes: React.FC<Props> = (props) => {
value={value}
onChange={handleNameChange(onChange)}
hasError={Boolean(errors.name)}
placeholder="Project name"
placeholder={t("project_name")}
className="w-full focus:border-blue-400"
tabIndex={getIndex("name")}
/>
@ -78,17 +80,17 @@ const ProjectCommonAttributes: React.FC<Props> = (props) => {
control={control}
name="identifier"
rules={{
required: "Project ID is required",
required: t("project_id_is_required"),
// allow only alphanumeric & non-latin characters
validate: (value) =>
/^[ÇŞĞIİÖÜA-Z0-9]+$/.test(value.toUpperCase()) || "Only Alphanumeric & Non-latin characters are allowed.",
/^[ÇŞĞIİÖÜA-Z0-9]+$/.test(value.toUpperCase()) || t("only_alphanumeric_non_latin_characters_allowed"),
minLength: {
value: 1,
message: "Project ID must at least be of 1 character",
message: t("project_id_must_be_at_least_1_character"),
},
maxLength: {
value: 5,
message: "Project ID must at most be of 5 characters",
message: t("project_id_must_be_at_most_5_characters"),
},
}}
render={({ field: { value, onChange } }) => (
@ -99,7 +101,7 @@ const ProjectCommonAttributes: React.FC<Props> = (props) => {
value={value}
onChange={handleIdentifierChange(onChange)}
hasError={Boolean(errors.identifier)}
placeholder="Project ID"
placeholder={t("project_id")}
className={cn("w-full text-xs focus:border-blue-400 pr-7", {
uppercase: value,
})}
@ -109,7 +111,7 @@ const ProjectCommonAttributes: React.FC<Props> = (props) => {
/>
<Tooltip
isMobile={isMobile}
tooltipContent="Helps you identify issues in the project uniquely. Max 5 characters."
tooltipContent={t("project_id_tooltip_content")}
className="text-sm"
position="right-top"
>
@ -126,7 +128,7 @@ const ProjectCommonAttributes: React.FC<Props> = (props) => {
id="description"
name="description"
value={value}
placeholder="Description..."
placeholder={t("description")}
onChange={onChange}
className="!h-24 text-sm focus:border-blue-400"
hasError={Boolean(errors?.description)}

View file

@ -1,6 +1,7 @@
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { X } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// plane types
import { IProject } from "@plane/types";
// plane ui
@ -21,6 +22,7 @@ type Props = {
const ProjectCreateHeader: React.FC<Props> = (props) => {
const { handleClose, isMobile = false } = props;
const { watch, control } = useFormContext<IProject>();
const { t } = useTranslation();
// derived values
const coverImage = watch("cover_image_url");
@ -33,7 +35,7 @@ const ProjectCreateHeader: React.FC<Props> = (props) => {
<img
src={getFileURL(coverImage)}
className="absolute left-0 top-0 h-full w-full rounded-lg object-cover"
alt="Project cover image"
alt={t("project_cover_image_alt")}
/>
)}
@ -48,7 +50,7 @@ const ProjectCreateHeader: React.FC<Props> = (props) => {
control={control}
render={({ field: { value, onChange } }) => (
<ImagePickerPopover
label="Change Cover"
label={t("change_cover")}
onChange={onChange}
control={control}
value={value}

View file

@ -1,4 +1,5 @@
import { useFormContext } from "react-hook-form";
import { useTranslation } from "@plane/i18n";
import { IProject } from "@plane/types";
// ui
import { Button } from "@plane/ui";
@ -13,6 +14,7 @@ type Props = {
};
const ProjectCreateButtons: React.FC<Props> = (props) => {
const { t } = useTranslation();
const { handleClose, isMobile = false } = props;
const {
formState: { isSubmitting },
@ -23,10 +25,10 @@ const ProjectCreateButtons: React.FC<Props> = (props) => {
return (
<div className="flex justify-end gap-2 py-4 border-t border-custom-border-100">
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={getIndex("cancel")}>
Cancel
{t("cancel")}
</Button>
<Button variant="primary" type="submit" size="sm" loading={isSubmitting} tabIndex={getIndex("submit")}>
{isSubmitting ? "Creating" : "Create project"}
{isSubmitting ? t("creating") : t("create_project")}
</Button>
</div>
);

View file

@ -3,6 +3,7 @@
import { FC, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Info, Lock } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// plane types
import { IProject, IWorkspace } from "@plane/types";
// plane ui
@ -43,6 +44,7 @@ export interface IProjectDetailsForm {
const projectService = new ProjectService();
export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
const { project, workspaceSlug, projectId, isAdmin } = props;
const { t } = useTranslation();
// states
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@ -361,8 +363,8 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
<div className="flex items-start gap-2">
<network.icon className="h-3.5 w-3.5" />
<div className="-mt-1">
<p>{network.label}</p>
<p className="text-xs text-custom-text-400">{network.description}</p>
<p>{t(network.label)}</p>
<p className="text-xs text-custom-text-400">{t(network.description)}</p>
</div>
</div>
</CustomSelect.Option>

View file

@ -3,6 +3,7 @@
import React, { FC } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useTranslation } from "@plane/i18n";
// ui
import { Button, getButtonStyling, Row } from "@plane/ui";
// components
@ -20,6 +21,7 @@ type Props = {
export const ProjectFeatureUpdate: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, onClose } = props;
// store hooks
const { t } = useTranslation();
const { getProjectById } = useProject();
if (!workspaceSlug || !projectId) return null;
@ -33,12 +35,12 @@ export const ProjectFeatureUpdate: FC<Props> = observer((props) => {
</Row>
<div className="flex items-center justify-between gap-2 mt-4 px-6 py-4 border-t border-custom-border-100">
<div className="flex gap-1 text-sm text-custom-text-300 font-medium">
Congrats! Project <Logo logo={currentProjectDetails.logo_props} />{" "}
<p className="break-all">{currentProjectDetails.name}</p> created.
{t("congrats")}! {t("project")} <Logo logo={currentProjectDetails.logo_props} />{" "}
<p className="break-all">{currentProjectDetails.name}</p> {t("created").toLowerCase()}.
</div>
<div className="flex gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose} tabIndex={1}>
Close
{t("close")}
</Button>
<Link
href={`/${workspaceSlug}/projects/${projectId}/issues`}
@ -46,7 +48,7 @@ export const ProjectFeatureUpdate: FC<Props> = observer((props) => {
className={getButtonStyling("primary", "sm")}
tabIndex={2}
>
Open project
{t("open_project")}
</Link>
</div>
</div>

View file

@ -2,6 +2,7 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { IProject } from "@plane/types";
import { ToggleSwitch, Tooltip, setPromiseToast } from "@plane/ui";
// hooks
@ -20,6 +21,7 @@ type Props = {
export const ProjectFeaturesList: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, isAdmin } = props;
// store hooks
const { t } = useTranslation();
const { captureEvent } = useEventTracker();
const { data: currentUser } = useUser();
const { getProjectById, updateProject } = useProject();
@ -62,8 +64,8 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
return (
<div key={featureSectionKey} className="">
<div className="flex flex-col justify-center pb-2 border-b border-custom-border-100">
<h3 className="text-xl font-medium">{feature.title}</h3>
<h4 className="text-sm leading-5 text-custom-text-200">{feature.description}</h4>
<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.keys(feature.featureList).map((featureItemKey) => {
const featureItem = feature.featureList[featureItemKey];
@ -79,7 +81,7 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium leading-5">{featureItem.title}</h4>
<h4 className="text-sm font-medium leading-5">{t(featureItem.key)}</h4>
{featureItem.isPro && (
<Tooltip tooltipContent="Pro feature" position="top">
<UpgradeBadge />
@ -87,7 +89,7 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
)}
</div>
<p className="text-sm leading-5 tracking-tight text-custom-text-300">
{featureItem.description}
{t(`${featureItem.key}_description`)}
</p>
</div>
</div>

View file

@ -3,8 +3,9 @@
import { Dispatch, SetStateAction, useEffect, useState, FC } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
// constants
import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// constants
// types
import { IWorkspace } from "@plane/types";
// ui
@ -35,14 +36,15 @@ type Props = {
const workspaceService = new WorkspaceService();
export const CreateWorkspaceForm: FC<Props> = observer((props) => {
const { t } = useTranslation();
const {
onSubmit,
defaultValues,
setDefaultValues,
secondaryButton,
primaryButtonText = {
loading: "Creating workspace",
default: "Create workspace",
loading: t("creating_workspace"),
default: t("create_workspace"),
},
} = props;
// states
@ -76,13 +78,13 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
payload: {
...res,
state: "SUCCESS",
element: "Create workspace page",
element: t("create_workspace_page"),
},
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Workspace created successfully.",
title: t("success"),
message: t("workspace_created_successfully"),
});
if (onSubmit) await onSubmit(res);
@ -92,13 +94,13 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
eventName: WORKSPACE_CREATED,
payload: {
state: "FAILED",
element: "Create workspace page",
element: t("create_workspace_page"),
},
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Workspace could not be created. Please try again.",
title: t("error"),
message: t("workspace_could_not_be_created_please_try_again"),
});
});
} else setSlugError(true);
@ -106,8 +108,8 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Some error occurred while creating workspace. Please try again.",
title: t("error"),
message: t("workspace_could_not_be_created_please_try_again"),
});
});
};
@ -125,7 +127,7 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
<div className="space-y-6 sm:space-y-7">
<div className="space-y-1 text-sm">
<label htmlFor="workspaceName">
Name your workspace
{t("name_your_workspace")}
<span className="ml-0.5 text-red-500">*</span>
</label>
<div className="flex flex-col gap-1">
@ -133,13 +135,13 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
control={control}
name="name"
rules={{
required: "This is a required field.",
required: t("this_is_a_required_field"),
validate: (value) =>
/^[\w\s-]*$/.test(value) ||
`Workspaces names can contain only (" "), ( - ), ( _ ) and alphanumeric characters.`,
t("workspaces_names_can_contain_only_space_dash_and_alphanumeric_characters"),
maxLength: {
value: 80,
message: "Limit your name to 80 characters.",
message: t("limit_your_name_to_80_characters"),
},
}}
render={({ field: { value, ref, onChange } }) => (
@ -156,7 +158,7 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
}}
ref={ref}
hasError={Boolean(errors.name)}
placeholder="Something familiar and recognizable is always best."
placeholder={t("something_familiar_and_recognizable_is_always_best")}
className="w-full"
/>
)}
@ -166,7 +168,7 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
</div>
<div className="space-y-1 text-sm">
<label htmlFor="workspaceUrl">
Set your workspace&apos;s URL
{t("set_your_workspace_url")}
<span className="ml-0.5 text-red-500">*</span>
</label>
<div className="flex w-full items-center rounded-md border-[0.5px] border-custom-border-200 px-3">
@ -175,10 +177,10 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
control={control}
name="slug"
rules={{
required: "This is a required field.",
required: t("this_is_a_required_field"),
maxLength: {
value: 48,
message: "Limit your URL to 48 characters.",
message: t("limit_your_url_to_48_characters"),
},
}}
render={({ field: { onChange, value, ref } }) => (
@ -193,34 +195,34 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
}}
ref={ref}
hasError={Boolean(errors.slug)}
placeholder="workspace-name"
placeholder={t("workspace_name")}
className="block w-full rounded-md border-none bg-transparent !px-0 py-2 text-sm"
/>
)}
/>
</div>
{slugError && <p className="-mt-3 text-sm text-red-500">Workspace URL is already taken!</p>}
{slugError && <p className="-mt-3 text-sm text-red-500">{t("workspace_url_is_already_taken")}</p>}
{invalidSlug && (
<p className="text-sm text-red-500">{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}</p>
<p className="text-sm text-red-500">{t("urls_can_contain_only_dash_and_alphanumeric_characters")}</p>
)}
{errors.slug && <span className="text-xs text-red-500">{errors.slug.message}</span>}
</div>
<div className="space-y-1 text-sm">
<span>
How many people will use this workspace?<span className="ml-0.5 text-red-500">*</span>
{t("how_many_people_will_use_this_workspace")}<span className="ml-0.5 text-red-500">*</span>
</span>
<div className="w-full">
<Controller
name="organization_size"
control={control}
rules={{ required: "This is a required field." }}
rules={{ required: t("this_is_a_required_field") }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={
ORGANIZATION_SIZE.find((c) => c === value) ?? (
<span className="text-custom-text-400">Select a range</span>
<span className="text-custom-text-400">{t("select_a_range")}</span>
)
}
buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none"
@ -249,7 +251,7 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
</Button>
{!secondaryButton && (
<Button variant="neutral-primary" type="button" size="md" onClick={() => router.back()}>
Go back
{t("go_back")}
</Button>
)}
</div>

View file

@ -1,6 +1,6 @@
"use client";
import { Fragment, Ref, useState } from "react";
import { Fragment, Ref, useState, useMemo } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
@ -10,6 +10,7 @@ import { Check, ChevronDown, LogOut, Mails, PlusSquare, Settings } from "lucide-
// ui
import { Menu, Transition } from "@headlessui/react";
// types
import { useTranslation } from "@plane/i18n";
import { IWorkspace } from "@plane/types";
// plane ui
import { Avatar, Loader, TOAST_TYPE, setToast } from "@plane/ui";
@ -25,25 +26,29 @@ import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.hel
// components
import { WorkspaceLogo } from "../logo";
// Static Data
const userLinks = (workspaceSlug: string) => [
{
key: "workspace_invites",
name: "Workspace invites",
href: "/invitations",
icon: Mails,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
},
{
key: "settings",
name: "Workspace settings",
href: `/${workspaceSlug}/settings`,
icon: Settings,
access: [EUserPermissions.ADMIN],
},
];
export const SidebarDropdown = observer(() => {
const { t } = useTranslation();
const userLinks = useMemo(
() => (workspaceSlug: string) => [
{
key: "workspace_invites",
name: t("workspace_invites"),
href: "/invitations",
icon: Mails,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
},
{
key: "settings",
name: t("workspace_settings"),
href: `/${workspaceSlug}/settings`,
icon: Settings,
access: [EUserPermissions.ADMIN],
},
],
[t]
);
// router params
const { workspaceSlug } = useParams();
// store hooks
@ -86,8 +91,8 @@ export const SidebarDropdown = observer(() => {
await signOut().catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to sign out. Please try again.",
title: t("error"),
message: t("failed_to_sign_out_please_try_again"),
})
);
};
@ -121,7 +126,7 @@ export const SidebarDropdown = observer(() => {
<WorkspaceLogo logo={activeWorkspace?.logo_url} name={activeWorkspace?.name} />
{!sidebarCollapsed && (
<h4 className="truncate text-base font-medium text-custom-text-100">
{activeWorkspace?.name ?? "Loading..."}
{activeWorkspace?.name ?? t("loading")}
</h4>
)}
</div>
@ -177,7 +182,7 @@ export const SidebarDropdown = observer(() => {
<img
src={getFileURL(workspace.logo_url)}
className="absolute left-0 top-0 h-full w-full rounded object-cover"
alt="Workspace Logo"
alt={t("workspace_logo")}
/>
) : (
(workspace?.name?.[0] ?? "...")
@ -217,7 +222,7 @@ export const SidebarDropdown = observer(() => {
className="flex items-center gap-2 rounded px-2 py-1 text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
>
<PlusSquare strokeWidth={1.75} className="h-4 w-4 flex-shrink-0" />
Create workspace
{t("create_workspace")}
</Menu.Item>
</Link>
)}
@ -251,7 +256,7 @@ export const SidebarDropdown = observer(() => {
onClick={handleSignOut}
>
<LogOut className="size-4 flex-shrink-0" />
Sign out
{t("sign_out")}
</Menu.Item>
</div>
</div>
@ -293,7 +298,7 @@ export const SidebarDropdown = observer(() => {
<Menu.Item as="div">
<span className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80">
<Settings className="h-4 w-4 stroke-[1.5]" />
<span>Settings</span>
<span>{t("settings")}</span>
</span>
</Menu.Item>
</Link>
@ -306,7 +311,7 @@ export const SidebarDropdown = observer(() => {
onClick={handleSignOut}
>
<LogOut className="size-4 stroke-[1.5]" />
Sign out
{t("sign_out")}
</Menu.Item>
</div>
{isUserInstanceAdmin && (
@ -314,7 +319,7 @@ export const SidebarDropdown = observer(() => {
<Link href={GOD_MODE_URL}>
<Menu.Item as="button" type="button" className="w-full">
<span className="flex w-full items-center justify-center rounded bg-custom-primary-100/20 px-2 py-1 text-sm font-medium text-custom-primary-100 hover:bg-custom-primary-100/30 hover:text-custom-primary-200">
Enter God Mode
{t("enter_god_mode")}
</span>
</Menu.Item>
</Link>

View file

@ -13,6 +13,7 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ChevronRight, FolderPlus } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
import { useTranslation } from "@plane/i18n";
// ui
import { IFavorite } from "@plane/types";
import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
@ -38,6 +39,7 @@ export const SidebarFavoritesMenu = observer(() => {
const [isDragging, setIsDragging] = useState(false);
// store hooks
const { t } = useTranslation();
const { sidebarCollapsed } = useAppTheme();
const {
favoriteIds,
@ -65,8 +67,8 @@ export const SidebarFavoritesMenu = observer(() => {
}).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to move favorite.",
title: t("error"),
message: t("failed_to_move_favorite"),
});
});
};
@ -116,15 +118,15 @@ export const SidebarFavoritesMenu = observer(() => {
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Favorite removed successfully.",
title: t("success"),
message: t("favorite_removed_successfully"),
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong!",
title: t("error"),
message: t("something_went_wrong"),
});
});
};
@ -132,8 +134,8 @@ export const SidebarFavoritesMenu = observer(() => {
removeFromFavoriteFolder(workspaceSlug.toString(), favoriteId).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to move favorite.",
title: t("error"),
message: t("failed_to_move_favorite"),
});
});
};
@ -143,8 +145,8 @@ export const SidebarFavoritesMenu = observer(() => {
reOrderFavorite(workspaceSlug.toString(), favoriteId, droppedFavId, edge).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed reorder favorite",
title: t("error"),
message: t("failed_to_reorder_favorite"),
});
});
},
@ -198,10 +200,10 @@ export const SidebarFavoritesMenu = observer(() => {
)}
>
<span onClick={() => toggleFavoriteMenu(!isFavoriteMenuOpen)} className="flex-1 text-start">
YOUR FAVORITES
{t("your_favorites").toUpperCase()}
</span>
<span className="flex flex-shrink-0 opacity-0 pointer-events-none group-hover/workspace-button:opacity-100 group-hover/workspace-button:pointer-events-auto rounded p-0.5 ">
<Tooltip tooltipHeading="Create folder" tooltipContent="">
<Tooltip tooltipHeading={t("create_folder")} tooltipContent="">
<FolderPlus
onClick={() => {
setCreateNewFolder(true);
@ -240,7 +242,9 @@ export const SidebarFavoritesMenu = observer(() => {
{Object.keys(groupedFavorites).length === 0 ? (
<>
{!sidebarCollapsed && (
<span className="text-custom-text-400 text-xs font-medium px-8 py-1.5">No favorites yet</span>
<span className="text-custom-text-400 text-xs font-medium px-8 py-1.5">
{t("no_favorites_yet")}
</span>
)}
</>
) : (

View file

@ -2,8 +2,9 @@ import { useEffect, useRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Controller, SubmitHandler, useForm } from "react-hook-form";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
// plane helpers
// plane ui
import { FavoriteFolderIcon, Input, setToast, TOAST_TYPE } from "@plane/ui";
// hooks
@ -24,6 +25,7 @@ type TProps = {
};
export const NewFavoriteFolder = observer((props: TProps) => {
const { setCreateNewFolder, actionType, defaultName, favoriteId } = props;
const { t } = useTranslation();
const { workspaceSlug } = useParams();
const { addFavorite, updateFavorite, existingFolders } = useFavorite();
@ -42,8 +44,8 @@ export const NewFavoriteFolder = observer((props: TProps) => {
if (existingFolders.includes(formData.name))
return setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Folder already exists",
title: t("error"),
message: t("folder_already_exists"),
});
formData = {
entity_type: "folder",
@ -56,23 +58,23 @@ export const NewFavoriteFolder = observer((props: TProps) => {
if (formData.name === "")
return setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Folder name cannot be empty",
title: t("error"),
message: t("folder_name_cannot_be_empty"),
});
addFavorite(workspaceSlug.toString(), formData)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Favorite created successfully.",
title: t("success"),
message: t("favorite_created_successfully"),
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong!",
title: t("error"),
message: t("something_went_wrong"),
});
});
setCreateNewFolder(false);
@ -84,8 +86,8 @@ export const NewFavoriteFolder = observer((props: TProps) => {
if (existingFolders.includes(formData.name))
return setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Folder already exists",
title: t("error"),
message: t("folder_already_exists"),
});
const payload = {
name: formData.name.trim(),
@ -94,23 +96,23 @@ export const NewFavoriteFolder = observer((props: TProps) => {
if (formData.name.trim() === "")
return setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Folder name cannot be empty",
title: t("error"),
message: t("folder_name_cannot_be_empty"),
});
updateFavorite(workspaceSlug.toString(), favoriteId, payload)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Favorite updated successfully.",
title: t("success"),
message: t("favorite_updated_successfully"),
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong!",
title: t("error"),
message: t("something_went_wrong"),
});
});
setCreateNewFolder(false);
@ -132,7 +134,7 @@ export const NewFavoriteFolder = observer((props: TProps) => {
name="name"
control={control}
rules={{ required: true }}
render={({ field }) => <Input className="w-full" placeholder="New folder" {...field} />}
render={({ field }) => <Input className="w-full" placeholder={t("new_folder")} {...field} />}
/>
</form>
</div>

View file

@ -4,6 +4,7 @@ import React, { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { FileText, HelpCircle, MessagesSquare, MoveLeft, User } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// ui
import { CustomMenu, Tooltip, ToggleSwitch } from "@plane/ui";
// components
@ -24,6 +25,7 @@ export interface WorkspaceHelpSectionProps {
export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(() => {
const { workspaceSlug, projectId } = useParams();
// store hooks
const { t } = useTranslation();
const { sidebarCollapsed, toggleSidebar } = useAppTheme();
const { toggleShortcutModal } = useCommandPalette();
const { isMobile } = usePlatformOS();
@ -83,7 +85,7 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
className="flex items-center justify- gap-x-2 rounded text-xs hover:bg-custom-background-80"
>
<FileText className="h-3.5 w-3.5 text-custom-text-200" size={14} />
<span className="text-xs">Documentation</span>
<span className="text-xs">{t("documentation")}</span>
</a>
</CustomMenu.MenuItem>
{config?.intercom_app_id && config?.is_intercom_enabled && (
@ -94,7 +96,7 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
className="flex w-full items-center gap-x-2 rounded text-xs hover:bg-custom-background-80"
>
<MessagesSquare className="h-3.5 w-3.5 text-custom-text-200" />
<span className="text-xs">Message support</span>
<span className="text-xs">{t("message_support")}</span>
</button>
</CustomMenu.MenuItem>
)}
@ -105,7 +107,7 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
className="flex items-center justify- gap-x-2 rounded text-xs hover:bg-custom-background-80"
>
<User className="h-3.5 w-3.5 text-custom-text-200" size={14} />
<span className="text-xs">Contact sales</span>
<span className="text-xs">{t("contact_sales")}</span>
</a>
</CustomMenu.MenuItem>
<div className="my-1 border-t border-custom-border-200" />
@ -117,7 +119,7 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
}}
className="flex w-full items-center justify-between text-xs hover:bg-custom-background-80"
>
<span className="racking-tight">Hyper Mode</span>
<span className="racking-tight">{t("hyper_mode")}</span>
<ToggleSwitch
value={canUseLocalDB}
onChange={() => toggleLocalDB(workspaceSlug?.toString(), projectId?.toString())}
@ -130,7 +132,7 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
onClick={() => toggleShortcutModal(true)}
className="flex w-full items-center justify-start text-xs hover:bg-custom-background-80"
>
<span className="text-xs">Keyboard shortcuts</span>
<span className="text-xs">{t("keyboard_shortcuts")}</span>
</button>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem>
@ -139,7 +141,7 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
onClick={() => setProductUpdatesModalOpen(true)}
className="flex w-full items-center justify-start text-xs hover:bg-custom-background-80"
>
<span className="text-xs">What&apos;s new</span>
<span className="text-xs">{t("whats_new")}</span>
</button>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem>

View file

@ -5,6 +5,7 @@ import { observer } from "mobx-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { FileText, Layers } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// plane ui
import { Tooltip, DiceIcon, ContrastIcon, LayersIcon, Intake } from "@plane/ui";
// components
@ -17,6 +18,7 @@ import { EUserPermissions } from "@/plane-web/constants";
import { EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
export type TNavigationItem = {
key: string;
name: string;
href: string;
icon: React.ElementType;
@ -34,6 +36,7 @@ type TProjectItemsProps = {
export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
const { workspaceSlug, projectId, additionalNavigationItems } = props;
// store hooks
const { t } = useTranslation();
const { sidebarCollapsed: isSidebarCollapsed, toggleSidebar } = useAppTheme();
const { getProjectById } = useProject();
const { isMobile } = usePlatformOS();
@ -54,6 +57,7 @@ export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
const baseNavigation = useCallback(
(workspaceSlug: string, projectId: string): TNavigationItem[] => [
{
key: "issues",
name: "Issues",
href: `/${workspaceSlug}/projects/${projectId}/issues`,
icon: LayersIcon,
@ -62,6 +66,7 @@ export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
sortOrder: 1,
},
{
key: "cycles",
name: "Cycles",
href: `/${workspaceSlug}/projects/${projectId}/cycles`,
icon: ContrastIcon,
@ -70,6 +75,7 @@ export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
sortOrder: 2,
},
{
key: "modules",
name: "Modules",
href: `/${workspaceSlug}/projects/${projectId}/modules`,
icon: DiceIcon,
@ -78,6 +84,7 @@ export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
sortOrder: 3,
},
{
key: "views",
name: "Views",
href: `/${workspaceSlug}/projects/${projectId}/views`,
icon: Layers,
@ -86,6 +93,7 @@ export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
sortOrder: 4,
},
{
key: "pages",
name: "Pages",
href: `/${workspaceSlug}/projects/${projectId}/pages`,
icon: FileText,
@ -94,6 +102,7 @@ export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
sortOrder: 5,
},
{
key: "intake",
name: "Intake",
href: `/${workspaceSlug}/projects/${projectId}/inbox`,
icon: Intake,
@ -137,7 +146,7 @@ export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
<Tooltip
key={item.name}
isMobile={isMobile}
tooltipContent={`${project?.name}: ${item.name}`}
tooltipContent={`${project?.name}: ${t(item.key)}`}
position="right"
className="ml-2"
disabled={!isSidebarCollapsed}
@ -151,7 +160,7 @@ export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
<item.icon
className={`flex-shrink-0 size-4 ${item.name === "Intake" ? "stroke-1" : "stroke-[1.5]"}`}
/>
{!isSidebarCollapsed && <span className="text-xs font-medium">{item.name}</span>}
{!isSidebarCollapsed && <span className="text-xs font-medium">{t(item.key)}</span>}
</div>
</SidebarNavItem>
</Link>

View file

@ -14,6 +14,7 @@ import { LinkIcon, Star, Settings, Share2, LogOut, MoreHorizontal, ChevronRight
import { Disclosure, Transition } from "@headlessui/react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
// ui
import { CustomMenu, Tooltip, ArchiveIcon, setPromiseToast, DropIndicator, DragHandle, ControlLink } from "@plane/ui";
// components
@ -49,6 +50,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
props;
// store hooks
const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme();
const { t } = useTranslation();
const { setTrackElement } = useEventTracker();
const { addProjectToFavorites, removeProjectFromFavorites, getProjectById } = useProject();
const { isMobile } = usePlatformOS();
@ -88,14 +90,14 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
const addToFavoritePromise = addProjectToFavorites(workspaceSlug.toString(), project.id);
setPromiseToast(addToFavoritePromise, {
loading: "Adding project to favorites...",
loading: t("adding_project_to_favorites"),
success: {
title: "Success!",
message: () => "Project added to favorites.",
title: t("success"),
message: () => t("project_added_to_favorites"),
},
error: {
title: "Error!",
message: () => "Couldn't add the project to favorites. Please try again.",
title: t("error"),
message: () => t("couldnt_add_the_project_to_favorites"),
},
});
};
@ -105,14 +107,14 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
const removeFromFavoritePromise = removeProjectFromFavorites(workspaceSlug.toString(), project.id);
setPromiseToast(removeFromFavoritePromise, {
loading: "Removing project from favorites...",
loading: t("removing_project_from_favorites"),
success: {
title: "Success!",
message: () => "Project removed from favorites.",
title: t("success"),
message: () => t("project_removed_from_favorites"),
},
error: {
title: "Error!",
message: () => "Couldn't remove the project from favorites. Please try again.",
title: t("error"),
message: () => t("couldnt_remove_the_project_from_favorites"),
},
});
};
@ -251,7 +253,9 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
{!disableDrag && (
<Tooltip
isMobile={isMobile}
tooltipContent={project.sort_order === null ? "Join the project to rearrange" : "Drag to rearrange"}
tooltipContent={
project.sort_order === null ? t("join_the_project_to_rearrange") : t("drag_to_rearrange")
}
position="top-right"
disabled={isDragging}
>
@ -343,7 +347,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
"fill-yellow-500 stroke-yellow-500": project.is_favorite,
})}
/>
<span>{project.is_favorite ? "Remove from favorites" : "Add to favorites"}</span>
<span>{project.is_favorite ? t("remove_from_favorites") : t("add_to_favorites")}</span>
</span>
</CustomMenu.MenuItem>
)}
@ -355,7 +359,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
<div className="flex h-4 w-4 cursor-pointer items-center justify-center rounded text-custom-sidebar-text-200 transition-all duration-300 hover:bg-custom-sidebar-background-80">
<Share2 className="h-3.5 w-3.5 stroke-[1.5]" />
</div>
<div>{project.anchor ? "Publish settings" : "Publish"}</div>
<div>{project.anchor ? t("publish_settings") : t("publish")}</div>
</div>
</CustomMenu.MenuItem>
)}
@ -372,7 +376,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Copy link</span>
<span>{t("copy_link")}</span>
</span>
</CustomMenu.MenuItem>
{isAuthorized && (
@ -380,7 +384,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
<Link href={`/${workspaceSlug}/projects/${project?.id}/archives/issues`}>
<div className="flex items-center justify-start gap-2">
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Archives</span>
<span>{t("archives")}</span>
</div>
</Link>
</CustomMenu.MenuItem>
@ -389,7 +393,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
<Link href={`/${workspaceSlug}/projects/${project?.id}/settings`}>
<div className="flex items-center justify-start gap-2">
<Settings className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Settings</span>
<span>{t("settings")}</span>
</div>
</Link>
</CustomMenu.MenuItem>
@ -398,7 +402,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
<CustomMenu.MenuItem onClick={handleLeaveProject}>
<div className="flex items-center justify-start gap-2">
<LogOut className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Leave project</span>
<span>{t("leave_project")}</span>
</div>
</CustomMenu.MenuItem>
)}

View file

@ -7,6 +7,7 @@ import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { Briefcase, ChevronRight, Plus } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
import { useTranslation } from "@plane/i18n";
// ui
import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
// components
@ -34,6 +35,7 @@ export const SidebarProjectsList: FC = observer(() => {
// refs
const containerRef = useRef<HTMLDivElement | null>(null);
// store hooks
const { t } = useTranslation();
const { toggleCreateProjectModal } = useCommandPalette();
const { sidebarCollapsed } = useAppTheme();
const { setTrackElement } = useEventTracker();
@ -54,8 +56,8 @@ export const SidebarProjectsList: FC = observer(() => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Project link copied to clipboard.",
title: t("link_copied"),
message: t("project_link_copied_to_clipboard"),
});
});
};
@ -84,8 +86,8 @@ export const SidebarProjectsList: FC = observer(() => {
updateProjectView(workspaceSlug.toString(), sourceId, { sort_order: updatedSortOrder }).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong. Please try again.",
title: t("error"),
message: t("something_went_wrong"),
});
});
};
@ -175,12 +177,17 @@ export const SidebarProjectsList: FC = observer(() => {
)}
onClick={() => toggleListDisclosure(!isAllProjectsListOpen)}
>
<Tooltip tooltipHeading="YOUR PROJECTS" tooltipContent="" position="right" disabled={!isCollapsed}>
<Tooltip
tooltipHeading={t("your_projects").toUpperCase()}
tooltipContent=""
position="right"
disabled={!isCollapsed}
>
<>
{isCollapsed ? (
<Briefcase className="flex-shrink-0 size-3" />
) : (
<span className="text-xs font-semibold">YOUR PROJECTS</span>
<span className="text-xs font-semibold">{t("your_projects").toUpperCase()}</span>
)}
</>
</Tooltip>
@ -188,7 +195,7 @@ export const SidebarProjectsList: FC = observer(() => {
{!isCollapsed && (
<div className="flex items-center opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto">
{isAuthorizedUser && (
<Tooltip tooltipHeading="Create project" tooltipContent="">
<Tooltip tooltipHeading={t("create_project")} tooltipContent="">
<button
type="button"
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
@ -265,7 +272,7 @@ export const SidebarProjectsList: FC = observer(() => {
toggleCreateProjectModal(true);
}}
>
{!isCollapsed && "Add project"}
{!isCollapsed && t("add_project")}
</button>
)}
</div>

View file

@ -2,6 +2,7 @@ import { useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ChevronUp, PenSquare, Search } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// types
import { TIssue } from "@plane/types";
// components
@ -15,6 +16,7 @@ import useLocalStorage from "@/hooks/use-local-storage";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
export const SidebarQuickActions = observer(() => {
const { t } = useTranslation();
// states
const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false);
const [isDraftButtonOpen, setIsDraftButtonOpen] = useState(false);
@ -92,7 +94,7 @@ export const SidebarQuickActions = observer(() => {
disabled={disabled}
>
<PenSquare className="size-4" />
{!isSidebarCollapsed && <span className="text-sm font-medium">New issue</span>}
{!isSidebarCollapsed && <span className="text-sm font-medium">{t("new_issue")}</span>}
</button>
<button
className={cn(

View file

@ -4,6 +4,7 @@ import React from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { useTranslation } from "@plane/i18n";
// components
import { Tooltip } from "@plane/ui";
import { SidebarNavItem } from "@/components/sidebar";
@ -22,6 +23,7 @@ import { EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
import { isUserFeatureEnabled } from "@/plane-web/helpers/dashboard.helper";
export const SidebarUserMenu = observer(() => {
const { t } = useTranslation();
// store hooks
const { toggleSidebar, sidebarCollapsed } = useAppTheme();
const { captureEvent } = useEventTracker();
@ -62,28 +64,28 @@ export const SidebarUserMenu = observer(() => {
})}
>
{SIDEBAR_USER_MENU_ITEMS.map((link) => {
if (link.key === "drafts" && draftIssueCount === 0) return null;
if (!isUserFeatureEnabled(link.key)) return null;
if (link.value === "drafts" && draftIssueCount === 0) return null;
if (!isUserFeatureEnabled(link.value)) return null;
return (
allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && (
<Tooltip
key={link.key}
tooltipContent={link.label}
key={link.value}
tooltipContent={t(link.key)}
position="right"
className="ml-2"
disabled={!sidebarCollapsed}
isMobile={isMobile}
>
<Link key={link.key} href={getHref(link)} onClick={() => handleLinkClick(link.key)}>
<Link key={link.value} href={getHref(link)} onClick={() => handleLinkClick(link.value)}>
<SidebarNavItem
className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}
isActive={link.highlight(pathname, `/${workspaceSlug}`, { userId: currentUser?.id })}
>
<div className="flex items-center gap-1.5 py-[1px]">
<link.Icon className="size-4 flex-shrink-0" />
{!sidebarCollapsed && <p className="text-sm leading-5 font-medium">{link.label}</p>}
{!sidebarCollapsed && <p className="text-sm leading-5 font-medium">{t(link.key)}</p>}
</div>
{link.key === "notifications" && notificationIndicatorElement}
{link.value === "notifications" && notificationIndicatorElement}
</SidebarNavItem>
</Link>
</Tooltip>

View file

@ -6,8 +6,8 @@ import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { ArchiveIcon, ChevronRight, MoreHorizontal, Settings } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
// ui
import { CustomMenu, Tooltip } from "@plane/ui";
// components
@ -38,6 +38,7 @@ export const SidebarWorkspaceMenu = observer(() => {
// pathname
const pathname = usePathname();
// store hooks
const { t } = useTranslation();
const { toggleSidebar, sidebarCollapsed } = useAppTheme();
const { captureEvent } = useEventTracker();
const { isMobile } = usePlatformOS();
@ -85,7 +86,7 @@ export const SidebarWorkspaceMenu = observer(() => {
className="flex-1 sticky top-0 z-10 w-full py-1.5 flex items-center justify-between gap-1 text-custom-sidebar-text-400 text-xs font-semibold"
onClick={() => toggleWorkspaceMenu(!isWorkspaceMenuOpen)}
>
<span>WORKSPACE</span>
<span>{t("workspace").toUpperCase()}</span>
</Disclosure.Button>
<CustomMenu
customButton={
@ -112,7 +113,7 @@ export const SidebarWorkspaceMenu = observer(() => {
<Link href={`/${workspaceSlug}/projects/archives`}>
<div className="flex items-center justify-start gap-2">
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Archives</span>
<span>{t("archives")}</span>
</div>
</Link>
</CustomMenu.MenuItem>
@ -122,7 +123,7 @@ export const SidebarWorkspaceMenu = observer(() => {
<Link href={`/${workspaceSlug}/settings`}>
<div className="flex items-center justify-start gap-2">
<Settings className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Settings</span>
<span>{t("settings")}</span>
</div>
</Link>
</CustomMenu.MenuItem>
@ -162,32 +163,32 @@ export const SidebarWorkspaceMenu = observer(() => {
static
>
{SIDEBAR_WORKSPACE_MENU_ITEMS.map((link) => {
if (!isWorkspaceFeatureEnabled(link.key, workspaceSlug.toString())) return null;
if (!isWorkspaceFeatureEnabled(link.value, workspaceSlug.toString())) return null;
return (
allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && (
<Tooltip
key={link.key}
tooltipContent={link.label}
key={link.value}
tooltipContent={t(link.key)}
position="right"
className="ml-2"
disabled={!sidebarCollapsed}
isMobile={isMobile}
>
<Link href={`/${workspaceSlug}${link.href}`} onClick={() => handleLinkClick(link.key)}>
<Link href={`/${workspaceSlug}${link.href}`} onClick={() => handleLinkClick(link.value)}>
<SidebarNavItem
key={link.key}
key={link.value}
className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}
isActive={link.highlight(pathname, `/${workspaceSlug}`)}
>
<div className="flex items-center gap-1.5 py-[1px]">
<link.Icon
className={cn("size-4", {
"rotate-180": link.key === "active-cycles",
"rotate-180": link.value === "active-cycles",
})}
/>
{!sidebarCollapsed && <p className="text-sm leading-5 font-medium">{link.label}</p>}
{!sidebarCollapsed && <p className="text-sm leading-5 font-medium">{t(link.key)}</p>}
</div>
{!sidebarCollapsed && link.key === "active-cycles" && indicatorElement}
{!sidebarCollapsed && link.value === "active-cycles" && indicatorElement}
</SidebarNavItem>
</Link>
</Tooltip>

View file

@ -93,34 +93,40 @@ export const CYCLE_STATUS: {
export const WORKSPACE_ACTIVE_CYCLES_DETAILS = [
{
key: "10000_feet_view",
title: "10,000-feet view of all active cycles.",
description:
"Zoom out to see running cycles across all your projects at once instead of going from Cycle to Cycle in each project.",
icon: Folder,
},
{
key: "get_snapshot_of_each_active_cycle",
title: "Get a snapshot of each active cycle.",
description:
"Track high-level metrics for all active cycles, see their state of progress, and get a sense of scope against deadlines.",
icon: CircleDashed,
},
{
key: "compare_burndowns",
title: "Compare burndowns.",
description: "Monitor how each of your teams are performing with a peek into each cycles burndown report.",
icon: BarChart4,
},
{
key: "quickly_see_make_or_break_issues",
title: "Quickly see make-or-break issues. ",
description:
"Preview high-priority issues for each cycle against due dates. See all of them per cycle in one click.",
icon: AlertOctagon,
},
{
key: "zoom_into_cycles_that_need_attention",
title: "Zoom into cycles that need attention. ",
description: "Investigate the state of any cycle that doesnt conform to expectations in one click.",
icon: Search,
},
{
key: "stay_ahead_of_blockers",
title: "Stay ahead of blockers.",
description:
"Spot challenges from one project to another and see inter-cycle dependencies that arent obvious from any other view.",

View file

@ -48,6 +48,7 @@ export const PROFILE_ACTION_LINKS: {
export const PROFILE_VIEWER_TAB = [
{
key: "summary",
route: "",
label: "Summary",
selected: "/",
@ -56,6 +57,7 @@ export const PROFILE_VIEWER_TAB = [
export const PROFILE_ADMINS_TAB = [
{
key: "assigned",
route: "assigned",
label: "Assigned",
selected: "/assigned/",
@ -66,11 +68,13 @@ export const PROFILE_ADMINS_TAB = [
selected: "/created/",
},
{
key: "subscribed",
route: "subscribed",
label: "Subscribed",
selected: "/subscribed/",
},
{
key: "activity",
route: "activity",
label: "Activity",
selected: "/activity/",

View file

@ -1,6 +1,7 @@
export const THEMES = ["light", "dark", "light-contrast", "dark-contrast", "custom"];
export interface I_THEME_OPTION {
key: string;
value: string;
label: string;
type: string;
@ -13,6 +14,7 @@ export interface I_THEME_OPTION {
export const THEME_OPTIONS: I_THEME_OPTION[] = [
{
key: "system_preference",
value: "system",
label: "System preference",
type: "light",
@ -23,6 +25,7 @@ export const THEME_OPTIONS: I_THEME_OPTION[] = [
},
},
{
key: "light",
value: "light",
label: "Light",
type: "light",
@ -33,6 +36,7 @@ export const THEME_OPTIONS: I_THEME_OPTION[] = [
},
},
{
key: "dark",
value: "dark",
label: "Dark",
type: "dark",
@ -43,6 +47,7 @@ export const THEME_OPTIONS: I_THEME_OPTION[] = [
},
},
{
key: "light_contrast",
value: "light-contrast",
label: "Light high contrast",
type: "light",
@ -53,6 +58,7 @@ export const THEME_OPTIONS: I_THEME_OPTION[] = [
},
},
{
key: "dark_contrast",
value: "dark-contrast",
label: "Dark high contrast",
type: "dark",
@ -63,6 +69,7 @@ export const THEME_OPTIONS: I_THEME_OPTION[] = [
},
},
{
key: "custom",
value: "custom",
label: "Custom theme",
type: "light",

View file

@ -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<TStoreWrapper> = 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<TStoreWrapper> = 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<TStoreWrapper> = 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<TStoreWrapper> = 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);

View file

@ -58,6 +58,7 @@ export class ProfileStore implements IUserProfileStore {
has_billing_address: false,
created_at: "",
updated_at: "",
language: ""
};
// services