chore: handle estimate update worklfow

This commit is contained in:
guru_sainath 2024-05-28 15:06:22 +05:30
parent 4fa3eda5cd
commit f362afb001
19 changed files with 450 additions and 373 deletions

View File

@ -64,9 +64,10 @@ class BulkEstimatePointEndpoint(BaseViewSet):
def create(self, request, slug, project_id): def create(self, request, slug, project_id):
estimate = request.data.get('estimate') estimate = request.data.get('estimate')
estimate_name = estimate.get("name", generate_random_name()) estimate_name = estimate.get("name", generate_random_name())
estimate_type = estimate.get("type", 'categories')
last_used = estimate.get("last_used", False) last_used = estimate.get("last_used", False)
estimate = Estimate.objects.create( estimate = Estimate.objects.create(
name=estimate_name, project_id=project_id, last_used=last_used name=estimate_name, project_id=project_id, last_used=last_used, type=estimate_type
) )
estimate_points = request.data.get("estimate_points", []) estimate_points = request.data.get("estimate_points", [])

View File

@ -31,7 +31,7 @@ export const CreateEstimateModal: FC<TCreateEstimateModal> = observer((props) =>
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
setEstimateSystem(EEstimateSystem.POINTS); setEstimateSystem(EEstimateSystem.CATEGORIES);
setEstimatePoints(undefined); setEstimatePoints(undefined);
} }
}, [isOpen]); }, [isOpen]);
@ -42,6 +42,7 @@ export const CreateEstimateModal: FC<TCreateEstimateModal> = observer((props) =>
const handleCreateEstimate = async () => { const handleCreateEstimate = async () => {
try { try {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
const validatedEstimatePoints: TEstimatePointsObject[] = []; const validatedEstimatePoints: TEstimatePointsObject[] = [];
if ([EEstimateSystem.POINTS, EEstimateSystem.TIME].includes(estimateSystem)) { if ([EEstimateSystem.POINTS, EEstimateSystem.TIME].includes(estimateSystem)) {
estimatePoints?.map((estimatePoint) => { estimatePoints?.map((estimatePoint) => {
@ -56,6 +57,7 @@ export const CreateEstimateModal: FC<TCreateEstimateModal> = observer((props) =>
if (estimatePoint.value) validatedEstimatePoints.push(estimatePoint); if (estimatePoint.value) validatedEstimatePoints.push(estimatePoint);
}); });
} }
if (validatedEstimatePoints.length === estimatePoints?.length) { if (validatedEstimatePoints.length === estimatePoints?.length) {
const payload: IEstimateFormData = { const payload: IEstimateFormData = {
estimate: { estimate: {
@ -80,7 +82,6 @@ export const CreateEstimateModal: FC<TCreateEstimateModal> = observer((props) =>
}); });
} }
} catch (error) { } catch (error) {
console.log(error);
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: "Error!",

View File

@ -18,7 +18,7 @@ export const EstimateCreateStageOne: FC<TEstimateCreateStageOne> = (props) => {
if (!currentEstimateSystem) return <></>; if (!currentEstimateSystem) return <></>;
return ( return (
<div className="space-y-2"> <div className="space-y-3">
<div className="space-y-4 sm:flex sm:items-center sm:space-x-10 sm:space-y-0 gap-2 mb-2"> <div className="space-y-4 sm:flex sm:items-center sm:space-x-10 sm:space-y-0 gap-2 mb-2">
<RadioInput <RadioInput
options={Object.keys(ESTIMATE_SYSTEMS).map((system) => { options={Object.keys(ESTIMATE_SYSTEMS).map((system) => {
@ -36,7 +36,8 @@ export const EstimateCreateStageOne: FC<TEstimateCreateStageOne> = (props) => {
className="mb-4" className="mb-4"
/> />
</div> </div>
<div className="space-y-3">
<div className="space-y-2">
<div className="text-sm font-medium text-custom-text-200">Start from scratch</div> <div className="text-sm font-medium text-custom-text-200">Start from scratch</div>
<button <button
className="border border-custom-border-200 rounded-md p-2 text-left flex-1 w-full block" className="border border-custom-border-200 rounded-md p-2 text-left flex-1 w-full block"
@ -47,7 +48,9 @@ export const EstimateCreateStageOne: FC<TEstimateCreateStageOne> = (props) => {
Add your own <span className="lowercase">{currentEstimateSystem.name}</span> from scratch Add your own <span className="lowercase">{currentEstimateSystem.name}</span> from scratch
</p> </p>
</button> </button>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-custom-text-200">Choose a template</div> <div className="text-sm font-medium text-custom-text-200">Choose a template</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{Object.keys(currentEstimateSystem.templates).map((name) => {Object.keys(currentEstimateSystem.templates).map((name) =>

View File

@ -1,100 +0,0 @@
import { FC, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { Check, X } from "lucide-react";
import { Spinner } from "@plane/ui";
// constants
import { EEstimateSystem } from "@/constants/estimates";
// hooks
import { useEstimate, useEstimatePoint } from "@/hooks/store";
type TEstimatePointDelete = {
workspaceSlug: string;
projectId: string;
estimateId: string;
estimatePointId: string | undefined;
};
export const EstimatePointDelete: FC<TEstimatePointDelete> = observer((props) => {
const { workspaceSlug, projectId, estimateId, estimatePointId } = props;
// hooks
const { asJson: estimate, estimatePointIds } = useEstimate(estimateId);
const { asJson: estimatePoint, updateEstimatePoint } = useEstimatePoint(estimateId, estimatePointId);
// states
const [loader, setLoader] = useState(false);
const [estimateValue, setEstimateValue] = useState<string | undefined>(undefined);
useEffect(() => {
if (estimateValue === undefined) setEstimateValue(estimatePoint?.value || "");
}, [estimateValue, estimatePoint]);
const handleCreate = async () => {
if (estimatePointId) {
if (!workspaceSlug || !projectId || !projectId || !estimatePointIds) return;
try {
const estimateType: EEstimateSystem | undefined = estimate?.type;
let isEstimateValid = false;
if (estimateType && [(EEstimateSystem.TIME, EEstimateSystem.POINTS)].includes(estimateType)) {
if (estimateValue && Number(estimateValue) && Number(estimateValue) >= 0) {
isEstimateValid = true;
}
} else if (estimateType && estimateType === EEstimateSystem.CATEGORIES) {
if (estimateValue && estimateValue.length > 0) {
isEstimateValid = true;
}
}
if (isEstimateValid) {
setLoader(true);
const payload = {
key: estimatePointIds?.length + 1,
value: estimateValue,
};
await updateEstimatePoint(workspaceSlug, projectId, payload);
setLoader(false);
handleClose();
} else {
console.log("please enter a valid estimate value");
}
} catch {
setLoader(false);
console.log("something went wrong. please try again later");
}
} else {
}
};
const handleClose = () => {
setEstimateValue("");
};
return (
<div className="relative flex items-center gap-2">
<div className="w-full border border-custom-border-200 rounded">
<input
type="text"
value={estimateValue}
onChange={(e) => setEstimateValue(e.target.value)}
className="border-none focus:ring-0 focus:border-0 focus:outline-none p-2.5 w-full bg-transparent"
/>
</div>
{loader ? (
<div className="w-6 h-6 flex-shrink-0 relative flex justify-center items-center rota">
<Spinner className="w-4 h-4" />
</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 text-green-500"
onClick={handleCreate}
>
<Check size={14} />
</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={handleClose}
>
<X size={14} className="text-custom-text-200" />
</div>
</div>
);
});

View File

@ -1,29 +0,0 @@
import { FC, useState } from "react";
import { observer } from "mobx-react";
import { Draggable } from "@plane/ui";
// constants
import { EEstimateUpdateStages } from "@/constants/estimates";
type TEstimatePointEditRoot = {
workspaceSlug: string;
projectId: string;
estimateId: string;
mode: EEstimateUpdateStages;
};
type TEstimatePointEditingState = "update" | "delete";
export const EstimatePointEditRoot: FC<TEstimatePointEditRoot> = observer((props) => {
// props
const { workspaceSlug, projectId, estimateId, mode } = props;
// hooks
// states
const [editingState, setEditingState] = useState<TEstimatePointEditingState | undefined>(undefined);
const [estimateEditLoader, setEstimateEditLoader] = useState(false);
const [deletedEstimateValue, setDeletedEstimateValue] = useState<string | undefined>(undefined);
const [isEstimateEditing, setIsEstimateEditing] = useState(false);
const [isEstimateDeleting, setIsEstimateDeleting] = useState(false);
return <Draggable data={item}></Draggable>;
});

View File

@ -1,9 +1,11 @@
import { FC, useState } from "react"; import { FC, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Check, X } from "lucide-react"; import { Check, Info, X } from "lucide-react";
import { Spinner } from "@plane/ui"; import { Spinner, Tooltip } from "@plane/ui";
// constants // constants
import { EEstimateSystem } from "@/constants/estimates"; import { EEstimateSystem } from "@/constants/estimates";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks // hooks
import { useEstimate } from "@/hooks/store"; import { useEstimate } from "@/hooks/store";
@ -11,66 +13,89 @@ type TEstimatePointCreate = {
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
estimateId: string; estimateId: string;
estimatePointId: string | undefined; callback: () => void;
}; };
export const EstimatePointCreate: FC<TEstimatePointCreate> = observer((props) => { export const EstimatePointCreate: FC<TEstimatePointCreate> = observer((props) => {
const { workspaceSlug, projectId, estimateId, estimatePointId } = props; const { workspaceSlug, projectId, estimateId, callback } = props;
// hooks // hooks
const { asJson: estimate, estimatePointIds, creteEstimatePoint } = useEstimate(estimateId); const { asJson: estimate, estimatePointIds, creteEstimatePoint } = useEstimate(estimateId);
// states // states
const [loader, setLoader] = useState(false); const [loader, setLoader] = useState(false);
const [estimateValue, setEstimateValue] = useState(""); const [estimateInputValue, setEstimateInputValue] = useState("");
const [error, setError] = useState<string | undefined>(undefined);
const handleClose = () => {
setEstimateInputValue("");
callback();
};
const handleCreate = async () => { const handleCreate = async () => {
if (estimatePointId) {
if (!workspaceSlug || !projectId || !projectId || !estimatePointIds) return; if (!workspaceSlug || !projectId || !projectId || !estimatePointIds) return;
if (estimateInputValue)
try { try {
setLoader(true);
setError(undefined);
const estimateType: EEstimateSystem | undefined = estimate?.type; const estimateType: EEstimateSystem | undefined = estimate?.type;
let isEstimateValid = false; let isEstimateValid = false;
if (estimateType && [(EEstimateSystem.TIME, EEstimateSystem.POINTS)].includes(estimateType)) { if (estimateType && [(EEstimateSystem.TIME, EEstimateSystem.POINTS)].includes(estimateType)) {
if (estimateValue && Number(estimateValue) && Number(estimateValue) >= 0) { if (estimateInputValue && Number(estimateInputValue) && Number(estimateInputValue) >= 0) {
isEstimateValid = true; isEstimateValid = true;
} }
} else if (estimateType && estimateType === EEstimateSystem.CATEGORIES) { } else if (estimateType && estimateType === EEstimateSystem.CATEGORIES) {
if (estimateValue && estimateValue.length > 0) { if (estimateInputValue && estimateInputValue.length > 0) {
isEstimateValid = true; isEstimateValid = true;
} }
} }
if (isEstimateValid) { if (isEstimateValid) {
setLoader(true);
const payload = { const payload = {
key: estimatePointIds?.length + 1, key: estimatePointIds?.length + 1,
value: estimateValue, value: estimateInputValue,
}; };
await creteEstimatePoint(workspaceSlug, projectId, payload); await creteEstimatePoint(workspaceSlug, projectId, payload);
setLoader(false); setLoader(false);
setError(undefined);
handleClose(); handleClose();
} else { } else {
console.log("please enter a valid estimate value"); setLoader(false);
setError("please enter a valid estimate value");
} }
} catch { } catch {
setLoader(false); setLoader(false);
console.log("something went wrong. please try again later"); setError("something went wrong. please try again later");
} }
} else { else {
setError("Please fill the input field");
} }
}; };
const handleClose = () => {
setEstimateValue("");
};
return ( return (
<div className="relative flex items-center gap-2"> <div className="relative flex items-center gap-2 text-base">
<div className="w-full border border-custom-border-200 rounded"> <div
className={cn(
"relative w-full border rounded flex items-center",
error ? `border-red-500` : `border-custom-border-200`
)}
>
<input <input
type="text" type="text"
value={estimateValue} value={estimateInputValue}
onChange={(e) => setEstimateValue(e.target.value)} onChange={(e) => setEstimateInputValue(e.target.value)}
className="border-none focus:ring-0 focus:border-0 focus:outline-none p-2.5 w-full bg-transparent" className="border-none focus:ring-0 focus:border-0 focus:outline-none p-2.5 w-full bg-transparent"
autoFocus
/> />
{error && (
<>
<Tooltip tooltipContent={error} 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> </div>
{loader ? ( {loader ? (
<div className="w-6 h-6 flex-shrink-0 relative flex justify-center items-center rota"> <div className="w-6 h-6 flex-shrink-0 relative flex justify-center items-center rota">

View File

@ -0,0 +1,111 @@
import { FC, useState } from "react";
import { observer } from "mobx-react";
import { Info, MoveRight, Trash2, X } from "lucide-react";
import { Select } from "@headlessui/react";
import { Spinner, Tooltip } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useEstimate, useEstimatePoint } from "@/hooks/store";
type TEstimatePointDelete = {
workspaceSlug: string;
projectId: string;
estimateId: string;
estimatePointId: string;
callback: () => void;
};
export const EstimatePointDelete: FC<TEstimatePointDelete> = observer((props) => {
const { workspaceSlug, projectId, estimateId, estimatePointId, callback } = props;
// hooks
const { asJson: estimate, deleteEstimatePoint } = useEstimate(estimateId);
const { asJson: estimatePoint } = useEstimatePoint(estimateId, estimatePointId);
// states
const [loader, setLoader] = useState(false);
const [estimateInputValue, setEstimateInputValue] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
const handleClose = () => {
setEstimateInputValue("");
callback();
};
const handleCreate = async () => {
if (!workspaceSlug || !projectId || !projectId) return;
try {
setLoader(true);
setError(undefined);
await deleteEstimatePoint(workspaceSlug, projectId, estimateId, estimateInputValue);
setLoader(false);
setError(undefined);
handleClose();
} catch {
setLoader(false);
setError("something went wrong. please try again later");
}
};
// derived values
const selectDropdownOptions =
estimate && estimate?.points ? estimate?.points.filter((point) => point.id !== estimatePointId) : [];
return (
<div className="relative flex items-center gap-2 text-base">
<div className="flex-grow relative flex items-center gap-3">
<div className="w-full border border-custom-border-200 rounded p-2.5 bg-custom-background-90">
{estimatePoint?.value}
</div>
<div className="relative flex justify-center items-center gap-2 whitespace-nowrap">
Mark as <MoveRight size={14} />
</div>
<div
className={cn(
"relative w-full rounded border flex items-center gap-3 p-2.5",
error ? `border-red-500` : `border-custom-border-200`
)}
>
<Select
className="bg-transparent flex-grow focus:ring-0 focus:border-0 focus:outline-none"
value={estimateInputValue}
onChange={(e) => setEstimateInputValue(e.target.value)}
>
<option value={undefined}>None</option>
{selectDropdownOptions.map((option) => (
<option key={option?.id} value={option?.value}>
{option?.value}
</option>
))}
</Select>
{error && (
<>
<Tooltip tooltipContent={error} position="bottom">
<div className="flex-shrink-0 w-3.5 h-3.5 overflow-hidden relative flex justify-center items-center text-red-500">
<Info size={14} />
</div>
</Tooltip>
</>
)}
</div>
</div>
{loader ? (
<div className="w-6 h-6 flex-shrink-0 relative flex justify-center items-center rota">
<Spinner className="w-4 h-4" />
</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 text-red-500"
onClick={handleCreate}
>
<Trash2 size={14} />
</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={handleClose}
>
<X size={14} className="text-custom-text-200" />
</div>
</div>
);
});

View File

@ -0,0 +1,5 @@
export * from "./root";
export * from "./preview";
export * from "./create";
export * from "./update";
export * from "./delete";

View File

@ -0,0 +1,78 @@
import { FC, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { GripVertical, Pencil, Trash2 } from "lucide-react";
// components
import { EstimatePointUpdate, EstimatePointDelete } from "@/components/estimates/points";
// hooks
import { useEstimatePoint } from "@/hooks/store";
type TEstimatePointItemPreview = {
workspaceSlug: string;
projectId: string;
estimateId: string;
estimatePointId: string;
};
export const EstimatePointItemPreview: FC<TEstimatePointItemPreview> = observer((props) => {
const { workspaceSlug, projectId, estimateId, estimatePointId } = props;
// hooks
const { asJson: estimatePoint } = useEstimatePoint(estimateId, estimatePointId);
// 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]);
if (!estimatePoint?.id) return <></>;
return (
<div>
{!estimatePointEditToggle && !estimatePointDeleteToggle && (
<div className="border border-custom-border-200 rounded relative flex items-center px-2.5 gap-2 text-base">
<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.5 w-full">
{estimatePoint?.value}
</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>
<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={() => setEstimatePointDeleteToggle(true)}
>
<Trash2 size={14} className="text-custom-text-200" />
</div>
</div>
)}
{estimatePoint && estimatePointEditToggle && (
<EstimatePointUpdate
workspaceSlug={workspaceSlug}
projectId={projectId}
estimateId={estimateId}
estimatePointId={estimatePointId}
callback={() => setEstimatePointEditToggle(false)}
/>
)}
{estimatePoint && estimatePointDeleteToggle && (
<EstimatePointDelete
workspaceSlug={workspaceSlug}
projectId={projectId}
estimateId={estimateId}
estimatePointId={estimatePointId}
callback={() => setEstimatePointDeleteToggle(false)}
/>
)}
</div>
);
});

View File

@ -0,0 +1,84 @@
import { FC, useState } from "react";
import { observer } from "mobx-react";
import { Plus } from "lucide-react";
import { TEstimatePointsObject } from "@plane/types";
import { Button, Draggable, Sortable } from "@plane/ui";
// components
import { EstimatePointItemPreview, EstimatePointCreate } from "@/components/estimates/points";
// constants
import { EEstimateUpdateStages, maxEstimatesCount } from "@/constants/estimates";
// hooks
import { useEstimate } from "@/hooks/store";
type TEstimatePointEditRoot = {
workspaceSlug: string;
projectId: string;
estimateId: string;
mode?: EEstimateUpdateStages;
};
export const EstimatePointEditRoot: FC<TEstimatePointEditRoot> = observer((props) => {
// props
const { workspaceSlug, projectId, estimateId } = props;
// hooks
const { asJson: estimate, estimatePointIds, estimatePointById, updateEstimateSortOrder } = useEstimate(estimateId);
// states
const [estimatePointCreateToggle, setEstimatePointCreateToggle] = useState(false);
const estimatePoints: TEstimatePointsObject[] =
estimatePointIds && estimatePointIds.length > 0
? (estimatePointIds.map((estimatePointId: string) => {
const estimatePoint = estimatePointById(estimatePointId);
if (estimatePoint) return { id: estimatePointId, key: estimatePoint.key, value: estimatePoint.value };
}) as TEstimatePointsObject[])
: ([] as TEstimatePointsObject[]);
const handleDragEstimatePoints = (updatedEstimatedOrder: TEstimatePointsObject[]) => {
const updatedEstimateKeysOrder = updatedEstimatedOrder.map((item, index) => ({ ...item, key: index + 1 }));
updateEstimateSortOrder(workspaceSlug, projectId, updatedEstimateKeysOrder);
};
if (!workspaceSlug || !projectId || !estimateId) return <></>;
return (
<div className="space-y-3">
<div className="text-sm font-medium text-custom-text-200">{estimate?.type}</div>
<Sortable
data={estimatePoints}
render={(value: TEstimatePointsObject) => (
<Draggable data={value}>
{value?.id && (
<EstimatePointItemPreview
workspaceSlug={workspaceSlug}
projectId={projectId}
estimateId={estimateId}
estimatePointId={value?.id}
/>
)}
</Draggable>
)}
onChange={(data: TEstimatePointsObject[]) => handleDragEstimatePoints(data)}
keyExtractor={(item: TEstimatePointsObject) => item?.id?.toString() || item.value.toString()}
/>
{estimatePointCreateToggle && (
<EstimatePointCreate
workspaceSlug={workspaceSlug}
projectId={projectId}
estimateId={estimateId}
callback={() => setEstimatePointCreateToggle(false)}
/>
)}
{estimatePoints && estimatePoints.length <= maxEstimatesCount && (
<Button
variant="link-primary"
size="sm"
prependIcon={<Plus />}
onClick={() => setEstimatePointCreateToggle(true)}
disabled={estimatePointCreateToggle}
>
Add {estimate?.type}
</Button>
)}
</div>
);
});

View File

@ -1,9 +1,11 @@
import { FC, useEffect, useState } from "react"; import { FC, useEffect, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Check, X } from "lucide-react"; import { Check, Info, X } from "lucide-react";
import { Spinner } from "@plane/ui"; import { Spinner, Tooltip } from "@plane/ui";
// constants // constants
import { EEstimateSystem } from "@/constants/estimates"; import { EEstimateSystem } from "@/constants/estimates";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks // hooks
import { useEstimate, useEstimatePoint } from "@/hooks/store"; import { useEstimate, useEstimatePoint } from "@/hooks/store";
@ -11,71 +13,94 @@ type TEstimatePointUpdate = {
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
estimateId: string; estimateId: string;
estimatePointId: string | undefined; estimatePointId: string;
callback: () => void;
}; };
export const EstimatePointUpdate: FC<TEstimatePointUpdate> = observer((props) => { export const EstimatePointUpdate: FC<TEstimatePointUpdate> = observer((props) => {
const { workspaceSlug, projectId, estimateId, estimatePointId } = props; const { workspaceSlug, projectId, estimateId, estimatePointId, callback } = props;
// hooks // hooks
const { asJson: estimate, estimatePointIds } = useEstimate(estimateId); const { asJson: estimate, estimatePointIds } = useEstimate(estimateId);
const { asJson: estimatePoint, updateEstimatePoint } = useEstimatePoint(estimateId, estimatePointId); const { asJson: estimatePoint, updateEstimatePoint } = useEstimatePoint(estimateId, estimatePointId);
// states // states
const [loader, setLoader] = useState(false); const [loader, setLoader] = useState(false);
const [estimateValue, setEstimateValue] = useState<string | undefined>(undefined); const [estimateInputValue, setEstimateInputValue] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
useEffect(() => { useEffect(() => {
if (estimateValue === undefined) setEstimateValue(estimatePoint?.value || ""); if (estimateInputValue === undefined && estimatePoint) setEstimateInputValue(estimatePoint?.value || "");
}, [estimateValue, estimatePoint]); }, [estimateInputValue, estimatePoint]);
const handleClose = () => {
setEstimateInputValue("");
callback();
};
const handleCreate = async () => { const handleCreate = async () => {
if (estimatePointId) {
if (!workspaceSlug || !projectId || !projectId || !estimatePointIds) return; if (!workspaceSlug || !projectId || !projectId || !estimatePointIds) return;
if (estimateInputValue)
try { try {
setLoader(true);
setError(undefined);
const estimateType: EEstimateSystem | undefined = estimate?.type; const estimateType: EEstimateSystem | undefined = estimate?.type;
let isEstimateValid = false; let isEstimateValid = false;
if (estimateType && [(EEstimateSystem.TIME, EEstimateSystem.POINTS)].includes(estimateType)) { if (estimateType && [(EEstimateSystem.TIME, EEstimateSystem.POINTS)].includes(estimateType)) {
if (estimateValue && Number(estimateValue) && Number(estimateValue) >= 0) { if (estimateInputValue && Number(estimateInputValue) && Number(estimateInputValue) >= 0) {
isEstimateValid = true; isEstimateValid = true;
} }
} else if (estimateType && estimateType === EEstimateSystem.CATEGORIES) { } else if (estimateType && estimateType === EEstimateSystem.CATEGORIES) {
if (estimateValue && estimateValue.length > 0) { if (estimateInputValue && estimateInputValue.length > 0) {
isEstimateValid = true; isEstimateValid = true;
} }
} }
if (isEstimateValid) { if (isEstimateValid) {
setLoader(true);
const payload = { const payload = {
key: estimatePointIds?.length + 1, value: estimateInputValue,
value: estimateValue,
}; };
await updateEstimatePoint(workspaceSlug, projectId, payload); await updateEstimatePoint(workspaceSlug, projectId, payload);
setLoader(false); setLoader(false);
setError(undefined);
handleClose(); handleClose();
} else { } else {
console.log("please enter a valid estimate value"); setLoader(false);
setError("please enter a valid estimate value");
} }
} catch { } catch {
setLoader(false); setLoader(false);
console.log("something went wrong. please try again later"); setError("something went wrong. please try again later");
} }
} else { else {
setError("Please fill the input field");
} }
}; };
const handleClose = () => {
setEstimateValue("");
};
return ( return (
<div className="relative flex items-center gap-2"> <div className="relative flex items-center gap-2 text-base">
<div className="w-full border border-custom-border-200 rounded"> <div
className={cn(
"relative w-full border rounded flex items-center",
error ? `border-red-500` : `border-custom-border-200`
)}
>
<input <input
type="text" type="text"
value={estimateValue} value={estimateInputValue}
onChange={(e) => setEstimateValue(e.target.value)} onChange={(e) => setEstimateInputValue(e.target.value)}
className="border-none focus:ring-0 focus:border-0 focus:outline-none p-2.5 w-full bg-transparent" className="border-none focus:ring-0 focus:border-0 focus:outline-none p-2.5 w-full bg-transparent"
autoFocus
/> />
{error && (
<>
<Tooltip tooltipContent={error} 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> </div>
{loader ? ( {loader ? (
<div className="w-6 h-6 flex-shrink-0 relative flex justify-center items-center rota"> <div className="w-6 h-6 flex-shrink-0 relative flex justify-center items-center rota">

View File

@ -1,8 +1,4 @@
export * from "./estimate-point-item"; export * from "./estimate-point-item";
export * from "./inline-editable"; export * from "./inline-editable";
export * from "./edit-root"; export * from "./edit";
export * from "./create";
export * from "./update";
export * from "./delete";

View File

@ -44,6 +44,7 @@ export const EstimateRoot: FC<TEstimateRoot> = observer((props) => {
Estimates Estimates
</div> </div>
{/* current active estimate section */}
{currentActiveEstimateId ? ( {currentActiveEstimateId ? (
<div className="space-y-4"> <div className="space-y-4">
{/* estimates activated deactivated section */} {/* estimates activated deactivated section */}
@ -88,7 +89,7 @@ export const EstimateRoot: FC<TEstimateRoot> = observer((props) => {
</div> </div>
)} )}
{/* modals for create and update */} {/* CRUD modals */}
<CreateEstimateModal <CreateEstimateModal
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}

View File

@ -1,3 +1,2 @@
export * from "./modal"; export * from "./modal";
export * from "./stage-one"; export * from "./stage-one";
export * from "./stage-two";

View File

@ -1,17 +1,11 @@
import { FC, useEffect, useMemo, useState } from "react"; import { FC, useEffect, useState } from "react";
import orderBy from "lodash/orderBy";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { ChevronLeft } from "lucide-react"; import { ChevronLeft } from "lucide-react";
import { TEstimatePointsObject, TEstimateUpdateStageKeys } from "@plane/types"; import { TEstimateUpdateStageKeys } from "@plane/types";
import { Button } from "@plane/ui"; import { Button } from "@plane/ui";
// components // components
import { EModalPosition, EModalWidth, ModalCore } from "@/components/core"; import { EModalPosition, EModalWidth, ModalCore } from "@/components/core";
import { EstimateUpdateStageOne, EstimateUpdateStageTwo } from "@/components/estimates"; import { EstimatePointEditRoot, EstimateUpdateStageOne } from "@/components/estimates";
// hooks
import {
useEstimate,
// useProjectEstimates
} from "@/hooks/store";
type TUpdateEstimateModal = { type TUpdateEstimateModal = {
workspaceSlug: string; workspaceSlug: string;
@ -24,39 +18,15 @@ type TUpdateEstimateModal = {
export const UpdateEstimateModal: FC<TUpdateEstimateModal> = observer((props) => { export const UpdateEstimateModal: FC<TUpdateEstimateModal> = observer((props) => {
// props // props
const { workspaceSlug, projectId, estimateId, isOpen, handleClose } = props; const { workspaceSlug, projectId, estimateId, isOpen, handleClose } = props;
// hooks
const { asJson: currentEstimate } = useEstimate(estimateId);
// states // states
const [estimateEditType, setEstimateEditType] = useState<TEstimateUpdateStageKeys | undefined>(undefined); const [estimateEditType, setEstimateEditType] = useState<TEstimateUpdateStageKeys | undefined>(undefined);
const [estimatePoints, setEstimatePoints] = useState<TEstimatePointsObject[] | undefined>(undefined);
const handleEstimateEditType = (type: TEstimateUpdateStageKeys) => { const handleEstimateEditType = (type: TEstimateUpdateStageKeys) => setEstimateEditType(type);
if (currentEstimate?.points && currentEstimate?.points.length > 0) {
let estimateValidatePoints: TEstimatePointsObject[] = [];
currentEstimate?.points.map(
(point) =>
point.key && point.value && estimateValidatePoints.push({ id: point.id, key: point.key, value: point.value })
);
estimateValidatePoints = orderBy(estimateValidatePoints, ["key"], ["asc"]);
if (estimateValidatePoints.length > 0) {
setEstimateEditType(type);
setEstimatePoints(estimateValidatePoints);
}
}
};
const handleUpdatePoints = (newPoints: TEstimatePointsObject[] | undefined) => setEstimatePoints(newPoints);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) setEstimateEditType(undefined);
setEstimateEditType(undefined);
setEstimatePoints(undefined);
}
}, [isOpen]); }, [isOpen]);
// derived values
const renderEstimateStepsCount = useMemo(() => (estimatePoints ? "2" : "1"), [estimatePoints]);
return ( return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXL}> <ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXL}>
<div className="relative space-y-6 py-5"> <div className="relative space-y-6 py-5">
@ -65,10 +35,7 @@ export const UpdateEstimateModal: FC<TUpdateEstimateModal> = observer((props) =>
<div className="relative flex items-center gap-1"> <div className="relative flex items-center gap-1">
{estimateEditType && ( {estimateEditType && (
<div <div
onClick={() => { onClick={() => setEstimateEditType(undefined)}
setEstimateEditType(undefined);
handleUpdatePoints(undefined);
}}
className="flex-shrink-0 cursor-pointer w-5 h-5 flex justify-center items-center" className="flex-shrink-0 cursor-pointer w-5 h-5 flex justify-center items-center"
> >
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
@ -76,29 +43,17 @@ export const UpdateEstimateModal: FC<TUpdateEstimateModal> = observer((props) =>
)} )}
<div className="text-xl font-medium text-custom-text-200">Edit estimate system</div> <div className="text-xl font-medium text-custom-text-200">Edit estimate system</div>
</div> </div>
<div className="text-xs text-gray-400">Step {renderEstimateStepsCount}/2</div> <Button variant="primary" size="sm" onClick={handleClose}>
</div> Done
{/* estimate steps */}
<div className="px-5">
{!estimateEditType && <EstimateUpdateStageOne handleEstimateEditType={handleEstimateEditType} />}
{estimateEditType && estimatePoints && (
<EstimateUpdateStageTwo
workspaceSlug={workspaceSlug}
projectId={projectId}
estimate={currentEstimate}
estimateEditType={estimateEditType}
estimatePoints={estimatePoints}
handleEstimatePoints={handleUpdatePoints}
/>
)}
</div>
<div className="relative flex justify-end items-center gap-3 px-5 pt-5 border-t border-custom-border-100">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button> </Button>
</div> </div>
<div className="px-5 pb-1">
{!estimateEditType && <EstimateUpdateStageOne handleEstimateEditType={handleEstimateEditType} />}
{estimateEditType && estimateId && (
<EstimatePointEditRoot workspaceSlug={workspaceSlug} projectId={projectId} estimateId={estimateId} />
)}
</div>
</div> </div>
</ModalCore> </ModalCore>
); );

View File

@ -1,111 +0,0 @@
import { FC } from "react";
import cloneDeep from "lodash/cloneDeep";
import { observer } from "mobx-react";
import { Plus } from "lucide-react";
import { IEstimate, TEstimatePointsObject, TEstimateUpdateStageKeys } from "@plane/types";
import { Button, Sortable } from "@plane/ui";
// components
import { EstimatePointItem } from "@/components/estimates";
// constants
import { EEstimateUpdateStages, maxEstimatesCount } from "@/constants/estimates";
import { useEstimate } from "@/hooks/store";
type TEstimateUpdateStageTwo = {
workspaceSlug: string;
projectId: string;
estimate: IEstimate;
estimateEditType: TEstimateUpdateStageKeys | undefined;
estimatePoints: TEstimatePointsObject[];
handleEstimatePoints: (value: TEstimatePointsObject[]) => void;
};
export const EstimateUpdateStageTwo: FC<TEstimateUpdateStageTwo> = observer((props) => {
const { workspaceSlug, projectId, estimate, estimateEditType, estimatePoints, handleEstimatePoints } = props;
// hooks
const { updateEstimate: updateEstimateRequest } = useEstimate(estimate?.id);
const currentEstimateSystem = estimate || undefined;
const addNewEstimationPoint = () => {
const currentEstimationPoints = cloneDeep(estimatePoints);
const newEstimationPoint: TEstimatePointsObject = {
key: currentEstimationPoints.length + 1,
value: "",
};
handleEstimatePoints([...currentEstimationPoints, newEstimationPoint]);
};
const editEstimationPoint = (index: number, value: string) => {
const newEstimationPoints = estimatePoints;
newEstimationPoints[index].value = value;
handleEstimatePoints(newEstimationPoints);
};
const deleteEstimationPoint = (index: number) => {
let newEstimationPoints = cloneDeep(estimatePoints);
newEstimationPoints.splice(index, 1);
newEstimationPoints = newEstimationPoints.map((item, index) => ({
...item,
key: index + 1,
}));
handleEstimatePoints(newEstimationPoints);
};
const replaceEstimateItem = (index: number, value: TEstimatePointsObject) => {
const newEstimationPoints = cloneDeep(estimatePoints);
newEstimationPoints[index].id = value.id;
newEstimationPoints[index].key = value.key;
newEstimationPoints[index].value = value.value;
handleEstimatePoints(newEstimationPoints);
};
const updatedSortedKeys = (updatedEstimatePoints: TEstimatePointsObject[]) => {
try {
const sortedEstimatePoints = cloneDeep(updatedEstimatePoints).map((item, index) => ({
...item,
key: index + 1,
})) as TEstimatePointsObject[];
handleEstimatePoints(sortedEstimatePoints);
updateEstimateRequest(workspaceSlug, projectId, { estimate_points: sortedEstimatePoints });
} catch (error) {
console.log(error);
}
};
if (!estimateEditType) return <></>;
return (
<div className="space-y-1">
<div className="text-sm font-medium text-custom-text-300">
{estimateEditType === EEstimateUpdateStages.SWITCH ? "Estimate type switching" : currentEstimateSystem?.type}
</div>
<div className="space-y-3">
<Sortable
data={estimatePoints}
render={(value: TEstimatePointsObject, index: number) => (
<EstimatePointItem
workspaceSlug={workspaceSlug}
projectId={projectId}
estimateId={estimate?.id || undefined}
mode={estimateEditType}
item={value}
editItem={(value: string) => editEstimationPoint(index, value)}
replaceEstimateItem={(value: TEstimatePointsObject) => replaceEstimateItem(index, value)}
deleteItem={() => deleteEstimationPoint(index)}
/>
)}
onChange={(data: TEstimatePointsObject[]) => updatedSortedKeys(data)}
keyExtractor={(item: TEstimatePointsObject) => item?.id?.toString() || item.value.toString()}
/>
{estimateEditType === EEstimateUpdateStages.EDIT && (
<>
{estimatePoints && estimatePoints.length <= maxEstimatesCount && (
<Button size="sm" prependIcon={<Plus />} onClick={addNewEstimationPoint}>
Add {currentEstimateSystem?.type}
</Button>
)}
</>
)}
</div>
</div>
);
});

View File

@ -61,7 +61,7 @@ export class EstimateService extends APIService {
projectId: string, projectId: string,
estimateId: string, estimateId: string,
payload: Partial<IEstimateFormData> payload: Partial<IEstimateFormData>
): Promise<IEstimate | undefined> { ): Promise<{ points: IEstimatePoint[] } | undefined> {
try { try {
const { data } = await this.patch( const { data } = await this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/`, `/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/`,

View File

@ -1,3 +1,4 @@
import orderBy from "lodash/orderBy";
import set from "lodash/set"; import set from "lodash/set";
import unset from "lodash/unset"; import unset from "lodash/unset";
import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { action, computed, makeObservable, observable, runInAction } from "mobx";
@ -7,6 +8,7 @@ import {
IEstimatePoint as IEstimatePointType, IEstimatePoint as IEstimatePointType,
TEstimateSystemKeys, TEstimateSystemKeys,
IEstimateFormData, IEstimateFormData,
TEstimatePointsObject,
} from "@plane/types"; } from "@plane/types";
// services // services
import { EstimateService } from "@/services/project/estimate.service"; import { EstimateService } from "@/services/project/estimate.service";
@ -26,13 +28,18 @@ export interface IEstimate extends IEstimateType {
// computed // computed
asJson: IEstimateType; asJson: IEstimateType;
estimatePointIds: string[] | undefined; estimatePointIds: string[] | undefined;
estimatePointById: (estimateId: string) => IEstimatePointType | undefined; estimatePointById: (estimatePointId: string) => IEstimatePointType | undefined;
// actions // actions
updateEstimate: ( updateEstimate: (
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
payload: Partial<IEstimateFormData> payload: Partial<IEstimateFormData>
) => Promise<IEstimateType | undefined>; ) => Promise<IEstimateType | undefined>;
updateEstimateSortOrder: (
workspaceSlug: string,
projectId: string,
payload: TEstimatePointsObject[]
) => Promise<void>;
creteEstimatePoint: ( creteEstimatePoint: (
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
@ -92,6 +99,7 @@ export class Estimate implements IEstimate {
estimatePointIds: computed, estimatePointIds: computed,
// actions // actions
updateEstimate: action, updateEstimate: action,
updateEstimateSortOrder: action,
creteEstimatePoint: action, creteEstimatePoint: action,
deleteEstimatePoint: action, deleteEstimatePoint: action,
}); });
@ -136,11 +144,11 @@ export class Estimate implements IEstimate {
get estimatePointIds() { get estimatePointIds() {
const { estimatePoints } = this; const { estimatePoints } = this;
if (!estimatePoints) return undefined; if (!estimatePoints) return undefined;
let currentEstimatePoints = Object.values(estimatePoints).filter(
const estimatePointIds = Object.values(estimatePoints) (estimatePoint) => estimatePoint?.estimate === this.id
.map((point) => point.estimate && this.id) );
.filter((id) => id) as string[]; currentEstimatePoints = orderBy(currentEstimatePoints, ["key"], "asc");
const estimatePointIds = currentEstimatePoints.map((estimatePoint) => estimatePoint.id) as string[];
return estimatePointIds ?? undefined; return estimatePointIds ?? undefined;
} }
@ -166,16 +174,38 @@ export class Estimate implements IEstimate {
if (!this.id || !payload) return; if (!this.id || !payload) return;
const estimate = await this.service.updateEstimate(workspaceSlug, projectId, this.id, payload); const estimate = await this.service.updateEstimate(workspaceSlug, projectId, this.id, payload);
if (estimate) { return estimate as any;
} catch (error) {
throw error;
}
};
/**
* @description update an estimate sort order
* @param { string } workspaceSlug
* @param { string } projectId
* @param { Partial<IEstimateFormData> } payload
* @returns { void }
*/
updateEstimateSortOrder = async (
workspaceSlug: string,
projectId: string,
payload: TEstimatePointsObject[]
): Promise<void> => {
try {
if (!this.id || !payload) return;
const estimatePoints = await this.service.updateEstimate(workspaceSlug, projectId, this.id, {
estimate_points: payload,
});
if (estimatePoints?.points && estimatePoints?.points.length > 0) {
runInAction(() => { runInAction(() => {
Object.keys(payload).map((key) => { estimatePoints?.points.map((estimatePoint) => {
const estimateKey = key as keyof IEstimateType; if (estimatePoint.id)
set(this, estimateKey, estimate[estimateKey]); set(this.estimatePoints, [estimatePoint.id], new EstimatePoint(this.store, this.data, estimatePoint));
}); });
}); });
} }
return estimate;
} catch (error) { } catch (error) {
throw error; throw error;
} }

View File

@ -162,6 +162,7 @@ export class ProjectEstimateStore implements IProjectEstimateStore {
status: "error", status: "error",
message: "Error fetching estimates", message: "Error fetching estimates",
}; };
throw error;
} }
}; };
@ -196,6 +197,7 @@ export class ProjectEstimateStore implements IProjectEstimateStore {
status: "error", status: "error",
message: "Error fetching estimates", message: "Error fetching estimates",
}; };
throw error;
} }
}; };
@ -231,6 +233,7 @@ export class ProjectEstimateStore implements IProjectEstimateStore {
status: "error", status: "error",
message: "Error fetching estimate by id", message: "Error fetching estimate by id",
}; };
throw error;
} }
}; };
@ -250,7 +253,6 @@ export class ProjectEstimateStore implements IProjectEstimateStore {
this.error = undefined; this.error = undefined;
const estimate = await this.service.createEstimate(workspaceSlug, projectId, payload); const estimate = await this.service.createEstimate(workspaceSlug, projectId, payload);
console.log("estimate", estimate);
if (estimate) { if (estimate) {
await this.store.projectRoot.project.updateProject(workspaceSlug, projectId, { await this.store.projectRoot.project.updateProject(workspaceSlug, projectId, {
estimate: estimate.id, estimate: estimate.id,
@ -266,6 +268,7 @@ export class ProjectEstimateStore implements IProjectEstimateStore {
status: "error", status: "error",
message: "Error creating estimate", message: "Error creating estimate",
}; };
throw error;
} }
}; };
} }