Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled

Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
chuan
2025-11-07 00:00:52 +08:00
commit 8ebde8aa05
4886 changed files with 462270 additions and 0 deletions

View File

@@ -0,0 +1,212 @@
"use client";
import type { FC } from "react";
import { useEffect, useMemo, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { EEstimateSystem, ESTIMATE_SYSTEMS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { ChevronLeftIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IEstimateFormData, TEstimateSystemKeys, TEstimatePointsObject, TEstimateTypeError } from "@plane/types";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import { useProjectEstimates } from "@/hooks/store/estimates";
// local imports
import { EstimatePointCreateRoot } from "../points";
import { EstimateCreateStageOne } from "./stage-one";
type TCreateEstimateModal = {
workspaceSlug: string;
projectId: string;
isOpen: boolean;
handleClose: () => void;
};
export const CreateEstimateModal: FC<TCreateEstimateModal> = observer((props) => {
// props
const { workspaceSlug, projectId, isOpen, handleClose } = props;
// hooks
const { createEstimate } = useProjectEstimates();
const { t } = useTranslation();
// states
const [estimateSystem, setEstimateSystem] = useState<TEstimateSystemKeys>(EEstimateSystem.POINTS);
const [estimatePoints, setEstimatePoints] = useState<TEstimatePointsObject[] | undefined>(undefined);
const [estimatePointError, setEstimatePointError] = useState<TEstimateTypeError>(undefined);
const [buttonLoader, setButtonLoader] = useState(false);
const handleUpdatePoints = (newPoints: TEstimatePointsObject[] | undefined) => setEstimatePoints(newPoints);
const handleEstimatePointError = (
key: number,
oldValue: string,
newValue: string,
message: string | undefined,
mode: "add" | "delete" = "add"
) => {
setEstimatePointError((prev) => {
if (mode === "add") {
return { ...prev, [key]: { oldValue, newValue, message } };
} else {
const newError = { ...prev };
delete newError[key];
return newError;
}
});
};
useEffect(() => {
if (isOpen) {
setEstimateSystem(EEstimateSystem.POINTS);
setEstimatePoints(undefined);
setEstimatePointError([]);
}
}, [isOpen]);
const validateEstimatePointError = () => {
let estimateError = false;
if (!estimatePointError) return estimateError;
Object.keys(estimatePointError || {}).forEach((key) => {
const currentKey = key as unknown as number;
if (
estimatePointError[currentKey]?.oldValue != estimatePointError[currentKey]?.newValue ||
estimatePointError[currentKey]?.newValue === "" ||
estimatePointError[currentKey]?.message
) {
estimateError = true;
}
});
return estimateError;
};
const handleCreateEstimate = async () => {
if (!validateEstimatePointError()) {
try {
if (!workspaceSlug || !projectId || !estimatePoints) return;
setButtonLoader(true);
const payload: IEstimateFormData = {
estimate: {
name: ESTIMATE_SYSTEMS[estimateSystem]?.name,
type: estimateSystem,
last_used: true,
},
estimate_points: estimatePoints,
};
await createEstimate(workspaceSlug, projectId, payload);
setButtonLoader(false);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("project_settings.estimates.toasts.created.success.title"),
message: t("project_settings.estimates.toasts.created.success.message"),
});
handleClose();
} catch {
setButtonLoader(false);
setToast({
type: TOAST_TYPE.ERROR,
title: t("project_settings.estimates.toasts.created.error.title"),
message: t("project_settings.estimates.toasts.created.error.message"),
});
}
} else {
setEstimatePointError((prev) => {
const newError = { ...prev };
Object.keys(newError || {}).forEach((key) => {
const currentKey = key as unknown as number;
if (
newError[currentKey]?.newValue != "" &&
newError[currentKey]?.oldValue === newError[currentKey]?.newValue
) {
delete newError[currentKey];
} else {
newError[currentKey].message =
newError[currentKey].message || t("project_settings.estimates.validation.remove_empty");
}
});
return newError;
});
}
};
// derived values
const renderEstimateStepsCount = useMemo(() => (estimatePoints ? "2" : "1"), [estimatePoints]);
// const isEstimatePointError = useMemo(() => {
// if (!estimatePointError) return false;
// return Object.keys(estimatePointError).length > 0;
// }, [estimatePointError]);
return (
<ModalCore isOpen={isOpen} position={EModalPosition.TOP} width={EModalWidth.XXL}>
<div className="relative space-y-6 py-5">
{/* heading */}
<div className="relative flex justify-between items-center gap-2 px-5">
<div className="relative flex items-center gap-1">
{estimatePoints && (
<div
onClick={() => {
setEstimateSystem(EEstimateSystem.POINTS);
handleUpdatePoints(undefined);
}}
className="flex-shrink-0 cursor-pointer w-5 h-5 flex justify-center items-center"
>
<ChevronLeftIcon className="w-4 h-4" />
</div>
)}
<div className="text-xl font-medium text-custom-text-100">{t("project_settings.estimates.new")}</div>
</div>
<div className="text-xs text-gray-400">
{t("project_settings.estimates.create.step", {
step: renderEstimateStepsCount,
total: 2,
})}
</div>
</div>
{/* estimate steps */}
<div className="px-5">
{!estimatePoints && (
<EstimateCreateStageOne
estimateSystem={estimateSystem}
handleEstimateSystem={setEstimateSystem}
handleEstimatePoints={(templateType: string) =>
handleUpdatePoints(ESTIMATE_SYSTEMS[estimateSystem].templates[templateType].values)
}
/>
)}
{estimatePoints && (
<EstimatePointCreateRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
estimateId={undefined}
estimateType={estimateSystem}
estimatePoints={estimatePoints}
setEstimatePoints={setEstimatePoints}
estimatePointError={estimatePointError}
handleEstimatePointError={handleEstimatePointError}
/>
)}
{/* {isEstimatePointError && (
<div className="pt-5 text-sm text-red-500">
Estimate points can&apos;t be empty. Enter a value in each field or remove those you don&apos;t have
values for.
</div>
)} */}
</div>
<div className="relative flex justify-end items-center gap-3 px-5 pt-5 border-t border-custom-border-200">
<Button variant="neutral-primary" size="sm" onClick={handleClose} disabled={buttonLoader}>
{t("common.cancel")}
</Button>
{estimatePoints && (
<Button variant="primary" size="sm" onClick={handleCreateEstimate} disabled={buttonLoader}>
{buttonLoader ? t("common.creating") : t("project_settings.estimates.create.label")}
</Button>
)}
</div>
</div>
</ModalCore>
);
});

View File

