snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { TEstimateSystemKeys } from "@plane/types";
import { EEstimateSystem } from "@plane/types";
export const isEstimateSystemEnabled = (key: TEstimateSystemKeys) => {
switch (key) {
case EEstimateSystem.POINTS:
return true;
case EEstimateSystem.CATEGORIES:
return true;
default:
return false;
}
};

View File

@@ -0,0 +1,215 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 { ChevronLeftOutline } from "@makeplane/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 = observer(function CreateEstimateModal(props: TCreateEstimateModal) {
// 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 items-center justify-between gap-2 px-5">
<div className="relative flex items-center gap-1">
{estimatePoints && (
<div
onClick={() => {
setEstimateSystem(EEstimateSystem.POINTS);
handleUpdatePoints(undefined);
}}
className="flex h-5 w-5 flex-shrink-0 cursor-pointer items-center justify-center"
>
<ChevronLeftOutline className="h-4 w-4" />
</div>
)}
<div className="text-18 font-medium text-primary">{t("project_settings.estimates.new")}</div>
</div>
<div className="text-gray-400 text-11">
{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-13 text-danger-primary">
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 items-center justify-end gap-3 border-t border-subtle px-5 pt-5">
<Button variant="secondary" size="lg" onClick={handleClose} disabled={buttonLoader}>
{t("common.cancel")}
</Button>
{estimatePoints && (
<Button variant="primary" size="lg" onClick={handleCreateEstimate} disabled={buttonLoader}>
{buttonLoader ? t("common.creating") : t("project_settings.estimates.create.label")}
</Button>
)}
</div>
</div>
</ModalCore>
);
});

View File

@@ -0,0 +1,126 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { InfoOutline } from "@makeplane/propel/icons";
// plane imports
import { EEstimateSystem, ESTIMATE_SYSTEMS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { TEstimateSystemKeys } from "@plane/types";
// helpers
import { isEstimateSystemEnabled } from "./helper";
import { convertMinutesToHoursMinutesString } from "@plane/utils";
// components
import { UpgradeBadge } from "@/components/workspace/upgrade-badge";
import { RadioInput } from "../radio-select";
type TEstimateCreateStageOne = {
estimateSystem: TEstimateSystemKeys;
handleEstimateSystem: (value: TEstimateSystemKeys) => void;
handleEstimatePoints: (value: string) => void;
};
export function EstimateCreateStageOne(props: TEstimateCreateStageOne) {
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="mb-2 gap-2 sm:flex sm:items-center sm:space-y-0 sm:space-x-10">
<RadioInput
options={Object.keys(ESTIMATE_SYSTEMS)
.map((system) => {
const currentSystem = system as TEstimateSystemKeys;
const isEnabled = isEstimateSystemEnabled(currentSystem);
if (!isEnabled) return null;
return {
label: !ESTIMATE_SYSTEMS[currentSystem]?.is_available ? (
<div className="relative flex cursor-no-drop items-center gap-2 text-tertiary">
{t(ESTIMATE_SYSTEMS[currentSystem]?.i18n_name)}
<Tooltip label={t("common.coming_soon")}>
<InfoOutline width={12} height={12} />
</Tooltip>
</div>
) : !isEnabled ? (
<div className="relative flex cursor-no-drop items-center gap-2 text-tertiary">
{t(ESTIMATE_SYSTEMS[currentSystem]?.i18n_name)}
<UpgradeBadge />
</div>
) : (
<div>{t(ESTIMATE_SYSTEMS[currentSystem]?.i18n_name)}</div>
),
value: system,
disabled: !isEnabled,
};
})
.filter((option) => option !== null)}
name="estimate-radio-input"
label={t("project_settings.estimates.create.choose_estimate_system")}
labelClassName="text-13 font-medium text-secondary 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-13 font-medium text-secondary">
{t("project_settings.estimates.create.start_from_scratch")}
</div>
<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"
onClick={() => handleEstimatePoints("custom")}
>
<p className="text-14 font-medium">{t("project_settings.estimates.create.custom")}</p>
<p className="text-11 text-tertiary">
{/* 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-13 font-medium text-secondary">
{t("project_settings.estimates.create.choose_template")}
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{Object.keys(currentEstimateSystem.templates).map((name) =>
currentEstimateSystem.templates[name]?.hide ? null : (
<button
key={name}
className="space-y-1 rounded-md border border-subtle p-3 py-2.5 text-left hover:bg-surface-2"
onClick={() => handleEstimatePoints(name)}
>
<p className="text-14 font-medium">{currentEstimateSystem.templates[name]?.title}</p>
<p className="text-11 text-tertiary">
{currentEstimateSystem.templates[name]?.values
?.map((template) =>
estimateSystem === (EEstimateSystem.TIME as TEstimateSystemKeys)
? convertMinutesToHoursMinutesString(Number(template.value)).trim()
: template.value
)
?.join(", ")}
</p>
</button>
)
)}
</div>
</div>
</>
)}
</div>
);
}
//

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 { useState } from "react";
import { observer } from "mobx-react";
// ui
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
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 = observer(function DeleteEstimateModal(props: TDeleteEstimateModal) {
// 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);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Estimate deleted",
message: "Estimate has been removed from your project.",
});
handleClose();
} catch (_error) {
setButtonLoader(false);
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 items-center justify-between gap-2 px-5">
<div className="text-18 font-medium text-primary">Delete Estimate System</div>
</div>
{/* estimate steps */}
<div className="px-5">
<div className="text-14 text-secondary">
Deleting the estimate <span className="font-bold text-primary">{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 items-center justify-end gap-3 border-t border-subtle px-5 pt-5">
<Button variant="secondary" size="lg" onClick={handleClose} disabled={buttonLoader}>
Cancel
</Button>
<Button variant="error-fill" size="lg" onClick={handleDeleteEstimate} disabled={buttonLoader}>
{buttonLoader ? "Deleting" : "Delete Estimate"}
</Button>
</div>
</div>
</ModalCore>
);
});

