Move code from EE to CE repo

This commit is contained in:
Satish Gandham 2024-05-23 13:41:30 +05:30
parent c26c8cfe19
commit 0af70136a9
37 changed files with 1817 additions and 38 deletions

View File

@ -1,36 +1,40 @@
export interface IEstimate {
created_at: Date;
created_by: string;
description: string;
id: string;
name: string;
project: string;
project_detail: IProject;
updated_at: Date;
updated_by: string;
points: IEstimatePoint[];
workspace: string;
workspace_detail: IWorkspace;
export interface IEstimatePoint {
id: string | undefined;
key: number | undefined;
value: string | undefined;
description: string | undefined;
workspace: string | undefined;
project: string | undefined;
estimate: string | undefined;
created_at: Date | undefined;
updated_at: Date | undefined;
created_by: string | undefined;
updated_by: string | undefined;
}
export interface IEstimatePoint {
created_at: string;
created_by: string;
description: string;
estimate: string;
id: string;
key: number;
project: string;
updated_at: string;
updated_by: string;
value: string;
workspace: string;
export type TEstimateType = "categories" | "points" | "time";
export interface IEstimate {
id: string | undefined;
name: string | undefined;
description: string | undefined;
type: TEstimateType | undefined; // categories, points, time
points: IEstimatePoint[] | undefined;
workspace: string | undefined;
workspace_detail: IWorkspace | undefined;
project: string | undefined;
project_detail: IProject | undefined;
created_at: Date | undefined;
updated_at: Date | undefined;
created_by: string | undefined;
updated_by: string | undefined;
}
export interface IEstimateFormData {
estimate: {
name: string;
description: string;
type: string;
};
estimate_points: {
id?: string;

View File

@ -13,4 +13,5 @@ export * from "./loader";
export * from "./control-link";
export * from "./toast";
export * from "./drag-handle";
export * from "./drop-indicator";
export * from "./typography";
export * from "./drop-indicator";

View File

@ -0,0 +1,36 @@
import type { Meta, StoryObj } from "@storybook/react";
import { fn } from "@storybook/test";
import { RadioInput } from "./radio-input";
const meta: Meta<typeof RadioInput> = {
title: "RadioInput",
component: RadioInput,
};
export default meta;
type Story = StoryObj<typeof RadioInput>;
const options = [
{ label: "Option 1", value: "option1" },
{
label:
"A very very long label, lets add some lipsum text and see what happens? May be we don't have to. This is long enough",
value: "option2",
},
{ label: "Option 3", value: "option3" },
];
export const Default: Story = {
args: {
options,
label: "Horizontal Radio Input",
},
};
export const vertical: Story = {
args: {
options,
label: "Vertical Radio Input",
vertical: true,
},
};

View File

@ -0,0 +1,46 @@
import { Field, Label, Radio, RadioGroup } from "@headlessui/react";
import React from "react";
type RadioInputProps = {
label: string | React.ReactNode | undefined;
ariaLabel?: string;
options: { label: string; value: string; disabled?: boolean }[];
vertical?: boolean;
selected: string;
};
const RadioInput = ({ label: inputLabel, options, vertical, selected, ariaLabel }: RadioInputProps) => {
const wrapperClass = vertical ? "flex flex-col gap-1" : "flex gap-2";
const setSelected = (value: string) => {
console.log(value);
};
let aria = ariaLabel ? ariaLabel.toLowerCase().replace(" ", "-") : "";
if (!aria && typeof inputLabel === "string") {
aria = inputLabel.toLowerCase().replace(" ", "-");
} else {
aria = "radio-input";
}
return (
<RadioGroup value={selected} onChange={setSelected} aria-label={aria}>
<Label className="">{inputLabel}</Label>
<div className={`${wrapperClass}`}>
{options.map(({ value, label }) => (
<Field key={label} className="flex items-center gap-2">
<Radio
value={value}
className="group flex size-5 items-center justify-center rounded-full border bg-white data-[checked]:bg-blue-400"
>
<span className="invisible size-2 rounded-full bg-white group-data-[checked]:visible" />
</Radio>
<Label>{label}</Label>
</Field>
))}
</div>
</RadioGroup>
);
};
export { RadioInput };

View File

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

View File

@ -0,0 +1,15 @@
import React from "react";
import { cn } from "../../helpers";
type Props = {
children: React.ReactNode;
className?: string;
noMargin?: boolean;
};
const SubHeading = ({ children, className, noMargin }: Props) => (
<h3 className={cn("text-xl font-medium text-custom-text-200 block leading-7", !noMargin && "mb-2", className)}>
{children}
</h3>
);
export { SubHeading };

View File

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

View File

@ -0,0 +1,66 @@
import React from "react";
import { Field, Label, Radio, RadioGroup } from "@headlessui/react";
// helpers
import { cn } from "@/helpers/common.helper";
type RadioInputProps = {
label: string | React.ReactNode | undefined;
labelClassName?: string;
ariaLabel?: string;
options: { label: string; value: string; disabled?: boolean }[];
vertical?: boolean;
selected: string;
onChange: (value: string) => void;
className?: string;
};
const RadioInput = ({
label: inputLabel,
labelClassName: inputLabelClassName,
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 <h1>Hello</h1>;
return (
<RadioGroup value={selected} onChange={setSelected} aria-label={aria} className={className}>
<Label className={cn(`mb-2`, inputLabelClassName)}>{inputLabel}</Label>
<div className={`${wrapperClass}`}>
{options.map(({ value, label, disabled }) => (
<Field key={label} className="flex items-center gap-2">
<Radio
value={value}
className="group flex size-5 items-center justify-center rounded-full border border-custom-border-400 bg-custom-background-500 data-[checked]:bg-custom-primary-200 data-[checked]:border-custom-primary-100 cursor-pointer
data-[disabled]:bg-custom-background-200
data-[disabled]:border-custom-border-200
data-[disabled]:cursor-not-allowed"
disabled={disabled}
>
<span className="invisible size-2 rounded-full bg-white group-data-[checked]:visible" />
</Radio>
<Label className="text-base cursor-pointer">{label}</Label>
</Field>
))}
</div>
</RadioGroup>
);
};
export { RadioInput };

View File

@ -0,0 +1,46 @@
import { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { isEqual } from "lodash";
import { cn } from "@/helpers/common.helper";
type Props = {
children: React.ReactNode;
data: any; //@todo make this generic
};
const Draggable = ({ children, data }: Props) => {
const ref = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<boolean>(false); // NEW
const [isDraggedOver, setIsDraggedOver] = useState(false);
useEffect(() => {
const el = ref.current;
if (el) {
combine(
draggable({
element: el,
onDragStart: () => setDragging(true), // NEW
onDrop: () => setDragging(false), // NEW
getInitialData: () => data,
}),
dropTargetForElements({
element: el,
onDragEnter: () => setIsDraggedOver(true),
onDragLeave: () => setIsDraggedOver(false),
onDrop: () => setIsDraggedOver(false),
canDrop: ({ source }) => !isEqual(source.data, data),
getData: () => data,
})
);
}
}, [data]);
return (
<div ref={ref} className={cn(dragging && "opacity-25", isDraggedOver && "bg-red-500")}>
{children}
</div>
);
};
export { Draggable };

View File

@ -0,0 +1,49 @@
import React, { Fragment, useEffect } from "react";
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
type Props<T> = {
data: T[];
render: (item: T, index: number) => React.ReactNode;
onChange: (data: T[]) => void;
keyExtractor: (item: T, index: number) => string;
};
const moveItems = <T,>(data: T[], source: T, destination: T): T[] => {
const sourceIndex = data.indexOf(source);
const destinationIndex = data.indexOf(destination);
if (sourceIndex === -1 || destinationIndex === -1) return data;
const newData = [...data];
newData.splice(sourceIndex, 1);
newData.splice(destinationIndex, 0, source);
return newData;
};
const Sortable = <T,>({ data, render, onChange, keyExtractor }: Props<T>) => {
useEffect(() => {
const unsubscribe = monitorForElements({
onDrop({ source, location }) {
const destination = location?.current?.dropTargets[0];
if (!destination) return;
onChange(moveItems(data, source.data as T, destination.data as T));
},
});
// Clean up the subscription on unmount
return () => {
if (unsubscribe) unsubscribe();
};
}, [data, onChange]);
return (
<>
{data.map((item, index) => (
<Fragment key={keyExtractor(item, index)}>{render(item, index)}</Fragment>
))}
</>
);
};
export { Sortable };

View File

@ -0,0 +1,95 @@
import { TEstimateSystems } from "@/ee/components/estimates/types";
export const ESTIMATE_SYSTEMS: TEstimateSystems = {
points: {
name: "Points",
templates: {
fibonacci: {
title: "Fibonacci",
values: [
{ key: 1, value: 1 },
{ key: 2, value: 2 },
{ key: 3, value: 3 },
{ key: 4, value: 5 },
{ key: 5, value: 8 },
{ key: 6, value: 13 },
{ key: 7, value: 21 },
],
},
linear: {
title: "Linear",
values: [
{ key: 1, value: 1 },
{ key: 2, value: 2 },
{ key: 3, value: 3 },
{ key: 4, value: 4 },
{ key: 5, value: 5 },
{ key: 6, value: 6 },
{ key: 7, value: 7 },
{ key: 8, value: 8 },
{ key: 9, value: 9 },
{ key: 10, value: 10 },
],
},
squares: {
title: "Squares",
values: [
{ key: 1, value: 1 },
{ key: 2, value: 4 },
{ key: 3, value: 9 },
{ key: 4, value: 16 },
{ key: 5, value: 25 },
{ key: 6, value: 36 },
],
},
},
is_available: true,
},
categories: {
name: "Categories",
templates: {
t_shirt_sizes: {
title: "T-Shirt Sizes",
values: [
{ key: 1, value: "XS" },
{ key: 2, value: "S" },
{ key: 3, value: "M" },
{ key: 4, value: "L" },
{ key: 5, value: "XL" },
{ key: 6, value: "XXL" },
],
},
easy_to_hard: {
title: "Easy to hard",
values: [
{ key: 1, value: "Easy" },
{ key: 2, value: "Medium" },
{ key: 3, value: "Hard" },
{ key: 4, value: "Very Hard" },
],
},
},
is_available: true,
},
time: {
name: "Time",
templates: {
hours: {
title: "Hours",
values: [
{ key: 1, value: 1 },
{ key: 2, value: 2 },
{ key: 3, value: 3 },
{ key: 4, value: 4 },
{ key: 5, value: 5 },
{ key: 6, value: 6 },
{ key: 7, value: 7 },
{ key: 8, value: 8 },
{ key: 9, value: 9 },
{ key: 10, value: 10 },
],
},
},
is_available: false,
},
};

View File

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

View File

@ -0,0 +1,101 @@
import { FC, useEffect, useMemo, useState } from "react";
import cloneDeep from "lodash/cloneDeep";
import { observer } from "mobx-react";
import { ChevronLeft } from "lucide-react";
import { IEstimate } from "@plane/types";
import { Button } from "@plane/ui";
// components
import { EModalPosition, EModalWidth, ModalCore } from "@/components/core";
// constants
import { ESTIMATE_SYSTEMS } from "@/constants/estimates";
// ee components
import { EstimateCreateStageOne, EstimateCreateStageTwo } from "@/ee/components/estimates";
// types
import { TEstimateSystemKeys, EEstimateSystem, TEstimateSystemKeyObject } from "@/ee/components/estimates/types";
type Props = {
isOpen: boolean;
handleClose: () => void;
data?: IEstimate;
};
export const CreateEstimateModal: FC<Props> = observer((props) => {
// props
const { handleClose, isOpen } = props;
// states
const [estimateSystem, setEstimateSystem] = useState<TEstimateSystemKeys>(EEstimateSystem.POINTS);
const [estimatePoints, setEstimatePoints] = useState<TEstimateSystemKeyObject[TEstimateSystemKeys] | undefined>(
undefined
);
const handleUpdatePoints = (newPoints: TEstimateSystemKeyObject[TEstimateSystemKeys] | undefined) => {
const points = cloneDeep(newPoints);
setEstimatePoints(points);
};
useEffect(() => {
if (!isOpen) {
setEstimateSystem(EEstimateSystem.POINTS);
setEstimatePoints(undefined);
}
}, [isOpen]);
// derived values
const renderEstimateStepsCount = useMemo(() => (estimatePoints ? "2" : "1"), [estimatePoints]);
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXL}>
<div className="relative space-y-6 py-5">
{/* heading */}
<div className="relative flex justify-between items-center gap-2 px-5">
<div className="relative flex items-center gap-1">
{estimatePoints && (
<div
onClick={() => {
setEstimateSystem(EEstimateSystem.POINTS);
handleUpdatePoints(undefined);
}}
className="flex-shrink-0 cursor-pointer w-5 h-5 flex justify-center items-center"
>
<ChevronLeft className="w-4 h-4" />
</div>
)}
<div className="text-xl font-medium text-custom-text-200 ">New Estimate System</div>
</div>
<div className="text-xs text-gray-400">Step {renderEstimateStepsCount}/2</div>
</div>
{/* estimate steps */}
<div className="px-5">
{!estimatePoints && (
<EstimateCreateStageOne
estimateSystem={estimateSystem}
handleEstimateSystem={setEstimateSystem}
handleEstimatePoints={(templateType) =>
handleUpdatePoints(ESTIMATE_SYSTEMS[estimateSystem].templates[templateType].values)
}
/>
)}
{estimatePoints && (
<EstimateCreateStageTwo
estimateSystem={estimateSystem}
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>
{estimatePoints && (
<Button variant="primary" size="sm" onClick={handleClose}>
Create Estimate
</Button>
)}
</div>
</div>
</ModalCore>
);
});

View File

@ -0,0 +1,59 @@
import { FC } from "react";
// components
import { RadioInput } from "@/components/radio-group";
// constants
import { ESTIMATE_SYSTEMS } from "@/constants/estimates";
// types
import { TEstimateSystemKeys } from "@/ee/components/estimates/types";
type TEstimateCreateStageOne = {
estimateSystem: TEstimateSystemKeys;
handleEstimateSystem: (value: TEstimateSystemKeys) => void;
handleEstimatePoints: (value: TEstimateSystemKeys) => void;
};
export const EstimateCreateStageOne: FC<TEstimateCreateStageOne> = (props) => {
const { estimateSystem, handleEstimateSystem, handleEstimatePoints } = props;
const currentEstimateSystem = ESTIMATE_SYSTEMS[estimateSystem] || undefined;
if (!currentEstimateSystem) return <></>;
return (
<div className="space-y-2">
<div className="space-y-4 sm:flex sm:items-center sm:space-x-10 sm:space-y-0 gap-2 mb-2">
<RadioInput
options={Object.keys(ESTIMATE_SYSTEMS).map((system) => {
const currentSystem = system as TEstimateSystemKeys;
return {
label: ESTIMATE_SYSTEMS[currentSystem]?.name,
value: system,
disabled: !ESTIMATE_SYSTEMS[currentSystem]?.is_available,
};
})}
label="Choose an estimate system"
labelClassName="text-sm font-medium text-custom-text-200 mb-3"
selected={estimateSystem}
onChange={(value) => handleEstimateSystem(value as TEstimateSystemKeys)}
className="mb-4"
/>
</div>
<div className="space-y-3">
<div className="text-sm font-medium text-custom-text-200 mb-3">Choose a template</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{Object.keys(currentEstimateSystem.templates).map((name) => (
<button
key={name}
className="border border-custom-border-200 rounded-md p-2 text-left"
onClick={() => handleEstimatePoints(name as TEstimateSystemKeys)}
>
<p className="block text-sm">{currentEstimateSystem.templates[name]?.title}</p>
<p className="text-xs text-gray-400">
{currentEstimateSystem.templates[name]?.values?.map((template) => template?.value)?.join(", ")}
</p>
</button>
))}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,80 @@
import { FC } from "react";
import { Plus } from "lucide-react";
import { Button } from "@plane/ui";
// components
import { Sortable } from "@/components/sortable/sortable";
// constants
import { ESTIMATE_SYSTEMS } from "@/constants/estimates";
import { EstimateItem } from "@/ee/components/estimates";
// types
import {
EEstimateSystem,
TEstimatePointNumeric,
TEstimatePointString,
TEstimateSystemKeyObject,
TEstimateSystemKeys,
} from "@/ee/components/estimates/types";
type TEstimateCreateStageTwo = {
estimateSystem: EEstimateSystem;
estimatePoints: TEstimateSystemKeyObject[TEstimateSystemKeys];
handleEstimatePoints: (value: TEstimateSystemKeyObject[TEstimateSystemKeys]) => void;
};
export const EstimateCreateStageTwo: FC<TEstimateCreateStageTwo> = (props) => {
const { estimateSystem, estimatePoints, handleEstimatePoints } = props;
const currentEstimateSystem = ESTIMATE_SYSTEMS[estimateSystem] || undefined;
const addNewEstimationPoint = () => {
const currentEstimationPoints = estimatePoints;
if ([EEstimateSystem.POINTS, EEstimateSystem.TIME].includes(estimateSystem)) {
const newEstimationPoint: TEstimatePointNumeric = {
key: currentEstimationPoints.length + 1,
value: 0,
};
handleEstimatePoints([...currentEstimationPoints, newEstimationPoint] as TEstimatePointNumeric[]);
}
if (estimateSystem === EEstimateSystem.CATEGORIES) {
const newEstimationPoint: TEstimatePointString = {
key: currentEstimationPoints.length + 1,
value: "",
};
handleEstimatePoints([...currentEstimationPoints, newEstimationPoint] as TEstimatePointString[]);
}
};
const deleteEstimationPoint = (index: number) => {
const newEstimationPoints = estimatePoints;
newEstimationPoints.splice(index, 1);
handleEstimatePoints(newEstimationPoints);
};
const updatedSortedKeys = (updatedEstimatePoints: TEstimateSystemKeyObject[TEstimateSystemKeys]) =>
updatedEstimatePoints.map((item, index) => ({
...item,
key: index + 1,
})) as TEstimateSystemKeyObject[TEstimateSystemKeys];
return (
<div className="space-y-4">
<Sortable
data={estimatePoints as any}
render={(value: TEstimatePointString | TEstimatePointNumeric, index: number) => (
<EstimateItem item={value} deleteItem={() => deleteEstimationPoint(index)} />
)}
onChange={(data: TEstimateSystemKeyObject[TEstimateSystemKeys]) => {
console.log("updatedSortedKeys(data)", updatedSortedKeys(data));
handleEstimatePoints(updatedSortedKeys(data));
}}
keyExtractor={(item: TEstimatePointString | TEstimatePointNumeric, index: number) =>
item?.id?.toString() || item.value.toString()
}
/>
<Button prependIcon={<Plus />} onClick={addNewEstimationPoint}>
Add {currentEstimateSystem?.name}
</Button>
</div>
);
};

View File

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

View File

@ -0,0 +1,24 @@
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

@ -0,0 +1,18 @@
import { FC } from "react";
import { observer } from "mobx-react";
type TEstimateDisable = {
workspaceSlug: string;
projectId: string;
};
export const EstimateDisable: FC<TEstimateDisable> = observer((props) => {
const { workspaceSlug, projectId } = props;
// hooks
return (
<div>
Estimate Disable {workspaceSlug} {projectId}
</div>
);
});

View File

@ -0,0 +1,90 @@
import { Fragment, useRef, useState } from "react";
import { Check, GripVertical, MoveRight, Pencil, Trash2, X } from "lucide-react";
import { Select } from "@headlessui/react";
import { Draggable } from "@/components/sortable/draggable";
import { InlineEdit } from "./inline-editable";
import { TEstimatePointNumeric, TEstimatePointString } from "./types";
type Props = {
item: TEstimatePointNumeric | TEstimatePointString;
deleteItem: () => void;
};
const EstimateItem = ({ item, deleteItem }: Props) => {
const { value, id } = item;
const inputRef = useRef<HTMLInputElement>(null);
const [showDeleteUI, setShowDeleteUI] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const handleDelete = () => {
if (id) {
setShowDeleteUI(true);
} else {
deleteItem();
}
};
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 (
<Draggable data={item}>
{isEditing && (
<div className="flex justify-between items-center gap-4 mb-2">
<input
type="text"
value={value}
onChange={() => {}}
className="border rounded-md border-custom-border-300 p-3 flex-grow"
ref={inputRef}
/>
<div>
<div className="flex gap-4 justify-between items-center">
<Check className="w-6 h-6" onClick={handleSave} />
<X className="w-6 h-6" onClick={() => setIsEditing(false)} />
</div>
</div>
</div>
)}
{!isEditing && (
<div className="border rounded-md border-custom-border-300 mb-2 p-3 flex justify-between items-center">
<div className="flex items-center">
<GripVertical className="w-4 h-4" />
{!showDeleteUI ? <InlineEdit value={value} /> : value}
{showDeleteUI && (
<Fragment>
<MoveRight className="w-4 h-4 mx-2" />
<Select>
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="delayed">Delayed</option>
<option value="canceled">Canceled</option>
</Select>
<Check className="w-4 h-4 rounded-md" />
<X className="w-4 h-4 rounded-md" onClick={() => setShowDeleteUI(false)} />
</Fragment>
)}
</div>
<div className="flex gap-4 items-center">
<Pencil className="w-4 h-4" onClick={handleEdit} />
{!showDeleteUI && <Trash2 className="w-4 h-4" onClick={handleDelete} />}
</div>
</div>
)}
</Draggable>
);
};
export { EstimateItem };

View File

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

View File

@ -0,0 +1,11 @@
export * from "./root";
export * from "./estimate-search";
export * from "./estimate-disable";
export * from "./estimate-item";
// estimate create
export * from "./create";
// estimate update
// estimate delete

View File

@ -0,0 +1,66 @@
import { HTMLInputTypeAttribute, useEffect, useRef, useState } from "react";
import { Check, X } from "lucide-react";
type Props = {
onSave?: (value: string | number) => void;
value: string | number;
inputType?: HTMLInputTypeAttribute;
isEditing?: boolean;
};
const InlineEdit = ({
onSave,
value: defaultValue,
inputType = "text",
isEditing: defaultIsEditing = false,
}: Props) => {
const [isEditing, setIsEditing] = useState(defaultIsEditing);
const [value, setValue] = useState(defaultValue);
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Add listener to double click on the div
useEffect(() => {
wrapperRef?.current?.addEventListener("dblclick", () => {
setIsEditing(true);
setTimeout(() => {
inputRef?.current?.select();
});
});
}, []);
const handleSave = () => {
if (value) {
typeof value === "string" && value.trim();
onSave && onSave(value);
setIsEditing(false);
}
};
return (
<div ref={wrapperRef}>
{isEditing ? (
<div className="flex justify-start items-center gap-2">
<input
ref={inputRef}
type={inputType}
value={value}
onChange={(e) => setValue(e.target.value)}
className="flex flex-grow border-custom-border-300 border rounded-sm"
/>
<Check onClick={handleSave} className="w-6 h-6 bg-custom-primary-100 rounded-sm" />
<X
onClick={() => {
setValue(defaultValue);
setIsEditing(false);
}}
className="w-6 h-6 bg-custom-background-80 rounded-sm"
/>
</div>
) : (
value
)}
</div>
);
};
export { InlineEdit };

View File

@ -0,0 +1,145 @@
import { FC, useState } from "react";
import useSWR from "swr";
import { IEstimate } from "@plane/types";
import { Button, Loader, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { EmptyState } from "@/components/empty-state";
import { DeleteEstimateModal, EstimateListItem } from "@/components/estimates";
// constants
import { EmptyStateType } from "@/constants/empty-state";
// ee components
import { CreateEstimateModal } from "@/ee/components/estimates";
// hooks
import { useProject, useProjectEstimates } from "@/hooks/store";
type TEstimateRoot = {
workspaceSlug: string;
projectId: string;
};
export const EstimateRoot: FC<TEstimateRoot> = (props) => {
const { workspaceSlug, projectId } = props;
// hooks
const { updateProject, currentProjectDetails } = useProject();
const { loader, projectEstimateIds, estimateById, getAllEstimates } = useProjectEstimates(projectId);
// states
const [isEstimateCreateModalOpen, setIsEstimateCreateModalOpen] = useState(true);
const [isEstimateDeleteModalOpen, setIsEstimateDeleteModalOpen] = useState<string | null>(null);
const [estimateToUpdate, setEstimateToUpdate] = useState<IEstimate | undefined>();
const { isLoading: isSWRLoading } = useSWR(
workspaceSlug && projectId ? `PROJECT_ESTIMATES_${workspaceSlug}_${projectId}` : null,
workspaceSlug && projectId ? () => getAllEstimates() : null
);
const editEstimate = (estimate: IEstimate) => {
setIsEstimateCreateModalOpen(true);
console.log("estimate", estimate);
// Order the points array by key before updating the estimate to update state
// setEstimateToUpdate({
// ...estimate,
// points: orderArrayBy(estimate.points, "key"),
// });
};
const disableEstimates = () => {
if (!workspaceSlug || !projectId) return;
updateProject(workspaceSlug.toString(), projectId.toString(), { estimate: null }).catch((err) => {
const error = err?.error;
const errorString = Array.isArray(error) ? error[0] : error;
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: errorString ?? "Estimate could not be disabled. Please try again",
});
});
};
if (!workspaceSlug || !projectId) return <></>;
return (
<div>
{/* modals */}
{/* create modal */}
<CreateEstimateModal
isOpen={isEstimateCreateModalOpen}
data={estimateToUpdate}
handleClose={() => {
setIsEstimateCreateModalOpen(false);
setEstimateToUpdate(undefined);
}}
/>
{/* edit modal */}
{/* delete modal */}
<DeleteEstimateModal
isOpen={!!isEstimateDeleteModalOpen}
handleClose={() => setIsEstimateDeleteModalOpen(null)}
data={
null
// getProjectEstimateById(isEstimateDeleteModalOpen!)
}
/>
{/* handling the estimates list */}
{loader === "init-loader" || isSWRLoading ? (
<Loader className="mt-5 space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
) : (
<>
{/* header section */}
<section className="flex items-center justify-between border-b border-custom-border-100 py-3.5">
<h3 className="text-xl font-medium">Estimates</h3>
<div className="col-span-12 space-y-5 sm:col-span-7">
<div className="flex items-center gap-2">
<Button
variant="primary"
onClick={() => {
setIsEstimateCreateModalOpen(true);
setEstimateToUpdate(undefined);
}}
size="sm"
>
Add Estimate
</Button>
{currentProjectDetails?.estimate && (
<Button variant="neutral-primary" onClick={disableEstimates} size="sm">
Disable Estimates
</Button>
)}
</div>
</div>
</section>
{/* listing of estimates */}
{!projectEstimateIds || (projectEstimateIds && projectEstimateIds.length <= 0) ? (
<div className="h-full w-full py-8">
<EmptyState type={EmptyStateType.PROJECT_SETTINGS_ESTIMATE} />
</div>
) : (
<section className="h-full overflow-y-auto bg-custom-background-100">
{projectEstimateIds &&
projectEstimateIds.map((estimateId: string) => {
const estimate = estimateById(estimateId);
if (!estimate) return <></>;
return (
<EstimateListItem
key={estimateId}
estimate={estimate}
editEstimate={(estimate) => editEstimate(estimate)}
deleteEstimate={(estimateId) => setIsEstimateDeleteModalOpen(estimateId)}
/>
);
})}
</section>
)}
</>
)}
</div>
);
};

View File

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

View File

@ -0,0 +1,207 @@
import React, { Fragment, useState } from "react";
import { observer } from "mobx-react";
// types
import { ChevronLeft, Plus } from "lucide-react";
import { IEstimate, IEstimateFormData } from "@plane/types";
// ui
import { SubHeading, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { EModalPosition, EModalWidth, ModalCore } from "@/components/core";
import { RadioInput } from "@/components/radio-group";
import { Sortable } from "@/components/sortable/sortable";
import { EstimateItem } from "./estimate-item";
import { useEstimate } from "@/hooks/store";
import { useRouter } from "next/router";
// helpers
// hooks
type Props = {
isOpen: boolean;
handleClose: () => void;
data?: IEstimate;
};
const ESTIMATE_SYSTEMS = {
Points: {
name: "Points",
templates: {
Fibonacci: [1, 2, 3, 5, 8, 13, 21],
Linear: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
Squares: [1, 4, 9, 16, 25, 36],
},
},
Categories: {
name: "Categories",
templates: {
"T-Shirt Sizes": ["XS", "S", "M", "L", "XL", "XXL"],
"Easy to hard": ["Easy", "Medium", "Hard", "Very Hard"],
},
},
Time: {
name: "Time",
templates: { Hours: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] },
},
};
type EstimatePoint = {
id?: string;
key: number;
value: string;
};
export const UpdateEstimateModal: React.FC<Props> = observer((props) => {
const { handleClose, isOpen, data } = props;
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { createEstimate, updateEstimate } = useEstimate();
console.log({ data });
const [estimateSystem, setEstimateSystem] = useState("Points");
const [points, setPoints] = useState<EstimatePoint[] | null>(data.points);
const currentEstimateSystem = ESTIMATE_SYSTEMS[estimateSystem];
const deleteItem = (index: number) => {
points.splice(index, 1);
setPoints([...points]);
};
const saveEstimate = async () => {
if (!workspaceSlug || !projectId || !data) return;
console.log({ points });
const payload: IEstimateFormData = {
estimate_points: points?.map((point, index) => {
point.key = index;
return point;
}),
estimate: {
name: data.name,
description: data.description,
},
};
console.log({ payload });
await updateEstimate(workspaceSlug.toString(), projectId.toString(), data.id, payload)
.then(() => {
// onClose();
})
.catch((err) => {
const error = err?.error;
const errorString = Array.isArray(error) ? error[0] : error;
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: errorString ?? "Estimate could not be updated. Please try again.",
});
});
};
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXL}>
<div className="p-5">
{!points && (
<Fragment>
<div className="flex justify-between items-center mb-6">
<div className="flex justify-start align-middle items-center ">
<SubHeading noMargin>New Estimate System</SubHeading>
</div>
<div>
<span className="text-xs text-gray-400">Step 2/2</span>
</div>
</div>
<form onSubmit={() => console.log("Submitted")}>
<div className="space-y-4 sm:flex sm:items-center sm:space-x-10 sm:space-y-0 gap-2 mb-2">
<RadioInput
options={Object.keys(ESTIMATE_SYSTEMS).map((name) => ({ label: name, value: name }))}
label="Choose an estimate system"
selected={estimateSystem}
onChange={(value) => setEstimateSystem(value)}
className="mb-4"
/>
</div>
<SubHeading>Choose a template</SubHeading>
<div className="flex flex-wrap gap-5 grid sm:grid-cols-2 mb-8">
{Object.keys(currentEstimateSystem.templates).map((name) => (
<button
key={name}
className=" border border-custom-border-200 rounded-md p-2 text-left"
onClick={() =>
setPoints(
currentEstimateSystem.templates[name].map((value: number | string, key: number) => ({
value,
key,
}))
)
}
>
<p className="block text-sm">{name}</p>
<p className="text-xs text-gray-400">{currentEstimateSystem.templates[name].join(", ")}</p>
</button>
))}
</div>
{/* Add modal footer */}
</form>
</Fragment>
)}
{points && (
<div>
<div className="flex justify-between items-center mb-6">
<div className="flex justify-start items-center">
<button onClick={() => setPoints(null)}>
<ChevronLeft className="w-6 h-6 mr-1" />
</button>
<SubHeading noMargin>New Estimate System</SubHeading>
</div>
<div>
<span className="text-xs text-gray-400">Step 2/2</span>
</div>
</div>
<Sortable
data={points}
render={(value: string, index: number) => (
<EstimateItem item={value} deleteItem={() => deleteItem(index)} />
)}
onChange={(data: number[]) => setPoints(data)}
keyExtractor={(value: number) => value}
/>
<button
className=" bg-custom-primary text-white rounded-md px-3 py-1 flex items-center gap-1"
onClick={() => {
setPoints([...points, ""]);
}}
>
<Plus className="w-4 h-4" />
<span className="text-sm">Add {estimateSystem}</span>
</button>
</div>
)}
<div className="flex justify-end mt-5 border-t -m-5 px-5 py-3 ">
<button
type="button"
onClick={handleClose}
className="inline-flex justify-center px-4 py-1 text-sm font-medium text-white bg-custom-primary border border-transparent rounded-md shadow-sm hover:bg-custom-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-custom-primary-dark"
>
Cancel
</button>
{points && (
<button
type="submit"
className="inline-flex justify-center px-4 py-1 ml-3 text-sm font-medium text-white bg-custom-primary border border-transparent rounded-md shadow-sm hover:bg-custom-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-custom-primary-dark"
onClick={saveEstimate}
>
Create Estimate
</button>
)}
</div>
</div>
</ModalCore>
);
});

View File

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

View File

@ -0,0 +1,54 @@
import { FC, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { IEstimate } from "@plane/types";
import { Button } from "@plane/ui";
// components
import { EModalPosition, EModalWidth, ModalCore } from "@/components/core";
// types
import { TEstimateSystemKeys, TEstimateSystemKeyObject } from "@/ee/components/estimates/types";
type Props = {
isOpen: boolean;
handleClose: () => void;
data?: IEstimate;
};
export const UpdateEstimateModal: FC<Props> = observer((props) => {
// props
const { handleClose, isOpen } = props;
// states
const [estimatePoints, setEstimatePoints] = useState<TEstimateSystemKeyObject[TEstimateSystemKeys] | undefined>(
undefined
);
useEffect(() => {
if (!isOpen) {
setEstimatePoints(undefined);
}
}, [isOpen]);
// derived values
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXL}>
<div className="relative space-y-6 py-5">
{/* heading */}
<div className="relative flex justify-between items-center gap-2 px-5">Heading</div>
{/* estimate steps */}
<div className="px-5">Content</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>
{estimatePoints && (
<Button variant="primary" size="sm" onClick={handleClose}>
Create Estimate
</Button>
)}
</div>
</div>
</ModalCore>
);
});

View File

@ -0,0 +1,3 @@
export * from "./use-project-estimate";
export * from "./use-estimate";
export * from "./use-estimate-point";

View File

@ -0,0 +1,16 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// mobx store
import { IEstimatePoint } from "@/store/estimates/estimate-point";
export const useEstimatePoint = (
estimateId: string | undefined,
estimatePointId: string | undefined
): IEstimatePoint => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useEstimatePoint must be used within StoreProvider");
if (!estimateId || !estimatePointId) return {} as IEstimatePoint;
return context.projectEstimate.estimates?.[estimateId]?.estimatePoints?.[estimatePointId] || {};
};

View File

@ -0,0 +1,13 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// mobx store
import { IEstimate } from "@/store/estimates/estimate";
export const useEstimate = (estimateId: string | undefined): IEstimate => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useEstimate must be used within StoreProvider");
if (!estimateId) return {} as IEstimate;
return context.projectEstimate.estimates?.[estimateId] ?? {};
};

