mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Move code from EE to CE repo
This commit is contained in:
parent
c26c8cfe19
commit
0af70136a9
54
packages/types/src/estimate.d.ts
vendored
54
packages/types/src/estimate.d.ts
vendored
@ -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;
|
||||
|
@ -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";
|
||||
|
36
packages/ui/src/radio-input/radio-input.stories.tsx
Normal file
36
packages/ui/src/radio-input/radio-input.stories.tsx
Normal 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,
|
||||
},
|
||||
};
|
46
packages/ui/src/radio-input/radio-input.tsx
Normal file
46
packages/ui/src/radio-input/radio-input.tsx
Normal 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 };
|
1
packages/ui/src/typography/index.tsx
Normal file
1
packages/ui/src/typography/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export * from "./sub-heading";
|
15
packages/ui/src/typography/sub-heading.tsx
Normal file
15
packages/ui/src/typography/sub-heading.tsx
Normal 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 };
|
1
web/components/radio-group/index.tsx
Normal file
1
web/components/radio-group/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export * from "./radio-group";
|
66
web/components/radio-group/radio-group.tsx
Normal file
66
web/components/radio-group/radio-group.tsx
Normal 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 };
|
46
web/components/sortable/draggable.tsx
Normal file
46
web/components/sortable/draggable.tsx
Normal 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 };
|
49
web/components/sortable/sortable.tsx
Normal file
49
web/components/sortable/sortable.tsx
Normal 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 };
|
95
web/constants/estimates.ts
Normal file
95
web/constants/estimates.ts
Normal 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,
|
||||
},
|
||||
};
|
3
web/ee/components/estimates/create/index.ts
Normal file
3
web/ee/components/estimates/create/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./modal";
|
||||
export * from "./stage-one";
|
||||
export * from "./stage-two";
|
101
web/ee/components/estimates/create/modal.tsx
Normal file
101
web/ee/components/estimates/create/modal.tsx
Normal 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>
|
||||
);
|
||||
});
|
59
web/ee/components/estimates/create/stage-one.tsx
Normal file
59
web/ee/components/estimates/create/stage-one.tsx
Normal 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>
|
||||
);
|
||||
};
|
80
web/ee/components/estimates/create/stage-two.tsx
Normal file
80
web/ee/components/estimates/create/stage-two.tsx
Normal 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>
|
||||
);
|
||||
};
|
1
web/ee/components/estimates/delete/index.ts
Normal file
1
web/ee/components/estimates/delete/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./modal";
|
24
web/ee/components/estimates/delete/modal.tsx
Normal file
24
web/ee/components/estimates/delete/modal.tsx
Normal 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>
|
||||
);
|
||||
});
|
18
web/ee/components/estimates/estimate-disable.tsx
Normal file
18
web/ee/components/estimates/estimate-disable.tsx
Normal 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>
|
||||
);
|
||||
});
|
90
web/ee/components/estimates/estimate-item.tsx
Normal file
90
web/ee/components/estimates/estimate-item.tsx
Normal 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 };
|
9
web/ee/components/estimates/estimate-search.tsx
Normal file
9
web/ee/components/estimates/estimate-search.tsx
Normal 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>;
|
||||
});
|
11
web/ee/components/estimates/index.ts
Normal file
11
web/ee/components/estimates/index.ts
Normal 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
|
66
web/ee/components/estimates/inline-editable.tsx
Normal file
66
web/ee/components/estimates/inline-editable.tsx
Normal 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 };
|
145
web/ee/components/estimates/root.tsx
Normal file
145
web/ee/components/estimates/root.tsx
Normal 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>
|
||||
);
|
||||
};
|
29
web/ee/components/estimates/types.ts
Normal file
29
web/ee/components/estimates/types.ts
Normal 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>;
|
||||
};
|
207
web/ee/components/estimates/update-estimate-modal.tsx
Normal file
207
web/ee/components/estimates/update-estimate-modal.tsx
Normal 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>
|
||||
);
|
||||
});
|
1
web/ee/components/estimates/update/index.ts
Normal file
1
web/ee/components/estimates/update/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./modal";
|
54
web/ee/components/estimates/update/modal.tsx
Normal file
54
web/ee/components/estimates/update/modal.tsx
Normal 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>
|
||||
);
|
||||
});
|
3
web/hooks/store/estimates/index.ts
Normal file
3
web/hooks/store/estimates/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./use-project-estimate";
|
||||
export * from "./use-estimate";
|
||||
export * from "./use-estimate-point";
|
16
web/hooks/store/estimates/use-estimate-point.ts
Normal file
16
web/hooks/store/estimates/use-estimate-point.ts
Normal 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] || {};
|
||||
};
|
13
web/hooks/store/estimates/use-estimate.ts
Normal file
13
web/hooks/store/estimates/use-estimate.ts
Normal 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] ?? {};
|
||||
};
|
14
web/hooks/store/estimates/use-project-estimate.ts
Normal file
14
web/hooks/store/estimates/use-project-estimate.ts
Normal 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;
|
||||
};
|
@ -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";
|
||||
|
@ -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;
|
||||
};
|
117
web/store/estimates/estimate-point.ts
Normal file
117
web/store/estimates/estimate-point.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
166
web/store/estimates/estimate.ts
Normal file
166
web/store/estimates/estimate.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
200
web/store/estimates/project-estimate.store.ts
Normal file
200
web/store/estimates/project-estimate.store.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -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() {
|
||||
|
Loading…
Reference in New Issue
Block a user