View File

@@ -0,0 +1,39 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useTheme } from "next-themes";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
type TEstimateEmptyScreen = {
onButtonClick: () => void;
};
export function EstimateEmptyScreen(props: TEstimateEmptyScreen) {
// 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 p-0!"
primaryButton={{
text: t("project_settings.empty_state.estimates.primary_button"),
onClick: () => {
onButtonClick();
},
}}
/>
);
}

View File

@@ -0,0 +1,67 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Switch } from "@makeplane/propel/components/switch";
// hooks
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 = observer(function EstimateDisableSwitch(props: TEstimateDisableSwitch) {
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,
});
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) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("project_settings.estimates.toasts.disabled.error.title"),
message: t("project_settings.estimates.toasts.disabled.error.message"),
});
}
};
return (
<Switch
size="sm"
checked={Boolean(currentProjectActiveEstimate)}
onCheckedChange={() => {
void disableEstimate();
}}
disabled={!isAdmin}
aria-label="Toggle estimates"
/>
);
});

View File

@@ -0,0 +1,33 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { DeleteOutline } from "@makeplane/propel/icons";
type TEstimateListItem = {
estimateId: string;
isAdmin: boolean;
isEstimateEnabled: boolean;
isEditable: boolean;
onEditClick?: (estimateId: string) => void;
onDeleteClick?: (estimateId: string) => void;
};
export const EstimateListItemButtons = observer(function EstimateListItemButtons(props: TEstimateListItem) {
const { estimateId, isAdmin, isEditable, onDeleteClick } = props;
if (!isAdmin || !isEditable) return <></>;
return (
<div className="relative flex items-center gap-1">
<button
className="relative flex h-6 w-6 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-sm transition-colors hover:bg-layer-1"
onClick={() => onDeleteClick && onDeleteClick(estimateId)}
>
<DeleteOutline width={12} height={12} />
</button>
</div>
);
});

View File