View File

@ -0,0 +1,14 @@
import { useContext } from "react";
// context
import { StoreContext } from "@/lib/store-context";
// mobx store
import { IProjectEstimateStore } from "@/store/estimates/project-estimate.store";
export const useProjectEstimates = (projectId: string | undefined): IProjectEstimateStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useProjectPage must be used within StoreProvider");
if (!projectId) throw new Error("projectId must be passed as a property");
return context.projectEstimate;
};

View File

@ -3,7 +3,6 @@ export * from "./use-cycle-filter";
export * from "./use-cycle";
export * from "./use-event-tracker";
export * from "./use-dashboard";
export * from "./use-estimate";
export * from "./use-global-view";
export * from "./use-label";
export * from "./use-member";
@ -32,3 +31,4 @@ export * from "./use-instance";
export * from "./use-app-theme";
export * from "./use-command-palette";
export * from "./use-app-router";
export * from "./estimates";

View File

@ -1,11 +0,0 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import { IEstimateStore } from "@/store/estimate.store";
export const useEstimate = (): IEstimateStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useEstimate must be used within StoreProvider");
return context.estimate;
};

View File

@ -0,0 +1,117 @@
import { action, computed, makeObservable, observable } from "mobx";
import { IEstimatePoint as IEstimatePointType } from "@plane/types";
// services
import { EstimateService } from "@/services/project/estimate.service";
// store
import { RootStore } from "@/store/root.store";
type TErrorCodes = {
status: string;
message?: string;
};
export interface IEstimatePoint extends IEstimatePointType {
// observables
error: TErrorCodes | undefined;
// computed
asJson: IEstimatePointType;
// actions
updateEstimatePoint: () => Promise<void>;
deleteEstimatePoint: () => Promise<void>;
}
export class EstimatePoint implements IEstimatePoint {
// data model observables
id: string | undefined = undefined;
key: number | undefined = undefined;
value: string | undefined = undefined;
description: string | undefined = undefined;
workspace: string | undefined = undefined;
project: string | undefined = undefined;
estimate: string | undefined = undefined;
created_at: Date | undefined = undefined;
updated_at: Date | undefined = undefined;
created_by: string | undefined = undefined;
updated_by: string | undefined = undefined;
// observables
error: TErrorCodes | undefined = undefined;
// service
service: EstimateService;
constructor(
private store: RootStore,
private data: IEstimatePointType
) {
makeObservable(this, {
// data model observables
id: observable.ref,
key: observable.ref,
value: observable.ref,
description: observable.ref,
workspace: observable.ref,
project: observable.ref,
estimate: observable.ref,
created_at: observable.ref,
updated_at: observable.ref,
created_by: observable.ref,
updated_by: observable.ref,
// observables
error: observable.ref,
// computed
asJson: computed,
// actions
updateEstimatePoint: action,
deleteEstimatePoint: action,
});
this.id = this.data.id;
this.key = this.data.key;
this.value = this.data.value;
this.description = this.data.description;
this.workspace = this.data.workspace;
this.project = this.data.project;
this.estimate = this.data.estimate;
this.created_at = this.data.created_at;
this.updated_at = this.data.updated_at;
this.created_by = this.data.created_by;
this.updated_by = this.data.updated_by;
// service
this.service = new EstimateService();
}
// computed
get asJson() {
return {
id: this.id,
key: this.key,
value: this.value,
description: this.description,
workspace: this.workspace,
project: this.project,
estimate: this.estimate,
created_at: this.created_at,
updated_at: this.updated_at,
created_by: this.created_by,
updated_by: this.updated_by,
};
}
// actions
updateEstimatePoint = async () => {
try {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
} catch (error) {
throw error;
}
};
deleteEstimatePoint = async () => {
try {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return;
} catch (error) {
throw error;
}
};
}