@@ -0,0 +1,121 @@
"use client";
import type { FC } from "react";
import { Info } from "lucide-react";
// plane imports
import { EEstimateSystem, ESTIMATE_SYSTEMS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/propel/tooltip";
import type { TEstimateSystemKeys } from "@plane/types";
// components
import { convertMinutesToHoursMinutesString } from "@plane/utils";
// plane web imports
import { isEstimateSystemEnabled } from "@/plane-web/components/estimates/helper";
import { UpgradeBadge } from "@/plane-web/components/workspace/upgrade-badge";
import { RadioInput } from "../radio-select";
// local imports
type TEstimateCreateStageOne = {
estimateSystem: TEstimateSystemKeys;
handleEstimateSystem: (value: TEstimateSystemKeys) => void;
handleEstimatePoints: (value: string) => void;
};
export const EstimateCreateStageOne: FC<TEstimateCreateStageOne> = (props) => {
const { estimateSystem, handleEstimateSystem, handleEstimatePoints } = props;
// i18n
const { t } = useTranslation();
const currentEstimateSystem = ESTIMATE_SYSTEMS[estimateSystem] || undefined;
if (!currentEstimateSystem) return <></>;
return (
<div className="space-y-6">
<div className="sm:flex sm:items-center sm:space-x-10 sm:space-y-0 gap-2 mb-2">
<RadioInput
options={Object.keys(ESTIMATE_SYSTEMS).map((system) => {
const currentSystem = system as TEstimateSystemKeys;
const isEnabled = isEstimateSystemEnabled(currentSystem);
return {
label: !ESTIMATE_SYSTEMS[currentSystem]?.is_available ? (
<div className="relative flex items-center gap-2 cursor-no-drop text-custom-text-300">
{t(ESTIMATE_SYSTEMS[currentSystem]?.i18n_name)}
<Tooltip tooltipContent={t("common.coming_soon")}>
<Info size={12} />
</Tooltip>
</div>
) : !isEnabled ? (
<div className="relative flex items-center gap-2 cursor-no-drop text-custom-text-300">
{t(ESTIMATE_SYSTEMS[currentSystem]?.i18n_name)}
<UpgradeBadge />
</div>
) : (
<div>{t(ESTIMATE_SYSTEMS[currentSystem]?.i18n_name)}</div>
),
value: system,
disabled: !isEnabled,
};
})}
name="estimate-radio-input"
label={t("project_settings.estimates.create.choose_estimate_system")}
labelClassName="text-sm font-medium text-custom-text-200 mb-1.5"
wrapperClassName="relative flex flex-wrap gap-14"
fieldClassName="relative flex items-center gap-1.5"
buttonClassName="size-4"
selected={estimateSystem}
onChange={(value) => handleEstimateSystem(value as TEstimateSystemKeys)}
/>
</div>
{ESTIMATE_SYSTEMS[estimateSystem]?.is_available && !ESTIMATE_SYSTEMS[estimateSystem]?.is_ee && (
<>
<div className="space-y-1.5">
<div className="text-sm font-medium text-custom-text-200">
{t("project_settings.estimates.create.start_from_scratch")}
</div>
<button
className="border border-custom-border-200 rounded-md p-3 py-2.5 text-left space-y-1 w-full block hover:bg-custom-background-90"
onClick={() => handleEstimatePoints("custom")}
>
<p className="text-base font-medium">{t("project_settings.estimates.create.custom")}</p>
<p className="text-xs text-custom-text-300">
{/* TODO: Translate here */}
Add your own <span className="lowercase">{currentEstimateSystem.name}</span> from scratch.
</p>
</button>
</div>
<div className="space-y-1.5">
<div className="text-sm font-medium text-custom-text-200">
{t("project_settings.estimates.create.choose_template")}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{Object.keys(currentEstimateSystem.templates).map((name) =>
currentEstimateSystem.templates[name]?.hide ? null : (
<button
key={name}
className="border border-custom-border-200 rounded-md p-3 py-2.5 text-left space-y-1 hover:bg-custom-background-90"
onClick={() => handleEstimatePoints(name)}
>
<p className="text-base font-medium">{currentEstimateSystem.templates[name]?.title}</p>
<p className="text-xs text-custom-text-300">
{currentEstimateSystem.templates[name]?.values
?.map((template) =>
estimateSystem === EEstimateSystem.TIME
? convertMinutesToHoursMinutesString(Number(template.value)).trim()
: template.value
)
?.join(", ")}
</p>
</button>
)
)}
</div>
</div>
</>
)}
</div>
);
};
//

View File

@@ -0,0 +1,101 @@
"use client";
import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
// ui
import { PROJECT_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useProjectEstimates } from "@/hooks/store/estimates";
import { useEstimate } from "@/hooks/store/estimates/use-estimate";
import { useProject } from "@/hooks/store/use-project";
type TDeleteEstimateModal = {
workspaceSlug: string;
projectId: string;
estimateId: string | undefined;
isOpen: boolean;
handleClose: () => void;
};
export const DeleteEstimateModal: FC<TDeleteEstimateModal> = observer((props) => {
// props
const { workspaceSlug, projectId, estimateId, isOpen, handleClose } = props;
// hooks
const { areEstimateEnabledByProjectId, deleteEstimate } = useProjectEstimates();
const { asJson: estimate } = useEstimate(estimateId);
const { updateProject } = useProject();
// states
const [buttonLoader, setButtonLoader] = useState(false);
const handleDeleteEstimate = async () => {
try {
if (!workspaceSlug || !projectId || !estimateId) return;
setButtonLoader(true);
await deleteEstimate(workspaceSlug, projectId, estimateId);
if (areEstimateEnabledByProjectId(projectId)) {
await updateProject(workspaceSlug, projectId, { estimate: null });
}
setButtonLoader(false);
captureSuccess({
eventName: PROJECT_SETTINGS_TRACKER_EVENTS.estimate_deleted,
payload: {
id: estimateId,
},
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Estimate deleted",
message: "Estimate has been removed from your project.",
});
handleClose();
} catch (error) {
setButtonLoader(false);
captureError({
eventName: PROJECT_SETTINGS_TRACKER_EVENTS.estimate_deleted,
payload: {
id: estimateId,
},
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Estimate creation failed",
message: "We were unable to delete the estimate, please try again.",
});
}
};
return (
<ModalCore isOpen={isOpen} position={EModalPosition.TOP} width={EModalWidth.XXL}>
<div className="relative space-y-6 py-5">
{/* heading */}
<div className="relative flex justify-between items-center gap-2 px-5">
<div className="text-xl font-medium text-custom-text-100">Delete Estimate System</div>
</div>
{/* estimate steps */}
<div className="px-5">
<div className="text-base text-custom-text-200">
Deleting the estimate <span className="font-bold text-custom-text-100">{estimate?.name}</span>
&nbsp;system will remove it from all work items permanently. This action cannot be undone. If you add
estimates again, you will need to update all the work items.
</div>
</div>
<div className="relative flex justify-end items-center gap-3 px-5 pt-5 border-t border-custom-border-200">
<Button variant="neutral-primary" size="sm" onClick={handleClose} disabled={buttonLoader}>
Cancel
</Button>
<Button variant="danger" size="sm" onClick={handleDeleteEstimate} disabled={buttonLoader}>
{buttonLoader ? "Deleting" : "Delete Estimate"}
</Button>
</div>
</div>
</ModalCore>
);
});

View File

@@ -0,0 +1,48 @@
"use client";
import type { FC } from "react";
import { useTheme } from "next-themes";
import { PROJECT_SETTINGS_TRACKER_ELEMENTS, PROJECT_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
// helpers
import { captureElementAndEvent } from "@/helpers/event-tracker.helper";
type TEstimateEmptyScreen = {
onButtonClick: () => void;
};
export const EstimateEmptyScreen: FC<TEstimateEmptyScreen> = (props) => {
// props
const { onButtonClick } = props;
const { resolvedTheme } = useTheme();
const { t } = useTranslation();
const resolvedPath = `/empty-state/project-settings/estimates-${resolvedTheme === "light" ? "light" : "dark"}.png`;
return (
<DetailedEmptyState
title={""}
description={""}
assetPath={resolvedPath}
className="w-full !px-0 !py-0"
primaryButton={{
text: t("project_settings.empty_state.estimates.primary_button"),
onClick: () => {
onButtonClick();
captureElementAndEvent({
element: {
elementName: PROJECT_SETTINGS_TRACKER_ELEMENTS.ESTIMATES_EMPTY_STATE_CREATE_BUTTON,
},
event: {
eventName: PROJECT_SETTINGS_TRACKER_EVENTS.estimate_created,
state: "SUCCESS",
},
});
},
}}
/>
);
};

View File

@@ -0,0 +1,81 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import { PROJECT_SETTINGS_TRACKER_ELEMENTS, PROJECT_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { ToggleSwitch } from "@plane/ui";
// hooks
import { captureElementAndEvent } from "@/helpers/event-tracker.helper";
import { useProjectEstimates } from "@/hooks/store/estimates";
import { useProject } from "@/hooks/store/use-project";
// i18n
type TEstimateDisableSwitch = {
workspaceSlug: string;
projectId: string;
isAdmin: boolean;
};
export const EstimateDisableSwitch: FC<TEstimateDisableSwitch> = observer((props) => {
const { workspaceSlug, projectId, isAdmin } = props;
// i18n
const { t } = useTranslation();
// hooks
const { updateProject, currentProjectDetails } = useProject();
const { currentActiveEstimateId } = useProjectEstimates();
const currentProjectActiveEstimate = currentProjectDetails?.estimate || undefined;
const disableEstimate = async () => {
if (!workspaceSlug || !projectId) return;
try {
await updateProject(workspaceSlug, projectId, {
estimate: currentProjectActiveEstimate ? null : currentActiveEstimateId,
});
captureElementAndEvent({
element: {
elementName: PROJECT_SETTINGS_TRACKER_ELEMENTS.ESTIMATES_TOGGLE_BUTTON,
},
event: {
eventName: PROJECT_SETTINGS_TRACKER_EVENTS.estimates_toggle,
state: "SUCCESS",
},
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: currentProjectActiveEstimate
? t("project_settings.estimates.toasts.disabled.success.title")
: t("project_settings.estimates.toasts.enabled.success.title"),
message: currentProjectActiveEstimate
? t("project_settings.estimates.toasts.disabled.success.message")
: t("project_settings.estimates.toasts.enabled.success.message"),
});
} catch (err) {
captureElementAndEvent({
element: {
elementName: PROJECT_SETTINGS_TRACKER_ELEMENTS.ESTIMATES_TOGGLE_BUTTON,
},
event: {
eventName: PROJECT_SETTINGS_TRACKER_EVENTS.estimates_toggle,
state: "ERROR",
},
});
setToast({
type: TOAST_TYPE.ERROR,
title: t("project_settings.estimates.toasts.disabled.error.title"),
message: t("project_settings.estimates.toasts.disabled.error.message"),
});
}
};
return (
<ToggleSwitch
value={Boolean(currentProjectActiveEstimate)}
onChange={disableEstimate}
disabled={!isAdmin}
size="sm"
/>
);
});

View File

@@ -0,0 +1,58 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { EEstimateSystem } from "@plane/constants";
import { convertMinutesToHoursMinutesString, cn } from "@plane/utils";
// helpers
// hooks
import { useProjectEstimates } from "@/hooks/store/estimates";
import { useEstimate } from "@/hooks/store/estimates/use-estimate";
// plane web components
import { EstimateListItemButtons } from "@/plane-web/components/estimates";
type TEstimateListItem = {
estimateId: string;
isAdmin: boolean;
isEstimateEnabled: boolean;
isEditable: boolean;
onEditClick?: (estimateId: string) => void;
onDeleteClick?: (estimateId: string) => void;
};
export const EstimateListItem: FC<TEstimateListItem> = observer((props) => {
const { estimateId, isAdmin, isEstimateEnabled, isEditable } = props;
// hooks
const { estimateById } = useProjectEstimates();
const { estimatePointIds, estimatePointById } = useEstimate(estimateId);
const currentEstimate = estimateById(estimateId);
// derived values
const estimatePointValues = estimatePointIds?.map((estimatePointId) => {
const estimatePoint = estimatePointById(estimatePointId);
if (estimatePoint) return estimatePoint.value;
});
if (!currentEstimate) return <></>;
return (
<div
className={cn(
"relative border-b border-custom-border-200 flex justify-between items-center gap-3 py-3.5",
isAdmin && isEditable && isEstimateEnabled ? `text-custom-text-100` : `text-custom-text-200`
)}
>
<div className="space-y-1">
<h3 className="font-medium text-base">{currentEstimate?.name}</h3>
<p className="text-xs">
{estimatePointValues
?.map((estimatePointValue) => {
if (currentEstimate?.type === EEstimateSystem.TIME) {
return convertMinutesToHoursMinutesString(Number(estimatePointValue));
}
return estimatePointValue;
})
.join(", ")}
</p>
</div>
<EstimateListItemButtons {...props} />
</div>
);
});

View File

@@ -0,0 +1,35 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// local imports
import { EstimateListItem } from "./estimate-list-item";
type TEstimateList = {
estimateIds: string[] | undefined;
isAdmin: boolean;
isEstimateEnabled?: boolean;
isEditable?: boolean;
onEditClick?: (estimateId: string) => void;
onDeleteClick?: (estimateId: string) => void;
};
export const EstimateList: FC<TEstimateList> = observer((props) => {
const { estimateIds, isAdmin, isEstimateEnabled = false, isEditable = false, onEditClick, onDeleteClick } = props;
if (!estimateIds || estimateIds?.length <= 0) return <></>;
return (
<div>
{estimateIds &&
estimateIds.map((estimateId) => (
<EstimateListItem
key={estimateId}
estimateId={estimateId}
isAdmin={isAdmin}
isEstimateEnabled={isEstimateEnabled}
isEditable={isEditable}
onEditClick={onEditClick}
onDeleteClick={onDeleteClick}
/>
))}
</div>
);
});

View File

@@ -0,0 +1,9 @@
import type { FC } from "react";
import { observer } from "mobx-react";
export const EstimateSearch: FC = observer(() => {
// hooks
const {} = {};
return <div>Estimate Search</div>;
});

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
import type { FC } from "react";
import { useTranslation } from "@plane/i18n";
type TEstimateNumberInputProps = {
value?: number;
handleEstimateInputValue: (value: string) => void;
};
export const EstimateNumberInput: FC<TEstimateNumberInputProps> = (props) => {
const { value, handleEstimateInputValue } = props;
// i18n
const { t } = useTranslation();
return (
<input
value={value}
onChange={(e) => handleEstimateInputValue(e.target.value)}
className="border-none focus:ring-0 focus:border-0 focus:outline-none px-2 py-2 w-full bg-transparent text-sm"
placeholder={t("project_settings.estimates.create.enter_estimate_point")}
autoFocus
type="number"
/>
);
};

View File

@@ -0,0 +1,40 @@
import type { FC } from "react";
// plane imports
import type { TEstimateSystemKeys } from "@plane/types";
import { EEstimateSystem } from "@plane/types";
// plane web imports
import { EstimateTimeInput } from "@/plane-web/components/estimates/inputs";
// local imports
import { EstimateNumberInput } from "./number-input";
import { EstimateTextInput } from "./text-input";
type TEstimateInputRootProps = {
estimateType: TEstimateSystemKeys;
handleEstimateInputValue: (value: string) => void;
value?: string;
};
export const EstimateInputRoot: FC<TEstimateInputRootProps> = (props) => {
const { estimateType, handleEstimateInputValue, value } = props;
switch (estimateType) {
case EEstimateSystem.POINTS:
return (
<EstimateNumberInput
value={value ? parseInt(value) : undefined}
handleEstimateInputValue={handleEstimateInputValue}
/>
);
case EEstimateSystem.CATEGORIES:
return <EstimateTextInput value={value} handleEstimateInputValue={handleEstimateInputValue} />;
case EEstimateSystem.TIME:
return (
<EstimateTimeInput
value={value ? parseInt(value) : undefined}
handleEstimateInputValue={handleEstimateInputValue}
/>
);
default:
return null;
}
};

View File

@@ -0,0 +1,24 @@
import type { FC } from "react";
import { useTranslation } from "@plane/i18n";
type TEstimateTextInputProps = {
value?: string;
handleEstimateInputValue: (value: string) => void;
};
export const EstimateTextInput: FC<TEstimateTextInputProps> = (props) => {
const { value, handleEstimateInputValue } = props;
// i18n
const { t } = useTranslation();
return (
<input
value={value}
onChange={(e) => handleEstimateInputValue(e.target.value)}
className="border-none focus:ring-0 focus:border-0 focus:outline-none px-3 py-2 w-full bg-transparent text-sm"
placeholder={t("project_settings.estimates.create.enter_estimate_point")}
autoFocus
type="text"
/>
);
};

View File

@@ -0,0 +1,13 @@
"use client";
import type { FC } from "react";
import { Loader } from "@plane/ui";
export const EstimateLoaderScreen: FC = () => (
<Loader className="mt-5 space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
);

View File

@@ -0,0 +1,176 @@
"use client";
import type { Dispatch, FC, SetStateAction } from "react";
import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import { Plus } from "lucide-react";
// plane imports
import { estimateCount } from "@plane/constants";
import { Button } from "@plane/propel/button";
import type { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeError } from "@plane/types";
import { Sortable } from "@plane/ui";
// local imports
import { EstimatePointCreate } from "./create";
import { EstimatePointItemPreview } from "./preview";
type TEstimatePointCreateRoot = {
workspaceSlug: string;
projectId: string;
estimateId: string | undefined;
estimateType: TEstimateSystemKeys;
estimatePoints: TEstimatePointsObject[];
setEstimatePoints: Dispatch<SetStateAction<TEstimatePointsObject[] | undefined>>;
estimatePointError?: TEstimateTypeError;
handleEstimatePointError?: (
key: number,
oldValue: string,
newValue: string,
message: string | undefined,
mode: "add" | "delete"
) => void;
};
export const EstimatePointCreateRoot: FC<TEstimatePointCreateRoot> = observer((props) => {
// props
const {
workspaceSlug,
projectId,
estimateId,
estimateType,
estimatePoints,
setEstimatePoints,
estimatePointError,
handleEstimatePointError,
} = props;
// states
const [estimatePointCreate, setEstimatePointCreate] = useState<TEstimatePointsObject[] | undefined>(undefined);
const handleEstimatePoint = useCallback(
(mode: "add" | "remove" | "update", value: TEstimatePointsObject) => {
switch (mode) {
case "add":
setEstimatePoints((prevValue) => {
prevValue = prevValue ? [...prevValue] : [];
return [...prevValue, value];
});
break;
case "update":
setEstimatePoints((prevValue) => {
prevValue = prevValue ? [...prevValue] : [];
return prevValue.map((item) => (item.key === value.key ? { ...item, value: value.value } : item));
});
break;
case "remove":
setEstimatePoints((prevValue) => {
prevValue = prevValue ? [...prevValue] : [];
return prevValue.filter((item) => item.key !== value.key);
});
break;
default:
break;
}
},
[setEstimatePoints]
);
const handleEstimatePointCreate = (mode: "add" | "remove", value: TEstimatePointsObject) => {
switch (mode) {
case "add":
setEstimatePointCreate((prevValue) => {
prevValue = prevValue ? [...prevValue] : [];
return [...prevValue, value];
});
break;
case "remove":
setEstimatePointCreate((prevValue) => {
prevValue = prevValue ? [...prevValue] : [];
return prevValue.filter((item) => item.key !== value.key);
});
break;
default:
break;
}
};
const handleDragEstimatePoints = (updatedEstimatedOrder: TEstimatePointsObject[]) => {
const updatedEstimateKeysOrder = updatedEstimatedOrder.map((item, index) => ({ ...item, key: index + 1 }));
setEstimatePoints(() => updatedEstimateKeysOrder);
};
const handleCreate = () => {
if (estimatePoints && estimatePoints.length + (estimatePointCreate?.length || 0) <= estimateCount.max - 1) {
const currentKey = estimatePoints.length + (estimatePointCreate?.length || 0) + 1;
handleEstimatePointCreate("add", {
id: undefined,
key: currentKey,
value: "",
});
handleEstimatePointError?.(currentKey, "", "", undefined, "add");
}
};
if (!workspaceSlug || !projectId) return <></>;
return (
<div className="space-y-1">
<div className="text-sm font-medium text-custom-text-200 capitalize">{estimateType}</div>
<div>
<Sortable
data={estimatePoints}
render={(value: TEstimatePointsObject) => (
<EstimatePointItemPreview
workspaceSlug={workspaceSlug}
projectId={projectId}
estimateId={estimateId}
estimateType={estimateType}
estimatePointId={value?.id}
estimatePoints={estimatePoints}
estimatePoint={value}
handleEstimatePointValueUpdate={(estimatePointValue: string) =>
handleEstimatePoint("update", { ...value, value: estimatePointValue })
}
handleEstimatePointValueRemove={() => handleEstimatePoint("remove", value)}
estimatePointError={estimatePointError?.[value.key] || undefined}
handleEstimatePointError={(
newValue: string,
message: string | undefined,
mode: "add" | "delete" = "add"
) =>
handleEstimatePointError && handleEstimatePointError(value.key, value.value, newValue, message, mode)
}
/>
)}
onChange={(data: TEstimatePointsObject[]) => handleDragEstimatePoints(data)}
keyExtractor={(item: TEstimatePointsObject) => item?.id?.toString() || item.value.toString()}
/>
</div>
{estimatePointCreate &&
estimatePointCreate.map((estimatePoint) => (
<EstimatePointCreate
key={estimatePoint?.key}
workspaceSlug={workspaceSlug}
projectId={projectId}
estimateId={estimateId}
estimateType={estimateType}
estimatePoints={estimatePoints}
handleEstimatePointValue={(estimatePointValue: string) =>
handleEstimatePoint("add", { ...estimatePoint, value: estimatePointValue })
}
closeCallBack={() => handleEstimatePointCreate("remove", estimatePoint)}
handleCreateCallback={() => estimatePointCreate.length === 1 && handleCreate()}
estimatePointError={estimatePointError?.[estimatePoint.key] || undefined}
handleEstimatePointError={(newValue: string, message: string | undefined, mode: "add" | "delete" = "add") =>
handleEstimatePointError &&
handleEstimatePointError(estimatePoint.key, estimatePoint.value, newValue, message, mode)
}
/>
))}
{estimatePoints && estimatePoints.length + (estimatePointCreate?.length || 0) <= estimateCount.max - 1 && (
<Button variant="link-primary" size="sm" prependIcon={<Plus />} onClick={handleCreate}>
Add {estimateType}
</Button>
)}
</div>
);
});

View File

@@ -0,0 +1,212 @@
"use client";
import type { FC, FormEvent } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import { Check, Info } from "lucide-react";
import { EEstimateSystem, MAX_ESTIMATE_POINT_INPUT_LENGTH } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { CloseIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeErrorObject } from "@plane/types";
import { Spinner } from "@plane/ui";
import { cn, isEstimatePointValuesRepeated } from "@plane/utils";
import { EstimateInputRoot } from "@/components/estimates/inputs/root";
// helpers
// hooks
import { useEstimate } from "@/hooks/store/estimates/use-estimate";
// plane web constants
type TEstimatePointCreate = {
workspaceSlug: string;
projectId: string;
estimateId: string | undefined;
estimateType: TEstimateSystemKeys;
estimatePoints: TEstimatePointsObject[];
handleEstimatePointValue?: (estimateValue: string) => void;
closeCallBack: () => void;
handleCreateCallback: () => void;
estimatePointError?: TEstimateTypeErrorObject | undefined;
handleEstimatePointError?: (newValue: string, message: string | undefined, mode?: "add" | "delete") => void;
};
export const EstimatePointCreate: FC<TEstimatePointCreate> = observer((props) => {
const {
workspaceSlug,
projectId,
estimateId,
estimateType,
estimatePoints,
handleEstimatePointValue,
closeCallBack,
handleCreateCallback,
estimatePointError,
handleEstimatePointError,
} = props;
// hooks
const { creteEstimatePoint } = useEstimate(estimateId);
// i18n
const { t } = useTranslation();
// states
const [estimateInputValue, setEstimateInputValue] = useState("");
const [loader, setLoader] = useState(false);
const handleSuccess = (value: string) => {
handleEstimatePointValue && handleEstimatePointValue(value);
setEstimateInputValue("");
closeCallBack();
};
const handleClose = () => {
handleEstimatePointError && handleEstimatePointError(estimateInputValue, undefined, "delete");
setEstimateInputValue("");
closeCallBack();
};
const handleEstimateInputValue = (value: string) => {
if (value.length <= MAX_ESTIMATE_POINT_INPUT_LENGTH) {
setEstimateInputValue(value);
if (handleEstimatePointError) handleEstimatePointError(value, undefined);
}
};
const handleCreate = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!workspaceSlug || !projectId) return;
if (handleEstimatePointError) handleEstimatePointError(estimateInputValue, undefined, "delete");
if (estimateInputValue) {
const currentEstimateType: EEstimateSystem | undefined = estimateType;
let isEstimateValid = false;
const currentEstimatePointValues = estimatePoints
.map((point) => point?.value || undefined)
.filter((value) => value != undefined) as string[];
const isRepeated =
(estimateType && isEstimatePointValuesRepeated(currentEstimatePointValues, estimateType, estimateInputValue)) ||
false;
if (!isRepeated) {
if (currentEstimateType && [EEstimateSystem.TIME, EEstimateSystem.POINTS].includes(currentEstimateType)) {
if (estimateInputValue && !isNaN(Number(estimateInputValue))) {
if (Number(estimateInputValue) <= 0) {
if (handleEstimatePointError)
handleEstimatePointError(estimateInputValue, t("project_settings.estimates.validation.min_length"));
return;
} else {
isEstimateValid = true;
}
}
} else if (currentEstimateType && currentEstimateType === EEstimateSystem.CATEGORIES) {
if (estimateInputValue && estimateInputValue.length > 0 && isNaN(Number(estimateInputValue))) {
isEstimateValid = true;
}
}
if (isEstimateValid) {
if (estimateId != undefined) {
try {
setLoader(true);
const payload = {
key: estimatePoints?.length + 1,
value: estimateInputValue,
};
await creteEstimatePoint(workspaceSlug, projectId, payload);
setLoader(false);
handleEstimatePointError && handleEstimatePointError(estimateInputValue, undefined, "delete");
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("project_settings.estimates.toasts.created.success.title"),
message: t("project_settings.estimates.toasts.created.success.message"),
});
handleClose();
} catch {
setLoader(false);
handleEstimatePointError &&
handleEstimatePointError(
estimateInputValue,
t("project_settings.estimates.validation.unable_to_process")
);
setToast({
type: TOAST_TYPE.ERROR,
title: t("project_settings.estimates.toasts.created.error.title"),
message: t("project_settings.estimates.toasts.created.error.message"),
});
}
} else {
handleSuccess(estimateInputValue);
if (handleCreateCallback) {
handleCreateCallback();
}
}
} else {
setLoader(false);
handleEstimatePointError &&
handleEstimatePointError(
estimateInputValue,
[EEstimateSystem.POINTS, EEstimateSystem.TIME].includes(estimateType)
? t("project_settings.estimates.validation.numeric")
: t("project_settings.estimates.validation.character")
);
}
} else
handleEstimatePointError &&
handleEstimatePointError(estimateInputValue, t("project_settings.estimates.validation.already_exists"));
} else
handleEstimatePointError &&
handleEstimatePointError(estimateInputValue, t("project_settings.estimates.validation.empty"));
};
// derived values
const inputProps = {
type: "text",
maxlength: MAX_ESTIMATE_POINT_INPUT_LENGTH,
};
return (
<form onSubmit={handleCreate} className="relative flex items-center gap-2 text-base pr-2.5">
<div
className={cn(
"relative w-full border rounded flex items-center my-1",
estimatePointError?.message ? `border-red-500` : `border-custom-border-200`
)}
>
<EstimateInputRoot
estimateType={estimateType}
handleEstimateInputValue={handleEstimateInputValue}
value={estimateInputValue}
/>
{estimatePointError?.message && (
<Tooltip tooltipContent={estimatePointError?.message} position="bottom">
<div className="flex-shrink-0 w-3.5 h-3.5 overflow-hidden mr-3 relative flex justify-center items-center text-red-500">
<Info size={14} />
</div>
</Tooltip>
)}
</div>
{estimateInputValue && estimateInputValue.length > 0 && (
<button
type="submit"
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer text-green-500"
disabled={loader}
>
{loader ? <Spinner className="w-4 h-4" /> : <Check size={14} />}
</button>
)}
<button
type="button"
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer"
onClick={handleClose}
disabled={loader}
>
<CloseIcon height={14} width={14} className="text-custom-text-200" />
</button>
</form>
);
});