@@ -0,0 +1,55 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { EEstimateSystem } from "@plane/constants";
import { convertMinutesToHoursMinutesString } from "@plane/utils";
// components
import { EstimateListItemButtons } from "./estimate-list-item-buttons";
import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item";
// hooks
import { useProjectEstimates } from "@/hooks/store/estimates";
import { useEstimate } from "@/hooks/store/estimates/use-estimate";
type TEstimateListItem = {
estimateId: string;
isAdmin: boolean;
isEstimateEnabled: boolean;
isEditable: boolean;
onEditClick?: (estimateId: string) => void;
onDeleteClick?: (estimateId: string) => void;
};
export const EstimateListItem = observer(function EstimateListItem(props: TEstimateListItem) {
const { estimateId } = props;
// store 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 null;
return (
<SettingsBoxedControlItem
title={currentEstimate.name}
description={estimatePointValues
?.map((estimatePointValue) => {
if (currentEstimate.type === EEstimateSystem.TIME) {
return convertMinutesToHoursMinutesString(Number(estimatePointValue));
}
return estimatePointValue;
})
.join(", ")}
control={<EstimateListItemButtons {...props} />}
/>
);
});

View File

@@ -0,0 +1,40 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 = observer(function EstimateList(props: TEstimateList) {
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,14 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
export const EstimateSearch = observer(function EstimateSearch() {
// hooks
const {} = {};
return <div>Estimate Search</div>;
});

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,29 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useTranslation } from "@plane/i18n";
type TEstimateNumberInputProps = {
value?: number;
handleEstimateInputValue: (value: string) => void;
};
export function EstimateNumberInput(props: TEstimateNumberInputProps) {
const { value, handleEstimateInputValue } = props;
// i18n
const { t } = useTranslation();
return (
<input
value={value}
onChange={(e) => handleEstimateInputValue(e.target.value)}
className="w-full border-none bg-transparent px-2 py-2 text-13 focus:border-0 focus:ring-0 focus:outline-none"
placeholder={t("project_settings.estimates.create.enter_estimate_point")}
autoFocus
step="any"
/>
);
}

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.
*/
// plane imports
import type { TEstimateSystemKeys } from "@plane/types";
import { EEstimateSystem } from "@plane/types";
// local imports
import { EstimateNumberInput } from "./number-input";
import { EstimateTextInput } from "./text-input";
type TEstimateInputRootProps = {
estimateType: TEstimateSystemKeys;
handleEstimateInputValue: (value: string) => void;
value?: string;
};
export function EstimateInputRoot(props: TEstimateInputRootProps) {
const { estimateType, handleEstimateInputValue, value } = props;
switch (estimateType) {
case EEstimateSystem.POINTS:
return (
<EstimateNumberInput
value={value ? parseFloat(value) : undefined}
handleEstimateInputValue={handleEstimateInputValue}
/>
);
case EEstimateSystem.CATEGORIES:
return <EstimateTextInput value={value} handleEstimateInputValue={handleEstimateInputValue} />;
case EEstimateSystem.TIME:
return <></>;
default:
return null;
}
}

View File

@@ -0,0 +1,29 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useTranslation } from "@plane/i18n";
type TEstimateTextInputProps = {
value?: string;
handleEstimateInputValue: (value: string) => void;
};
export function EstimateTextInput(props: TEstimateTextInputProps) {
const { value, handleEstimateInputValue } = props;
// i18n
const { t } = useTranslation();
return (
<input
value={value}
onChange={(e) => handleEstimateInputValue(e.target.value)}
className="w-full border-none bg-transparent px-3 py-2 text-13 focus:border-0 focus:ring-0 focus:outline-none"
placeholder={t("project_settings.estimates.create.enter_estimate_point")}
autoFocus
type="text"
/>
);
}

View File

@@ -0,0 +1,18 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Loader } from "@plane/ui";
export function EstimateLoaderScreen() {
return (
<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,180 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Dispatch, SetStateAction } from "react";
import { useCallback, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { estimateCount } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { AddOutline } from "@makeplane/propel/icons";
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 = observer(function EstimatePointCreateRoot(props: TEstimatePointCreateRoot) {
// 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-13 font-medium text-secondary 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" prependIcon={<AddOutline />} onClick={handleCreate}>
Add {estimateType}
</Button>
)}
</div>
);
});

View File

