chore: create estimate workflow update

This commit is contained in:
guru_sainath 2024-05-27 09:47:31 +05:30
parent a3c9d5639e
commit 1e59d5b735
19 changed files with 518 additions and 154 deletions

View File

@ -24,3 +24,16 @@ export enum EIssueCommentAccessSpecifier {
EXTERNAL = "EXTERNAL", EXTERNAL = "EXTERNAL",
INTERNAL = "INTERNAL", INTERNAL = "INTERNAL",
} }
// estimates
export enum EEstimateSystem {
POINTS = "points",
CATEGORIES = "categories",
TIME = "time",
}
export enum EEstimateUpdateStages {
CREATE = "create",
EDIT = "edit",
SWITCH = "switch",
}

View File

@ -1,3 +1,6 @@
import { IWorkspace, IProject } from "./";
import { EEstimateSystem, EEstimateUpdateStages } from "./enums";
export interface IEstimatePoint { export interface IEstimatePoint {
id: string | undefined; id: string | undefined;
key: number | undefined; key: number | undefined;
@ -12,13 +15,16 @@ export interface IEstimatePoint {
updated_by: string | undefined; updated_by: string | undefined;
} }
export type TEstimateType = "categories" | "points" | "time"; export type TEstimateSystemKeys =
| EEstimateSystem.POINTS
| EEstimateSystem.CATEGORIES
| EEstimateSystem.TIME;
export interface IEstimate { export interface IEstimate {
id: string | undefined; id: string | undefined;
name: string | undefined; name: string | undefined;
description: string | undefined; description: string | undefined;
type: TEstimateType | undefined; // categories, points, time type: TEstimateSystemKeys | undefined; // categories, points, time
points: IEstimatePoint[] | undefined; points: IEstimatePoint[] | undefined;
workspace: string | undefined; workspace: string | undefined;
workspace_detail: IWorkspace | undefined; workspace_detail: IWorkspace | undefined;
@ -40,3 +46,30 @@ export interface IEstimateFormData {
value: string; value: string;
}[]; }[];
} }
export type TEstimatePointsObject = {
id?: string | undefined;
key: number;
value: string;
};
export type TTemplateValues = {
title: string;
values: TEstimatePointsObject[];
};
export type TEstimateSystem = {
name: string;
templates: Record<string, TTemplateValues>;
is_available: boolean;
};
export type TEstimateSystems = {
[K in TEstimateSystemKeys]: TEstimateSystem;
};
// update estimates
export type TEstimateUpdateStageKeys =
| EEstimateUpdateStages.CREATE
| EEstimateUpdateStages.EDIT
| EEstimateUpdateStages.SWITCH;

View File