View File

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

View File

@@ -0,0 +1,126 @@
import type { FC } from "react";
import { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { GripVertical, Pencil, Trash2 } from "lucide-react";
// plane imports
import { EEstimateSystem, estimateCount } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeErrorObject } from "@plane/types";
import { convertMinutesToHoursMinutesString } from "@plane/utils";
// plane web imports
import { EstimatePointDelete } from "@/plane-web/components/estimates";
// local imports
import { EstimatePointUpdate } from "./update";
type TEstimatePointItemPreview = {
workspaceSlug: string;
projectId: string;
estimateId: string | undefined;
estimateType: TEstimateSystemKeys;
estimatePointId: string | undefined;
estimatePoint: TEstimatePointsObject;
estimatePoints: TEstimatePointsObject[];
handleEstimatePointValueUpdate?: (estimateValue: string) => void;
handleEstimatePointValueRemove?: () => void;
estimatePointError?: TEstimateTypeErrorObject | undefined;
handleEstimatePointError?: (newValue: string, message: string | undefined) => void;
};
export const EstimatePointItemPreview: FC<TEstimatePointItemPreview> = observer((props) => {
const {
workspaceSlug,
projectId,
estimateId,
estimateType,
estimatePointId,
estimatePoint,
estimatePoints,
handleEstimatePointValueUpdate,
handleEstimatePointValueRemove,
estimatePointError,
handleEstimatePointError,
} = props;
// i18n
const { t } = useTranslation();
// state
const [estimatePointEditToggle, setEstimatePointEditToggle] = useState(false);
const [estimatePointDeleteToggle, setEstimatePointDeleteToggle] = useState(false);
// ref
const EstimatePointValueRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!estimatePointEditToggle && !estimatePointDeleteToggle)
EstimatePointValueRef?.current?.addEventListener("dblclick", () => setEstimatePointEditToggle(true));
}, [estimatePointDeleteToggle, estimatePointEditToggle]);
return (
<div>
{!estimatePointEditToggle && !estimatePointDeleteToggle && (
<div className="border border-custom-border-200 rounded relative flex items-center px-1 gap-2 text-base my-1">
<div className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer">
<GripVertical size={14} className="text-custom-text-200" />
</div>
<div ref={EstimatePointValueRef} className="py-2 w-full text-sm">
{estimatePoint?.value ? (
`${estimateType === EEstimateSystem.TIME ? convertMinutesToHoursMinutesString(Number(estimatePoint?.value)) : estimatePoint?.value}`
) : (
<span className="text-custom-text-400">
{t("project_settings.estimates.create.enter_estimate_point")}
</span>
)}
</div>
<div
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer"
onClick={() => setEstimatePointEditToggle(true)}
>
<Pencil size={14} className="text-custom-text-200" />
</div>
{estimatePoints.length > estimateCount.min && (
<div
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer"
onClick={() =>
estimateId && estimatePointId
? setEstimatePointDeleteToggle(true)
: handleEstimatePointValueRemove && handleEstimatePointValueRemove()
}
>
<Trash2 size={14} className="text-custom-text-200" />
</div>
)}
</div>
)}
{estimatePoint && estimatePointEditToggle && (
<EstimatePointUpdate
workspaceSlug={workspaceSlug}
projectId={projectId}
estimateId={estimateId}
estimateType={estimateType}
estimatePointId={estimatePointId}
estimatePoints={estimatePoints}
estimatePoint={estimatePoint}
handleEstimatePointValueUpdate={(estimatePointValue: string) =>
handleEstimatePointValueUpdate && handleEstimatePointValueUpdate(estimatePointValue)
}
closeCallBack={() => setEstimatePointEditToggle(false)}
estimatePointError={estimatePointError}
handleEstimatePointError={handleEstimatePointError}
/>
)}
{estimateId && estimatePointId && estimatePointDeleteToggle && (
<EstimatePointDelete
workspaceSlug={workspaceSlug}
projectId={projectId}
estimateId={estimateId}
estimatePointId={estimatePointId}
estimatePoints={estimatePoints}
callback={() => estimateId && setEstimatePointDeleteToggle(false)}
estimatePointError={estimatePointError}
handleEstimatePointError={handleEstimatePointError}
estimateSystem={estimateType}
/>
)}
</div>
);
});