@@ -0,0 +1,209 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { FormEvent } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import { CloseOutline, InfoOutline, TickOutline } from "@makeplane/propel/icons";
import { EEstimateSystem, MAX_ESTIMATE_POINT_INPUT_LENGTH } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@makeplane/propel/components/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 = observer(function EstimatePointCreate(props: TEstimatePointCreate) {
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);
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"));
};
return (
<form onSubmit={handleCreate} className="relative flex items-center gap-2 pr-2.5 text-14">
<div
className={cn(
"relative my-1 flex w-full items-center rounded-sm border",
estimatePointError?.message ? `border-danger-strong` : `border-subtle`
)}
>
<EstimateInputRoot
estimateType={estimateType}
handleEstimateInputValue={handleEstimateInputValue}
value={estimateInputValue}
/>
{estimatePointError?.message && (
<Tooltip label={estimatePointError?.message} layout="stacked" side="bottom">
<div className="relative mr-3 flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center overflow-hidden text-danger-primary">
<InfoOutline width={14} height={14} />
</div>
</Tooltip>
)}
</div>
{estimateInputValue && estimateInputValue.length > 0 && (
<button
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"
disabled={loader}
>
{loader ? <Spinner className="h-4 w-4" /> : <TickOutline width={14} height={14} />}
</button>
)}
<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"
onClick={handleClose}
disabled={loader}
>
<CloseOutline height={14} width={14} className="text-secondary" />
</button>
</form>
);
});

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./create-root";

View File

@@ -0,0 +1,122 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { DeleteOutline, DragDropOutline, EditOutline } from "@makeplane/propel/icons";
// 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";
// 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 = observer(function EstimatePointItemPreview(props: TEstimatePointItemPreview) {
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(() => {
const estimatePointValueElement = EstimatePointValueRef.current;
if (!estimatePointValueElement || estimatePointEditToggle || estimatePointDeleteToggle) return;
const handleDoubleClick = () => setEstimatePointEditToggle(true);
estimatePointValueElement.addEventListener("dblclick", handleDoubleClick);
return () => {
estimatePointValueElement.removeEventListener("dblclick", handleDoubleClick);
};
}, [estimatePointDeleteToggle, estimatePointEditToggle]);
return (
<div>
{!estimatePointEditToggle && !estimatePointDeleteToggle && (
<div className="relative my-1 flex items-center gap-2 rounded-sm border border-subtle px-1 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">
<DragDropOutline width={14} height={14} className="text-secondary" />
</div>
<div ref={EstimatePointValueRef} className="w-full py-2 text-13">
{estimatePoint?.value ? (
`${estimateType === EEstimateSystem.TIME ? convertMinutesToHoursMinutesString(Number(estimatePoint?.value)) : estimatePoint?.value}`
) : (
<span className="text-placeholder">{t("project_settings.estimates.create.enter_estimate_point")}</span>
)}
</div>
{/* oxlint-disable-next-line jsx_a11y/click-events-have-key-events oxlint-disable-next-line jsx_a11y/no-static-element-interactions */}
<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"
onClick={() => setEstimatePointEditToggle(true)}
>
<EditOutline width={14} height={14} className="text-secondary" />
</div>
{estimatePoints.length > estimateCount.min && (
// oxlint-disable-next-line jsx_a11y/click-events-have-key-events oxlint-disable-next-line jsx_a11y/no-static-element-interactions
<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"
onClick={() =>
estimateId && estimatePointId
? setEstimatePointDeleteToggle(true)
: handleEstimatePointValueRemove && handleEstimatePointValueRemove()
}
>
<DeleteOutline width={14} height={14} className="text-secondary" />
</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}
/>
)}
</div>
);
});

View File