View File

@ -0,0 +1,166 @@
import set from "lodash/set";
import { action, computed, makeObservable, observable } from "mobx";
import { computedFn } from "mobx-utils";
import {
IEstimate as IEstimateType,
IEstimatePoint as IEstimatePointType,
IProject,
IWorkspace,
TEstimateType,
} from "@plane/types";
// services
import { EstimateService } from "@/services/project/estimate.service";
// store
import { IEstimatePoint, EstimatePoint } from "@/store/estimates/estimate-point";
import { RootStore } from "@/store/root.store";
type TErrorCodes = {
status: string;
message?: string;
};
export interface IEstimate extends IEstimateType {
// observables
error: TErrorCodes | undefined;
estimatePoints: Record<string, IEstimatePoint>;
// computed
asJson: IEstimateType;
EstimatePointIds: string[] | undefined;
estimatePointById: (estimateId: string) => IEstimatePointType | undefined;
// helper actions
// actions
updateEstimate: (data: Partial<IEstimateType>) => Promise<IEstimateType | undefined>;
deleteEstimatePoint: (data: Partial<IEstimateType>) => Promise<void>;
}
export class Estimate implements IEstimate {
// data model observables
id: string | undefined = undefined;
name: string | undefined = undefined;
description: string | undefined = undefined;
type: TEstimateType | undefined = undefined;
points: IEstimatePointType[] | undefined = undefined;
workspace: string | undefined = undefined;
workspace_detail: IWorkspace | undefined = undefined;
project: string | undefined = undefined;
project_detail: IProject | undefined = undefined;
created_at: Date | undefined = undefined;
updated_at: Date | undefined = undefined;
created_by: string | undefined = undefined;
updated_by: string | undefined = undefined;
// observables
error: TErrorCodes | undefined = undefined;
estimatePoints: Record<string, IEstimatePoint> = {};
// service
service: EstimateService;
constructor(
private store: RootStore,
private data: IEstimateType
) {
makeObservable(this, {
// data model observables
id: observable.ref,
name: observable.ref,
description: observable.ref,
type: observable.ref,
points: observable,
workspace: observable.ref,
workspace_detail: observable,
project: observable.ref,
project_detail: observable,
created_at: observable.ref,
updated_at: observable.ref,
created_by: observable.ref,
updated_by: observable.ref,
// observables
error: observable.ref,
estimatePoints: observable,
// computed
asJson: computed,
EstimatePointIds: computed,
// actions
updateEstimate: action,
deleteEstimatePoint: action,
});
this.id = this.data.id;
this.name = this.data.name;
this.description = this.data.description;
this.type = this.data.type;
this.points = this.data.points;
this.workspace = this.data.workspace;
this.workspace_detail = this.data.workspace_detail;
this.project = this.data.project;
this.project_detail = this.data.project_detail;
this.created_at = this.data.created_at;
this.updated_at = this.data.updated_at;
this.created_by = this.data.created_by;
this.updated_by = this.data.updated_by;
this.data.points?.forEach((estimationPoint) => {
if (estimationPoint.id)
set(this.estimatePoints, [estimationPoint.id], new EstimatePoint(this.store, estimationPoint));
});
// service
this.service = new EstimateService();
}
// computed
get asJson() {
return {
id: this.id,
name: this.name,
description: this.description,
type: this.type,
points: this.points,
workspace: this.workspace,
workspace_detail: this.workspace_detail,
project: this.project,
project_detail: this.project_detail,
created_at: this.created_at,
updated_at: this.updated_at,
created_by: this.created_by,
updated_by: this.updated_by,
};
}
get EstimatePointIds() {
const { estimatePoints } = this;
if (!estimatePoints) return undefined;
const estimatePointIds = Object.values(estimatePoints)
.map((point) => point.estimate && this.id)
.filter((id) => id) as string[];
return estimatePointIds ?? undefined;
}
estimatePointById = computedFn((estimatePointId: string) => {
if (!estimatePointId) return undefined;
return this.estimatePoints[estimatePointId] ?? undefined;
});
// actions
updateEstimate = async (estimatePoint: Partial<IEstimateType>) => {
try {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
Object.entries(estimatePoint ?? {}).forEach(([key, value]) => {
if (!key || !value) return;
set(this, key, value);
});
} catch (error) {
throw error;
}
};
deleteEstimatePoint = async () => {
try {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return;
} catch (error) {
throw error;
}
};
}