View File

@@ -0,0 +1,220 @@
"use client";
import type { FC, FormEvent } from "react";
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { Check, Info } from "lucide-react";
import { EEstimateSystem, MAX_ESTIMATE_POINT_INPUT_LENGTH } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { CloseIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeErrorObject } from "@plane/types";
import { Spinner } from "@plane/ui";
import { cn, isEstimatePointValuesRepeated } from "@plane/utils";
import { EstimateInputRoot } from "@/components/estimates/inputs/root";
// helpers
// hooks
import { useEstimatePoint } from "@/hooks/store/estimates/use-estimate-point";
// plane web constants
type TEstimatePointUpdate = {
workspaceSlug: string;
projectId: string;
estimateId: string | undefined;
estimatePointId: string | undefined;
estimateType: TEstimateSystemKeys;
estimatePoints: TEstimatePointsObject[];
estimatePoint: TEstimatePointsObject;
handleEstimatePointValueUpdate: (estimateValue: string) => void;
closeCallBack: () => void;
estimatePointError?: TEstimateTypeErrorObject | undefined;
handleEstimatePointError?: (newValue: string, message: string | undefined, mode?: "add" | "delete") => void;
};
export const EstimatePointUpdate: FC<TEstimatePointUpdate> = observer((props) => {
const {
workspaceSlug,
projectId,
estimateId,
estimatePointId,
estimateType,
estimatePoints,
estimatePoint,
handleEstimatePointValueUpdate,
closeCallBack,
estimatePointError,
handleEstimatePointError,
} = props;
// hooks
const { updateEstimatePoint } = useEstimatePoint(estimateId, estimatePointId);
// i18n
const { t } = useTranslation();
// states
const [loader, setLoader] = useState(false);
const [estimateInputValue, setEstimateInputValue] = useState<string | undefined>(undefined);
useEffect(() => {
if (estimateInputValue === undefined && estimatePoint) setEstimateInputValue(estimatePoint?.value || "");
}, [estimateInputValue, estimatePoint]);
const handleSuccess = (value: string) => {
handleEstimatePointValueUpdate(value);
setEstimateInputValue("");
closeCallBack();
};
const handleClose = () => {
setEstimateInputValue("");
closeCallBack();
};
const handleEstimateInputValue = (value: string) => {
if (value.length <= MAX_ESTIMATE_POINT_INPUT_LENGTH) {
setEstimateInputValue(() => value);
handleEstimatePointError && handleEstimatePointError(value, undefined);
}
};
const handleUpdate = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!workspaceSlug || !projectId) return;
handleEstimatePointError && handleEstimatePointError(estimateInputValue || "", undefined, "delete");
if (estimateInputValue) {
const currentEstimateType: EEstimateSystem | undefined = estimateType;
let isEstimateValid = false;
const currentEstimatePointValues = estimatePoints
.map((point) => (point?.key != estimatePoint?.key ? point?.value : undefined))
.filter((value) => value != undefined) as string[];
const isRepeated =
(estimateType && isEstimatePointValuesRepeated(currentEstimatePointValues, estimateType, estimateInputValue)) ||
false;
if (!isRepeated) {
if (currentEstimateType && [EEstimateSystem.TIME, EEstimateSystem.POINTS].includes(currentEstimateType)) {
if (estimateInputValue && !isNaN(Number(estimateInputValue))) {
if (Number(estimateInputValue) <= 0) {
if (handleEstimatePointError)
handleEstimatePointError(estimateInputValue, t("project_settings.estimates.validation.min_length"));
return;
} else {
isEstimateValid = true;
}
}
} else if (currentEstimateType && currentEstimateType === EEstimateSystem.CATEGORIES) {
if (estimateInputValue && estimateInputValue.length > 0 && isNaN(Number(estimateInputValue))) {
isEstimateValid = true;
}
}
if (isEstimateValid) {
if (estimateId != undefined) {
if (estimateInputValue === estimatePoint.value) {
setLoader(false);
if (handleEstimatePointError) handleEstimatePointError(estimateInputValue, undefined);
handleClose();
} else
try {
setLoader(true);
const payload = {
value: estimateInputValue,
};
await updateEstimatePoint(workspaceSlug, projectId, payload);
setLoader(false);
if (handleEstimatePointError) handleEstimatePointError(estimateInputValue, undefined, "delete");
handleClose();
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("project_settings.estimates.toasts.updated.success.title"),
message: t("project_settings.estimates.toasts.updated.success.message"),
});
} catch {
setLoader(false);
if (handleEstimatePointError)
handleEstimatePointError(
estimateInputValue,
t("project_settings.estimates.validation.unable_to_process")
);
setToast({
type: TOAST_TYPE.ERROR,
title: t("project_settings.estimates.toasts.updated.error.title"),
message: t("project_settings.estimates.toasts.updated.error.message"),
});
}
} else {
handleSuccess(estimateInputValue);
}
} else {
setLoader(false);
if (handleEstimatePointError)
handleEstimatePointError(
estimateInputValue,
[EEstimateSystem.POINTS, EEstimateSystem.TIME].includes(estimateType)
? t("project_settings.estimates.validation.numeric")
: t("project_settings.estimates.validation.character")
);
}
} else if (handleEstimatePointError)
handleEstimatePointError(estimateInputValue, t("project_settings.estimates.validation.already_exists"));
} else if (handleEstimatePointError)
handleEstimatePointError(estimateInputValue || "", t("project_settings.estimates.validation.empty"));
};
return (
<form onSubmit={handleUpdate} className="relative flex items-center gap-2 text-base pr-2.5">
<div
className={cn(
"relative w-full border rounded flex items-center my-1",
estimatePointError?.message ? `border-red-500` : `border-custom-border-200`
)}
>
<EstimateInputRoot
estimateType={estimateType}
handleEstimateInputValue={handleEstimateInputValue}
value={estimateInputValue}
/>
{estimatePointError?.message && (
<>
<Tooltip
tooltipContent={
(estimateInputValue || "")?.length >= 1
? t("project_settings.estimates.validation.unsaved_changes")
: estimatePointError?.message
}
position="bottom"
>
<div className="flex-shrink-0 w-3.5 h-3.5 overflow-hidden mr-3 relative flex justify-center items-center text-red-500">
<Info size={14} />
</div>
</Tooltip>
</>
)}
</div>
{estimateInputValue && estimateInputValue.length > 0 && (
<button
type="submit"
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer text-green-500"
disabled={loader}
>
{loader ? <Spinner className="w-4 h-4" /> : <Check size={14} />}
</button>
)}
<button
type="button"
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer"
onClick={handleClose}
disabled={loader}
>
<CloseIcon height={14} width={14} className="text-custom-text-200" />
</button>
</form>
);
});