@@ -0,0 +1,224 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { FormEvent } from "react";
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { CloseOutline, InfoOutline, TickOutline } from "@makeplane/propel/icons";
import { EEstimateSystem, MAX_ESTIMATE_POINT_INPUT_LENGTH } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@makeplane/propel/components/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 = observer(function EstimatePointUpdate(props: TEstimatePointUpdate) {
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);
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 pr-2.5 text-14">
<div
className={cn(
"relative my-1 flex w-full items-center rounded-sm border",
estimatePointError?.message ? `border-danger-strong` : `border-subtle`
)}
>
<EstimateInputRoot
estimateType={estimateType}
handleEstimateInputValue={handleEstimateInputValue}
value={estimateInputValue}
/>
{estimatePointError?.message && (
<>
<Tooltip
label={
(estimateInputValue || "")?.length >= 1
? t("project_settings.estimates.validation.unsaved_changes")
: estimatePointError?.message
}
layout="stacked"
side="bottom"
>
<div className="relative mr-3 flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center overflow-hidden text-danger-primary">
<InfoOutline width={14} height={14} />
</div>
</Tooltip>
</>
)}
</div>
{estimateInputValue && estimateInputValue.length > 0 && (
<button
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"
disabled={loader}
>
{loader ? <Spinner className="h-4 w-4" /> : <TickOutline width={14} height={14} />}
</button>
)}
<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"
onClick={handleClose}
disabled={loader}
>
<CloseOutline height={14} width={14} className="text-secondary" />
</button>
</form>
);
});

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 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 function 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-14",
disabled ? `cursor-not-allowed border-subtle bg-layer-1` : ``,
inputFieldClassName
)}
>
<input
id={`${name}_${index}`}
name={name}
className={cn(
`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"
value={value}
disabled={disabled}
checked={selected === value}
/>
<label htmlFor={`${name}_${index}`} className="w-full cursor-pointer">
{label}
</label>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,146 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item";
import { SettingsHeading } from "@/components/settings/heading";
// hooks
import { EmptyStateCompact } from "@plane/propel/empty-state";
import { useProjectEstimates } from "@/hooks/store/estimates";
import { useProject } from "@/hooks/store/use-project";
// local imports
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 = observer(function EstimateRoot(props: TEstimateRoot) {
const { workspaceSlug, projectId, isAdmin } = props;
// hooks
const { currentProjectDetails } = useProject();
const { loader, currentActiveEstimateId, archivedEstimateIds, getProjectEstimates } = useProjectEstimates();
// states
const [isEstimateCreateModalOpen, setIsEstimateCreateModalOpen] = useState(false);
// oxlint-disable-next-line no-unused-vars
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)
);
if (loader === "init-loader" || isSWRLoading) {
return <EstimateLoaderScreen />;
}
return (
<>
<div>
{/* header */}
<SettingsHeading
title={t("project_settings.estimates.heading")}
description={t("project_settings.estimates.description")}
/>
<div className="mt-6">
{/* current active estimate section */}
{currentActiveEstimateId ? (
<>
{/* estimates activated deactivated section */}
<SettingsBoxedControlItem
title={t("project_settings.estimates.title")}
description={t("project_settings.estimates.enable_description")}
control={
<EstimateDisableSwitch workspaceSlug={workspaceSlug} projectId={projectId} isAdmin={isAdmin} />
}
/>
{/* active estimates section */}
<div className="mt-12 flex flex-col gap-y-4">
<SettingsHeading title="Estimates list" variant="h6" />
<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="mt-12 flex flex-col gap-y-4">
<SettingsHeading
title="Archived estimates"
description={
<>
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-accent-primary/80 hover:text-accent-primary"
rel="noreferrer"
>
here.
</a>
</>
}
variant="h6"
/>
<EstimateList estimateIds={archivedEstimateIds} isAdmin={isAdmin} />
</div>
)}
</div>
</div>
{/* CRUD modals */}
<CreateEstimateModal
workspaceSlug={workspaceSlug}
projectId={projectId}
isOpen={isEstimateCreateModalOpen}
handleClose={() => setIsEstimateCreateModalOpen(false)}
/>
<DeleteEstimateModal
workspaceSlug={workspaceSlug}
projectId={projectId}
estimateId={estimateToDelete ? estimateToDelete : undefined}
// oxlint-disable-next-line no-unneeded-ternary
isOpen={estimateToDelete ? true : false}
handleClose={() => setEstimateToDelete(undefined)}
/>
</>
);
});