@ -1,15 +1,13 @@
import { FC, useEffect, useMemo, useState } from "react"; import { FC, useEffect, useMemo, useState } from "react";
import cloneDeep from "lodash/cloneDeep";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { ChevronLeft } from "lucide-react"; import { ChevronLeft } from "lucide-react";
import { IEstimate, IEstimateFormData } from "@plane/types"; import { IEstimateFormData, TEstimateSystemKeys, TEstimatePointsObject } from "@plane/types";
import { Button, TOAST_TYPE, setToast } from "@plane/ui"; import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// components // components
import { EModalPosition, EModalWidth, ModalCore } from "@/components/core"; import { EModalPosition, EModalWidth, ModalCore } from "@/components/core";
import { EstimateCreateStageOne, EstimateCreateStageTwo } from "@/components/estimates"; import { EstimateCreateStageOne, EstimateCreateStageTwo } from "@/components/estimates";
import { TEstimateSystemKeys, EEstimateSystem, TEstimatePointsObject } from "@/components/estimates/types";
// constants // constants
import { ESTIMATE_SYSTEMS } from "@/constants/estimates"; import { EEstimateSystem, ESTIMATE_SYSTEMS } from "@/constants/estimates";
// hooks // hooks
import { useProjectEstimates } from "@/hooks/store"; import { useProjectEstimates } from "@/hooks/store";
@ -18,7 +16,6 @@ type TCreateEstimateModal = {
projectId: string; projectId: string;
isOpen: boolean; isOpen: boolean;
handleClose: () => void; handleClose: () => void;
data?: IEstimate;
}; };
export const CreateEstimateModal: FC<TCreateEstimateModal> = observer((props) => { export const CreateEstimateModal: FC<TCreateEstimateModal> = observer((props) => {
@ -30,10 +27,7 @@ export const CreateEstimateModal: FC<TCreateEstimateModal> = observer((props) =>
const [estimateSystem, setEstimateSystem] = useState<TEstimateSystemKeys>(EEstimateSystem.POINTS); const [estimateSystem, setEstimateSystem] = useState<TEstimateSystemKeys>(EEstimateSystem.POINTS);
const [estimatePoints, setEstimatePoints] = useState<TEstimatePointsObject[] | undefined>(undefined); const [estimatePoints, setEstimatePoints] = useState<TEstimatePointsObject[] | undefined>(undefined);
const handleUpdatePoints = (newPoints: TEstimatePointsObject[] | undefined) => { const handleUpdatePoints = (newPoints: TEstimatePointsObject[] | undefined) => setEstimatePoints(newPoints);
const points = cloneDeep(newPoints);
setEstimatePoints(points);
};
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
@ -47,17 +41,21 @@ export const CreateEstimateModal: FC<TCreateEstimateModal> = observer((props) =>
const handleCreateEstimate = async () => { const handleCreateEstimate = async () => {
try { try {
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) => {
if (estimatePoint.value && Number(estimatePoint.value)) validatedEstimatePoints.push(estimatePoint); if (
estimatePoint.value &&
((estimatePoint.value != "0" && Number(estimatePoint.value)) || estimatePoint.value === "0")
)
validatedEstimatePoints.push(estimatePoint);
}); });
} else { } else {
estimatePoints?.map((estimatePoint) => { estimatePoints?.map((estimatePoint) => {
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: {
@ -65,8 +63,12 @@ export const CreateEstimateModal: FC<TCreateEstimateModal> = observer((props) =>
}, },
estimate_points: validatedEstimatePoints, estimate_points: validatedEstimatePoints,
}; };
await createEstimate(workspaceSlug, projectId, payload); await createEstimate(workspaceSlug, projectId, payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Estimate system created",
message: "Created and Enabled successfully",
});
handleClose(); handleClose();
} else { } else {
setToast({ setToast({

View File

@ -1,8 +1,7 @@
import { FC } from "react"; import { FC } from "react";
// components import { TEstimateSystemKeys } from "@plane/types";
// constants // constants
import { RadioInput } from "@plane/ui"; import { RadioInput } from "@plane/ui";
import { TEstimateSystemKeys } from "@/components/estimates/types";
import { ESTIMATE_SYSTEMS } from "@/constants/estimates"; import { ESTIMATE_SYSTEMS } from "@/constants/estimates";
// types // types

View File

@ -1,11 +1,11 @@
import { FC } from "react"; import { FC } from "react";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { TEstimatePointsObject } from "@plane/types";
import { Button, Sortable } from "@plane/ui"; import { Button, Sortable } from "@plane/ui";
// components // components
import { EstimateItem } from "@/components/estimates"; import { EstimatePointItem } from "@/components/estimates";
import { EEstimateSystem, TEstimatePointsObject } from "@/components/estimates/types";
// constants // constants
import { ESTIMATE_SYSTEMS } from "@/constants/estimates"; import { EEstimateSystem, EEstimateUpdateStages, ESTIMATE_SYSTEMS } from "@/constants/estimates";
type TEstimateCreateStageTwo = { type TEstimateCreateStageTwo = {
estimateSystem: EEstimateSystem; estimateSystem: EEstimateSystem;
@ -17,10 +17,10 @@ export const EstimateCreateStageTwo: FC<TEstimateCreateStageTwo> = (props) => {
const { estimateSystem, estimatePoints, handleEstimatePoints } = props; const { estimateSystem, estimatePoints, handleEstimatePoints } = props;
const currentEstimateSystem = ESTIMATE_SYSTEMS[estimateSystem] || undefined; const currentEstimateSystem = ESTIMATE_SYSTEMS[estimateSystem] || undefined;
const maxEstimatesCount = 11;
const addNewEstimationPoint = () => { const addNewEstimationPoint = () => {
const currentEstimationPoints = estimatePoints; const currentEstimationPoints = estimatePoints;
const newEstimationPoint: TEstimatePointsObject = { const newEstimationPoint: TEstimatePointsObject = {
key: currentEstimationPoints.length + 1, key: currentEstimationPoints.length + 1,
value: "0", value: "0",
@ -28,32 +28,53 @@ export const EstimateCreateStageTwo: FC<TEstimateCreateStageTwo> = (props) => {
handleEstimatePoints([...currentEstimationPoints, newEstimationPoint]); handleEstimatePoints([...currentEstimationPoints, newEstimationPoint]);
}; };
const deleteEstimationPoint = (index: number) => { const editEstimationPoint = (index: number, value: string) => {
const newEstimationPoints = estimatePoints; const newEstimationPoints = estimatePoints;
newEstimationPoints.splice(index, 1); newEstimationPoints[index].value = value;
handleEstimatePoints(newEstimationPoints); handleEstimatePoints(newEstimationPoints);
}; };
const updatedSortedKeys = (updatedEstimatePoints: TEstimatePointsObject[]) => const deleteEstimationPoint = (index: number) => {
updatedEstimatePoints.map((item, index) => ({ let newEstimationPoints = estimatePoints;
newEstimationPoints.splice(index, 1);
newEstimationPoints = newEstimationPoints.map((item, index) => ({
...item,
key: index + 1,
}));
handleEstimatePoints(newEstimationPoints);
};
const updatedSortedKeys = (updatedEstimatePoints: TEstimatePointsObject[]) => {
const sortedEstimatePoints = updatedEstimatePoints.map((item, index) => ({
...item, ...item,
key: index + 1, key: index + 1,
})) as TEstimatePointsObject[]; })) as TEstimatePointsObject[];
return sortedEstimatePoints;
};
return ( return (
<div className="space-y-4"> <div className="space-y-1">
<Sortable <div className="text-sm font-medium text-custom-text-300">{estimateSystem}</div>
data={estimatePoints} <div className="space-y-3">
render={(value: TEstimatePointsObject, index: number) => ( <Sortable
<EstimateItem item={value} deleteItem={() => deleteEstimationPoint(index)} /> data={estimatePoints}
render={(value: TEstimatePointsObject, index: number) => (
<EstimatePointItem
mode={EEstimateUpdateStages.CREATE}
item={value}
editItem={(value: string) => editEstimationPoint(index, value)}
deleteItem={() => deleteEstimationPoint(index)}
/>
)}
onChange={(data: TEstimatePointsObject[]) => handleEstimatePoints(updatedSortedKeys(data))}
keyExtractor={(item: TEstimatePointsObject) => item?.id?.toString() || item.value.toString()}
/>
{estimatePoints && estimatePoints.length <= maxEstimatesCount && (
<Button size="sm" prependIcon={<Plus />} onClick={addNewEstimationPoint}>
Add {currentEstimateSystem?.name}
</Button>
)} )}
onChange={(data: TEstimatePointsObject[]) => handleEstimatePoints(updatedSortedKeys(data))} </div>
keyExtractor={(item: TEstimatePointsObject) => item?.id?.toString() || item.value.toString()}
/>
<Button prependIcon={<Plus />} onClick={addNewEstimationPoint}>
Add {currentEstimateSystem?.name}
</Button>
</div> </div>
); );
}; };

View File

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

View File

@ -1,24 +0,0 @@
import React from "react";
import { observer } from "mobx-react";
import { IEstimate } from "@plane/types";
// components
import { EModalPosition, EModalWidth, ModalCore } from "@/components/core";
type Props = {
isOpen: boolean;
handleClose: () => void;
data?: IEstimate;
};
export const DeleteEstimateModal: React.FC<Props> = observer((props) => {
const { handleClose, isOpen, data } = props;
console.log("data", data);
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXL}>
<div>Delete Estimate Modal</div>
</ModalCore>
);
});

View File

@ -1,4 +1,5 @@
import { FC } from "react"; import { FC } from "react";
import sortBy from "lodash/sortBy";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Pen } from "lucide-react"; import { Pen } from "lucide-react";
// helpers // helpers
@ -31,7 +32,11 @@ export const EstimateListItem: FC<TEstimateListItem> = observer((props) => {
> >
<div className="space-y-1"> <div className="space-y-1">
<h3 className="font-medium text-base">{currentEstimate?.name}</h3> <h3 className="font-medium text-base">{currentEstimate?.name}</h3>
<p className="text-xs">{currentEstimate?.points?.map((estimatePoint) => estimatePoint?.value).join(", ")}</p> <p className="text-xs">
{sortBy(currentEstimate?.points, ["key"])
?.map((estimatePoint) => estimatePoint?.value)
.join(", ")}
</p>
</div> </div>
{isAdmin && isEditable && ( {isAdmin && isEditable && (
<div <div

View File

@ -13,6 +13,9 @@ export * from "./estimate-list-item";
// create // create
export * from "./create"; export * from "./create";
// create
export * from "./update";
// estimate points // estimate points
export * from "./points/estimate-point-item"; export * from "./points/estimate-point-item";
export * from "./points/inline-editable"; export * from "./points/inline-editable";

View File

@ -1,20 +1,55 @@
import { Fragment, useRef, useState } from "react"; import { FC, Fragment, useEffect, useRef, useState } from "react";
import { Check, GripVertical, MoveRight, Pencil, Trash2, X } from "lucide-react"; import { Check, GripVertical, MoveRight, Pencil, Trash2, X } from "lucide-react";
import { Select } from "@headlessui/react"; import { Select } from "@headlessui/react";
import { TEstimatePointsObject } from "@plane/types";
import { Draggable } from "@plane/ui"; import { Draggable } from "@plane/ui";
// constants
import { EEstimateUpdateStages } from "@/constants/estimates";
// components
import { InlineEdit } from "./inline-editable"; import { InlineEdit } from "./inline-editable";
import { TEstimatePointsObject } from "../types";
type Props = { type TEstimatePointItem = {
mode: EEstimateUpdateStages;
item: TEstimatePointsObject; item: TEstimatePointsObject;
editItem: (value: string) => void;
deleteItem: () => void; deleteItem: () => void;
}; };
const EstimateItem = ({ item, deleteItem }: Props) => {
const { value, id } = item;
const EstimatePointItem: FC<TEstimatePointItem> = (props) => {
// props
const { mode, item, editItem, deleteItem } = props;
const { id, key, value } = item;
// ref
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [showDeleteUI, setShowDeleteUI] = useState(false); // states
const [inputValue, setInputValue] = useState<string | undefined>(undefined);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [showDeleteUI, setShowDeleteUI] = useState(false);
useEffect(() => {
if (inputValue === undefined) setInputValue(value);
}, [value, inputValue]);
const handleSave = () => {
if (id) {
// Make the api call to save the estimate point
// Show a spinner
setIsEditing(false);
}
};
const handleEdit = (value: string) => {
if (id) {
setIsEditing(true);
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
} else {
setInputValue(value);
editItem(value);
}
};
const handleDelete = () => { const handleDelete = () => {
if (id) { if (id) {
@ -24,30 +59,107 @@ const EstimateItem = ({ item, deleteItem }: Props) => {
} }
}; };
const handleEdit = () => {
setIsEditing(true);
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
};
const handleSave = () => {
if (id) {
// Make the api call to save the estimate point
// Show a spinner
setIsEditing(false);
}
};
return ( return (
<Draggable data={item}> <Draggable data={item}>
{isEditing && ( {mode === EEstimateUpdateStages.CREATE && (
<div className="flex justify-between items-center gap-4 mb-2"> <div className="border border-custom-border-200 rounded relative flex items-center px-2.5 gap-2">
<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>
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => handleEdit(e.target.value)}
className="flex-grow border-none bg-transparent focus:ring-0 focus:border-0 focus:outline-none py-2.5"
/>
<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={handleDelete}
>
<Trash2 size={14} className="text-custom-text-200" />
</div>
</div>
)}
{mode === EEstimateUpdateStages.EDIT && (
<>
<div className="border border-custom-border-200 rounded relative flex items-center px-2.5 gap-2">
<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>
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
className="flex-grow border-none bg-transparent focus:ring-0 focus:border-0 focus:outline-none py-2.5"
/>
<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={handleDelete}
>
<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={handleDelete}
>
<Trash2 size={14} className="text-custom-text-200" />
</div>
</div>
</>
)}
{mode === EEstimateUpdateStages.SWITCH && (
<div className="border border-custom-border-200 rounded relative flex items-center px-2.5 gap-2">
<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>
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
className="flex-grow border-none bg-transparent focus:ring-0 focus:border-0 focus:outline-none py-2.5"
/>
<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={handleDelete}
>
<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={handleDelete}
>
<Trash2 size={14} className="text-custom-text-200" />
</div>
</div>
)}
{/* <div className="border border-custom-border-200 rounded relative flex items-center px-2.5 gap-3">
<GripVertical size={14} className="text-custom-text-200" />
<input
ref={inputRef}
type="text"
value={value}
onChange={() => {}}
className="flex-grow border-none bg-transparent focus:ring-0 focus:border-0 focus:outline-none py-2.5"
/>
<Pencil size={14} className="text-custom-text-200" />
<Trash2 size={14} className="text-custom-text-200" />
<Check size={14} className="text-custom-text-200" />
<X size={14} className="text-custom-text-200" />
</div> */}
{/* {isEditing && (
<div className="flex justify-between items-center gap-4">
<input <input
type="text" type="text"
value={value} value={value}
onChange={() => {}} onChange={() => {}}
className="border rounded-md border-custom-border-300 p-3 flex-grow" className="border rounded-md border-custom-border-300 p-3 flex-grow"
ref={inputRef} ref={inputRef}
/> />
<div> <div>
@ -58,6 +170,7 @@ const EstimateItem = ({ item, deleteItem }: Props) => {
</div> </div>
</div> </div>
)} )}
{!isEditing && ( {!isEditing && (
<div className="border rounded-md border-custom-border-300 mb-2 p-3 flex justify-between items-center"> <div className="border rounded-md border-custom-border-300 mb-2 p-3 flex justify-between items-center">
<div className="flex items-center"> <div className="flex items-center">
@ -82,9 +195,9 @@ const EstimateItem = ({ item, deleteItem }: Props) => {
{!showDeleteUI && <Trash2 className="w-4 h-4" onClick={handleDelete} />} {!showDeleteUI && <Trash2 className="w-4 h-4" onClick={handleDelete} />}
</div> </div>
</div> </div>
)} )} */}
</Draggable> </Draggable>
); );
}; };
export { EstimateItem }; export { EstimatePointItem };

View File

@ -1,13 +1,13 @@
import { FC, useState } from "react"; import { FC, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import useSWR from "swr"; import useSWR from "swr";
import { IEstimate } from "@plane/types";
// components // components
import { import {
EstimateLoaderScreen, EstimateLoaderScreen,
EstimateEmptyScreen, EstimateEmptyScreen,
EstimateDisableSwitch, EstimateDisableSwitch,
CreateEstimateModal, CreateEstimateModal,
UpdateEstimateModal,
EstimateList, EstimateList,
} from "@/components/estimates"; } from "@/components/estimates";
// hooks // hooks
@ -22,29 +22,18 @@ type TEstimateRoot = {
export const EstimateRoot: FC<TEstimateRoot> = observer((props) => { export const EstimateRoot: FC<TEstimateRoot> = observer((props) => {
const { workspaceSlug, projectId, isAdmin } = props; const { workspaceSlug, projectId, isAdmin } = props;
// hooks // hooks
const { loader, currentActiveEstimateId, estimateById, archivedEstimateIds, getProjectEstimates } = const { loader, currentActiveEstimateId, archivedEstimateIds, getProjectEstimates } = useProjectEstimates();
useProjectEstimates();
// states // states
const [isEstimateCreateModalOpen, setIsEstimateCreateModalOpen] = useState(false); const [isEstimateCreateModalOpen, setIsEstimateCreateModalOpen] = useState(false);
// const [isEstimateDeleteModalOpen, setIsEstimateDeleteModalOpen] = useState<string | null>(null); const [estimateToUpdate, setEstimateToUpdate] = useState<string | undefined>();
const [estimateToUpdate, setEstimateToUpdate] = useState<IEstimate | undefined>();
const { isLoading: isSWRLoading } = useSWR( const { isLoading: isSWRLoading } = useSWR(
workspaceSlug && projectId ? `PROJECT_ESTIMATES_${workspaceSlug}_${projectId}` : null, workspaceSlug && projectId ? `PROJECT_ESTIMATES_${workspaceSlug}_${projectId}` : null,
async () => workspaceSlug && projectId && getProjectEstimates(workspaceSlug, projectId) async () => workspaceSlug && projectId && getProjectEstimates(workspaceSlug, projectId)
); );
const onEditClick = (estimateId: string) => {
const currentEstimate = estimateById(estimateId);
setEstimateToUpdate(currentEstimate);
setIsEstimateCreateModalOpen(true);
};
return ( return (
<div className="container mx-auto"> <div className="container mx-auto">
{/* <EstimateLoaderScreen />
<EstimateEmptyScreen onButtonClick={() => {}} /> */}
{loader === "init-loader" || isSWRLoading ? ( {loader === "init-loader" || isSWRLoading ? (
<EstimateLoaderScreen /> <EstimateLoaderScreen />
) : ( ) : (
@ -71,7 +60,7 @@ export const EstimateRoot: FC<TEstimateRoot> = observer((props) => {
estimateIds={[currentActiveEstimateId]} estimateIds={[currentActiveEstimateId]}
isAdmin={isAdmin} isAdmin={isAdmin}
isEditable isEditable
onEditClick={onEditClick} onEditClick={(estimateId: string) => setEstimateToUpdate(estimateId)}
/> />
</div> </div>
) : ( ) : (
@ -102,17 +91,21 @@ export const EstimateRoot: FC<TEstimateRoot> = observer((props) => {
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
isOpen={isEstimateCreateModalOpen} isOpen={isEstimateCreateModalOpen}
data={estimateToUpdate}
handleClose={() => { handleClose={() => {
setIsEstimateCreateModalOpen(false); setIsEstimateCreateModalOpen(false);
setEstimateToUpdate(undefined); setEstimateToUpdate(undefined);
}} }}
/> />
{/* <DeleteEstimateModal <UpdateEstimateModal
isOpen={!!isEstimateDeleteModalOpen} workspaceSlug={workspaceSlug}
handleClose={() => setIsEstimateDeleteModalOpen(null)} projectId={projectId}
data={} estimateId={estimateToUpdate ? estimateToUpdate : undefined}
/> */} isOpen={estimateToUpdate ? true : false}
handleClose={() => {
setIsEstimateCreateModalOpen(false);
setEstimateToUpdate(undefined);
}}
/>
</div> </div>
); );
}); });

View File

@ -1,23 +0,0 @@
export enum EEstimateSystem {
POINTS = "points",
CATEGORIES = "categories",
TIME = "time",
}
export type TEstimateSystemKeys = EEstimateSystem.POINTS | EEstimateSystem.CATEGORIES | EEstimateSystem.TIME;
export type TEstimatePointsObject = { id?: string | undefined; key: number; value: string };
export type TTemplateValues = {
title: string;
values: TEstimatePointsObject[];
};
export type TEstimateSystem = {
name: string;
templates: Record<string, TTemplateValues>;
is_available: boolean;
};
export type TEstimateSystems = {
[K in TEstimateSystemKeys]: TEstimateSystem;
};

View File

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

View File

@ -1,47 +1,157 @@
import { FC, useEffect, useState } from "react"; import { FC, useEffect, useMemo, useState } from "react";
import cloneDeep from "lodash/cloneDeep";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { IEstimate } from "@plane/types"; import { ChevronLeft } from "lucide-react";
import { Button } from "@plane/ui"; import { IEstimateFormData, TEstimatePointsObject, TEstimateUpdateStageKeys, TEstimateSystemKeys } from "@plane/types";
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// components // components
import { EModalPosition, EModalWidth, ModalCore } from "@/components/core"; import { EModalPosition, EModalWidth, ModalCore } from "@/components/core";
// types import { EstimateUpdateStageOne, EstimateUpdateStageTwo } from "@/components/estimates";
import { TEstimatePointsObject } from "@/components/estimates/types"; // constants
import { EEstimateSystem } from "@/constants/estimates";
// hooks
import {
useEstimate,
// useProjectEstimates
} from "@/hooks/store";
type Props = { type TUpdateEstimateModal = {
workspaceSlug: string;
projectId: string;
estimateId: string | undefined;
isOpen: boolean; isOpen: boolean;
handleClose: () => void; handleClose: () => void;
data?: IEstimate;
}; };
export const UpdateEstimateModal: FC<Props> = observer((props) => { export const UpdateEstimateModal: FC<TUpdateEstimateModal> = observer((props) => {
// props // props
const { handleClose, isOpen } = props; const { workspaceSlug, projectId, estimateId, isOpen, handleClose } = props;
// hooks
const { asJson: currentEstimate, updateEstimate } = useEstimate(estimateId);
// states // states
const [estimateEditType, setEstimateEditType] = useState<TEstimateUpdateStageKeys | undefined>(undefined);
const [estimatePoints, setEstimatePoints] = useState<TEstimatePointsObject[] | undefined>(undefined); const [estimatePoints, setEstimatePoints] = useState<TEstimatePointsObject[] | undefined>(undefined);
const handleEstimateEditType = (type: TEstimateUpdateStageKeys) => {
if (currentEstimate?.points && currentEstimate?.points.length > 0) {
const estimateValidatePoints: TEstimatePointsObject[] = [];
currentEstimate?.points.map(
(point) =>
point.key && point.value && estimateValidatePoints.push({ id: point.id, key: point.key, value: point.value })
);
if (estimateValidatePoints.length > 0) {
setEstimateEditType(type);
setEstimatePoints(estimateValidatePoints);
}
}
};
const handleUpdatePoints = (newPoints: TEstimatePointsObject[] | undefined) => {
const points = cloneDeep(newPoints);
setEstimatePoints(points);
};
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
setEstimateEditType(undefined);
setEstimatePoints(undefined); setEstimatePoints(undefined);
} }
}, [isOpen]); }, [isOpen]);
// derived values // derived values
const renderEstimateStepsCount = useMemo(() => (estimatePoints ? "2" : "1"), [estimatePoints]);
const handleCreateEstimate = async () => {
try {
if (!workspaceSlug || !projectId || !estimateId || currentEstimate?.type === undefined) return;
const currentEstimationType: TEstimateSystemKeys = currentEstimate?.type;
const validatedEstimatePoints: TEstimatePointsObject[] = [];
if ([EEstimateSystem.POINTS, EEstimateSystem.TIME].includes(currentEstimationType)) {
estimatePoints?.map((estimatePoint) => {
if (
estimatePoint.value &&
((estimatePoint.value != "0" && Number(estimatePoint.value)) || estimatePoint.value === "0")
)
validatedEstimatePoints.push(estimatePoint);
});
} else {
estimatePoints?.map((estimatePoint) => {
if (estimatePoint.value) validatedEstimatePoints.push(estimatePoint);
});
}
if (validatedEstimatePoints.length === estimatePoints?.length) {
const payload: IEstimateFormData = {
estimate: {
type: "points",
},
estimate_points: validatedEstimatePoints,
};
await updateEstimate(payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Estimate system created",
message: "Created and Enabled successfully",
});
handleClose();
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "something went wrong",
});
}
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "something went wrong",
});
}
};
console.log("estimateStage", estimateEditType);
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">
{/* heading */} {/* heading */}
<div className="relative flex justify-between items-center gap-2 px-5">Heading</div> <div className="relative flex justify-between items-center gap-2 px-5">
<div className="relative flex items-center gap-1">
{estimateEditType && (
<div
onClick={() => {
setEstimateEditType(undefined);
handleUpdatePoints(undefined);
}}
className="flex-shrink-0 cursor-pointer w-5 h-5 flex justify-center items-center"
>
<ChevronLeft className="w-4 h-4" />
</div>
)}
<div className="text-xl font-medium text-custom-text-200">Edit estimate system</div>
</div>
<div className="text-xs text-gray-400">Step {renderEstimateStepsCount}/2</div>
</div>
{/* estimate steps */} {/* estimate steps */}
<div className="px-5">Content</div> <div className="px-5">
{!estimateEditType && <EstimateUpdateStageOne handleEstimateEditType={handleEstimateEditType} />}
{estimateEditType && estimatePoints && (
<EstimateUpdateStageTwo
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"> <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}> <Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel Cancel
</Button> </Button>
{estimatePoints && ( {estimatePoints && (
<Button variant="primary" size="sm" onClick={handleClose}> <Button variant="primary" size="sm" onClick={handleCreateEstimate}>
Create Estimate Create Estimate
</Button> </Button>
)} )}

View File

@ -0,0 +1,32 @@
import { FC } from "react";
import { TEstimateUpdateStageKeys } from "@plane/types";
// constants
import { ESTIMATE_OPTIONS_STAGE_ONE } from "@/constants/estimates";
// helpers
import { cn } from "@/helpers/common.helper";
type TEstimateUpdateStageOne = {
handleEstimateEditType: (stage: TEstimateUpdateStageKeys) => void;
};
export const EstimateUpdateStageOne: FC<TEstimateUpdateStageOne> = (props) => {
const { handleEstimateEditType } = props;
return (
<div className="space-y-3">
{ESTIMATE_OPTIONS_STAGE_ONE &&
ESTIMATE_OPTIONS_STAGE_ONE.map((stage) => (
<div
key={stage.key}
className={cn(
"border border-custom-border-300 cursor-pointer space-y-1 p-3 rounded hover:bg-custom-background-90 transition-colors"
)}
onClick={() => handleEstimateEditType(stage.key)}
>
<h3 className="text-base font-medium">{stage.title}</h3>
<p className="text-sm text-custom-text-200">{stage.description}</p>
</div>
))}
</div>
);
};

View File

@ -0,0 +1,58 @@
import { FC } from "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";
type TEstimateUpdateStageTwo = {
estimate: IEstimate;
estimateEditType: TEstimateUpdateStageKeys | undefined;
estimatePoints: TEstimatePointsObject[];
handleEstimatePoints: (value: TEstimatePointsObject[]) => void;
};
export const EstimateUpdateStageTwo: FC<TEstimateUpdateStageTwo> = (props) => {
const { estimate, estimateEditType, estimatePoints, handleEstimatePoints } = props;
const currentEstimateSystem = estimate || undefined;
const addNewEstimationPoint = () => {
const currentEstimationPoints = estimatePoints;
const newEstimationPoint: TEstimatePointsObject = {
key: currentEstimationPoints.length + 1,
value: "0",
};
handleEstimatePoints([...currentEstimationPoints, newEstimationPoint]);
};
const deleteEstimationPoint = (index: number) => {
const newEstimationPoints = estimatePoints;
newEstimationPoints.splice(index, 1);
handleEstimatePoints(newEstimationPoints);
};
const updatedSortedKeys = (updatedEstimatePoints: TEstimatePointsObject[]) =>
updatedEstimatePoints.map((item, index) => ({
...item,
key: index + 1,
})) as TEstimatePointsObject[];
return (
<div className="space-y-4">
<Sortable
data={estimatePoints}
render={(value: TEstimatePointsObject, index: number) => (
<EstimatePointItem item={value} deleteItem={() => deleteEstimationPoint(index)} />
)}
onChange={(data: TEstimatePointsObject[]) => handleEstimatePoints(updatedSortedKeys(data))}
keyExtractor={(item: TEstimatePointsObject) => item?.id?.toString() || item.value.toString()}
/>
<Button prependIcon={<Plus />} onClick={addNewEstimationPoint}>
Add {currentEstimateSystem?.name}
</Button>
</div>
);
};

View File

@ -1,4 +1,17 @@
import { TEstimateSystems } from "@/components/estimates/types"; export enum EEstimateSystem {
POINTS = "points",
CATEGORIES = "categories",
TIME = "time",
}
export enum EEstimateUpdateStages {
CREATE = "create",
EDIT = "edit",
SWITCH = "switch",
}
// types
import { TEstimateSystems } from "@plane/types";
export const ESTIMATE_SYSTEMS: TEstimateSystems = { export const ESTIMATE_SYSTEMS: TEstimateSystems = {
points: { points: {
@ -93,3 +106,16 @@ export const ESTIMATE_SYSTEMS: TEstimateSystems = {
is_available: false, is_available: false,
}, },
}; };
export const ESTIMATE_OPTIONS_STAGE_ONE = [
{
key: EEstimateUpdateStages.EDIT,
title: "Add, update or remove estimates",
description: "Manage current system either adding, updating or removing the points or categories.",
},
{
key: EEstimateUpdateStages.SWITCH,
title: "Change estimate type",
description: "Convert your points system to categories system and vice versa.",
},
];

View File

@ -8,7 +8,7 @@ import {
IEstimatePoint as IEstimatePointType, IEstimatePoint as IEstimatePointType,
IProject, IProject,
IWorkspace, IWorkspace,
TEstimateType, TEstimateSystemKeys,
IEstimateFormData, IEstimateFormData,
} from "@plane/types"; } from "@plane/types";
// services // services
@ -31,8 +31,8 @@ export interface IEstimate extends IEstimateType {
EstimatePointIds: string[] | undefined; EstimatePointIds: string[] | undefined;
estimatePointById: (estimateId: string) => IEstimatePointType | undefined; estimatePointById: (estimateId: string) => IEstimatePointType | undefined;
// actions // actions
updateEstimatePointSorting: (payload: IEstimateFormData) => Promise<void>; updateEstimate: (payload: IEstimateFormData) => Promise<void>;
deleteEstimatePoint: (estimatePointId: string) => Promise<void>; deleteEstimate: (estimatePointId: string) => Promise<void>;
} }
export class Estimate implements IEstimate { export class Estimate implements IEstimate {
@ -40,7 +40,7 @@ export class Estimate implements IEstimate {
id: string | undefined = undefined; id: string | undefined = undefined;
name: string | undefined = undefined; name: string | undefined = undefined;
description: string | undefined = undefined; description: string | undefined = undefined;
type: TEstimateType | undefined = undefined; type: TEstimateSystemKeys | undefined = undefined;
points: IEstimatePointType[] | undefined = undefined; points: IEstimatePointType[] | undefined = undefined;
workspace: string | undefined = undefined; workspace: string | undefined = undefined;
workspace_detail: IWorkspace | undefined = undefined; workspace_detail: IWorkspace | undefined = undefined;
@ -82,8 +82,8 @@ export class Estimate implements IEstimate {
asJson: computed, asJson: computed,
EstimatePointIds: computed, EstimatePointIds: computed,
// actions // actions
updateEstimatePointSorting: action, updateEstimate: action,
deleteEstimatePoint: action, deleteEstimate: action,
}); });
this.id = this.data.id; this.id = this.data.id;
this.name = this.data.name; this.name = this.data.name;
@ -143,7 +143,7 @@ export class Estimate implements IEstimate {
}); });
// actions // actions
updateEstimatePointSorting = async (payload: IEstimateFormData) => { updateEstimate = async (payload: IEstimateFormData) => {
try { try {
const { workspaceSlug, projectId } = this.store.router; const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id || !payload) return; if (!workspaceSlug || !projectId || !this.id || !payload) return;
@ -159,7 +159,7 @@ export class Estimate implements IEstimate {
} }
}; };
deleteEstimatePoint = async (estimatePointId: string) => { deleteEstimate = async (estimatePointId: string) => {
try { try {
const { workspaceSlug, projectId } = this.store.router; const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !estimatePointId) return; if (!workspaceSlug || !projectId || !estimatePointId) return;

View File

@ -1,4 +1,5 @@
import set from "lodash/set"; import set from "lodash/set";
import sortBy from "lodash/sortBy";
import update from "lodash/update"; import update from "lodash/update";
import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils"; import { computedFn } from "mobx-utils";
@ -253,7 +254,8 @@ export class ProjectEstimateStore implements IProjectEstimateStore {
const estimate = await this.service.createEstimate(workspaceSlug, projectId, payload); const estimate = await this.service.createEstimate(workspaceSlug, projectId, payload);
// FIXME: i am getting different response from the server and once backend changes remove the get request and uncomment the commented code // FIXME: i am getting different response from the server and once backend changes remove the get request and uncomment the commented code
const estimates = await this.getProjectEstimates(workspaceSlug, projectId, "mutation-loader"); let estimates = await this.getProjectEstimates(workspaceSlug, projectId, "mutation-loader");
estimates = sortBy(estimates, "created_at");
if (estimates && estimates.length > 0) if (estimates && estimates.length > 0)
await this.store.projectRoot.project.updateProject(workspaceSlug, projectId, { await this.store.projectRoot.project.updateProject(workspaceSlug, projectId, {
estimate: estimates[estimates.length - 1].id, estimate: estimates[estimates.length - 1].id,