View File

@ -0,0 +1,200 @@
import set from "lodash/set";
import unset from "lodash/unset";
import update from "lodash/update";
import { action, computed, makeObservable, observable } from "mobx";
import { computedFn } from "mobx-utils";
import { IEstimate as IEstimateType } from "@plane/types";
// services
import { EstimateService } from "@/services/project/estimate.service";
// store
import { IEstimate, Estimate } from "@/store/estimates/estimate";
import { RootStore } from "@/store/root.store";
type TEstimateLoader = "init-loader" | "mutation-loader" | undefined;
type TErrorCodes = {
status: string;
message?: string;
};
export interface IProjectEstimateStore {
// observables
loader: TEstimateLoader;
estimates: Record<string, IEstimate>;
error: TErrorCodes | undefined;
// computed
projectEstimateIds: string[] | undefined;
estimateById: (estimateId: string) => IEstimate | undefined;
// actions
getAllEstimates: () => Promise<IEstimateType[] | undefined>;
getEstimateById: (estimateId: string) => Promise<IEstimateType | undefined>;
createEstimate: (data: Partial<IEstimateType>) => Promise<IEstimateType | undefined>;
deleteEstimate: (estimateId: string) => Promise<void>;
}
export class ProjectEstimateStore implements IProjectEstimateStore {
// observables
loader: TEstimateLoader = undefined;
estimates: Record<string, IEstimate> = {}; // estimate_id -> estimate
error: TErrorCodes | undefined = undefined;
// service
service: EstimateService;
constructor(private store: RootStore) {
makeObservable(this, {
// observables
loader: observable.ref,
estimates: observable,
error: observable,
// computed
projectEstimateIds: computed,
// actions
getAllEstimates: action,
getEstimateById: action,
createEstimate: action,
deleteEstimate: action,
});
// service
this.service = new EstimateService();
}
// computed
get projectEstimateIds() {
const { projectId } = this.store.router;
if (!projectId) return undefined;
const projectEstimatesIds = Object.values(this.estimates || {})
.filter((p) => p.project === projectId)
.map((p) => p.id && p != undefined) as string[];
return projectEstimatesIds ?? undefined;
}
estimateById = computedFn((estimateId: string) => {
if (!estimateId) return undefined;
return this.estimates[estimateId] ?? undefined;
});
// actions
/**
* @description fetch all estimates for a project
* @returns { IEstimateType[] | undefined }
*/
async getWorkspaceAllEstimates(): Promise<IEstimateType[] | undefined> {
try {
const { workspaceSlug } = this.store.router;
if (!workspaceSlug) return;
this.error = undefined;
const estimates = await this.service.fetchWorkspacesList(workspaceSlug);
if (estimates && estimates.length > 0)
estimates.forEach((estimate) => {
if (estimate.id) set(this.estimates, [estimate.id], new Estimate(this.store, estimate));
});
return estimates;
} catch (error) {
this.error = {
status: "error",
message: "Error fetching estimates",
};
}
}
async getAllEstimates(): Promise<IEstimateType[] | undefined> {
try {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId) return;
this.error = undefined;
const estimates = await this.service.fetchAll(workspaceSlug, projectId);
if (estimates && estimates.length > 0)
estimates.forEach((estimate) => {
if (estimate.id) set(this.estimates, [estimate.id], new Estimate(this.store, estimate));
});
return estimates;
} catch (error) {
this.error = {
status: "error",
message: "Error fetching estimates",
};
}
}
/**
* @description update an estimate for a project
* @param { string } estimateId
* @returns IEstimateType | undefined
*/
async getEstimateById(estimateId: string): Promise<IEstimateType | undefined> {
try {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId) return;
this.error = undefined;
const estimate = await this.service.fetchById(workspaceSlug, projectId, estimateId);
if (estimate && estimate.id)
update(this.estimates, [estimate.id], (estimateStore) => {
if (estimateStore) estimateStore.updateEstimate(estimate);
else return new Estimate(this.store, estimate);
});
return estimate;
} catch (error) {
this.error = {
status: "error",
message: "Error fetching estimate by id",
};
}
}
/**
* @description create an estimate for a project
* @param { Partial<IEstimateType> } data
* @returns
*/
async createEstimate(data: Partial<IEstimateType>): Promise<IEstimateType | undefined> {
try {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId) return;
this.error = undefined;
const estimate = await this.service.create(workspaceSlug, projectId, data);
if (estimate && estimate.id) set(this.estimates, [estimate.id], new Estimate(this.store, estimate));
return estimate;
} catch (error) {
console.error("Error creating estimate");
this.error = {
status: "error",
message: "Error creating estimate",
};
}
}
/**
* @description delete an estimate for a project
* @param { string } estimateId
* @returns void
*/
async deleteEstimate(estimateId: string): Promise<void> {
try {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId) return;
this.error = undefined;
await this.service.remove(workspaceSlug, projectId, estimateId);
unset(this.estimates, [estimateId]);
} catch (error) {
console.error("Error deleting estimate");
this.error = {
status: "error",
message: "Error deleting estimate",
};
}
}
}

View File

@ -5,6 +5,7 @@ import { CycleStore, ICycleStore } from "./cycle.store";
import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store";
import { DashboardStore, IDashboardStore } from "./dashboard.store";
import { EstimateStore, IEstimateStore } from "./estimate.store";
import { IProjectEstimateStore, ProjectEstimateStore } from "./estimates/project-estimate.store";
import { EventTrackerStore, IEventTrackerStore } from "./event-tracker.store";
import { GlobalViewStore, IGlobalViewStore } from "./global-view.store";
import { IProjectInboxStore, ProjectInboxStore } from "./inbox/project-inbox.store";
@ -48,6 +49,7 @@ export class RootStore {
instance: IInstanceStore;
user: IUserStore;
projectInbox: IProjectInboxStore;
projectEstimate: IProjectEstimateStore;
constructor() {
this.router = new RouterStore();
@ -74,6 +76,7 @@ export class RootStore {
this.projectInbox = new ProjectInboxStore(this);
this.projectPages = new ProjectPageStore(this);
this.theme = new ThemeStore(this);
this.projectEstimate = new ProjectEstimateStore(this);
}
resetOnSignOut() {