UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: канонизация статусов и системы оценок в настройках проекта

This commit is contained in:
DCCONSTRUCTIONS 2026-04-22 22:05:31 +03:00
parent b8f2654e80
commit b7b0388dc1
17 changed files with 292 additions and 63 deletions

View File

@ -18,6 +18,7 @@ import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
import { useProjectEstimates } from "@/hooks/store/estimates"; import { useProjectEstimates } from "@/hooks/store/estimates";
// local imports // local imports
import { EstimatePointCreateRoot } from "../points"; import { EstimatePointCreateRoot } from "../points";
import { getLocalizedEstimateSystemName, getLocalizedEstimateTemplateValues } from "../helpers";
import { EstimateCreateStageOne } from "./stage-one"; import { EstimateCreateStageOne } from "./stage-one";
type TCreateEstimateModal = { type TCreateEstimateModal = {
@ -92,7 +93,7 @@ export const CreateEstimateModal = observer(function CreateEstimateModal(props:
setButtonLoader(true); setButtonLoader(true);
const payload: IEstimateFormData = { const payload: IEstimateFormData = {
estimate: { estimate: {
name: ESTIMATE_SYSTEMS[estimateSystem]?.name, name: getLocalizedEstimateSystemName(estimateSystem, t, ESTIMATE_SYSTEMS[estimateSystem]?.name),
type: estimateSystem, type: estimateSystem,
last_used: true, last_used: true,
}, },
@ -142,15 +143,19 @@ export const CreateEstimateModal = observer(function CreateEstimateModal(props:
// }, [estimatePointError]); // }, [estimatePointError]);
return ( return (
<ModalCore isOpen={isOpen} position={EModalPosition.TOP} width={EModalWidth.XXL}> <ModalCore
<div className="relative space-y-6 py-5"> isOpen={isOpen}
position={EModalPosition.CENTER}
width={EModalWidth.XXL}
className="max-w-[52rem] overflow-hidden rounded-[1.85rem] p-0"
>
<div className="relative space-y-6 px-5 py-5">
{/* heading */} {/* heading */}
<div className="relative flex items-center justify-between gap-2 px-5"> <div className="relative flex items-center justify-between gap-2">
<div className="relative flex items-center gap-1"> <div className="relative flex items-center gap-1">
{estimatePoints && ( {estimatePoints && (
<div <div
onClick={() => { onClick={() => {
setEstimateSystem(EEstimateSystem.POINTS);
handleUpdatePoints(undefined); handleUpdatePoints(undefined);
}} }}
className="flex h-5 w-5 flex-shrink-0 cursor-pointer items-center justify-center" className="flex h-5 w-5 flex-shrink-0 cursor-pointer items-center justify-center"
@ -169,13 +174,13 @@ export const CreateEstimateModal = observer(function CreateEstimateModal(props:
</div> </div>
{/* estimate steps */} {/* estimate steps */}
<div className="px-5"> <div>
{!estimatePoints && ( {!estimatePoints && (
<EstimateCreateStageOne <EstimateCreateStageOne
estimateSystem={estimateSystem} estimateSystem={estimateSystem}
handleEstimateSystem={setEstimateSystem} handleEstimateSystem={setEstimateSystem}
handleEstimatePoints={(templateType: string) => handleEstimatePoints={(templateType: string) =>
handleUpdatePoints(ESTIMATE_SYSTEMS[estimateSystem].templates[templateType].values) handleUpdatePoints(getLocalizedEstimateTemplateValues(estimateSystem, templateType, t))
} }
/> />
)} )}
@ -199,12 +204,24 @@ export const CreateEstimateModal = observer(function CreateEstimateModal(props:
)} */} )} */}
</div> </div>
<div className="relative flex items-center justify-end gap-3 border-t border-subtle px-5 pt-5"> <div className="relative flex items-center justify-end gap-3 border-t border-subtle pt-5">
<Button variant="secondary" size="lg" onClick={handleClose} disabled={buttonLoader}> <Button
variant="secondary"
size="lg"
onClick={handleClose}
disabled={buttonLoader}
className="nodedc-modal-secondary-button min-w-[8.75rem]"
>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
{estimatePoints && ( {estimatePoints && (
<Button variant="primary" size="lg" onClick={handleCreateEstimate} disabled={buttonLoader}> <Button
variant="primary"
size="lg"
onClick={handleCreateEstimate}
disabled={buttonLoader}
className="nodedc-modal-primary-button min-w-[10.5rem]"
>
{buttonLoader ? t("common.creating") : t("project_settings.estimates.create.label")} {buttonLoader ? t("common.creating") : t("project_settings.estimates.create.label")}
</Button> </Button>
)} )}

View File

@ -16,6 +16,10 @@ import { convertMinutesToHoursMinutesString } from "@plane/utils";
import { isEstimateSystemEnabled } from "@/plane-web/components/estimates/helper"; import { isEstimateSystemEnabled } from "@/plane-web/components/estimates/helper";
import { UpgradeBadge } from "@/plane-web/components/workspace/upgrade-badge"; import { UpgradeBadge } from "@/plane-web/components/workspace/upgrade-badge";
import { RadioInput } from "../radio-select"; import { RadioInput } from "../radio-select";
import {
getLocalizedEstimatePointValue,
getLocalizedEstimateTemplateTitle,
} from "../helpers";
// local imports // local imports
type TEstimateCreateStageOne = { type TEstimateCreateStageOne = {
@ -65,10 +69,10 @@ export function EstimateCreateStageOne(props: TEstimateCreateStageOne) {
.filter((option) => option !== null)} .filter((option) => option !== null)}
name="estimate-radio-input" name="estimate-radio-input"
label={t("project_settings.estimates.create.choose_estimate_system")} label={t("project_settings.estimates.create.choose_estimate_system")}
labelClassName="text-13 font-medium text-secondary mb-1.5" labelClassName="mb-1.5 text-13 font-medium text-secondary"
wrapperClassName="relative flex flex-wrap gap-14" wrapperClassName="relative flex flex-wrap gap-x-10 gap-y-4"
fieldClassName="relative flex items-center gap-1.5" fieldClassName="relative flex items-center gap-2.5 text-primary"
buttonClassName="size-4" buttonClassName="size-[1.05rem]"
selected={estimateSystem} selected={estimateSystem}
onChange={(value) => handleEstimateSystem(value as TEstimateSystemKeys)} onChange={(value) => handleEstimateSystem(value as TEstimateSystemKeys)}
/> />
@ -80,13 +84,13 @@ export function EstimateCreateStageOne(props: TEstimateCreateStageOne) {
{t("project_settings.estimates.create.start_from_scratch")} {t("project_settings.estimates.create.start_from_scratch")}
</div> </div>
<button <button
className="block w-full space-y-1 rounded-md border border-subtle p-3 py-2.5 text-left hover:bg-layer-transparent-hover" type="button"
className="nodedc-modal-field block w-full space-y-1.5 rounded-[1.35rem] px-4 py-4 text-left transition-colors hover:bg-white/8"
onClick={() => handleEstimatePoints("custom")} onClick={() => handleEstimatePoints("custom")}
> >
<p className="text-14 font-medium">{t("project_settings.estimates.create.custom")}</p> <p className="text-14 font-medium">{t("project_settings.estimates.create.custom")}</p>
<p className="text-11 text-tertiary"> <p className="text-11 text-tertiary">
{/* TODO: Translate here */} {t(`project_settings.estimates.create.custom_description.${estimateSystem}`)}
Add your own <span className="lowercase">{currentEstimateSystem.name}</span> from scratch.
</p> </p>
</button> </button>
</div> </div>
@ -100,16 +104,17 @@ export function EstimateCreateStageOne(props: TEstimateCreateStageOne) {
currentEstimateSystem.templates[name]?.hide ? null : ( currentEstimateSystem.templates[name]?.hide ? null : (
<button <button
key={name} key={name}
className="space-y-1 rounded-md border border-subtle p-3 py-2.5 text-left hover:bg-surface-2" type="button"
className="nodedc-modal-field space-y-1.5 rounded-[1.35rem] px-4 py-4 text-left transition-colors hover:bg-white/8"
onClick={() => handleEstimatePoints(name)} onClick={() => handleEstimatePoints(name)}
> >
<p className="text-14 font-medium">{currentEstimateSystem.templates[name]?.title}</p> <p className="text-14 font-medium">{getLocalizedEstimateTemplateTitle(estimateSystem, name, t)}</p>
<p className="text-11 text-tertiary"> <p className="text-11 text-tertiary">
{currentEstimateSystem.templates[name]?.values {currentEstimateSystem.templates[name]?.values
?.map((template) => ?.map((template) =>
estimateSystem === (EEstimateSystem.TIME as TEstimateSystemKeys) estimateSystem === (EEstimateSystem.TIME as TEstimateSystemKeys)
? convertMinutesToHoursMinutesString(Number(template.value)).trim() ? convertMinutesToHoursMinutesString(Number(template.value)).trim()
: template.value : getLocalizedEstimatePointValue(estimateSystem, template.value, t)
) )
?.join(", ")} ?.join(", ")}
</p> </p>

View File

@ -14,6 +14,7 @@ import { AlertModalCore } from "@plane/ui";
import { useProjectEstimates } from "@/hooks/store/estimates"; import { useProjectEstimates } from "@/hooks/store/estimates";
import { useEstimate } from "@/hooks/store/estimates/use-estimate"; import { useEstimate } from "@/hooks/store/estimates/use-estimate";
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
import { getLocalizedEstimateDisplayName } from "../helpers";
type TDeleteEstimateModal = { type TDeleteEstimateModal = {
workspaceSlug: string; workspaceSlug: string;
@ -33,6 +34,8 @@ export const DeleteEstimateModal = observer(function DeleteEstimateModal(props:
const { updateProject } = useProject(); const { updateProject } = useProject();
// states // states
const [buttonLoader, setButtonLoader] = useState(false); const [buttonLoader, setButtonLoader] = useState(false);
const localizedEstimateName =
estimate?.type && estimate?.name ? getLocalizedEstimateDisplayName(estimate.name, estimate.type, t) : estimate?.name ?? "";
const handleDeleteEstimate = async () => { const handleDeleteEstimate = async () => {
try { try {
@ -66,7 +69,7 @@ export const DeleteEstimateModal = observer(function DeleteEstimateModal(props:
isSubmitting={buttonLoader} isSubmitting={buttonLoader}
isOpen={isOpen} isOpen={isOpen}
title={t("project_settings.estimates.delete_modal.title")} title={t("project_settings.estimates.delete_modal.title")}
content={t("project_settings.estimates.delete_modal.description", { value: estimate?.name ?? "" })} content={t("project_settings.estimates.delete_modal.description", { value: localizedEstimateName })}
primaryButtonText={{ primaryButtonText={{
loading: t("project_settings.estimates.delete_modal.loading"), loading: t("project_settings.estimates.delete_modal.loading"),
default: t("project_settings.estimates.delete_modal.submit"), default: t("project_settings.estimates.delete_modal.submit"),

View File

@ -7,9 +7,11 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// plane imports // plane imports
import { EEstimateSystem } from "@plane/constants"; import { EEstimateSystem } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { convertMinutesToHoursMinutesString } from "@plane/utils"; import { convertMinutesToHoursMinutesString } from "@plane/utils";
// components // components
import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item"; import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item";
import { getLocalizedEstimateDisplayName, getLocalizedEstimatePointValue } from "./helpers";
// hooks // hooks
import { useProjectEstimates } from "@/hooks/store/estimates"; import { useProjectEstimates } from "@/hooks/store/estimates";
import { useEstimate } from "@/hooks/store/estimates/use-estimate"; import { useEstimate } from "@/hooks/store/estimates/use-estimate";
@ -27,6 +29,7 @@ type TEstimateListItem = {
export const EstimateListItem = observer(function EstimateListItem(props: TEstimateListItem) { export const EstimateListItem = observer(function EstimateListItem(props: TEstimateListItem) {
const { estimateId } = props; const { estimateId } = props;
const { t } = useTranslation();
// store hooks // store hooks
const { estimateById } = useProjectEstimates(); const { estimateById } = useProjectEstimates();
const { estimatePointIds, estimatePointById } = useEstimate(estimateId); const { estimatePointIds, estimatePointById } = useEstimate(estimateId);
@ -41,13 +44,13 @@ export const EstimateListItem = observer(function EstimateListItem(props: TEstim
return ( return (
<SettingsBoxedControlItem <SettingsBoxedControlItem
title={currentEstimate.name} title={getLocalizedEstimateDisplayName(currentEstimate.name, currentEstimate.type, t)}
description={estimatePointValues description={estimatePointValues
?.map((estimatePointValue) => { ?.map((estimatePointValue) => {
if (currentEstimate.type === EEstimateSystem.TIME) { if (currentEstimate.type === EEstimateSystem.TIME) {
return convertMinutesToHoursMinutesString(Number(estimatePointValue)); return convertMinutesToHoursMinutesString(Number(estimatePointValue));
} }
return estimatePointValue; return getLocalizedEstimatePointValue(currentEstimate.type, estimatePointValue || "", t);
}) })
.join(", ")} .join(", ")}
control={<EstimateListItemButtons {...props} />} control={<EstimateListItemButtons {...props} />}

View File

@ -0,0 +1,89 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { ESTIMATE_SYSTEMS, EEstimateSystem } from "@plane/constants";
import type { TEstimatePointsObject, TEstimateSystemKeys } from "@plane/types";
const CATEGORY_POINT_TRANSLATION_KEYS: Record<string, string> = {
Easy: "project_settings.estimates.values.easy",
Medium: "project_settings.estimates.values.medium",
Hard: "project_settings.estimates.values.hard",
"Very Hard": "project_settings.estimates.values.very_hard",
};
const ESTIMATE_ADD_LABELS: Record<TEstimateSystemKeys, string> = {
points: "project_settings.estimates.create.add_points",
categories: "project_settings.estimates.create.add_categories",
time: "project_settings.estimates.create.add_time",
};
export const getLocalizedEstimateSystemName = (
estimateType: TEstimateSystemKeys,
t: (key: string) => string,
fallbackName?: string
) => {
const localizedName = ESTIMATE_SYSTEMS[estimateType]?.i18n_name
? t(ESTIMATE_SYSTEMS[estimateType].i18n_name)
: undefined;
return localizedName || fallbackName || ESTIMATE_SYSTEMS[estimateType]?.name || "";
};
export const getLocalizedEstimateDisplayName = (
name: string | undefined,
estimateType: TEstimateSystemKeys,
t: (key: string) => string
) => {
if (!name) return "";
const defaultName = ESTIMATE_SYSTEMS[estimateType]?.name;
if (defaultName && name.trim().toLowerCase() === defaultName.trim().toLowerCase()) {
return getLocalizedEstimateSystemName(estimateType, t, name);
}
return name;
};
export const getLocalizedEstimateTemplateTitle = (
estimateType: TEstimateSystemKeys,
templateName: string,
t: (key: string) => string
) => {
const template = ESTIMATE_SYSTEMS[estimateType]?.templates?.[templateName];
if (!template) return templateName;
return template.i18n_title ? t(template.i18n_title) : template.title;
};
export const getLocalizedEstimatePointValue = (
estimateType: TEstimateSystemKeys,
value: string,
t: (key: string) => string
) => {
if (estimateType !== EEstimateSystem.CATEGORIES) return value;
const translationKey = CATEGORY_POINT_TRANSLATION_KEYS[value];
return translationKey ? t(translationKey) : value;
};
export const getLocalizedEstimateTemplateValues = (
estimateType: TEstimateSystemKeys,
templateName: string,
t: (key: string) => string
): TEstimatePointsObject[] => {
const template = ESTIMATE_SYSTEMS[estimateType]?.templates?.[templateName];
if (!template) return [];
return template.values.map((templateValue) => ({
...templateValue,
value: getLocalizedEstimatePointValue(estimateType, templateValue.value, t),
}));
};
export const getLocalizedEstimateAddLabel = (
estimateType: TEstimateSystemKeys,
t: (key: string) => string
) => t(ESTIMATE_ADD_LABELS[estimateType]);

View File

@ -9,11 +9,13 @@ import { useCallback, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// plane imports // plane imports
import { estimateCount } from "@plane/constants"; import { estimateCount } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
import { PlusIcon } from "@plane/propel/icons"; import { PlusIcon } from "@plane/propel/icons";
import type { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeError } from "@plane/types"; import type { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeError } from "@plane/types";
import { Sortable } from "@plane/ui"; import { Sortable } from "@plane/ui";
// local imports // local imports
import { getLocalizedEstimateAddLabel, getLocalizedEstimateSystemName } from "../helpers";
import { EstimatePointCreate } from "./create"; import { EstimatePointCreate } from "./create";
import { EstimatePointItemPreview } from "./preview"; import { EstimatePointItemPreview } from "./preview";
@ -46,6 +48,7 @@ export const EstimatePointCreateRoot = observer(function EstimatePointCreateRoot
estimatePointError, estimatePointError,
handleEstimatePointError, handleEstimatePointError,
} = props; } = props;
const { t } = useTranslation();
// states // states
const [estimatePointCreate, setEstimatePointCreate] = useState<TEstimatePointsObject[] | undefined>(undefined); const [estimatePointCreate, setEstimatePointCreate] = useState<TEstimatePointsObject[] | undefined>(undefined);
@ -115,8 +118,8 @@ export const EstimatePointCreateRoot = observer(function EstimatePointCreateRoot
if (!workspaceSlug || !projectId) return <></>; if (!workspaceSlug || !projectId) return <></>;
return ( return (
<div className="space-y-1"> <div className="space-y-3">
<div className="text-13 font-medium text-secondary capitalize">{estimateType}</div> <div className="text-13 font-medium text-secondary">{getLocalizedEstimateSystemName(estimateType, t)}</div>
<div> <div>
<Sortable <Sortable
@ -171,8 +174,14 @@ export const EstimatePointCreateRoot = observer(function EstimatePointCreateRoot
/> />
))} ))}
{estimatePoints && estimatePoints.length + (estimatePointCreate?.length || 0) <= estimateCount.max - 1 && ( {estimatePoints && estimatePoints.length + (estimatePointCreate?.length || 0) <= estimateCount.max - 1 && (
<Button variant="link" prependIcon={<PlusIcon />} onClick={handleCreate}> <Button
Add {estimateType} type="button"
variant="ghost"
prependIcon={<PlusIcon />}
onClick={handleCreate}
className="nodedc-modal-chip inline-flex w-fit items-center gap-2 px-4"
>
{getLocalizedEstimateAddLabel(estimateType, t)}
</Button> </Button>
)} )}
</div> </div>

View File

@ -170,8 +170,8 @@ export const EstimatePointCreate = observer(function EstimatePointCreate(props:
<form onSubmit={handleCreate} className="relative flex items-center gap-2 pr-2.5 text-14"> <form onSubmit={handleCreate} className="relative flex items-center gap-2 pr-2.5 text-14">
<div <div
className={cn( className={cn(
"relative my-1 flex w-full items-center rounded-sm border", "nodedc-modal-field relative my-1 flex min-h-[3.25rem] w-full items-center rounded-[1.25rem] pr-2",
estimatePointError?.message ? `border-danger-strong` : `border-subtle` estimatePointError?.message && "!bg-danger-500/10"
)} )}
> >
<EstimateInputRoot <EstimateInputRoot
@ -191,7 +191,7 @@ export const EstimatePointCreate = observer(function EstimatePointCreate(props:
{estimateInputValue && estimateInputValue.length > 0 && ( {estimateInputValue && estimateInputValue.length > 0 && (
<button <button
type="submit" type="submit"
className="relative flex h-6 w-6 flex-shrink-0 cursor-pointer items-center justify-center rounded-xs text-success-primary transition-colors hover:bg-layer-1" className="nodedc-settings-primary-button relative flex h-9 w-9 min-h-0 min-w-0 flex-shrink-0 cursor-pointer items-center justify-center rounded-full !px-0"
disabled={loader} disabled={loader}
> >
{loader ? <Spinner className="h-4 w-4" /> : <CheckIcon width={14} height={14} />} {loader ? <Spinner className="h-4 w-4" /> : <CheckIcon width={14} height={14} />}
@ -199,7 +199,7 @@ export const EstimatePointCreate = observer(function EstimatePointCreate(props:
)} )}
<button <button
type="button" type="button"
className="relative flex h-6 w-6 flex-shrink-0 cursor-pointer items-center justify-center rounded-xs transition-colors hover:bg-layer-1" className="nodedc-settings-chip relative flex h-9 w-9 min-h-0 min-w-0 flex-shrink-0 cursor-pointer items-center justify-center rounded-full !px-0"
onClick={handleClose} onClick={handleClose}
disabled={loader} disabled={loader}
> >

View File

@ -16,6 +16,7 @@ import { convertMinutesToHoursMinutesString } from "@plane/utils";
// plane web imports // plane web imports
import { EstimatePointDelete } from "@/plane-web/components/estimates"; import { EstimatePointDelete } from "@/plane-web/components/estimates";
// local imports // local imports
import { getLocalizedEstimatePointValue } from "../helpers";
import { EstimatePointUpdate } from "./update"; import { EstimatePointUpdate } from "./update";
type TEstimatePointItemPreview = { type TEstimatePointItemPreview = {
@ -62,26 +63,30 @@ export const EstimatePointItemPreview = observer(function EstimatePointItemPrevi
return ( return (
<div> <div>
{!estimatePointEditToggle && !estimatePointDeleteToggle && ( {!estimatePointEditToggle && !estimatePointDeleteToggle && (
<div className="relative my-1 flex items-center gap-2 rounded-sm border border-subtle px-1 text-14"> <div className="nodedc-modal-field relative my-1 flex min-h-[3.25rem] items-center gap-2 rounded-[1.25rem] px-2 text-14">
<div className="relative flex h-6 w-6 flex-shrink-0 cursor-pointer items-center justify-center rounded-xs transition-colors hover:bg-layer-1"> <div className="nodedc-settings-chip relative flex h-9 w-9 min-h-0 min-w-0 flex-shrink-0 cursor-pointer items-center justify-center rounded-full !px-0">
<GripVertical size={14} className="text-secondary" /> <GripVertical size={14} className="text-secondary" />
</div> </div>
<div ref={EstimatePointValueRef} className="w-full py-2 text-13"> <div ref={EstimatePointValueRef} className="w-full py-2 text-13 font-medium">
{estimatePoint?.value ? ( {estimatePoint?.value ? (
`${estimateType === EEstimateSystem.TIME ? convertMinutesToHoursMinutesString(Number(estimatePoint?.value)) : estimatePoint?.value}` `${
estimateType === EEstimateSystem.TIME
? convertMinutesToHoursMinutesString(Number(estimatePoint?.value))
: getLocalizedEstimatePointValue(estimateType, estimatePoint?.value, t)
}`
) : ( ) : (
<span className="text-placeholder">{t("project_settings.estimates.create.enter_estimate_point")}</span> <span className="text-placeholder">{t("project_settings.estimates.create.enter_estimate_point")}</span>
)} )}
</div> </div>
<div <div
className="relative flex h-6 w-6 flex-shrink-0 cursor-pointer items-center justify-center rounded-xs transition-colors hover:bg-layer-1" className="nodedc-settings-chip relative flex h-9 w-9 min-h-0 min-w-0 flex-shrink-0 cursor-pointer items-center justify-center rounded-full !px-0"
onClick={() => setEstimatePointEditToggle(true)} onClick={() => setEstimatePointEditToggle(true)}
> >
<EditIcon width={14} height={14} className="text-secondary" /> <EditIcon width={14} height={14} className="text-secondary" />
</div> </div>
{estimatePoints.length > estimateCount.min && ( {estimatePoints.length > estimateCount.min && (
<div <div
className="relative flex h-6 w-6 flex-shrink-0 cursor-pointer items-center justify-center rounded-xs transition-colors hover:bg-layer-1" className="nodedc-settings-chip relative flex h-9 w-9 min-h-0 min-w-0 flex-shrink-0 cursor-pointer items-center justify-center rounded-full !px-0"
onClick={() => onClick={() =>
estimateId && estimatePointId estimateId && estimatePointId
? setEstimatePointDeleteToggle(true) ? setEstimatePointDeleteToggle(true)

View File

@ -175,8 +175,8 @@ export const EstimatePointUpdate = observer(function EstimatePointUpdate(props:
<form onSubmit={handleUpdate} className="relative flex items-center gap-2 pr-2.5 text-14"> <form onSubmit={handleUpdate} className="relative flex items-center gap-2 pr-2.5 text-14">
<div <div
className={cn( className={cn(
"relative my-1 flex w-full items-center rounded-sm border", "nodedc-modal-field relative my-1 flex min-h-[3.25rem] w-full items-center rounded-[1.25rem] pr-2",
estimatePointError?.message ? `border-danger-strong` : `border-subtle` estimatePointError?.message && "!bg-danger-500/10"
)} )}
> >
<EstimateInputRoot <EstimateInputRoot
@ -205,7 +205,7 @@ export const EstimatePointUpdate = observer(function EstimatePointUpdate(props:
{estimateInputValue && estimateInputValue.length > 0 && ( {estimateInputValue && estimateInputValue.length > 0 && (
<button <button
type="submit" type="submit"
className="relative flex h-6 w-6 flex-shrink-0 cursor-pointer items-center justify-center rounded-xs text-success-primary transition-colors hover:bg-layer-1" className="nodedc-settings-primary-button relative flex h-9 w-9 min-h-0 min-w-0 flex-shrink-0 cursor-pointer items-center justify-center rounded-full !px-0"
disabled={loader} disabled={loader}
> >
{loader ? <Spinner className="h-4 w-4" /> : <CheckIcon width={14} height={14} />} {loader ? <Spinner className="h-4 w-4" /> : <CheckIcon width={14} height={14} />}
@ -213,7 +213,7 @@ export const EstimatePointUpdate = observer(function EstimatePointUpdate(props:
)} )}
<button <button
type="button" type="button"
className="relative flex h-6 w-6 flex-shrink-0 cursor-pointer items-center justify-center rounded-xs transition-colors hover:bg-layer-1" className="nodedc-settings-chip relative flex h-9 w-9 min-h-0 min-w-0 flex-shrink-0 cursor-pointer items-center justify-center rounded-full !px-0"
onClick={handleClose} onClick={handleClose}
disabled={loader} disabled={loader}
> >

View File

@ -53,35 +53,43 @@ export function RadioInput({
return ( return (
<div className={className}> <div className={className}>
{inputLabel && <div className={cn(`mb-2`, inputLabelClassName)}>{inputLabel}</div>} {inputLabel && <div className={cn(`mb-2`, inputLabelClassName)}>{inputLabel}</div>}
<div className={cn(`${wrapperClass}`, inputWrapperClassName)}> <div className={cn(`${wrapperClass}`, inputWrapperClassName)} role="radiogroup" aria-label={aria}>
{options.map(({ value, label, disabled }, index) => ( {options.map(({ value, label, disabled }, index) => (
<div <button
key={index} key={index}
type="button"
onClick={() => !disabled && setSelected(value)} onClick={() => !disabled && setSelected(value)}
className={cn( className={cn(
"flex items-center gap-2 text-14", "flex items-center gap-2.5 text-14 transition-opacity",
disabled ? `cursor-not-allowed border-subtle bg-layer-1` : ``, disabled ? "cursor-not-allowed opacity-45" : "cursor-pointer",
inputFieldClassName inputFieldClassName
)} )}
role="radio"
aria-checked={selected === value}
aria-disabled={disabled}
disabled={disabled}
> >
<input <input
id={`${name}_${index}`} id={`${name}_${index}`}
name={name} name={name}
className={cn( className="sr-only"
`group flex size-5 flex-shrink-0 cursor-pointer items-center justify-center rounded-full border border-strong-1 bg-layer-2`,
selected === value ? `border-accent-strong bg-accent-primary/80` : ``,
disabled ? `cursor-not-allowed border-subtle bg-layer-1` : ``,
inputButtonClassName
)}
type="radio" type="radio"
value={value} value={value}
disabled={disabled} disabled={disabled}
checked={selected === value} checked={selected === value}
onChange={() => !disabled && setSelected(value)}
/> />
<label htmlFor={`${name}_${index}`} className="w-full cursor-pointer"> <span
className={cn(
"flex size-4 flex-shrink-0 items-center justify-center rounded-full border-0 transition-colors",
selected === value ? "bg-[rgb(var(--nodedc-accent-rgb))]" : "bg-white/14",
inputButtonClassName
)}
/>
<span className="w-full text-left">
{label} {label}
</label> </span>
</div> </button>
))} ))}
</div> </div>
</div> </div>

View File

@ -11,6 +11,7 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IState, TStateOperationsCallbacks } from "@plane/types"; import type { IState, TStateOperationsCallbacks } from "@plane/types";
// components // components
import { StateForm } from "@/components/project-states"; import { StateForm } from "@/components/project-states";
import { getLocalizedStateName } from "../helpers";
type TStateUpdate = { type TStateUpdate = {
state: IState; state: IState;
@ -62,9 +63,14 @@ export const StateUpdate = observer(function StateUpdate(props: TStateUpdate) {
} }
}; };
const localizedState = {
...state,
name: getLocalizedStateName(state.name, state.group, t),
};
return ( return (
<StateForm <StateForm
data={state} data={localizedState}
onSubmit={onSubmit} onSubmit={onSubmit}
onCancel={onCancel} onCancel={onCancel}
buttonDisabled={loader} buttonDisabled={loader}

View File

@ -0,0 +1,38 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { TStateGroups } from "@plane/types";
const DEFAULT_STATE_KEYS_BY_GROUP: Record<TStateGroups, string> = {
backlog: "workspace_projects.state.backlog",
unstarted: "workspace_projects.state.unstarted",
started: "workspace_projects.state.started",
completed: "workspace_projects.state.completed",
cancelled: "workspace_projects.state.cancelled",
};
const DEFAULT_STATE_NAMES_BY_GROUP: Record<TStateGroups, string[]> = {
backlog: ["backlog"],
unstarted: ["todo", "to do", "unstarted"],
started: ["in progress", "in-progress", "started"],
completed: ["done", "completed"],
cancelled: ["cancelled", "canceled"],
};
export const getLocalizedStateName = (
name: string | undefined,
group: TStateGroups,
t: (key: string) => string
) => {
if (!name) return "";
const normalizedName = name.trim().toLowerCase();
if (DEFAULT_STATE_NAMES_BY_GROUP[group]?.includes(normalizedName)) {
return t(DEFAULT_STATE_KEYS_BY_GROUP[group]);
}
return name;
};

View File

@ -17,6 +17,7 @@ import { AlertModalCore } from "@plane/ui";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// hooks // hooks
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
import { getLocalizedStateName } from "../helpers";
type TStateDelete = { type TStateDelete = {
totalStates: number; totalStates: number;
@ -35,6 +36,7 @@ export const StateDelete = observer(function StateDelete(props: TStateDelete) {
const [isDelete, setIsDelete] = useState(false); const [isDelete, setIsDelete] = useState(false);
// derived values // derived values
const isDeleteDisabled = state.default ? true : totalStates === 1 ? true : false; const isDeleteDisabled = state.default ? true : totalStates === 1 ? true : false;
const localizedStateName = getLocalizedStateName(state?.name, state.group, t);
const handleDeleteState = async () => { const handleDeleteState = async () => {
if (isDeleteDisabled) return; if (isDeleteDisabled) return;
@ -73,7 +75,7 @@ export const StateDelete = observer(function StateDelete(props: TStateDelete) {
title={t("entity.delete.label", { entity: t("common.state") })} title={t("entity.delete.label", { entity: t("common.state") })}
content={t("entity.delete.confirmation", { content={t("entity.delete.confirmation", {
entity: t("common.state").toLowerCase(), entity: t("common.state").toLowerCase(),
identifier: state?.name ? `"${state.name}"` : "", identifier: localizedStateName ? `"${localizedStateName}"` : "",
})} })}
/> />

View File

@ -7,6 +7,7 @@
import { useState } from "react"; import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// plane imports // plane imports
import { useTranslation } from "@plane/i18n";
import type { TStateOperationsCallbacks } from "@plane/types"; import type { TStateOperationsCallbacks } from "@plane/types";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
@ -18,6 +19,7 @@ type TStateMarksAsDefault = {
export const StateMarksAsDefault = observer(function StateMarksAsDefault(props: TStateMarksAsDefault) { export const StateMarksAsDefault = observer(function StateMarksAsDefault(props: TStateMarksAsDefault) {
const { stateId, isDefault, markStateAsDefaultCallback } = props; const { stateId, isDefault, markStateAsDefaultCallback } = props;
const { t } = useTranslation();
// states // states
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -43,7 +45,11 @@ export const StateMarksAsDefault = observer(function StateMarksAsDefault(props:
disabled={isDefault || isLoading} disabled={isDefault || isLoading}
onClick={handleMarkAsDefault} onClick={handleMarkAsDefault}
> >
{isLoading ? "Marking as default" : isDefault ? `Default` : `Mark as default`} {isLoading
? t("project_settings.states.marking_as_default")
: isDefault
? t("project_settings.states.default_label")
: t("project_settings.states.mark_as_default")}
</button> </button>
); );
}); });

View File

@ -11,9 +11,11 @@ import { EIconSize, STATE_TRACKER_ELEMENTS } from "@plane/constants";
// plane imports // plane imports
import { EditIcon, StateGroupIcon } from "@plane/propel/icons"; import { EditIcon, StateGroupIcon } from "@plane/propel/icons";
import type { IState, TStateOperationsCallbacks } from "@plane/types"; import type { IState, TStateOperationsCallbacks } from "@plane/types";
import { useTranslation } from "@plane/i18n";
// local imports // local imports
import { useProjectState } from "@/hooks/store/use-project-state"; import { useProjectState } from "@/hooks/store/use-project-state";
import { StateDelete, StateMarksAsDefault } from "./options"; import { StateDelete, StateMarksAsDefault } from "./options";
import { getLocalizedStateName } from "./helpers";
type TBaseStateItemTitleProps = { type TBaseStateItemTitleProps = {
stateCount: number; stateCount: number;
@ -38,9 +40,11 @@ export const StateItemTitle = observer(function StateItemTitle(props: TStateItem
const { stateCount, setUpdateStateModal, disabled, state, shouldShowDescription = true } = props; const { stateCount, setUpdateStateModal, disabled, state, shouldShowDescription = true } = props;
// store hooks // store hooks
const { getStatePercentageInGroup } = useProjectState(); const { getStatePercentageInGroup } = useProjectState();
const { t } = useTranslation();
// derived values // derived values
const statePercentage = getStatePercentageInGroup(state.id); const statePercentage = getStatePercentageInGroup(state.id);
const percentage = statePercentage ? statePercentage / 100 : undefined; const percentage = statePercentage ? statePercentage / 100 : undefined;
const stateTitle = getLocalizedStateName(state.name, state.group, t);
return ( return (
<div className="flex w-full items-center justify-between gap-2"> <div className="flex w-full items-center justify-between gap-2">
@ -57,7 +61,7 @@ export const StateItemTitle = observer(function StateItemTitle(props: TStateItem
</div> </div>
{/* state title and description */} {/* state title and description */}
<div className="min-h-5 px-2 text-13"> <div className="min-h-5 px-2 text-13">
<h6 className="text-13 font-medium">{state.name}</h6> <h6 className="text-13 font-medium">{stateTitle}</h6>
{shouldShowDescription && <p className="text-11 text-secondary">{state.description}</p>} {shouldShowDescription && <p className="text-11 text-secondary">{state.description}</p>}
</div> </div>
</div> </div>

View File

@ -1930,6 +1930,9 @@ export default {
heading: "States", heading: "States",
description: "Define and customize workflow states to track the progress of your work items.", description: "Define and customize workflow states to track the progress of your work items.",
describe_this_state_for_your_members: "Describe this state for your members.", describe_this_state_for_your_members: "Describe this state for your members.",
default_label: "Default",
mark_as_default: "Mark as default",
marking_as_default: "Marking as default",
delete: { delete: {
blocked: "This state still contains work items. Move them to another state before deleting it.", blocked: "This state still contains work items. Move them to another state before deleting it.",
error: "State could not be deleted. Please try again.", error: "State could not be deleted. Please try again.",
@ -1988,6 +1991,14 @@ export default {
enter_estimate_point: "Enter estimate", enter_estimate_point: "Enter estimate",
step: "Step {step} of {total}", step: "Step {step} of {total}",
label: "Create estimate", label: "Create estimate",
add_points: "Add points",
add_categories: "Add categories",
add_time: "Add time",
custom_description: {
points: "Build your own point scale from scratch.",
categories: "Build your own category scale from scratch.",
time: "Build your own time scale from scratch.",
},
}, },
toasts: { toasts: {
created: { created: {
@ -2056,6 +2067,12 @@ export default {
hours: "Hours", hours: "Hours",
}, },
}, },
values: {
easy: "Easy",
medium: "Medium",
hard: "Hard",
very_hard: "Very hard",
},
}, },
automations: { automations: {
label: "Automations", label: "Automations",

View File

@ -2089,6 +2089,9 @@ export default {
heading: "Статусы", heading: "Статусы",
description: "Определяйте и настраивайте статусы рабочего процесса для отслеживания прогресса рабочих элементов.", description: "Определяйте и настраивайте статусы рабочего процесса для отслеживания прогресса рабочих элементов.",
describe_this_state_for_your_members: "Опишите этот статус для участников", describe_this_state_for_your_members: "Опишите этот статус для участников",
default_label: "По умолчанию",
mark_as_default: "Сделать по умолчанию",
marking_as_default: "Назначение по умолчанию",
delete: { delete: {
blocked: "В этом статусе есть рабочие элементы. Переместите их в другой статус, чтобы удалить текущий.", blocked: "В этом статусе есть рабочие элементы. Переместите их в другой статус, чтобы удалить текущий.",
error: "Не удалось удалить статус. Попробуйте снова.", error: "Не удалось удалить статус. Попробуйте снова.",
@ -2147,6 +2150,14 @@ export default {
enter_estimate_point: "Ввести оценку", enter_estimate_point: "Ввести оценку",
step: "Шаг {step} из {total}", step: "Шаг {step} из {total}",
label: "Создать оценку", label: "Создать оценку",
add_points: "Добавить баллы",
add_categories: "Добавить категории",
add_time: "Добавить время",
custom_description: {
points: "Создайте собственную шкалу баллов с нуля.",
categories: "Создайте собственную шкалу категорий с нуля.",
time: "Создайте собственную шкалу времени с нуля.",
},
}, },
toasts: { toasts: {
created: { created: {
@ -2216,6 +2227,12 @@ export default {
hours: "Часы", hours: "Часы",
}, },
}, },
values: {
easy: "Легко",
medium: "Средне",
hard: "Сложно",
very_hard: "Очень сложно",
},
}, },
automations: { automations: {
label: "Автоматизация", label: "Автоматизация",