View File

@@ -0,0 +1,83 @@
import React from "react";
// helpers
import { cn } from "@plane/utils";
type RadioInputProps = {
name?: string;
label?: string | React.ReactNode;
wrapperClassName?: string;
fieldClassName?: string;
buttonClassName?: string;
labelClassName?: string;
ariaLabel?: string;
options: { label: string | React.ReactNode; value: string; disabled?: boolean }[];
vertical?: boolean;
selected: string;
onChange: (value: string) => void;
className?: string;
};
export const RadioInput = ({
name = "radio-input",
label: inputLabel,
labelClassName: inputLabelClassName = "",
wrapperClassName: inputWrapperClassName = "",
fieldClassName: inputFieldClassName = "",
buttonClassName: inputButtonClassName = "",
options,
vertical,
selected,
ariaLabel,
onChange,
className,
}: RadioInputProps) => {
const wrapperClass = vertical ? "flex flex-col gap-1" : "flex gap-2";
const setSelected = (value: string) => {
onChange(value);
};
let aria = ariaLabel ? ariaLabel.toLowerCase().replace(" ", "-") : "";
if (!aria && typeof inputLabel === "string") {
aria = inputLabel.toLowerCase().replace(" ", "-");
} else {
aria = "radio-input";
}
return (
<div className={className}>
{inputLabel && <div className={cn(`mb-2`, inputLabelClassName)}>{inputLabel}</div>}
<div className={cn(`${wrapperClass}`, inputWrapperClassName)}>
{options.map(({ value, label, disabled }, index) => (
<div
key={index}
onClick={() => !disabled && setSelected(value)}
className={cn(
"flex items-center gap-2 text-base",
disabled ? `bg-custom-background-200 border-custom-border-200 cursor-not-allowed` : ``,
inputFieldClassName
)}
>
<input
id={`${name}_${index}`}
name={name}
className={cn(
`group flex flex-shrink-0 size-5 items-center justify-center rounded-full border border-custom-border-400 bg-custom-background-500 cursor-pointer`,
selected === value ? `bg-custom-primary-200 border-custom-primary-100 ` : ``,
disabled ? `bg-custom-background-200 border-custom-border-200 cursor-not-allowed` : ``,
inputButtonClassName
)}
type="radio"
value={value}
disabled={disabled}
checked={selected === value}
/>
<label htmlFor={`${name}_${index}`} className="cursor-pointer w-full">
{label}
</label>
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,141 @@
import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// plane imports
import { useTranslation } from "@plane/i18n";
// hooks
import { EmptyStateCompact } from "@plane/propel/empty-state";
import { useProjectEstimates } from "@/hooks/store/estimates";
import { useProject } from "@/hooks/store/use-project";
// plane web components
import { UpdateEstimateModal } from "@/plane-web/components/estimates";
// local imports
import { SettingsHeading } from "../settings/heading";
import { CreateEstimateModal } from "./create/modal";
import { DeleteEstimateModal } from "./delete/modal";
import { EstimateDisableSwitch } from "./estimate-disable-switch";
import { EstimateList } from "./estimate-list";
import { EstimateLoaderScreen } from "./loader-screen";
type TEstimateRoot = {
workspaceSlug: string;
projectId: string;
isAdmin: boolean;
};
export const EstimateRoot: FC<TEstimateRoot> = observer((props) => {
const { workspaceSlug, projectId, isAdmin } = props;
// hooks
const { currentProjectDetails } = useProject();
const { loader, currentActiveEstimateId, archivedEstimateIds, getProjectEstimates } = useProjectEstimates();
// states
const [isEstimateCreateModalOpen, setIsEstimateCreateModalOpen] = useState(false);
const [estimateToUpdate, setEstimateToUpdate] = useState<string | undefined>();
const [estimateToDelete, setEstimateToDelete] = useState<string | undefined>();
const { t } = useTranslation();
const { isLoading: isSWRLoading } = useSWR(
workspaceSlug && projectId ? `PROJECT_ESTIMATES_${workspaceSlug}_${projectId}` : null,
async () => workspaceSlug && projectId && getProjectEstimates(workspaceSlug, projectId)
);
return (
<div className="container mx-auto">
{loader === "init-loader" || isSWRLoading ? (
<EstimateLoaderScreen />
) : (
<div className="space-y-2">
{/* header */}
<SettingsHeading
title={t("project_settings.estimates.heading")}
description={t("project_settings.estimates.description")}
/>
{/* current active estimate section */}
{currentActiveEstimateId ? (
<div className="">
{/* estimates activated deactivated section */}
<div className="relative border-b border-custom-border-200 pb-4 flex justify-between items-center gap-3">
<div className="space-y-1">
<h3 className="text-lg font-medium text-custom-text-100">{t("project_settings.estimates.title")}</h3>
<p className="text-sm text-custom-text-200">{t("project_settings.estimates.enable_description")}</p>
</div>
<EstimateDisableSwitch workspaceSlug={workspaceSlug} projectId={projectId} isAdmin={isAdmin} />
</div>
{/* active estimates section */}
<EstimateList
estimateIds={[currentActiveEstimateId]}
isAdmin={isAdmin}
isEstimateEnabled={Boolean(currentProjectDetails?.estimate)}
isEditable
onEditClick={(estimateId: string) => setEstimateToUpdate(estimateId)}
onDeleteClick={(estimateId: string) => setEstimateToDelete(estimateId)}
/>
</div>
) : (
<EmptyStateCompact
assetKey="estimate"
assetClassName="size-20"
title={t("settings_empty_state.estimates.title")}
description={t("settings_empty_state.estimates.description")}
actions={[
{
label: t("settings_empty_state.estimates.cta_primary"),
onClick: () => setIsEstimateCreateModalOpen(true),
},
]}
align="start"
rootClassName="py-20"
/>
)}
{/* archived estimates section */}
{archivedEstimateIds && archivedEstimateIds.length > 0 && (
<div className="">
<div className="border-b border-custom-border-200 space-y-1 pb-4">
<h3 className="text-lg font-medium text-custom-text-100">Archived estimates</h3>
<p className="text-sm text-custom-text-200">
Estimates have gone through a change, these are the estimates you had in your older versions which
were not in use. Read more about them&nbsp;
<a
href={"https://docs.plane.so/core-concepts/projects/run-project#estimate"}
target="_blank"
className="text-custom-primary-100/80 hover:text-custom-primary-100"
>
here.
</a>
</p>
</div>
<EstimateList estimateIds={archivedEstimateIds} isAdmin={isAdmin} />
</div>
)}
</div>
)}
{/* CRUD modals */}
<CreateEstimateModal
workspaceSlug={workspaceSlug}
projectId={projectId}
isOpen={isEstimateCreateModalOpen}
handleClose={() => setIsEstimateCreateModalOpen(false)}
/>
<UpdateEstimateModal
workspaceSlug={workspaceSlug}
projectId={projectId}
estimateId={estimateToUpdate ? estimateToUpdate : undefined}
isOpen={estimateToUpdate ? true : false}
handleClose={() => setEstimateToUpdate(undefined)}
/>
<DeleteEstimateModal
workspaceSlug={workspaceSlug}
projectId={projectId}
estimateId={estimateToDelete ? estimateToDelete : undefined}
isOpen={estimateToDelete ? true : false}
handleClose={() => setEstimateToDelete(undefined)}
/>
</div>
);
});