feat: estimates revamp and space app refactor (#4742)
* Move code from EE to CE repo * chore: folder structure updates * Move sortabla and radio input to packages/ui * chore: updated empty and loading screens * chore: delete an estimate point * chore: estimate point response change * chore: updated create estimate and handled the build error * chore: migration fixes * chore: updated create estimate * chore: create estimate workflow update * chore: editing and deleting the existing estimate updates * chore: updating the new estinates in update modal * chore: ui changed * chore: response changes of get and post * chore: new field added in estimates * chore: individual endpoint for estimate points * chore: typo changes * chore: create estimate point * chore: integrated new endpoints * chore: update key value pair * chore: update sorting in the estimates * Add custom option in the estimate templates * chore: handled current project active estimate * chore: handle estimate update worklfow * chore: handled estimates switch * chore: handled estimate edit * chore: handled close button in estimate edit * chore: updated ceate estimare workflow * chore: updated switch estimate * chore: UI and typos * chore: resolved build error * chore: updated delete dropdown and handled the repeated values while creating and updating the estimate point * chore: handled inline errors in the estimate switch * chore: handled active and availability vadilation * chore: handled create and update components in projecr estimates * chore: added migration * Add category specific values for custom template * chore: estimate dropdown handled in issues * chore: estimate alerts * chore: updated alerts * Extract the list row actions * fix: updated and handled the estimate points * fix: upgrader ee banner * Fix issues with sortable * Fix sortable spacing issue in create estimate modal * fix: updated the issue create sorting * chore: removed radio button from ui and updated in the estimates * chore: resolved import error in packaged ui * chore: handled props in create modal * chore: removed ee files * chore: changed default analytics * chore: removed the migration file * chore: estimate point value in graph * chore: estimate point key change * chore: squashed migration (#4634) * chore: squashed migration * chore: removed instance migraion * chore: key changes * chore: issue activity back migration * dev: replaced estimate key with estimate id and replaced estimate type from number to string in issue * chore: estimate point value field * chore: estimate point activity * chore: removed the unused function * chore: resolved merge conflicts * chore: deploy board keys changed * chore: yarn lock file change * chore: resolved frontend build --------- Co-authored-by: guru_sainath <gurusainath007@gmail.com> * [WEB-1516] refactor: space app routing and layouts (#4705) * dev: change layout * chore: replace workspace slug and project id with anchor * chore: migration fixes * chore: update filtering logic * chore: endpoint changes * chore: update endpoint * chore: changed url pratterns * chore: use client side for layout and page * chore: issue vote changes * chore: project deploy board response change * refactor: publish project store and components * fix: update layout options after fetching settings * chore: remove unnecessary types * style: peek overview * refactor: components folder structure * fix: redirect from old path * chore: make the whole issue block clickable * chore: removed the migration file * chore: add server side redirection for old routes * chore: is enabled key change * chore: update types * chore: removed the migration file --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> * Merge develop into revamp-estimates-ce * chore: removed migration file and updated the estimate system order and removed ee banner * chore: initial radio select in create estimate * chore: space key changes * Fix sortable component as the sort order was broken. * [WEB-1516] refactor: publish project modal and types (#4716) * refacotr: project publish * chore: rename service names * chore: is_deployed changed to anchor * chore: update is_deployed key --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> * [WEB-412] chore: estimates analytics (#4730) * chore: estimate points in modules and cycle * chore: burn down chart analytics * chore: module serializer change * dev: handled y-axis estimates in analytics, implemented estimate points on modules * chore: burn down analytics * chore: state estimate point analytics * chore: updated the burn down values * Remove check mark from estimate point edit field in create estimate flow --------- Co-authored-by: guru_sainath <gurusainath007@gmail.com> Co-authored-by: Satish Gandham <satish.iitg@gmail.com> --------- Co-authored-by: Satish Gandham <satish.iitg@gmail.com> Co-authored-by: guru_sainath <gurusainath007@gmail.com> Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Co-authored-by: pushya22 <130810100+pushya22@users.noreply.github.com>
This commit is contained in:
parent
fb2b4ae303
commit
59fdd611e4
223 changed files with 6874 additions and 4658 deletions
|
|
@ -1,26 +1,54 @@
|
|||
// ui
|
||||
import { observer } from "mobx-react";
|
||||
import { TYAxisValues } from "@plane/types";
|
||||
import { CustomSelect } from "@plane/ui";
|
||||
// types
|
||||
import { ANALYTICS_Y_AXIS_VALUES } from "@/constants/analytics";
|
||||
// constants
|
||||
import { ANALYTICS_Y_AXIS_VALUES } from "@/constants/analytics";
|
||||
import { EEstimateSystem } from "@/constants/estimates";
|
||||
// hooks
|
||||
import { useAppRouter, useProjectEstimates } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
value: TYAxisValues;
|
||||
onChange: () => void;
|
||||
};
|
||||
|
||||
export const SelectYAxis: React.FC<Props> = ({ value, onChange }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={<span>{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === value)?.label ?? "None"}</span>}
|
||||
onChange={onChange}
|
||||
maxHeight="lg"
|
||||
>
|
||||
{ANALYTICS_Y_AXIS_VALUES.map((item) => (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
);
|
||||
export const SelectYAxis: React.FC<Props> = observer(({ value, onChange }) => {
|
||||
// hooks
|
||||
const { projectId } = useAppRouter();
|
||||
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
|
||||
|
||||
const isEstimateEnabled = (analyticsOption: string) => {
|
||||
if (analyticsOption === "estimate") {
|
||||
if (
|
||||
projectId &&
|
||||
currentActiveEstimateId &&
|
||||
areEstimateEnabledByProjectId(projectId) &&
|
||||
estimateById(currentActiveEstimateId)?.type === EEstimateSystem.POINTS
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={<span>{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === value)?.label ?? "None"}</span>}
|
||||
onChange={onChange}
|
||||
maxHeight="lg"
|
||||
>
|
||||
{ANALYTICS_Y_AXIS_VALUES.map(
|
||||
(item) =>
|
||||
isEstimateEnabled(item.value) && (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
)
|
||||
)}
|
||||
</CustomSelect>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import { Tooltip, BlockedIcon, BlockerIcon, RelatedIcon, LayersIcon, DiceIcon }
|
|||
// helpers
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { capitalizeFirstLetter } from "@/helpers/string.helper";
|
||||
import { useEstimate, useLabel } from "@/hooks/store";
|
||||
import { useLabel } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// types
|
||||
|
||||
|
|
@ -97,22 +97,6 @@ const LabelPill = observer(({ labelId, workspaceSlug }: { labelId: string; works
|
|||
);
|
||||
});
|
||||
|
||||
const EstimatePoint = observer((props: { point: string }) => {
|
||||
const { point } = props;
|
||||
const { areEstimatesEnabledForCurrentProject, getEstimatePointValue } = useEstimate();
|
||||
const currentPoint = Number(point) + 1;
|
||||
|
||||
const estimateValue = getEstimatePointValue(Number(point), null);
|
||||
|
||||
return (
|
||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">
|
||||
{areEstimatesEnabledForCurrentProject
|
||||
? estimateValue
|
||||
: `${currentPoint} ${currentPoint > 1 ? "points" : "point"}`}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
const inboxActivityMessage = {
|
||||
declined: {
|
||||
showIssue: "declined issue",
|
||||
|
|
@ -267,7 +251,7 @@ const activityDetails: {
|
|||
else
|
||||
return (
|
||||
<>
|
||||
set the estimate point to <EstimatePoint point={activity.new_value} />
|
||||
set the estimate point to {activity.new_value}
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useState, useRef, Fragment } from "react";
|
||||
import React, { useEffect, useState, useRef, Fragment, Ref } from "react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { useRouter } from "next/router";
|
||||
import { Controller, useForm } from "react-hook-form"; // services
|
||||
|
|
@ -196,7 +196,7 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
|
|||
<Popover.Panel
|
||||
as="div"
|
||||
className={`fixed z-10 flex w-full min-w-[50rem] max-w-full flex-col space-y-4 overflow-hidden rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4 shadow ${className}`}
|
||||
ref={setPopperElement}
|
||||
ref={setPopperElement as Ref<HTMLDivElement>}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Fragment, ReactNode, useRef, useState } from "react";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Check, ChevronDown, Search, Triangle } from "lucide-react";
|
||||
|
|
@ -7,7 +6,12 @@ import { Combobox } from "@headlessui/react";
|
|||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useAppRouter, useEstimate } from "@/hooks/store";
|
||||
import {
|
||||
useAppRouter,
|
||||
useEstimate,
|
||||
useProjectEstimates,
|
||||
// useEstimate
|
||||
} from "@/hooks/store";
|
||||
import { useDropdown } from "@/hooks/use-dropdown";
|
||||
// components
|
||||
import { DropdownButton } from "./buttons";
|
||||
|
|
@ -19,15 +23,15 @@ type Props = TDropdownProps & {
|
|||
button?: ReactNode;
|
||||
dropdownArrow?: boolean;
|
||||
dropdownArrowClassName?: string;
|
||||
onChange: (val: number | null) => void;
|
||||
onChange: (val: string | undefined) => void;
|
||||
onClose?: () => void;
|
||||
projectId: string;
|
||||
value: number | null;
|
||||
value: string | undefined;
|
||||
};
|
||||
|
||||
type DropdownOptions =
|
||||
| {
|
||||
value: number | null;
|
||||
value: string | null;
|
||||
query: string;
|
||||
content: JSX.Element;
|
||||
}[]
|
||||
|
|
@ -76,19 +80,29 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
|||
});
|
||||
// store hooks
|
||||
const { workspaceSlug } = useAppRouter();
|
||||
const { fetchProjectEstimates, getProjectActiveEstimateDetails, getEstimatePointValue } = useEstimate();
|
||||
const activeEstimate = getProjectActiveEstimateDetails(projectId);
|
||||
|
||||
const options: DropdownOptions = sortBy(activeEstimate?.points ?? [], "key")?.map((point) => ({
|
||||
value: point.key,
|
||||
query: `${point?.value}`,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Triangle className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="flex-grow truncate">{point.value}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
const { currentActiveEstimateId, getProjectEstimates } = useProjectEstimates();
|
||||
const { estimatePointIds, estimatePointById } = useEstimate(
|
||||
currentActiveEstimateId ? currentActiveEstimateId : undefined
|
||||
);
|
||||
|
||||
const options: DropdownOptions = (estimatePointIds ?? [])
|
||||
?.map((estimatePoint) => {
|
||||
const currentEstimatePoint = estimatePointById(estimatePoint);
|
||||
if (currentEstimatePoint)
|
||||
return {
|
||||
value: currentEstimatePoint.id,
|
||||
query: `${currentEstimatePoint?.value}`,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Triangle className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="flex-grow truncate">{currentEstimatePoint.value}</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
else undefined;
|
||||
})
|
||||
.filter((estimatePointDropdownOption) => estimatePointDropdownOption != undefined) as DropdownOptions;
|
||||
options?.unshift({
|
||||
value: null,
|
||||
query: "No estimate",
|
||||
|
|
@ -103,10 +117,10 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
|||
const filteredOptions =
|
||||
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const selectedEstimate = value !== null ? getEstimatePointValue(value, projectId) : null;
|
||||
const selectedEstimate = value && estimatePointById ? estimatePointById(value) : undefined;
|
||||
|
||||
const onOpen = async () => {
|
||||
if (!activeEstimate && workspaceSlug) await fetchProjectEstimates(workspaceSlug, projectId);
|
||||
if (!currentActiveEstimateId && workspaceSlug) await getProjectEstimates(workspaceSlug, projectId);
|
||||
};
|
||||
|
||||
const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({
|
||||
|
|
@ -120,7 +134,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
|||
setQuery,
|
||||
});
|
||||
|
||||
const dropdownOnChange = (val: number | null) => {
|
||||
const dropdownOnChange = (val: string | undefined) => {
|
||||
onChange(val);
|
||||
handleClose();
|
||||
};
|
||||
|
|
@ -164,13 +178,13 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
|||
className={buttonClassName}
|
||||
isActive={isOpen}
|
||||
tooltipHeading="Estimate"
|
||||
tooltipContent={selectedEstimate !== null ? selectedEstimate : placeholder}
|
||||
tooltipContent={selectedEstimate ? selectedEstimate?.value : placeholder}
|
||||
showTooltip={showTooltip}
|
||||
variant={buttonVariant}
|
||||
>
|
||||
{!hideIcon && <Triangle className="h-3 w-3 flex-shrink-0" />}
|
||||
{(selectedEstimate || placeholder) && BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
||||
<span className="flex-grow truncate">{selectedEstimate !== null ? selectedEstimate : placeholder}</span>
|
||||
<span className="flex-grow truncate">{selectedEstimate ? selectedEstimate?.value : placeholder}</span>
|
||||
)}
|
||||
{dropdownArrow && (
|
||||
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
|
||||
|
|
@ -204,20 +218,14 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
|||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
`flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 ${
|
||||
active ? "bg-custom-background-80" : ""
|
||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<Combobox.Option key={option.value} value={option.value}>
|
||||
{({ active, selected }) => (
|
||||
<div
|
||||
className={`flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 ${active ? `!hover:bg-custom-background-80` : ``} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`}
|
||||
>
|
||||
<span className="flex-grow truncate">{option.content}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { Ref, useState } from "react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Popover } from "@headlessui/react";
|
||||
// popper
|
||||
|
|
@ -60,7 +60,7 @@ export const ComicBoxButton: React.FC<Props> = (props) => {
|
|||
<Popover.Panel
|
||||
as="div"
|
||||
className="flex flex-col rounded border border-custom-border-200 bg-custom-background-100 p-5 relative min-w-80"
|
||||
ref={setPopperElement}
|
||||
ref={setPopperElement as Ref<HTMLDivElement>}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
static
|
||||
|
|
|
|||
|
|
@ -1,292 +0,0 @@
|
|||
import React, { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// types
|
||||
import { IEstimate, IEstimateFormData } from "@plane/types";
|
||||
// ui
|
||||
import { Button, Input, TextArea, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@/components/core";
|
||||
// helpers
|
||||
import { checkDuplicates } from "@/helpers/array.helper";
|
||||
// hooks
|
||||
import { useEstimate } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
data?: IEstimate;
|
||||
};
|
||||
|
||||
const defaultValues = {
|
||||
name: "",
|
||||
description: "",
|
||||
value1: "",
|
||||
value2: "",
|
||||
value3: "",
|
||||
value4: "",
|
||||
value5: "",
|
||||
value6: "",
|
||||
};
|
||||
|
||||
type FormValues = typeof defaultValues;
|
||||
|
||||
export const CreateUpdateEstimateModal: React.FC<Props> = observer((props) => {
|
||||
const { handleClose, data, isOpen } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// store hooks
|
||||
const { createEstimate, updateEstimate } = useEstimate();
|
||||
// form info
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
} = useForm<FormValues>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleCreateEstimate = async (payload: IEstimateFormData) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
await createEstimate(workspaceSlug.toString(), projectId.toString(), 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 ?? err.status === 400
|
||||
? "Estimate with that name already exists. Please try again with another name."
|
||||
: "Estimate could not be created. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateEstimate = async (payload: IEstimateFormData) => {
|
||||
if (!workspaceSlug || !projectId || !data) return;
|
||||
|
||||
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.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: FormValues) => {
|
||||
if (!formData.name || formData.name === "") {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Estimate title cannot be empty.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
formData.value1 === "" ||
|
||||
formData.value2 === "" ||
|
||||
formData.value3 === "" ||
|
||||
formData.value4 === "" ||
|
||||
formData.value5 === "" ||
|
||||
formData.value6 === ""
|
||||
) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Estimate point cannot be empty.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
formData.value1.length > 20 ||
|
||||
formData.value2.length > 20 ||
|
||||
formData.value3.length > 20 ||
|
||||
formData.value4.length > 20 ||
|
||||
formData.value5.length > 20 ||
|
||||
formData.value6.length > 20
|
||||
) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Estimate point cannot have more than 20 characters.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
checkDuplicates([
|
||||
formData.value1,
|
||||
formData.value2,
|
||||
formData.value3,
|
||||
formData.value4,
|
||||
formData.value5,
|
||||
formData.value6,
|
||||
])
|
||||
) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Estimate points cannot have duplicate values.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: IEstimateFormData = {
|
||||
estimate: {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
},
|
||||
estimate_points: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const point = {
|
||||
key: i,
|
||||
value: formData[`value${i + 1}` as keyof FormValues],
|
||||
};
|
||||
|
||||
if (data)
|
||||
payload.estimate_points.push({
|
||||
id: data.points[i].id,
|
||||
...point,
|
||||
});
|
||||
else payload.estimate_points.push({ ...point });
|
||||
}
|
||||
|
||||
if (data) await handleUpdateEstimate(payload);
|
||||
else await handleCreateEstimate(payload);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data)
|
||||
reset({
|
||||
...defaultValues,
|
||||
...data,
|
||||
value1: data.points[0]?.value,
|
||||
value2: data.points[1]?.value,
|
||||
value3: data.points[2]?.value,
|
||||
value4: data.points[3]?.value,
|
||||
value5: data.points[4]?.value,
|
||||
value6: data.points[5]?.value,
|
||||
});
|
||||
else reset({ ...defaultValues });
|
||||
}, [data, reset]);
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXL}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="space-y-5 p-5">
|
||||
<div className="text-xl font-medium text-custom-text-200">{data ? "Update" : "Create"} Estimate</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="name"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.name)}
|
||||
placeholder="Title"
|
||||
className="w-full text-base"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TextArea
|
||||
id="description"
|
||||
name="description"
|
||||
value={value}
|
||||
placeholder="Description"
|
||||
onChange={onChange}
|
||||
className="w-full text-base resize-none min-h-24"
|
||||
hasError={Boolean(errors?.description)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* list of all the points */}
|
||||
{/* since they are all the same, we can use a loop to render them */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{Array(6)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<div className="flex items-center" key={i}>
|
||||
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
|
||||
<span className="rounded-lg px-2 text-sm text-custom-text-200">{i + 1}</span>
|
||||
<span className="rounded-r-lg bg-custom-background-100">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`value${i + 1}` as keyof FormValues}
|
||||
rules={{
|
||||
maxLength: {
|
||||
value: 20,
|
||||
message: "Estimate point must at most be of 20 characters",
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
ref={ref}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
id={`value${i + 1}`}
|
||||
name={`value${i + 1}`}
|
||||
placeholder={`Point ${i + 1}`}
|
||||
className="w-full rounded-l-none"
|
||||
hasError={Boolean(errors[`value${i + 1}` as keyof FormValues])}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{data ? (isSubmitting ? "Updating" : "Update Estimate") : isSubmitting ? "Creating" : "Create Estimate"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
2
web/components/estimates/create/index.ts
Normal file
2
web/components/estimates/create/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./modal";
|
||||
export * from "./stage-one";
|
||||
134
web/components/estimates/create/modal.tsx
Normal file
134
web/components/estimates/create/modal.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { FC, useEffect, useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { IEstimateFormData, TEstimateSystemKeys, TEstimatePointsObject } from "@plane/types";
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@/components/core";
|
||||
import { EstimateCreateStageOne, EstimatePointCreateRoot } from "@/components/estimates";
|
||||
// constants
|
||||
import { EEstimateSystem, ESTIMATE_SYSTEMS } from "@/constants/estimates";
|
||||
// hooks
|
||||
import { useProjectEstimates } from "@/hooks/store";
|
||||
|
||||
type TCreateEstimateModal = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const CreateEstimateModal: FC<TCreateEstimateModal> = observer((props) => {
|
||||
// props
|
||||
const { workspaceSlug, projectId, isOpen, handleClose } = props;
|
||||
// hooks
|
||||
const { createEstimate } = useProjectEstimates();
|
||||
// states
|
||||
const [estimateSystem, setEstimateSystem] = useState<TEstimateSystemKeys>(EEstimateSystem.POINTS);
|
||||
const [estimatePoints, setEstimatePoints] = useState<TEstimatePointsObject[] | undefined>(undefined);
|
||||
const [buttonLoader, setButtonLoader] = useState(false);
|
||||
|
||||
const handleUpdatePoints = (newPoints: TEstimatePointsObject[] | undefined) => setEstimatePoints(newPoints);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setEstimateSystem(EEstimateSystem.POINTS);
|
||||
setEstimatePoints(undefined);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleCreateEstimate = async () => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !estimatePoints) return;
|
||||
setButtonLoader(true);
|
||||
const payload: IEstimateFormData = {
|
||||
estimate: {
|
||||
name: ESTIMATE_SYSTEMS[estimateSystem]?.name,
|
||||
type: estimateSystem,
|
||||
last_used: true,
|
||||
},
|
||||
estimate_points: estimatePoints,
|
||||
};
|
||||
await createEstimate(workspaceSlug, projectId, payload);
|
||||
|
||||
setButtonLoader(false);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Estimate created",
|
||||
message: "A new estimate has been added in your project.",
|
||||
});
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
setButtonLoader(false);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Estimate creation failed",
|
||||
message: "We were unable to create the new estimate, please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 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-100">New Estimate System</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">Step {renderEstimateStepsCount} of 2</div>
|
||||
</div>
|
||||
|
||||
{/* estimate steps */}
|
||||
<div className="px-5">
|
||||
{!estimatePoints && (
|
||||
<EstimateCreateStageOne
|
||||
estimateSystem={estimateSystem}
|
||||
handleEstimateSystem={setEstimateSystem}
|
||||
handleEstimatePoints={(templateType: string) =>
|
||||
handleUpdatePoints(ESTIMATE_SYSTEMS[estimateSystem].templates[templateType].values)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{estimatePoints && (
|
||||
<>
|
||||
<EstimatePointCreateRoot
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
estimateId={undefined}
|
||||
estimateType={estimateSystem}
|
||||
estimatePoints={estimatePoints}
|
||||
setEstimatePoints={setEstimatePoints}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex justify-end items-center gap-3 px-5 pt-5 border-t border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose} disabled={buttonLoader}>
|
||||
Cancel
|
||||
</Button>
|
||||
{estimatePoints && (
|
||||
<Button variant="primary" size="sm" onClick={handleCreateEstimate} disabled={buttonLoader}>
|
||||
{buttonLoader ? `Creating` : `Create Estimate`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
101
web/components/estimates/create/stage-one.tsx
Normal file
101
web/components/estimates/create/stage-one.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { FC } from "react";
|
||||
import { Crown, Info } from "lucide-react";
|
||||
import { TEstimateSystemKeys } from "@plane/types";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { RadioInput } from "@/components/estimates";
|
||||
// constants
|
||||
import { ESTIMATE_SYSTEMS } from "@/constants/estimates";
|
||||
|
||||
type TEstimateCreateStageOne = {
|
||||
estimateSystem: TEstimateSystemKeys;
|
||||
handleEstimateSystem: (value: TEstimateSystemKeys) => void;
|
||||
handleEstimatePoints: (value: string) => 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-6">
|
||||
<div className="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]?.is_available ? (
|
||||
<div className="relative flex items-center gap-2 cursor-no-drop text-custom-text-300">
|
||||
{ESTIMATE_SYSTEMS[currentSystem]?.name}
|
||||
<Tooltip tooltipContent={"Coming soon"}>
|
||||
<Info size={12} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : ESTIMATE_SYSTEMS[currentSystem]?.is_ee ? (
|
||||
<div className="relative flex items-center gap-2 cursor-no-drop text-custom-text-300">
|
||||
{ESTIMATE_SYSTEMS[currentSystem]?.name}
|
||||
<Tooltip tooltipContent={"upgrade"}>
|
||||
<Crown size={12} className="text-amber-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<div>{ESTIMATE_SYSTEMS[currentSystem]?.name}</div>
|
||||
),
|
||||
value: system,
|
||||
disabled: !ESTIMATE_SYSTEMS[currentSystem]?.is_available || ESTIMATE_SYSTEMS[currentSystem]?.is_ee,
|
||||
};
|
||||
})}
|
||||
name="estimate-radio-input"
|
||||
label="Choose an estimate system"
|
||||
labelClassName="text-sm font-medium text-custom-text-200 mb-1.5"
|
||||
wrapperClassName="relative flex flex-wrap gap-14"
|
||||
fieldClassName="relative flex items-center gap-1.5"
|
||||
buttonClassName="size-4"
|
||||
selected={estimateSystem}
|
||||
onChange={(value) => handleEstimateSystem(value as TEstimateSystemKeys)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ESTIMATE_SYSTEMS[estimateSystem]?.is_available && !ESTIMATE_SYSTEMS[estimateSystem]?.is_ee && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium text-custom-text-200">Start from scratch</div>
|
||||
<button
|
||||
className="border border-custom-border-200 rounded-md p-3 py-2.5 text-left space-y-1 w-full block hover:bg-custom-background-90"
|
||||
onClick={() => handleEstimatePoints("custom")}
|
||||
>
|
||||
<p className="text-base font-medium">Custom</p>
|
||||
<p className="text-xs text-custom-text-300">
|
||||
Add your own <span className="lowercase">{currentEstimateSystem.name}</span> from scratch
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium text-custom-text-200">Choose a template</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{Object.keys(currentEstimateSystem.templates).map((name) =>
|
||||
currentEstimateSystem.templates[name]?.hide ? null : (
|
||||
<button
|
||||
key={name}
|
||||
className="border border-custom-border-200 rounded-md p-3 py-2.5 text-left space-y-1 hover:bg-custom-background-90"
|
||||
onClick={() => handleEstimatePoints(name)}
|
||||
>
|
||||
<p className="text-base font-medium">{currentEstimateSystem.templates[name]?.title}</p>
|
||||
<p className="text-xs text-custom-text-300">
|
||||
{currentEstimateSystem.templates[name]?.values?.map((template) => template?.value)?.join(", ")}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
//
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
// types
|
||||
import { IEstimate } from "@plane/types";
|
||||
// ui
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { AlertModalCore } from "@/components/core";
|
||||
// hooks
|
||||
import { useEstimate } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
data: IEstimate | null;
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const DeleteEstimateModal: React.FC<Props> = observer((props) => {
|
||||
const { isOpen, handleClose, data } = props;
|
||||
// states
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// store hooks
|
||||
const { deleteEstimate } = useEstimate();
|
||||
|
||||
const handleEstimateDelete = async () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
|
||||
const estimateId = data?.id!;
|
||||
|
||||
await deleteEstimate(workspaceSlug.toString(), projectId.toString(), estimateId)
|
||||
.then(() => {
|
||||
handleClose();
|
||||
})
|
||||
.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 deleted. Please try again",
|
||||
});
|
||||
})
|
||||
.finally(() => setIsDeleteLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsDeleteLoading(false);
|
||||
}, [isOpen]);
|
||||
|
||||
const onClose = () => {
|
||||
setIsDeleteLoading(false);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertModalCore
|
||||
handleClose={onClose}
|
||||
handleSubmit={handleEstimateDelete}
|
||||
isSubmitting={isDeleteLoading}
|
||||
isOpen={isOpen}
|
||||
title="Delete Estimate"
|
||||
content={
|
||||
<>
|
||||
Are you sure you want to delete estimate-{" "}
|
||||
<span className="break-words font-medium text-custom-text-100">{data?.name}</span>
|
||||
{""}? All of the data related to the estiamte will be permanently removed. This action cannot be undone.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
1
web/components/estimates/delete/index.ts
Normal file
1
web/components/estimates/delete/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./modal";
|
||||
81
web/components/estimates/delete/modal.tsx
Normal file
81
web/components/estimates/delete/modal.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { FC, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@/components/core";
|
||||
// hooks
|
||||
import { useEstimate, useProject, useProjectEstimates } from "@/hooks/store";
|
||||
|
||||
type TDeleteEstimateModal = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
estimateId: string | undefined;
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const DeleteEstimateModal: FC<TDeleteEstimateModal> = observer((props) => {
|
||||
// props
|
||||
const { workspaceSlug, projectId, estimateId, isOpen, handleClose } = props;
|
||||
// hooks
|
||||
const { areEstimateEnabledByProjectId, deleteEstimate } = useProjectEstimates();
|
||||
const { asJson: estimate } = useEstimate(estimateId);
|
||||
const { updateProject } = useProject();
|
||||
// states
|
||||
const [buttonLoader, setButtonLoader] = useState(false);
|
||||
|
||||
const handleDeleteEstimate = async () => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !estimateId) return;
|
||||
setButtonLoader(true);
|
||||
|
||||
await deleteEstimate(workspaceSlug, projectId, estimateId);
|
||||
if (areEstimateEnabledByProjectId(projectId)) {
|
||||
await updateProject(workspaceSlug, projectId, { estimate: null });
|
||||
}
|
||||
setButtonLoader(false);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Estimate deleted",
|
||||
message: "Estimate has been removed from your project.",
|
||||
});
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
setButtonLoader(false);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Estimate creation failed",
|
||||
message: "We were unable to delete the estimate, please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} 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="text-xl font-medium text-custom-text-100">Delete Estimate System</div>
|
||||
</div>
|
||||
|
||||
{/* estimate steps */}
|
||||
<div className="px-5">
|
||||
<div className="text-base text-custom-text-200">
|
||||
Deleting the estimate <span className="font-bold text-custom-text-100">{estimate?.name}</span>
|
||||
system will remove it from all issues permanently. This action cannot be undone. If you add estimates
|
||||
again, you will need to update all the issues.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex justify-end items-center gap-3 px-5 pt-5 border-t border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose} disabled={buttonLoader}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={handleDeleteEstimate} disabled={buttonLoader}>
|
||||
{buttonLoader ? "Deleting" : "Delete Estimate"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
42
web/components/estimates/empty-screen.tsx
Normal file
42
web/components/estimates/empty-screen.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { FC } from "react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Button } from "@plane/ui";
|
||||
// public images
|
||||
import EstimateEmptyDarkImage from "@/public/empty-state/estimates/dark.svg";
|
||||
import EstimateEmptyLightImage from "@/public/empty-state/estimates/light.svg";
|
||||
|
||||
type TEstimateEmptyScreen = {
|
||||
onButtonClick: () => void;
|
||||
};
|
||||
|
||||
export const EstimateEmptyScreen: FC<TEstimateEmptyScreen> = (props) => {
|
||||
// props
|
||||
const { onButtonClick } = props;
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const emptyScreenImage = resolvedTheme === "light" ? EstimateEmptyLightImage : EstimateEmptyDarkImage;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col justify-center items-center text-center gap-8 border border-custom-border-300 rounded bg-custom-background-90 py-10">
|
||||
<div className="flex-shrink-0 w-[120px] h-[120px] overflow-hidden relative flex justify-center items-center">
|
||||
<Image
|
||||
src={emptyScreenImage}
|
||||
alt="Empty estimate image"
|
||||
width={100}
|
||||
height={100}
|
||||
className="object-contain w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="text-xl font-semibold text-custom-text-100">No estimate systems yet</h3>
|
||||
<p className="text-sm text-custom-text-300">
|
||||
Create a set of estimates to communicate the amount of work per issue.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={onButtonClick}>Add Estimate System</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
50
web/components/estimates/estimate-disable-switch.tsx
Normal file
50
web/components/estimates/estimate-disable-switch.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
|
||||
// hooks
|
||||
import { useProject, useProjectEstimates } from "@/hooks/store";
|
||||
|
||||
type TEstimateDisableSwitch = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
export const EstimateDisableSwitch: FC<TEstimateDisableSwitch> = observer((props) => {
|
||||
const { workspaceSlug, projectId, isAdmin } = props;
|
||||
// hooks
|
||||
const { updateProject, currentProjectDetails } = useProject();
|
||||
const { currentActiveEstimateId } = useProjectEstimates();
|
||||
|
||||
const currentProjectActiveEstimate = currentProjectDetails?.estimate || undefined;
|
||||
|
||||
const disableEstimate = async () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
try {
|
||||
await updateProject(workspaceSlug, projectId, {
|
||||
estimate: currentProjectActiveEstimate ? null : currentActiveEstimateId,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: currentProjectActiveEstimate ? "Estimates have been disabled" : "Estimates have been enabled",
|
||||
});
|
||||
} catch (err) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Estimate could not be disabled. Please try again",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToggleSwitch
|
||||
value={Boolean(currentProjectActiveEstimate)}
|
||||
onChange={disableEstimate}
|
||||
disabled={!isAdmin}
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
});
|
||||
34
web/components/estimates/estimate-list-item-buttons.tsx
Normal file
34
web/components/estimates/estimate-list-item-buttons.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Pen, Trash } from "lucide-react";
|
||||
|
||||
type TEstimateListItem = {
|
||||
estimateId: string;
|
||||
isAdmin: boolean;
|
||||
isEstimateEnabled: boolean;
|
||||
isEditable: boolean;
|
||||
onEditClick?: (estimateId: string) => void;
|
||||
onDeleteClick?: (estimateId: string) => void;
|
||||
};
|
||||
|
||||
export const EstimateListItemButtons: FC<TEstimateListItem> = observer((props) => {
|
||||
const { estimateId, isAdmin, isEditable, onEditClick, onDeleteClick } = props;
|
||||
if (!isAdmin || !isEditable) return <></>;
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-1">
|
||||
<button
|
||||
className="relative flex-shrink-0 w-6 h-6 flex justify-center items-center rounded cursor-pointer transition-colors overflow-hidden hover:bg-custom-background-80"
|
||||
onClick={() => onEditClick && onEditClick(estimateId)}
|
||||
>
|
||||
<Pen size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="relative flex-shrink-0 w-6 h-6 flex justify-center items-center rounded cursor-pointer transition-colors overflow-hidden hover:bg-custom-background-80"
|
||||
onClick={() => onDeleteClick && onDeleteClick(estimateId)}
|
||||
>
|
||||
<Trash size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,114 +1,46 @@
|
|||
import React from "react";
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
// hooks
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { IEstimate } from "@plane/types";
|
||||
import { Button, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { orderArrayBy } from "@/helpers/array.helper";
|
||||
import { useProject } from "@/hooks/store";
|
||||
// ui
|
||||
//icons
|
||||
// helpers
|
||||
// types
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useEstimate, useProjectEstimates } from "@/hooks/store";
|
||||
import { EstimateListItemButtons } from "./estimate-list-item-buttons";
|
||||
|
||||
type Props = {
|
||||
estimate: IEstimate;
|
||||
editEstimate: (estimate: IEstimate) => void;
|
||||
deleteEstimate: (estimateId: string) => void;
|
||||
type TEstimateListItem = {
|
||||
estimateId: string;
|
||||
isAdmin: boolean;
|
||||
isEstimateEnabled: boolean;
|
||||
isEditable: boolean;
|
||||
onEditClick?: (estimateId: string) => void;
|
||||
onDeleteClick?: (estimateId: string) => void;
|
||||
};
|
||||
|
||||
export const EstimateListItem: React.FC<Props> = observer((props) => {
|
||||
const { estimate, editEstimate, deleteEstimate } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// store hooks
|
||||
const { currentProjectDetails, updateProject } = useProject();
|
||||
export const EstimateListItem: FC<TEstimateListItem> = observer((props) => {
|
||||
const { estimateId, isAdmin, isEstimateEnabled, isEditable } = props;
|
||||
// hooks
|
||||
const { estimateById } = useProjectEstimates();
|
||||
const { estimatePointIds, estimatePointById } = useEstimate(estimateId);
|
||||
const currentEstimate = estimateById(estimateId);
|
||||
|
||||
const handleUseEstimate = async () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
await updateProject(workspaceSlug.toString(), projectId.toString(), {
|
||||
estimate: estimate.id,
|
||||
}).catch((err) => {
|
||||
const error = err?.error;
|
||||
const errorString = Array.isArray(error) ? error[0] : error;
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: errorString ?? "Estimate points could not be used. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
// derived values
|
||||
const estimatePointValues = estimatePointIds?.map((estimatePointId) => {
|
||||
const estimatePoint = estimatePointById(estimatePointId);
|
||||
if (estimatePoint) return estimatePoint.value;
|
||||
});
|
||||
|
||||
if (!currentEstimate) return <></>;
|
||||
return (
|
||||
<>
|
||||
<div className="gap-2 border-b border-custom-border-100 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h6 className="flex w-[40vw] items-center gap-2 truncate text-sm font-medium">
|
||||
{estimate.name}
|
||||
{currentProjectDetails?.estimate && currentProjectDetails?.estimate === estimate.id && (
|
||||
<span className="rounded bg-green-500/20 px-2 py-0.5 text-xs text-green-500">In use</span>
|
||||
)}
|
||||
</h6>
|
||||
<p className="font-sm w-[40vw] truncate text-[14px] font-normal text-custom-text-200">
|
||||
{estimate.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentProjectDetails?.estimate !== estimate?.id && estimate?.points?.length > 0 && (
|
||||
<Button variant="neutral-primary" onClick={handleUseEstimate} size="sm">
|
||||
Use
|
||||
</Button>
|
||||
)}
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
editEstimate(estimate);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
<span>Edit estimate</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{currentProjectDetails?.estimate !== estimate.id && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
deleteEstimate(estimate.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span>Delete estimate</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
{estimate?.points?.length > 0 ? (
|
||||
<div className="flex text-xs text-custom-text-200">
|
||||
Estimate points (
|
||||
<span className="flex gap-1">
|
||||
{orderArrayBy(estimate.points, "key").map((point, index) => (
|
||||
<h6 key={point.id} className="text-custom-text-200">
|
||||
{point.value}
|
||||
{index !== estimate.points.length - 1 && ","}{" "}
|
||||
</h6>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-xs text-custom-text-200">No estimate points</p>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"relative border-b border-custom-border-200 flex justify-between items-center gap-3 py-3.5",
|
||||
isAdmin && isEditable && isEstimateEnabled ? `text-custom-text-100` : `text-custom-text-200`
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-base">{currentEstimate?.name}</h3>
|
||||
<p className="text-xs">{(estimatePointValues || [])?.join(", ")}</p>
|
||||
</div>
|
||||
</>
|
||||
<EstimateListItemButtons {...props} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
35
web/components/estimates/estimate-list.tsx
Normal file
35
web/components/estimates/estimate-list.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { EstimateListItem } from "@/components/estimates";
|
||||
|
||||
type TEstimateList = {
|
||||
estimateIds: string[] | undefined;
|
||||
isAdmin: boolean;
|
||||
isEstimateEnabled?: boolean;
|
||||
isEditable?: boolean;
|
||||
onEditClick?: (estimateId: string) => void;
|
||||
onDeleteClick?: (estimateId: string) => void;
|
||||
};
|
||||
|
||||
export const EstimateList: FC<TEstimateList> = observer((props) => {
|
||||
const { estimateIds, isAdmin, isEstimateEnabled = false, isEditable = false, onEditClick, onDeleteClick } = props;
|
||||
|
||||
if (!estimateIds || estimateIds?.length <= 0) return <></>;
|
||||
return (
|
||||
<div>
|
||||
{estimateIds &&
|
||||
estimateIds.map((estimateId) => (
|
||||
<EstimateListItem
|
||||
key={estimateId}
|
||||
estimateId={estimateId}
|
||||
isAdmin={isAdmin}
|
||||
isEstimateEnabled={isEstimateEnabled}
|
||||
isEditable={isEditable}
|
||||
onEditClick={onEditClick}
|
||||
onDeleteClick={onDeleteClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
9
web/components/estimates/estimate-search.tsx
Normal file
9
web/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>;
|
||||
});
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { IEstimate } from "@plane/types";
|
||||
// store hooks
|
||||
import { Button, Loader, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { CreateUpdateEstimateModal, DeleteEstimateModal, EstimateListItem } from "@/components/estimates";
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
import { orderArrayBy } from "@/helpers/array.helper";
|
||||
import { useEstimate, useProject } from "@/hooks/store";
|
||||
// components
|
||||
// ui
|
||||
// types
|
||||
// helpers
|
||||
// constants
|
||||
|
||||
export const EstimatesList: React.FC = observer(() => {
|
||||
// states
|
||||
const [estimateFormOpen, setEstimateFormOpen] = useState(false);
|
||||
const [estimateToDelete, setEstimateToDelete] = useState<string | null>(null);
|
||||
const [estimateToUpdate, setEstimateToUpdate] = useState<IEstimate | undefined>();
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// store hooks
|
||||
const { updateProject, currentProjectDetails } = useProject();
|
||||
const { projectEstimates, getProjectEstimateById } = useEstimate();
|
||||
|
||||
const editEstimate = (estimate: IEstimate) => {
|
||||
setEstimateFormOpen(true);
|
||||
// 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",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateEstimateModal
|
||||
isOpen={estimateFormOpen}
|
||||
data={estimateToUpdate}
|
||||
handleClose={() => {
|
||||
setEstimateFormOpen(false);
|
||||
setEstimateToUpdate(undefined);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteEstimateModal
|
||||
isOpen={!!estimateToDelete}
|
||||
handleClose={() => setEstimateToDelete(null)}
|
||||
data={getProjectEstimateById(estimateToDelete!)}
|
||||
/>
|
||||
|
||||
<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={() => {
|
||||
setEstimateFormOpen(true);
|
||||
setEstimateToUpdate(undefined);
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
Add Estimate
|
||||
</Button>
|
||||
{currentProjectDetails?.estimate && (
|
||||
<Button variant="neutral-primary" onClick={disableEstimates} size="sm">
|
||||
Disable Estimates
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{projectEstimates ? (
|
||||
projectEstimates.length > 0 ? (
|
||||
<section className="h-full overflow-y-auto bg-custom-background-100">
|
||||
{projectEstimates.map((estimate) => (
|
||||
<EstimateListItem
|
||||
key={estimate.id}
|
||||
estimate={estimate}
|
||||
editEstimate={(estimate) => editEstimate(estimate)}
|
||||
deleteEstimate={(estimateId) => setEstimateToDelete(estimateId)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
<div className="h-full w-full py-8">
|
||||
<EmptyState type={EmptyStateType.PROJECT_SETTINGS_ESTIMATE} />
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="mt-5 space-y-5">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,4 +1,25 @@
|
|||
export * from "./create-update-estimate-modal";
|
||||
export * from "./delete-estimate-modal";
|
||||
export * from "./root";
|
||||
|
||||
export * from "./empty-screen";
|
||||
export * from "./loader-screen";
|
||||
export * from "./radio-select";
|
||||
|
||||
export * from "./estimate-search";
|
||||
export * from "./estimate-disable-switch";
|
||||
|
||||
// estimates
|
||||
export * from "./estimate-list";
|
||||
export * from "./estimate-list-item";
|
||||
export * from "./estimates-list";
|
||||
export * from "./estimate-list-item-buttons";
|
||||
|
||||
// create
|
||||
export * from "./create";
|
||||
|
||||
// update
|
||||
export * from "./update";
|
||||
|
||||
// delete
|
||||
export * from "./delete";
|
||||
|
||||
// estimate points
|
||||
export * from "./points";
|
||||
|
|
|
|||
11
web/components/estimates/loader-screen.tsx
Normal file
11
web/components/estimates/loader-screen.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { FC } from "react";
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const EstimateLoaderScreen: FC = () => (
|
||||
<Loader className="mt-5 space-y-5">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
);
|
||||
139
web/components/estimates/points/create-root.tsx
Normal file
139
web/components/estimates/points/create-root.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { Dispatch, FC, SetStateAction, useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { TEstimatePointsObject, TEstimateSystemKeys } from "@plane/types";
|
||||
import { Button, Sortable } from "@plane/ui";
|
||||
// components
|
||||
import { EstimatePointCreate, EstimatePointItemPreview } from "@/components/estimates/points";
|
||||
// constants
|
||||
import { maxEstimatesCount } from "@/constants/estimates";
|
||||
|
||||
type TEstimatePointCreateRoot = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
estimateId: string | undefined;
|
||||
estimateType: TEstimateSystemKeys;
|
||||
estimatePoints: TEstimatePointsObject[];
|
||||
setEstimatePoints: Dispatch<SetStateAction<TEstimatePointsObject[] | undefined>>;
|
||||
};
|
||||
|
||||
export const EstimatePointCreateRoot: FC<TEstimatePointCreateRoot> = observer((props) => {
|
||||
// props
|
||||
const { workspaceSlug, projectId, estimateId, estimateType, estimatePoints, setEstimatePoints } = props;
|
||||
// states
|
||||
const [estimatePointCreate, setEstimatePointCreate] = useState<TEstimatePointsObject[] | undefined>(undefined);
|
||||
|
||||
const handleEstimatePoint = useCallback(
|
||||
(mode: "add" | "remove" | "update", value: TEstimatePointsObject) => {
|
||||
switch (mode) {
|
||||
case "add":
|
||||
setEstimatePoints((prevValue) => {
|
||||
prevValue = prevValue ? [...prevValue] : [];
|
||||
return [...prevValue, value];
|
||||
});
|
||||
break;
|
||||
case "update":
|
||||
setEstimatePoints((prevValue) => {
|
||||
prevValue = prevValue ? [...prevValue] : [];
|
||||
return prevValue.map((item) => (item.key === value.key ? { ...item, value: value.value } : item));
|
||||
});
|
||||
break;
|
||||
case "remove":
|
||||
setEstimatePoints((prevValue) => {
|
||||
prevValue = prevValue ? [...prevValue] : [];
|
||||
return prevValue.filter((item) => item.key !== value.key);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[setEstimatePoints]
|
||||
);
|
||||
|
||||
const handleEstimatePointCreate = (mode: "add" | "remove", value: TEstimatePointsObject) => {
|
||||
switch (mode) {
|
||||
case "add":
|
||||
setEstimatePointCreate((prevValue) => {
|
||||
prevValue = prevValue ? [...prevValue] : [];
|
||||
return [...prevValue, value];
|
||||
});
|
||||
break;
|
||||
case "remove":
|
||||
setEstimatePointCreate((prevValue) => {
|
||||
prevValue = prevValue ? [...prevValue] : [];
|
||||
return prevValue.filter((item) => item.key !== value.key);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEstimatePoints = (updatedEstimatedOrder: TEstimatePointsObject[]) => {
|
||||
const updatedEstimateKeysOrder = updatedEstimatedOrder.map((item, index) => ({ ...item, key: index + 1 }));
|
||||
setEstimatePoints(() => updatedEstimateKeysOrder);
|
||||
};
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-custom-text-200 capitalize">{estimateType}</div>
|
||||
|
||||
<div>
|
||||
<Sortable
|
||||
data={estimatePoints}
|
||||
render={(value: TEstimatePointsObject) => (
|
||||
<EstimatePointItemPreview
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
estimateId={estimateId}
|
||||
estimateType={estimateType}
|
||||
estimatePointId={value?.id}
|
||||
estimatePoints={estimatePoints}
|
||||
estimatePoint={value}
|
||||
handleEstimatePointValueUpdate={(estimatePointValue: string) =>
|
||||
handleEstimatePoint("update", { ...value, value: estimatePointValue })
|
||||
}
|
||||
handleEstimatePointValueRemove={() => handleEstimatePoint("remove", value)}
|
||||
/>
|
||||
)}
|
||||
onChange={(data: TEstimatePointsObject[]) => handleDragEstimatePoints(data)}
|
||||
keyExtractor={(item: TEstimatePointsObject) => item?.id?.toString() || item.value.toString()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{estimatePointCreate &&
|
||||
estimatePointCreate.map((estimatePoint) => (
|
||||
<EstimatePointCreate
|
||||
key={estimatePoint?.key}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
estimateId={estimateId}
|
||||
estimateType={estimateType}
|
||||
estimatePoints={estimatePoints}
|
||||
handleEstimatePointValue={(estimatePointValue: string) =>
|
||||
handleEstimatePoint("add", { ...estimatePoint, value: estimatePointValue })
|
||||
}
|
||||
closeCallBack={() => handleEstimatePointCreate("remove", estimatePoint)}
|
||||
/>
|
||||
))}
|
||||
{estimatePoints && estimatePoints.length + (estimatePointCreate?.length || 0) <= maxEstimatesCount && (
|
||||
<Button
|
||||
variant="link-primary"
|
||||
size="sm"
|
||||
prependIcon={<Plus />}
|
||||
onClick={() =>
|
||||
handleEstimatePointCreate("add", {
|
||||
id: undefined,
|
||||
key: estimatePoints.length + (estimatePointCreate?.length || 0) + 1,
|
||||
value: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
Add {estimateType}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
173
web/components/estimates/points/create.tsx
Normal file
173
web/components/estimates/points/create.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { FC, MouseEvent, FocusEvent, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Check, Info, X } from "lucide-react";
|
||||
import { TEstimatePointsObject, TEstimateSystemKeys } from "@plane/types";
|
||||
import { Spinner, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
|
||||
// constants
|
||||
import { EEstimateSystem } from "@/constants/estimates";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { isEstimatePointValuesRepeated } from "@/helpers/estimates";
|
||||
// hooks
|
||||
import { useEstimate } from "@/hooks/store";
|
||||
|
||||
type TEstimatePointCreate = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
estimateId: string | undefined;
|
||||
estimateType: TEstimateSystemKeys;
|
||||
estimatePoints: TEstimatePointsObject[];
|
||||
handleEstimatePointValue?: (estimateValue: string) => void;
|
||||
closeCallBack: () => void;
|
||||
};
|
||||
|
||||
export const EstimatePointCreate: FC<TEstimatePointCreate> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
estimateId,
|
||||
estimateType,
|
||||
estimatePoints,
|
||||
handleEstimatePointValue,
|
||||
closeCallBack,
|
||||
} = props;
|
||||
// hooks
|
||||
const { creteEstimatePoint } = useEstimate(estimateId);
|
||||
// states
|
||||
const [estimateInputValue, setEstimateInputValue] = useState("");
|
||||
const [loader, setLoader] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleSuccess = (value: string) => {
|
||||
handleEstimatePointValue && handleEstimatePointValue(value);
|
||||
setEstimateInputValue("");
|
||||
closeCallBack();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setEstimateInputValue("");
|
||||
closeCallBack();
|
||||
};
|
||||
|
||||
const handleCreate = async (event: MouseEvent<HTMLButtonElement> | FocusEvent<HTMLInputElement, Element>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
setError(undefined);
|
||||
|
||||
if (estimateInputValue) {
|
||||
const currentEstimateType: EEstimateSystem | undefined = estimateType;
|
||||
let isEstimateValid = false;
|
||||
|
||||
const currentEstimatePointValues = estimatePoints
|
||||
.map((point) => point?.value || undefined)
|
||||
.filter((value) => value != undefined) as string[];
|
||||
const isRepeated =
|
||||
(estimateType && isEstimatePointValuesRepeated(currentEstimatePointValues, estimateType, estimateInputValue)) ||
|
||||
false;
|
||||
|
||||
if (!isRepeated) {
|
||||
if (currentEstimateType && [(EEstimateSystem.TIME, EEstimateSystem.POINTS)].includes(currentEstimateType)) {
|
||||
if (estimateInputValue && Number(estimateInputValue) && Number(estimateInputValue) >= 0) {
|
||||
isEstimateValid = true;
|
||||
}
|
||||
} else if (currentEstimateType && currentEstimateType === EEstimateSystem.CATEGORIES) {
|
||||
if (estimateInputValue && estimateInputValue.length > 0) {
|
||||
isEstimateValid = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEstimateValid) {
|
||||
if (estimateId != undefined) {
|
||||
try {
|
||||
setLoader(true);
|
||||
|
||||
const payload = {
|
||||
key: estimatePoints?.length + 1,
|
||||
value: estimateInputValue,
|
||||
};
|
||||
await creteEstimatePoint(workspaceSlug, projectId, payload);
|
||||
|
||||
setLoader(false);
|
||||
setError(undefined);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Estimate point created",
|
||||
message: "The estimate point has been created successfully.",
|
||||
});
|
||||
handleClose();
|
||||
} catch {
|
||||
setLoader(false);
|
||||
setError("We are unable to process your request, please try again.");
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Estimate point creation failed",
|
||||
message: "We were unable to create the new estimate point, please try again.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
handleSuccess(estimateInputValue);
|
||||
}
|
||||
} else {
|
||||
setLoader(false);
|
||||
setError(
|
||||
[EEstimateSystem.POINTS, EEstimateSystem.TIME].includes(estimateType)
|
||||
? "Estimate point needs to be a numeric value."
|
||||
: "Estimate point needs to be a character value."
|
||||
);
|
||||
}
|
||||
} else setError("Estimate value already exists.");
|
||||
} else setError("Estimate value cannot be empty.");
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="relative flex items-center gap-2 text-base">
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full border rounded flex items-center my-1",
|
||||
error ? `border-red-500` : `border-custom-border-200`
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={estimateInputValue}
|
||||
onChange={(e) => setEstimateInputValue(e.target.value)}
|
||||
className="border-none focus:ring-0 focus:border-0 focus:outline-none p-2.5 w-full bg-transparent"
|
||||
placeholder="Enter estimate point"
|
||||
autoFocus
|
||||
onBlur={(e) => !estimateId && handleCreate(e)}
|
||||
/>
|
||||
{error && (
|
||||
<>
|
||||
<Tooltip tooltipContent={error} position="bottom">
|
||||
<div className="flex-shrink-0 w-3.5 h-3.5 overflow-hidden mr-3 relative flex justify-center items-center text-red-500">
|
||||
<Info size={14} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{estimateId && (
|
||||
<>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer text-green-500"
|
||||
disabled={loader}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{loader ? <Spinner className="w-4 h-4" /> : <Check size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer"
|
||||
onClick={handleClose}
|
||||
disabled={loader}
|
||||
>
|
||||
<X size={14} className="text-custom-text-200" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
});
|
||||
118
web/components/estimates/points/delete.tsx
Normal file
118
web/components/estimates/points/delete.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { FC, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { MoveRight, Trash2, X } from "lucide-react";
|
||||
import { TEstimatePointsObject } from "@plane/types";
|
||||
import { Spinner, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { EstimatePointDropdown } from "@/components/estimates/points";
|
||||
// hooks
|
||||
import { useEstimate, useEstimatePoint } from "@/hooks/store";
|
||||
|
||||
type TEstimatePointDelete = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
estimateId: string;
|
||||
estimatePointId: string;
|
||||
callback: () => void;
|
||||
};
|
||||
|
||||
export const EstimatePointDelete: FC<TEstimatePointDelete> = observer((props) => {
|
||||
const { workspaceSlug, projectId, estimateId, estimatePointId, callback } = props;
|
||||
// hooks
|
||||
const { estimatePointIds, estimatePointById, deleteEstimatePoint } = useEstimate(estimateId);
|
||||
const { asJson: estimatePoint } = useEstimatePoint(estimateId, estimatePointId);
|
||||
// states
|
||||
const [loader, setLoader] = useState(false);
|
||||
const [estimateInputValue, setEstimateInputValue] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleClose = () => {
|
||||
setEstimateInputValue("");
|
||||
callback();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!workspaceSlug || !projectId || !projectId) return;
|
||||
|
||||
setError(undefined);
|
||||
|
||||
if (estimateInputValue)
|
||||
try {
|
||||
setLoader(true);
|
||||
|
||||
await deleteEstimatePoint(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
estimatePointId,
|
||||
estimateInputValue === "none" ? undefined : estimateInputValue
|
||||
);
|
||||
|
||||
setLoader(false);
|
||||
setError(undefined);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Estimate point updated",
|
||||
message: "The estimate point has been updated successfully.",
|
||||
});
|
||||
handleClose();
|
||||
} catch {
|
||||
setLoader(false);
|
||||
setError("something went wrong. please try again later");
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Estimate point failed to updated",
|
||||
message: "We are unable to process your request, please try again.",
|
||||
});
|
||||
}
|
||||
else setError("please select option");
|
||||
};
|
||||
|
||||
// derived values
|
||||
const selectDropdownOptionIds = estimatePointIds?.filter((pointId) => pointId != estimatePointId) as string[];
|
||||
const selectDropdownOptions = (selectDropdownOptionIds || [])
|
||||
?.map((pointId) => {
|
||||
const estimatePoint = estimatePointById(pointId);
|
||||
if (estimatePoint && estimatePoint?.id)
|
||||
return { id: estimatePoint.id, key: estimatePoint.key, value: estimatePoint.value };
|
||||
})
|
||||
.filter((estimatePoint) => estimatePoint != undefined) as TEstimatePointsObject[];
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-2 text-base">
|
||||
<div className="flex-grow relative flex items-center gap-3">
|
||||
<div className="w-full border border-custom-border-200 rounded p-2.5 bg-custom-background-90">
|
||||
{estimatePoint?.value}
|
||||
</div>
|
||||
<div className="text-sm first-letter:relative flex justify-center items-center gap-2 whitespace-nowrap">
|
||||
Mark as <MoveRight size={14} />
|
||||
</div>
|
||||
<EstimatePointDropdown
|
||||
options={selectDropdownOptions}
|
||||
error={error}
|
||||
callback={(estimateId: string) => {
|
||||
setEstimateInputValue(estimateId);
|
||||
setError(undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{loader ? (
|
||||
<div className="w-6 h-6 flex-shrink-0 relative flex justify-center items-center rota">
|
||||
<Spinner className="w-4 h-4" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer text-red-500"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={14} className="text-custom-text-200" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
7
web/components/estimates/points/index.ts
Normal file
7
web/components/estimates/points/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export * from "./preview";
|
||||
export * from "./create";
|
||||
export * from "./update";
|
||||
export * from "./delete";
|
||||
export * from "./select-dropdown";
|
||||
|
||||
export * from "./create-root";
|
||||
103
web/components/estimates/points/preview.tsx
Normal file
103
web/components/estimates/points/preview.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { FC, useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { GripVertical, Pencil, Trash2 } from "lucide-react";
|
||||
import { TEstimatePointsObject, TEstimateSystemKeys } from "@plane/types";
|
||||
// components
|
||||
import { EstimatePointUpdate, EstimatePointDelete } from "@/components/estimates/points";
|
||||
|
||||
type TEstimatePointItemPreview = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
estimateId: string | undefined;
|
||||
estimateType: TEstimateSystemKeys;
|
||||
estimatePointId: string | undefined;
|
||||
estimatePoint: TEstimatePointsObject;
|
||||
estimatePoints: TEstimatePointsObject[];
|
||||
handleEstimatePointValueUpdate?: (estimateValue: string) => void;
|
||||
handleEstimatePointValueRemove?: () => void;
|
||||
};
|
||||
|
||||
export const EstimatePointItemPreview: FC<TEstimatePointItemPreview> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
estimateId,
|
||||
estimateType,
|
||||
estimatePointId,
|
||||
estimatePoint,
|
||||
estimatePoints,
|
||||
handleEstimatePointValueUpdate,
|
||||
handleEstimatePointValueRemove,
|
||||
} = props;
|
||||
// state
|
||||
const [estimatePointEditToggle, setEstimatePointEditToggle] = useState(false);
|
||||
const [estimatePointDeleteToggle, setEstimatePointDeleteToggle] = useState(false);
|
||||
// ref
|
||||
const EstimatePointValueRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!estimatePointEditToggle && !estimatePointDeleteToggle)
|
||||
EstimatePointValueRef?.current?.addEventListener("dblclick", () => setEstimatePointEditToggle(true));
|
||||
}, [estimatePointDeleteToggle, estimatePointEditToggle]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!estimatePointEditToggle && !estimatePointDeleteToggle && (
|
||||
<div className="border border-custom-border-200 rounded relative flex items-center px-2.5 gap-2 text-base my-1">
|
||||
<div className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer">
|
||||
<GripVertical size={14} className="text-custom-text-200" />
|
||||
</div>
|
||||
<div ref={EstimatePointValueRef} className="py-2.5 w-full">
|
||||
{estimatePoint?.value ? (
|
||||
`${estimatePoint?.value}`
|
||||
) : (
|
||||
<span className="text-custom-text-400">Enter estimate point</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer"
|
||||
onClick={() => setEstimatePointEditToggle(true)}
|
||||
>
|
||||
<Pencil size={14} className="text-custom-text-200" />
|
||||
</div>
|
||||
<div
|
||||
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer"
|
||||
onClick={() =>
|
||||
estimateId && estimatePointId
|
||||
? setEstimatePointDeleteToggle(true)
|
||||
: handleEstimatePointValueRemove && handleEstimatePointValueRemove()
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} className="text-custom-text-200" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{estimatePoint && estimatePointEditToggle && (
|
||||
<EstimatePointUpdate
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
estimateId={estimateId}
|
||||
estimateType={estimateType}
|
||||
estimatePointId={estimatePointId}
|
||||
estimatePoints={estimatePoints}
|
||||
estimatePoint={estimatePoint}
|
||||
handleEstimatePointValueUpdate={(estimatePointValue: string) =>
|
||||
handleEstimatePointValueUpdate && handleEstimatePointValueUpdate(estimatePointValue)
|
||||
}
|
||||
closeCallBack={() => setEstimatePointEditToggle(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{estimateId && estimatePointId && estimatePointDeleteToggle && (
|
||||
<EstimatePointDelete
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
estimateId={estimateId}
|
||||
estimatePointId={estimatePointId}
|
||||
callback={() => estimateId && setEstimatePointDeleteToggle(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
127
web/components/estimates/points/select-dropdown.tsx
Normal file
127
web/components/estimates/points/select-dropdown.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { FC, useRef, Fragment, useState } from "react";
|
||||
import { Info, Check, ChevronDown } from "lucide-react";
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { TEstimatePointsObject } from "@plane/types";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import useDynamicDropdownPosition from "@/hooks/use-dynamic-dropdown";
|
||||
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
|
||||
type TEstimatePointDropdown = {
|
||||
options: TEstimatePointsObject[];
|
||||
error: string | undefined;
|
||||
callback: (estimateId: string) => void;
|
||||
};
|
||||
|
||||
export const EstimatePointDropdown: FC<TEstimatePointDropdown> = (props) => {
|
||||
const { options, error, callback } = props;
|
||||
// states
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [selectedOption, setSelectedOption] = useState<string | undefined>(undefined);
|
||||
// ref
|
||||
const dropdownContainerRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useDynamicDropdownPosition(isDropdownOpen, () => setIsDropdownOpen(false), buttonRef, dropdownRef);
|
||||
useOutsideClickDetector(dropdownContainerRef, () => setIsDropdownOpen(false));
|
||||
|
||||
// derived values
|
||||
const selectedValue = selectedOption
|
||||
? selectedOption === "none"
|
||||
? {
|
||||
id: undefined,
|
||||
key: undefined,
|
||||
value: "None",
|
||||
}
|
||||
: options.find((option) => option?.id === selectedOption)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div ref={dropdownContainerRef} className="w-full relative">
|
||||
<Listbox
|
||||
as="div"
|
||||
value={selectedOption}
|
||||
onChange={(selectedOption) => {
|
||||
setSelectedOption(selectedOption);
|
||||
callback(selectedOption);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className="w-full flex-shrink-0 text-left"
|
||||
>
|
||||
<Listbox.Button
|
||||
type="button"
|
||||
ref={buttonRef}
|
||||
onClick={() => setIsDropdownOpen((prev) => !prev)}
|
||||
className={cn(
|
||||
"relative w-full rounded border flex items-center gap-3 p-2.5",
|
||||
error ? `border-red-500` : `border-custom-border-200`
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(`w-full text-sm text-left`, !selectedValue ? "text-custom-text-300" : "text-custom-text-100")}
|
||||
>
|
||||
{selectedValue?.value || "Select an estimate point"}
|
||||
</div>
|
||||
<ChevronDown className={`size-3 ${true ? "stroke-onboarding-text-400" : "stroke-onboarding-text-100"}`} />
|
||||
{error && (
|
||||
<>
|
||||
<Tooltip tooltipContent={error} position="bottom">
|
||||
<div className="flex-shrink-0 w-3.5 h-3.5 overflow-hidden relative flex justify-center items-center text-red-500">
|
||||
<Info size={14} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
show={isDropdownOpen}
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Listbox.Options
|
||||
ref={dropdownRef}
|
||||
className="fixed z-10 mt-1 h-fit w-48 sm:w-60 overflow-y-auto rounded-md border border-custom-border-200 bg-custom-background-100 shadow-sm focus:outline-none"
|
||||
>
|
||||
<div className="p-1.5">
|
||||
<Listbox.Option
|
||||
value={"none"}
|
||||
className={cn(
|
||||
`cursor-pointer select-none truncate rounded px-1 py-1.5 hover:bg-custom-background-90`,
|
||||
selectedOption === "none" ? "text-custom-text-100" : "text-custom-text-300"
|
||||
)}
|
||||
>
|
||||
<div className="relative flex items-center text-wrap gap-2 px-1 py-0.5">
|
||||
<div className="text-sm font-medium w-full line-clamp-1">None</div>
|
||||
{selectedOption === "none" && <Check size={12} />}
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
{options.map((option) => (
|
||||
<Listbox.Option
|
||||
key={option?.key}
|
||||
value={option?.id}
|
||||
className={cn(
|
||||
`cursor-pointer select-none truncate rounded px-1 py-1.5 hover:bg-custom-background-90`,
|
||||
selectedOption === option?.id ? "text-custom-text-100" : "text-custom-text-300"
|
||||
)}
|
||||
>
|
||||
<div className="relative flex items-center text-wrap gap-2 px-1 py-0.5">
|
||||
<div className="text-sm font-medium w-full line-clamp-1">{option.value}</div>
|
||||
{selectedOption === option?.id && <Check size={12} />}
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
184
web/components/estimates/points/update.tsx
Normal file
184
web/components/estimates/points/update.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { FC, MouseEvent, useEffect, FocusEvent, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Check, Info, X } from "lucide-react";
|
||||
import { TEstimatePointsObject, TEstimateSystemKeys } from "@plane/types";
|
||||
import { Spinner, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
|
||||
// constants
|
||||
import { EEstimateSystem } from "@/constants/estimates";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { isEstimatePointValuesRepeated } from "@/helpers/estimates";
|
||||
// hooks
|
||||
import { useEstimatePoint } from "@/hooks/store";
|
||||
|
||||
type TEstimatePointUpdate = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
estimateId: string | undefined;
|
||||
estimatePointId: string | undefined;
|
||||
estimateType: TEstimateSystemKeys;
|
||||
estimatePoints: TEstimatePointsObject[];
|
||||
estimatePoint: TEstimatePointsObject;
|
||||
handleEstimatePointValueUpdate: (estimateValue: string) => void;
|
||||
closeCallBack: () => void;
|
||||
};
|
||||
|
||||
export const EstimatePointUpdate: FC<TEstimatePointUpdate> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
estimateId,
|
||||
estimatePointId,
|
||||
estimateType,
|
||||
estimatePoints,
|
||||
estimatePoint,
|
||||
handleEstimatePointValueUpdate,
|
||||
closeCallBack,
|
||||
} = props;
|
||||
// hooks
|
||||
const { updateEstimatePoint } = useEstimatePoint(estimateId, estimatePointId);
|
||||
// states
|
||||
const [loader, setLoader] = useState(false);
|
||||
const [estimateInputValue, setEstimateInputValue] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (estimateInputValue === undefined && estimatePoint) setEstimateInputValue(estimatePoint?.value || "");
|
||||
}, [estimateInputValue, estimatePoint]);
|
||||
|
||||
const handleSuccess = (value: string) => {
|
||||
handleEstimatePointValueUpdate(value);
|
||||
setEstimateInputValue("");
|
||||
closeCallBack();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setEstimateInputValue("");
|
||||
closeCallBack();
|
||||
};
|
||||
|
||||
const handleUpdate = async (event: MouseEvent<HTMLButtonElement> | FocusEvent<HTMLInputElement, Element>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
setError(undefined);
|
||||
|
||||
if (estimateInputValue) {
|
||||
const currentEstimateType: EEstimateSystem | undefined = estimateType;
|
||||
let isEstimateValid = false;
|
||||
|
||||
const currentEstimatePointValues = estimatePoints
|
||||
.map((point) => (point?.id != estimatePoint?.id ? point?.value : undefined))
|
||||
.filter((value) => value != undefined) as string[];
|
||||
const isRepeated =
|
||||
(estimateType && isEstimatePointValuesRepeated(currentEstimatePointValues, estimateType, estimateInputValue)) ||
|
||||
false;
|
||||
|
||||
if (!isRepeated) {
|
||||
if (currentEstimateType && [(EEstimateSystem.TIME, EEstimateSystem.POINTS)].includes(currentEstimateType)) {
|
||||
if (estimateInputValue && Number(estimateInputValue) && Number(estimateInputValue) >= 0) {
|
||||
isEstimateValid = true;
|
||||
}
|
||||
} else if (currentEstimateType && currentEstimateType === EEstimateSystem.CATEGORIES) {
|
||||
if (estimateInputValue && estimateInputValue.length > 0) {
|
||||
isEstimateValid = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEstimateValid) {
|
||||
if (estimateId != undefined) {
|
||||
if (estimateInputValue === estimatePoint.value) {
|
||||
setLoader(false);
|
||||
setError(undefined);
|
||||
handleClose();
|
||||
} else
|
||||
try {
|
||||
setLoader(true);
|
||||
|
||||
const payload = {
|
||||
value: estimateInputValue,
|
||||
};
|
||||
await updateEstimatePoint(workspaceSlug, projectId, payload);
|
||||
|
||||
setLoader(false);
|
||||
setError(undefined);
|
||||
handleClose();
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Estimate modified",
|
||||
message: "The estimate point has been updated in your project.",
|
||||
});
|
||||
} catch {
|
||||
setLoader(false);
|
||||
setError("We are unable to process your request, please try again.");
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Estimate modification failed",
|
||||
message: "We were unable to modify the estimate, please try again",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
handleSuccess(estimateInputValue);
|
||||
}
|
||||
} else {
|
||||
setLoader(false);
|
||||
setError(
|
||||
[EEstimateSystem.POINTS, EEstimateSystem.TIME].includes(estimateType)
|
||||
? "Estimate point needs to be a numeric value."
|
||||
: "Estimate point needs to be a character value."
|
||||
);
|
||||
}
|
||||
} else setError("Estimate value already exists.");
|
||||
} else setError("Estimate value cannot be empty.");
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="relative flex items-center gap-2 text-base">
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full border rounded flex items-center my-1",
|
||||
error ? `border-red-500` : `border-custom-border-200`
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={estimateInputValue}
|
||||
onChange={(e) => setEstimateInputValue(e.target.value)}
|
||||
className="border-none focus:ring-0 focus:border-0 focus:outline-none p-2.5 w-full bg-transparent"
|
||||
placeholder="Enter estimate point"
|
||||
onBlur={(e) => !estimateId && handleUpdate(e)}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<>
|
||||
<Tooltip tooltipContent={error} position="bottom">
|
||||
<div className="flex-shrink-0 w-3.5 h-3.5 overflow-hidden mr-3 relative flex justify-center items-center text-red-500">
|
||||
<Info size={14} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{estimateId && (
|
||||
<>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer text-green-500"
|
||||
disabled={loader}
|
||||
onClick={handleUpdate}
|
||||
>
|
||||
{loader ? <Spinner className="w-4 h-4" /> : <Check size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer"
|
||||
onClick={handleClose}
|
||||
disabled={loader}
|
||||
>
|
||||
<X size={14} className="text-custom-text-200" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
});
|
||||
85
web/components/estimates/radio-select.tsx
Normal file
85
web/components/estimates/radio-select.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import React from "react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
type RadioInputProps = {
|
||||
name?: string;
|
||||
label: string | React.ReactNode | undefined;
|
||||
wrapperClassName?: string;
|
||||
fieldClassName?: string;
|
||||
buttonClassName?: string;
|
||||
labelClassName?: string;
|
||||
ariaLabel?: string;
|
||||
options: { label: string | React.ReactNode; value: string; disabled?: boolean }[];
|
||||
vertical?: boolean;
|
||||
selected: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const RadioInput = ({
|
||||
name = "radio-input",
|
||||
label: inputLabel,
|
||||
labelClassName: inputLabelClassName = "",
|
||||
wrapperClassName: inputWrapperClassName = "",
|
||||
fieldClassName: inputFieldClassName = "",
|
||||
buttonClassName: inputButtonClassName = "",
|
||||
options,
|
||||
vertical,
|
||||
selected,
|
||||
ariaLabel,
|
||||
onChange,
|
||||
className,
|
||||
}: RadioInputProps) => {
|
||||
const wrapperClass = vertical ? "flex flex-col gap-1" : "flex gap-2";
|
||||
|
||||
const setSelected = (value: string) => {
|
||||
onChange(value);
|
||||
};
|
||||
|
||||
let aria = ariaLabel ? ariaLabel.toLowerCase().replace(" ", "-") : "";
|
||||
if (!aria && typeof inputLabel === "string") {
|
||||
aria = inputLabel.toLowerCase().replace(" ", "-");
|
||||
} else {
|
||||
aria = "radio-input";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={cn(`mb-2`, inputLabelClassName)}>{inputLabel}</div>
|
||||
<div className={cn(`${wrapperClass}`, inputWrapperClassName)}>
|
||||
{options.map(({ value, label, disabled }, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => !disabled && setSelected(value)}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
disabled ? `bg-custom-background-200 border-custom-border-200 cursor-not-allowed` : ``,
|
||||
inputFieldClassName
|
||||
)}
|
||||
>
|
||||
<input
|
||||
id={`${name}_${index}`}
|
||||
name={name}
|
||||
className={cn(
|
||||
`group flex size-5 items-center justify-center rounded-full border border-custom-border-400 bg-custom-background-500 cursor-pointer`,
|
||||
selected === value ? `bg-custom-primary-200 border-custom-primary-100 ` : ``,
|
||||
disabled ? `bg-custom-background-200 border-custom-border-200 cursor-not-allowed` : ``,
|
||||
inputButtonClassName
|
||||
)}
|
||||
type="radio"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
checked={selected === value}
|
||||
/>
|
||||
<label htmlFor={`${name}_${index}`} className="text-base cursor-pointer">
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RadioInput;
|
||||
118
web/components/estimates/root.tsx
Normal file
118
web/components/estimates/root.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { FC, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import {
|
||||
EstimateLoaderScreen,
|
||||
EstimateEmptyScreen,
|
||||
EstimateDisableSwitch,
|
||||
CreateEstimateModal,
|
||||
UpdateEstimateModal,
|
||||
DeleteEstimateModal,
|
||||
EstimateList,
|
||||
} from "@/components/estimates";
|
||||
// hooks
|
||||
import { useProject, useProjectEstimates } from "@/hooks/store";
|
||||
|
||||
type TEstimateRoot = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
export const EstimateRoot: FC<TEstimateRoot> = observer((props) => {
|
||||
const { workspaceSlug, projectId, isAdmin } = props;
|
||||
// hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { loader, currentActiveEstimateId, archivedEstimateIds, getProjectEstimates } = useProjectEstimates();
|
||||
// states
|
||||
const [isEstimateCreateModalOpen, setIsEstimateCreateModalOpen] = useState(false);
|
||||
const [estimateToUpdate, setEstimateToUpdate] = useState<string | undefined>();
|
||||
const [estimateToDelete, setEstimateToDelete] = useState<string | undefined>();
|
||||
|
||||
const { isLoading: isSWRLoading } = useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_ESTIMATES_${workspaceSlug}_${projectId}` : null,
|
||||
async () => workspaceSlug && projectId && getProjectEstimates(workspaceSlug, projectId)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
{loader === "init-loader" || isSWRLoading ? (
|
||||
<EstimateLoaderScreen />
|
||||
) : (
|
||||
<div className="space-y-12">
|
||||
{/* header */}
|
||||
<div className="text-xl font-medium text-custom-text-100 border-b border-custom-border-200 py-3.5">
|
||||
Estimates
|
||||
</div>
|
||||
|
||||
{/* current active estimate section */}
|
||||
{currentActiveEstimateId ? (
|
||||
<div className="">
|
||||
{/* estimates activated deactivated section */}
|
||||
<div className="relative border-b border-custom-border-200 pb-4 flex justify-between items-center gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-medium text-custom-text-100">Enable estimates for my project</h3>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
They help you in communicating complexity and workload of the team.
|
||||
</p>
|
||||
</div>
|
||||
<EstimateDisableSwitch workspaceSlug={workspaceSlug} projectId={projectId} isAdmin={isAdmin} />
|
||||
</div>
|
||||
{/* active estimates section */}
|
||||
<EstimateList
|
||||
estimateIds={[currentActiveEstimateId]}
|
||||
isAdmin={isAdmin}
|
||||
isEstimateEnabled={Boolean(currentProjectDetails?.estimate)}
|
||||
isEditable
|
||||
onEditClick={(estimateId: string) => setEstimateToUpdate(estimateId)}
|
||||
onDeleteClick={(estimateId: string) => setEstimateToDelete(estimateId)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EstimateEmptyScreen onButtonClick={() => setIsEstimateCreateModalOpen(true)} />
|
||||
)}
|
||||
|
||||
{/* archived estimates section */}
|
||||
{archivedEstimateIds && archivedEstimateIds.length > 0 && (
|
||||
<div className="">
|
||||
<div className="border-b border-custom-border-200 space-y-1 pb-4">
|
||||
<h3 className="text-lg font-medium text-custom-text-100">Archived estimates</h3>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Estimates have gone through a change, these are the estimates you had in your older versions which
|
||||
were not in use. Read more about them
|
||||
<a href={"#"} target="_blank" className="text-custom-primary-100/80 hover:text-custom-primary-100">
|
||||
here.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<EstimateList estimateIds={archivedEstimateIds} isAdmin={isAdmin} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CRUD modals */}
|
||||
<CreateEstimateModal
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
isOpen={isEstimateCreateModalOpen}
|
||||
handleClose={() => setIsEstimateCreateModalOpen(false)}
|
||||
/>
|
||||
<UpdateEstimateModal
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
estimateId={estimateToUpdate ? estimateToUpdate : undefined}
|
||||
isOpen={estimateToUpdate ? true : false}
|
||||
handleClose={() => setEstimateToUpdate(undefined)}
|
||||
/>
|
||||
<DeleteEstimateModal
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
estimateId={estimateToDelete ? estimateToDelete : undefined}
|
||||
isOpen={estimateToDelete ? true : false}
|
||||
handleClose={() => setEstimateToDelete(undefined)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
2
web/components/estimates/update/index.ts
Normal file
2
web/components/estimates/update/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./modal";
|
||||
export * from "./stage-one";
|
||||
42
web/components/estimates/update/modal.tsx
Normal file
42
web/components/estimates/update/modal.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Button } from "@plane/ui";
|
||||
// components
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@/components/core";
|
||||
import { EstimateUpdateStageOne } from "@/components/estimates";
|
||||
|
||||
type TUpdateEstimateModal = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
estimateId: string | undefined;
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const UpdateEstimateModal: FC<TUpdateEstimateModal> = observer((props) => {
|
||||
// props
|
||||
const { isOpen, handleClose } = props;
|
||||
|
||||
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">
|
||||
<div className="text-xl font-medium text-custom-text-200">Edit estimate system</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5">
|
||||
<EstimateUpdateStageOne />
|
||||
</div>
|
||||
|
||||
<div className="relative flex justify-end items-center gap-3 px-5 pt-5 border-t border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
42
web/components/estimates/update/stage-one.tsx
Normal file
42
web/components/estimates/update/stage-one.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { FC } from "react";
|
||||
import { Crown } from "lucide-react";
|
||||
import { TEstimateUpdateStageKeys } from "@plane/types";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// constants
|
||||
import { ESTIMATE_OPTIONS_STAGE_ONE } from "@/constants/estimates";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
type TEstimateUpdateStageOne = {
|
||||
handleEstimateEditType?: (stage: TEstimateUpdateStageKeys) => void;
|
||||
};
|
||||
|
||||
export const EstimateUpdateStageOne: FC<TEstimateUpdateStageOne> = (props) => {
|
||||
const { handleEstimateEditType } = props;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{ESTIMATE_OPTIONS_STAGE_ONE &&
|
||||
ESTIMATE_OPTIONS_STAGE_ONE.map((stage) => (
|
||||
<div
|
||||
key={stage.key}
|
||||
className={cn(
|
||||
"border border-custom-border-300 cursor-pointer space-y-1 p-3 rounded transition-colors",
|
||||
stage?.is_ee ? `bg-custom-background-90` : `hover:bg-custom-background-90`
|
||||
)}
|
||||
onClick={() => !stage?.is_ee && handleEstimateEditType && handleEstimateEditType(stage.key)}
|
||||
>
|
||||
<h3 className="text-base font-medium relative flex items-center gap-2">
|
||||
{stage.title}
|
||||
{stage?.is_ee && (
|
||||
<Tooltip tooltipContent={"upgrade"}>
|
||||
<Crown size={12} className="text-amber-400" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-custom-text-200">{stage.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -15,7 +15,7 @@ import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelect
|
|||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { SPACE_BASE_PATH, SPACE_BASE_URL } from "@/helpers/common.helper";
|
||||
import { SPACE_BASE_URL } from "@/helpers/common.helper";
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import {
|
||||
|
|
@ -100,7 +100,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
|
|||
[workspaceSlug, projectId, updateFilters]
|
||||
);
|
||||
|
||||
const DEPLOY_URL = SPACE_BASE_URL + SPACE_BASE_PATH;
|
||||
const publishedURL = `${SPACE_BASE_URL}/issues/${currentProjectDetails?.anchor}`;
|
||||
|
||||
const canUserCreateIssue =
|
||||
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
||||
|
|
@ -159,9 +159,9 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
|
|||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
{currentProjectDetails?.is_deployed && DEPLOY_URL && (
|
||||
{currentProjectDetails?.anchor && (
|
||||
<a
|
||||
href={`${DEPLOY_URL}/${workspaceSlug}/${currentProjectDetails?.id}`}
|
||||
href={publishedURL}
|
||||
className="group flex items-center gap-1.5 rounded bg-custom-primary-100/10 px-2.5 py-1 text-xs font-medium text-custom-primary-100"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { IssueLabelSelect } from "@/components/issues/select";
|
|||
// helpers
|
||||
import { renderFormattedPayloadDate, getDate } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useEstimate } from "@/hooks/store";
|
||||
import { useProjectEstimates } from "@/hooks/store";
|
||||
|
||||
type TInboxIssueProperties = {
|
||||
projectId: string;
|
||||
|
|
@ -29,7 +29,7 @@ type TInboxIssueProperties = {
|
|||
export const InboxIssueProperties: FC<TInboxIssueProperties> = observer((props) => {
|
||||
const { projectId, data, handleData, isVisible = false } = props;
|
||||
// hooks
|
||||
const { areEstimatesEnabledForProject } = useEstimate();
|
||||
const { areEstimateEnabledByProjectId } = useProjectEstimates();
|
||||
// states
|
||||
const [parentIssueModalOpen, setParentIssueModalOpen] = useState(false);
|
||||
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | undefined>(undefined);
|
||||
|
|
@ -142,10 +142,10 @@ export const InboxIssueProperties: FC<TInboxIssueProperties> = observer((props)
|
|||
)}
|
||||
|
||||
{/* estimate */}
|
||||
{isVisible && areEstimatesEnabledForProject(projectId) && (
|
||||
{isVisible && projectId && areEstimateEnabledByProjectId(projectId) && (
|
||||
<div className="h-7">
|
||||
<EstimateDropdown
|
||||
value={data?.estimate_point || null}
|
||||
value={data?.estimate_point || undefined}
|
||||
onChange={(estimatePoint) => handleData("estimate_point", estimatePoint)}
|
||||
projectId={projectId}
|
||||
buttonVariant="border-with-text"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { FC } from "react";
|
|||
import { observer } from "mobx-react";
|
||||
import { Triangle } from "lucide-react";
|
||||
// hooks
|
||||
import { useEstimate, useIssueDetail } from "@/hooks/store";
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
// components
|
||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||
|
||||
|
|
@ -14,15 +14,11 @@ export const IssueEstimateActivity: FC<TIssueEstimateActivity> = observer((props
|
|||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
const { areEstimatesEnabledForCurrentProject, getEstimatePointValue } = useEstimate();
|
||||
|
||||
const activity = getActivityById(activityId);
|
||||
|
||||
if (!activity) return <></>;
|
||||
|
||||
const estimateValue = getEstimatePointValue(Number(activity.new_value), null);
|
||||
const currentPoint = Number(activity.new_value) + 1;
|
||||
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<Triangle size={14} color="#6b7280" aria-hidden="true" />}
|
||||
|
|
@ -31,15 +27,7 @@ export const IssueEstimateActivity: FC<TIssueEstimateActivity> = observer((props
|
|||
>
|
||||
<>
|
||||
{activity.new_value ? `set the estimate point to ` : `removed the estimate point `}
|
||||
{activity.new_value && (
|
||||
<>
|
||||
<span className="font-medium text-custom-text-100">
|
||||
{areEstimatesEnabledForCurrentProject
|
||||
? estimateValue
|
||||
: `${currentPoint} ${currentPoint > 1 ? "points" : "point"}`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{activity.new_value ? activity.new_value : activity?.old_value || ""}
|
||||
{showIssue && (activity.new_value ? ` to ` : ` from `)}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
|
|||
workspaceSlug={workspaceSlug}
|
||||
issueId={issueId}
|
||||
activityOperations={activityOperations}
|
||||
showAccessSpecifier={project.is_deployed}
|
||||
showAccessSpecifier={!!project.anchor}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{!disabled && (
|
||||
|
|
@ -150,7 +150,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
|
|||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
activityOperations={activityOperations}
|
||||
showAccessSpecifier={project.is_deployed}
|
||||
showAccessSpecifier={!!project.anchor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -161,7 +161,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
|
|||
workspaceSlug={workspaceSlug}
|
||||
issueId={issueId}
|
||||
activityOperations={activityOperations}
|
||||
showAccessSpecifier={project.is_deployed}
|
||||
showAccessSpecifier={!!project.anchor}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{!disabled && (
|
||||
|
|
@ -170,7 +170,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
|
|||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
activityOperations={activityOperations}
|
||||
showAccessSpecifier={project.is_deployed}
|
||||
showAccessSpecifier={!!project.anchor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"
|
|||
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
||||
import { copyTextToClipboard } from "@/helpers/string.helper";
|
||||
// types
|
||||
import { useEstimate, useIssueDetail, useProject, useProjectState, useUser } from "@/hooks/store";
|
||||
import { useProjectEstimates, useIssueDetail, useProject, useProjectState, useUser } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// components
|
||||
import type { TIssueOperations } from "./root";
|
||||
|
|
@ -82,7 +82,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { data: currentUser } = useUser();
|
||||
const { areEstimatesEnabledForCurrentProject } = useEstimate();
|
||||
const { areEstimateEnabledByProjectId } = useProjectEstimates();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
|
|
@ -311,15 +311,17 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{areEstimatesEnabledForCurrentProject && (
|
||||
{projectId && areEstimateEnabledByProjectId(projectId) && (
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<Triangle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Estimate</span>
|
||||
</div>
|
||||
<EstimateDropdown
|
||||
value={issue?.estimate_point !== null ? issue.estimate_point : null}
|
||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val })}
|
||||
value={issue?.estimate_point ?? undefined}
|
||||
onChange={(val: string | undefined) =>
|
||||
issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val })
|
||||
}
|
||||
projectId={projectId}
|
||||
disabled={!isEditable}
|
||||
buttonVariant="transparent-with-text"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import { cn } from "@/helpers/common.helper";
|
|||
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useEstimate, useLabel, useIssues, useProjectState, useProject } from "@/hooks/store";
|
||||
import { useEventTracker, useLabel, useIssues, useProjectState, useProject, useProjectEstimates } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// local components
|
||||
import { IssuePropertyLabels } from "../properties/labels";
|
||||
|
|
@ -42,6 +42,9 @@ export interface IIssueProperties {
|
|||
}
|
||||
|
||||
export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
const { issue, updateIssue, displayProperties, activeLayout, isReadOnly, className } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
|
|
@ -53,13 +56,10 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
|||
const {
|
||||
issues: { addCycleToIssue, removeCycleFromIssue },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
const { areEstimatesEnabledForCurrentProject } = useEstimate();
|
||||
const { areEstimateEnabledByProjectId } = useProjectEstimates();
|
||||
const { getStateById } = useProjectState();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
const currentLayout = `${activeLayout} layout`;
|
||||
// derived values
|
||||
const stateDetails = getStateById(issue.state_id);
|
||||
|
|
@ -220,7 +220,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
|||
);
|
||||
};
|
||||
|
||||
const handleEstimate = (value: number | null) => {
|
||||
const handleEstimate = (value: string | undefined) => {
|
||||
updateIssue &&
|
||||
updateIssue(issue.project_id, issue.id, { estimate_point: value }).then(() => {
|
||||
captureIssueEvent({
|
||||
|
|
@ -394,11 +394,11 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
|||
)}
|
||||
|
||||
{/* estimates */}
|
||||
{areEstimatesEnabledForCurrentProject && (
|
||||
{projectId && areEstimateEnabledByProjectId(projectId?.toString()) && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="estimate">
|
||||
<div className="h-5" onClick={handleEventPropagation}>
|
||||
<EstimateDropdown
|
||||
value={issue.estimate_point}
|
||||
value={issue.estimate_point ?? undefined}
|
||||
onChange={handleEstimate}
|
||||
projectId={issue.project_id}
|
||||
disabled={isReadOnly}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const SpreadsheetEstimateColumn: React.FC<Props> = observer((props: Props
|
|||
return (
|
||||
<div className="h-11 border-b-[0.5px] border-custom-border-200">
|
||||
<EstimateDropdown
|
||||
value={issue.estimate_point}
|
||||
value={issue.estimate_point || undefined}
|
||||
onChange={(data) =>
|
||||
onChange(issue, { estimate_point: data }, { changed_property: "estimate_point", change_details: data })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -499,7 +499,7 @@ export const handleGroupDragDrop = async (
|
|||
// update updatedIssue values based on the source and destination groupIds
|
||||
if (source.groupId && destination.groupId && source.groupId !== destination.groupId) {
|
||||
const groupKey = ISSUE_FILTER_DEFAULT_DATA[groupBy];
|
||||
let groupValue = clone(sourceIssue[groupKey]);
|
||||
let groupValue: any = clone(sourceIssue[groupKey]);
|
||||
|
||||
// If groupValues is an array, remove source groupId and add destination groupId
|
||||
if (Array.isArray(groupValue)) {
|
||||
|
|
@ -519,7 +519,7 @@ export const handleGroupDragDrop = async (
|
|||
// update updatedIssue values based on the source and destination subGroupIds
|
||||
if (subGroupBy && source.subGroupId && destination.subGroupId && source.subGroupId !== destination.subGroupId) {
|
||||
const subGroupKey = ISSUE_FILTER_DEFAULT_DATA[subGroupBy];
|
||||
let subGroupValue = clone(sourceIssue[subGroupKey]);
|
||||
let subGroupValue: any = clone(sourceIssue[subGroupKey]);
|
||||
|
||||
// If subGroupValue is an array, remove source subGroupId and add destination subGroupId
|
||||
if (Array.isArray(subGroupValue)) {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,14 @@ import { renderFormattedPayloadDate, getDate } from "@/helpers/date-time.helper"
|
|||
import { getChangedIssuefields, getDescriptionPlaceholder } from "@/helpers/issue.helper";
|
||||
import { shouldRenderProject } from "@/helpers/project.helper";
|
||||
// hooks
|
||||
import { useAppRouter, useEstimate, useInstance, useIssueDetail, useProject, useWorkspace } from "@/hooks/store";
|
||||
import {
|
||||
useAppRouter,
|
||||
useProjectEstimates,
|
||||
useInstance,
|
||||
useIssueDetail,
|
||||
useProject,
|
||||
useWorkspace,
|
||||
} from "@/hooks/store";
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties";
|
||||
// services
|
||||
|
|
@ -120,7 +127,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||
const { projectId: routeProjectId } = useAppRouter();
|
||||
const { config } = useInstance();
|
||||
const { getProjectById } = useProject();
|
||||
const { areEstimatesEnabledForProject } = useEstimate();
|
||||
const { areEstimateEnabledByProjectId } = useProjectEstimates();
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (editorRef.current?.isEditorReadyToDiscard()) {
|
||||
|
|
@ -659,14 +666,14 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||
)}
|
||||
/>
|
||||
)}
|
||||
{areEstimatesEnabledForProject(projectId) && (
|
||||
{projectId && areEstimateEnabledByProjectId(projectId) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="estimate_point"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<EstimateDropdown
|
||||
value={value}
|
||||
value={value || undefined}
|
||||
onChange={(estimatePoint) => {
|
||||
onChange(estimatePoint);
|
||||
handleFormChange();
|
||||
|
|
|
|||
|
|
@ -197,14 +197,14 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
|||
<span>Estimate</span>
|
||||
</div>
|
||||
<EstimateDropdown
|
||||
value={issue?.estimate_point !== null ? issue.estimate_point : null}
|
||||
value={issue.estimate_point ?? undefined}
|
||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val })}
|
||||
projectId={projectId}
|
||||
disabled={disabled}
|
||||
buttonVariant="transparent-with-text"
|
||||
className="w-3/4 flex-grow group"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName={`text-sm ${issue?.estimate_point !== null ? "" : "text-custom-text-400"}`}
|
||||
buttonClassName={`text-sm ${issue?.estimate_point !== undefined ? "" : "text-custom-text-400"}`}
|
||||
placeholder="None"
|
||||
hideIcon
|
||||
dropdownArrow
|
||||
|
|
|
|||
|
|
@ -10,13 +10,14 @@ import { FavoriteStar } from "@/components/core";
|
|||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import { ModuleQuickActions } from "@/components/modules";
|
||||
// constants
|
||||
import { EEstimateSystem } from "@/constants/estimates";
|
||||
import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "@/constants/event-tracker";
|
||||
import { MODULE_STATUS } from "@/constants/module";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { getDate, renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useMember, useModule, useUser } from "@/hooks/store";
|
||||
import { useEventTracker, useMember, useModule, useProjectEstimates, useUser } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -37,6 +38,8 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
|
|||
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule();
|
||||
const { getUserDetails } = useMember();
|
||||
const { captureEvent } = useEventTracker();
|
||||
const { currentActiveEstimateId, areEstimateEnabledByProjectId, estimateById } = useProjectEstimates();
|
||||
|
||||
// derived values
|
||||
const moduleDetails = getModuleById(moduleId);
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
|
|
@ -120,14 +123,30 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
|
|||
|
||||
if (!moduleDetails) return null;
|
||||
|
||||
const moduleTotalIssues =
|
||||
moduleDetails.backlog_issues +
|
||||
moduleDetails.unstarted_issues +
|
||||
moduleDetails.started_issues +
|
||||
moduleDetails.completed_issues +
|
||||
moduleDetails.cancelled_issues;
|
||||
/**
|
||||
* NOTE: This completion percentage calculation is based on the total issues count.
|
||||
* when estimates are available and estimate type is points, we should consider the estimate point count
|
||||
* when estimates are available and estimate type is not points, then by default we consider the issue count
|
||||
*/
|
||||
const isEstimateEnabled =
|
||||
projectId &&
|
||||
currentActiveEstimateId &&
|
||||
areEstimateEnabledByProjectId(projectId.toString()) &&
|
||||
estimateById(currentActiveEstimateId)?.type === EEstimateSystem.POINTS;
|
||||
|
||||
const completionPercentage = (moduleDetails.completed_issues / moduleTotalIssues) * 100;
|
||||
const moduleTotalIssues = isEstimateEnabled
|
||||
? moduleDetails?.total_estimate_points || 0
|
||||
: moduleDetails.backlog_issues +
|
||||
moduleDetails.unstarted_issues +
|
||||
moduleDetails.started_issues +
|
||||
moduleDetails.completed_issues +
|
||||
moduleDetails.cancelled_issues;
|
||||
|
||||
const moduleCompletedIssues = isEstimateEnabled
|
||||
? moduleDetails?.completed_estimate_points || 0
|
||||
: moduleDetails.completed_issues;
|
||||
|
||||
const completionPercentage = (moduleCompletedIssues / moduleTotalIssues) * 100;
|
||||
|
||||
const endDate = getDate(moduleDetails.target_date);
|
||||
const startDate = getDate(moduleDetails.start_date);
|
||||
|
|
@ -140,11 +159,11 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
|
|||
|
||||
const issueCount = module
|
||||
? !moduleTotalIssues || moduleTotalIssues === 0
|
||||
? "0 Issue"
|
||||
: moduleTotalIssues === moduleDetails.completed_issues
|
||||
? `${moduleTotalIssues} Issue${moduleTotalIssues > 1 ? "s" : ""}`
|
||||
: `${moduleDetails.completed_issues}/${moduleTotalIssues} Issues`
|
||||
: "0 Issue";
|
||||
? `0 ${isEstimateEnabled ? `Point` : `Issue`}`
|
||||
: moduleTotalIssues === moduleCompletedIssues
|
||||
? `${moduleTotalIssues} Issue${moduleTotalIssues > 1 ? `s` : ``}`
|
||||
: `${moduleCompletedIssues}/${moduleTotalIssues} ${isEstimateEnabled ? `Points` : `Issues`}`
|
||||
: `0 ${isEstimateEnabled ? `Point` : `Issue`}`;
|
||||
|
||||
const moduleLeadDetails = moduleDetails.lead_id ? getUserDetails(moduleDetails.lead_id) : undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ import { CircularProgressIndicator } from "@plane/ui";
|
|||
// components
|
||||
import { ListItem } from "@/components/core/list";
|
||||
import { ModuleListItemAction } from "@/components/modules";
|
||||
// constants
|
||||
import { EEstimateSystem } from "@/constants/estimates";
|
||||
// hooks
|
||||
import { useModule } from "@/hooks/store";
|
||||
import { useAppRouter, useModule, useProjectEstimates } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -26,14 +28,28 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
|
|||
// store hooks
|
||||
const { getModuleById } = useModule();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { projectId } = useAppRouter();
|
||||
const { currentActiveEstimateId, areEstimateEnabledByProjectId, estimateById } = useProjectEstimates();
|
||||
|
||||
// derived values
|
||||
const moduleDetails = getModuleById(moduleId);
|
||||
|
||||
if (!moduleDetails) return null;
|
||||
|
||||
const completionPercentage =
|
||||
((moduleDetails.completed_issues + moduleDetails.cancelled_issues) / moduleDetails.total_issues) * 100;
|
||||
/**
|
||||
* NOTE: This completion percentage calculation is based on the total issues count.
|
||||
* when estimates are available and estimate type is points, we should consider the estimate point count
|
||||
* when estimates are available and estimate type is not points, then by default we consider the issue count
|
||||
*/
|
||||
const isEstimateEnabled =
|
||||
projectId &&
|
||||
currentActiveEstimateId &&
|
||||
areEstimateEnabledByProjectId(projectId) &&
|
||||
estimateById(currentActiveEstimateId)?.type === EEstimateSystem.POINTS;
|
||||
|
||||
const completionPercentage = isEstimateEnabled
|
||||
? ((moduleDetails?.completed_estimate_points || 0) / (moduleDetails?.total_estimate_points || 0)) * 100
|
||||
: ((moduleDetails.completed_issues + moduleDetails.cancelled_issues) / moduleDetails.total_issues) * 100;
|
||||
|
||||
const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
|
||||
|
||||
|
|
|
|||
1
web/components/project/publish-project/index.ts
Normal file
1
web/components/project/publish-project/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./modal";
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from "./modal";
|
||||
export * from "./popover";
|
||||
|
|
@ -1,22 +1,19 @@
|
|||
import { Fragment, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Check, ExternalLink, Globe2 } from "lucide-react";
|
||||
// types
|
||||
import { IProject, TProjectPublishLayouts, TPublishSettings } from "@plane/types";
|
||||
// ui
|
||||
import { Check, CircleDot, Globe2 } from "lucide-react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
import { IProject } from "@plane/types";
|
||||
// ui
|
||||
import { Button, Loader, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { Button, Loader, ToggleSwitch, TOAST_TYPE, setToast, CustomSelect } from "@plane/ui";
|
||||
// components
|
||||
import { EModalWidth, ModalCore } from "@/components/core";
|
||||
// helpers
|
||||
import { SPACE_BASE_PATH, SPACE_BASE_URL } from "@/helpers/common.helper";
|
||||
import { SPACE_BASE_URL } from "@/helpers/common.helper";
|
||||
import { copyTextToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useProjectPublish } from "@/hooks/store";
|
||||
// store
|
||||
import { IProjectPublishSettings, TProjectPublishViews } from "@/store/project/project-publish.store";
|
||||
// local components
|
||||
import { CustomPopover } from "./popover";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
|
|
@ -24,63 +21,47 @@ type Props = {
|
|||
onClose: () => void;
|
||||
};
|
||||
|
||||
type FormData = {
|
||||
id: string | null;
|
||||
comments: boolean;
|
||||
reactions: boolean;
|
||||
votes: boolean;
|
||||
inbox: string | null;
|
||||
views: TProjectPublishViews[];
|
||||
};
|
||||
|
||||
const defaultValues: FormData = {
|
||||
id: null,
|
||||
comments: false,
|
||||
reactions: false,
|
||||
votes: false,
|
||||
const defaultValues: Partial<TPublishSettings> = {
|
||||
is_comments_enabled: false,
|
||||
is_reactions_enabled: false,
|
||||
is_votes_enabled: false,
|
||||
inbox: null,
|
||||
views: ["list", "kanban"],
|
||||
view_props: {
|
||||
list: true,
|
||||
kanban: true,
|
||||
},
|
||||
};
|
||||
|
||||
const viewOptions: {
|
||||
key: TProjectPublishViews;
|
||||
const VIEW_OPTIONS: {
|
||||
key: TProjectPublishLayouts;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ key: "list", label: "List" },
|
||||
{ key: "kanban", label: "Kanban" },
|
||||
// { key: "calendar", label: "Calendar" },
|
||||
// { key: "gantt", label: "Gantt" },
|
||||
// { key: "spreadsheet", label: "Spreadsheet" },
|
||||
];
|
||||
|
||||
export const PublishProjectModal: React.FC<Props> = observer((props) => {
|
||||
const { isOpen, project, onClose } = props;
|
||||
// hooks
|
||||
// const { instance } = useInstance();
|
||||
// states
|
||||
const [isUnPublishing, setIsUnPublishing] = useState(false);
|
||||
const [isUpdateRequired, setIsUpdateRequired] = useState(false);
|
||||
|
||||
// const plane_deploy_url = instance?.config?.space_base_url || "";
|
||||
const SPACE_URL = (SPACE_BASE_URL === "" ? window.location.origin : SPACE_BASE_URL) + SPACE_BASE_PATH;
|
||||
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
// store hooks
|
||||
const {
|
||||
projectPublishSettings,
|
||||
getProjectSettingsAsync,
|
||||
fetchPublishSettings,
|
||||
getPublishSettingsByProjectID,
|
||||
publishProject,
|
||||
updateProjectSettingsAsync,
|
||||
updatePublishSettings,
|
||||
unPublishProject,
|
||||
fetchSettingsLoader,
|
||||
} = useProjectPublish();
|
||||
// derived values
|
||||
const projectPublishSettings = getPublishSettingsByProjectID(project.id);
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
getValues,
|
||||
formState: { isDirty, isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
|
|
@ -90,76 +71,35 @@ export const PublishProjectModal: React.FC<Props> = observer((props) => {
|
|||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
|
||||
setIsUpdateRequired(false);
|
||||
reset({ ...defaultValues });
|
||||
};
|
||||
|
||||
// prefill form with the saved settings if the project is already published
|
||||
useEffect(() => {
|
||||
if (projectPublishSettings && projectPublishSettings !== "not-initialized") {
|
||||
let userBoards: TProjectPublishViews[] = [];
|
||||
|
||||
if (projectPublishSettings?.views) {
|
||||
const savedViews = projectPublishSettings?.views;
|
||||
|
||||
if (!savedViews) return;
|
||||
|
||||
if (savedViews.list) userBoards.push("list");
|
||||
if (savedViews.kanban) userBoards.push("kanban");
|
||||
if (savedViews.calendar) userBoards.push("calendar");
|
||||
if (savedViews.gantt) userBoards.push("gantt");
|
||||
if (savedViews.spreadsheet) userBoards.push("spreadsheet");
|
||||
|
||||
userBoards = userBoards && userBoards.length > 0 ? userBoards : ["list"];
|
||||
}
|
||||
|
||||
const updatedData = {
|
||||
id: projectPublishSettings?.id || null,
|
||||
comments: projectPublishSettings?.comments || false,
|
||||
reactions: projectPublishSettings?.reactions || false,
|
||||
votes: projectPublishSettings?.votes || false,
|
||||
inbox: projectPublishSettings?.inbox || null,
|
||||
views: userBoards,
|
||||
};
|
||||
|
||||
reset({ ...updatedData });
|
||||
}
|
||||
}, [reset, projectPublishSettings, isOpen]);
|
||||
|
||||
// fetch publish settings
|
||||
useEffect(() => {
|
||||
if (!workspaceSlug || !isOpen) return;
|
||||
|
||||
if (projectPublishSettings === "not-initialized") {
|
||||
getProjectSettingsAsync(workspaceSlug.toString(), project.id);
|
||||
if (!projectPublishSettings) {
|
||||
fetchPublishSettings(workspaceSlug.toString(), project.id);
|
||||
}
|
||||
}, [isOpen, workspaceSlug, project, projectPublishSettings, getProjectSettingsAsync]);
|
||||
}, [fetchPublishSettings, isOpen, project, projectPublishSettings, workspaceSlug]);
|
||||
|
||||
const handlePublishProject = async (payload: IProjectPublishSettings) => {
|
||||
const handlePublishProject = async (payload: Partial<TPublishSettings>) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
return publishProject(workspaceSlug.toString(), project.id, payload);
|
||||
await publishProject(workspaceSlug.toString(), project.id, payload);
|
||||
};
|
||||
|
||||
const handleUpdatePublishSettings = async (payload: IProjectPublishSettings) => {
|
||||
if (!workspaceSlug) return;
|
||||
const handleUpdatePublishSettings = async (payload: Partial<TPublishSettings>) => {
|
||||
if (!workspaceSlug || !payload.id) return;
|
||||
|
||||
await updateProjectSettingsAsync(workspaceSlug.toString(), project.id, payload.id ?? "", payload)
|
||||
.then((res) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Publish settings updated successfully!",
|
||||
});
|
||||
|
||||
handleClose();
|
||||
return res;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("error", error);
|
||||
return error;
|
||||
await updatePublishSettings(workspaceSlug.toString(), project.id, payload.id, payload).then((res) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Publish settings updated successfully!",
|
||||
});
|
||||
|
||||
handleClose();
|
||||
return res;
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnPublishProject = async (publishId: string) => {
|
||||
|
|
@ -172,35 +112,21 @@ export const PublishProjectModal: React.FC<Props> = observer((props) => {
|
|||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Something went wrong while un-publishing the project.",
|
||||
message: "Something went wrong while unpublishing the project.",
|
||||
})
|
||||
)
|
||||
.finally(() => setIsUnPublishing(false));
|
||||
};
|
||||
|
||||
const CopyLinkToClipboard = ({ copy_link }: { copy_link: string }) => {
|
||||
const [status, setStatus] = useState(false);
|
||||
const selectedLayouts = Object.entries(watch("view_props") ?? {})
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
.filter(([key, value]) => value)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
.map(([key, value]) => key)
|
||||
.filter((l) => VIEW_OPTIONS.find((o) => o.key === l));
|
||||
|
||||
const copyText = () => {
|
||||
navigator.clipboard.writeText(copy_link);
|
||||
setStatus(true);
|
||||
setTimeout(() => {
|
||||
setStatus(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-[30px] min-w-[30px] cursor-pointer items-center justify-center rounded border border-custom-border-100 bg-custom-background-100 px-2 text-xs hover:bg-custom-background-90"
|
||||
onClick={() => copyText()}
|
||||
>
|
||||
{status ? "Copied" : "Copy Link"}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: FormData) => {
|
||||
if (!formData.views || formData.views.length === 0) {
|
||||
const handleFormSubmit = async (formData: Partial<TPublishSettings>) => {
|
||||
if (!selectedLayouts || selectedLayouts.length === 0) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
|
|
@ -209,278 +135,195 @@ export const PublishProjectModal: React.FC<Props> = observer((props) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
comments: formData.comments,
|
||||
reactions: formData.reactions,
|
||||
votes: formData.votes,
|
||||
inbox: formData.inbox,
|
||||
views: {
|
||||
list: formData.views.includes("list"),
|
||||
kanban: formData.views.includes("kanban"),
|
||||
calendar: formData.views.includes("calendar"),
|
||||
gantt: formData.views.includes("gantt"),
|
||||
spreadsheet: formData.views.includes("spreadsheet"),
|
||||
},
|
||||
const payload: Partial<TPublishSettings> = {
|
||||
id: formData.id,
|
||||
is_comments_enabled: formData.is_comments_enabled,
|
||||
is_reactions_enabled: formData.is_reactions_enabled,
|
||||
is_votes_enabled: formData.is_votes_enabled,
|
||||
view_props: formData.view_props,
|
||||
};
|
||||
|
||||
if (project.is_deployed) await handleUpdatePublishSettings({ id: watch("id") ?? "", ...payload });
|
||||
if (formData.id && project.anchor) await handleUpdatePublishSettings(payload);
|
||||
else await handlePublishProject(payload);
|
||||
};
|
||||
|
||||
// check if an update is required or not
|
||||
const checkIfUpdateIsRequired = () => {
|
||||
if (!projectPublishSettings || projectPublishSettings === "not-initialized") return;
|
||||
// prefill form values for already published projects
|
||||
useEffect(() => {
|
||||
if (!projectPublishSettings?.anchor) return;
|
||||
|
||||
const currentSettings = projectPublishSettings;
|
||||
const newSettings = getValues();
|
||||
|
||||
if (
|
||||
currentSettings.comments !== newSettings.comments ||
|
||||
currentSettings.reactions !== newSettings.reactions ||
|
||||
currentSettings.votes !== newSettings.votes
|
||||
) {
|
||||
setIsUpdateRequired(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let viewCheckFlag = 0;
|
||||
viewOptions.forEach((option) => {
|
||||
if (currentSettings.views[option.key] !== newSettings.views.includes(option.key)) viewCheckFlag++;
|
||||
reset({
|
||||
...defaultValues,
|
||||
...projectPublishSettings,
|
||||
});
|
||||
}, [projectPublishSettings, reset]);
|
||||
|
||||
if (viewCheckFlag !== 0) {
|
||||
setIsUpdateRequired(true);
|
||||
return;
|
||||
}
|
||||
const publishLink = `${SPACE_BASE_URL}/issues/${projectPublishSettings?.anchor}`;
|
||||
|
||||
setIsUpdateRequired(false);
|
||||
};
|
||||
const handleCopyLink = () =>
|
||||
copyTextToClipboard(publishLink).then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "",
|
||||
message: "Published page link copied successfully.",
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-100"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="w-full transform rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:w-3/5 lg:w-1/2 xl:w-2/5">
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
{/* heading */}
|
||||
<div className="flex items-center justify-between gap-2 px-6 pt-4">
|
||||
<h5 className="inline-block text-xl font-semibold">Publish</h5>
|
||||
{project.is_deployed && (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => handleUnPublishProject(watch("id") ?? "")}
|
||||
loading={isUnPublishing}
|
||||
>
|
||||
{isUnPublishing ? "Un-publishing..." : "Un-publish"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* content */}
|
||||
{fetchSettingsLoader ? (
|
||||
<Loader className="space-y-4 px-6">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
</Loader>
|
||||
) : (
|
||||
<div className="px-6">
|
||||
{project.is_deployed && (
|
||||
<>
|
||||
<div className="relative flex items-center gap-2 rounded-md border border-custom-border-100 bg-custom-background-80 px-3 py-2">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
{`${SPACE_URL}/${workspaceSlug}/${project.id}`}
|
||||
</div>
|
||||
<div className="relative flex flex-shrink-0 items-center gap-1">
|
||||
<CopyLinkToClipboard copy_link={`${SPACE_URL}/${workspaceSlug}/${project.id}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-1 text-custom-primary-100">
|
||||
<div className="flex h-5 w-5 items-center overflow-hidden">
|
||||
<CircleDot className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="text-sm">This project is live on web</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="relative flex items-center justify-between gap-2">
|
||||
<div className="text-sm">Views</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="views"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<CustomPopover
|
||||
label={
|
||||
value.length > 0
|
||||
? viewOptions
|
||||
.filter((v) => value.includes(v.key))
|
||||
.map((v) => v.label)
|
||||
.join(", ")
|
||||
: ``
|
||||
}
|
||||
placeholder="Select views"
|
||||
>
|
||||
<>
|
||||
{viewOptions.map((option) => (
|
||||
<div
|
||||
key={option.key}
|
||||
className={`relative m-1 flex cursor-pointer items-center justify-between gap-2 rounded-sm p-1 px-2 text-custom-text-200 ${
|
||||
value.includes(option.key)
|
||||
? "bg-custom-background-80 text-custom-text-100"
|
||||
: "hover:bg-custom-background-80 hover:text-custom-text-100"
|
||||
}`}
|
||||
onClick={() => {
|
||||
const optionViews =
|
||||
value.length > 0
|
||||
? value.includes(option.key)
|
||||
? value.filter((_o: string) => _o !== option.key)
|
||||
: [...value, option.key]
|
||||
: [option.key];
|
||||
|
||||
if (optionViews.length === 0) return;
|
||||
|
||||
onChange(optionViews);
|
||||
checkIfUpdateIsRequired();
|
||||
}}
|
||||
>
|
||||
<div className="text-sm">{option.label}</div>
|
||||
<div className={`relative flex h-4 w-4 items-center justify-center`}>
|
||||
{value.length > 0 && value.includes(option.key) && (
|
||||
<Check className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
</CustomPopover>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex items-center justify-between gap-2">
|
||||
<div className="text-sm">Allow comments</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="comments"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<ToggleSwitch
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
checkIfUpdateIsRequired();
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex items-center justify-between gap-2">
|
||||
<div className="text-sm">Allow reactions</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="reactions"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<ToggleSwitch
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
checkIfUpdateIsRequired();
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex items-center justify-between gap-2">
|
||||
<div className="text-sm">Allow voting</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="votes"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<ToggleSwitch
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
checkIfUpdateIsRequired();
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* toggle inbox */}
|
||||
{/* <div className="relative flex justify-between items-center gap-2">
|
||||
<div className="text-sm">Allow issue proposals</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inbox"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<ToggleSwitch value={value} onChange={onChange} size="sm" />
|
||||
)}
|
||||
/>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* modal handlers */}
|
||||
<div className="relative flex items-center justify-between border-t border-custom-border-200 px-6 py-5">
|
||||
<div className="flex items-center gap-1 text-sm text-custom-text-400">
|
||||
<Globe2 className="h-4 w-4" />
|
||||
<div className="text-sm">Anyone with the link can access</div>
|
||||
</div>
|
||||
{!fetchSettingsLoader && (
|
||||
<div className="relative flex items-center gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{project.is_deployed ? (
|
||||
<>
|
||||
{isUpdateRequired && (
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Updating..." : "Update settings"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Publishing..." : "Publish"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} width={EModalWidth.XXL}>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="flex items-center justify-between gap-2 p-5">
|
||||
<h5 className="text-xl font-medium text-custom-text-200">Publish page</h5>
|
||||
{project.anchor && (
|
||||
<Button variant="danger" onClick={() => handleUnPublishProject(watch("id") ?? "")} loading={isUnPublishing}>
|
||||
{isUnPublishing ? "Unpublishing" : "Unpublish"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
|
||||
{/* content */}
|
||||
{fetchSettingsLoader ? (
|
||||
<Loader className="space-y-4 px-5">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
</Loader>
|
||||
) : (
|
||||
<div className="px-5 space-y-4">
|
||||
{project.anchor && projectPublishSettings && (
|
||||
<>
|
||||
<div className="bg-custom-background-80 border border-custom-border-300 rounded-md py-1.5 pl-4 pr-1 flex items-center justify-between gap-2">
|
||||
<a
|
||||
href={publishLink}
|
||||
className="text-sm text-custom-text-200 truncate"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{publishLink}
|
||||
</a>
|
||||
<div className="flex-shrink-0 flex items-center gap-1">
|
||||
<a
|
||||
href={publishLink}
|
||||
className="size-8 grid place-items-center bg-custom-background-90 hover:bg-custom-background-100 rounded"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 bg-custom-background-90 hover:bg-custom-background-100 rounded text-xs font-medium py-2 px-3"
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
Copy link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-custom-primary-100 flex items-center gap-1 mt-3">
|
||||
<span className="relative grid place-items-center size-2.5">
|
||||
<span className="animate-ping absolute inline-flex size-full rounded-full bg-custom-primary-100 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full size-1.5 bg-custom-primary-100" />
|
||||
</span>
|
||||
This project is now live on web
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div className="relative flex items-center justify-between gap-2">
|
||||
<div className="text-sm">Views</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="view_props"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={VIEW_OPTIONS.filter((o) => selectedLayouts.includes(o.key))
|
||||
.map((o) => o.label)
|
||||
.join(", ")}
|
||||
onChange={(val: TProjectPublishLayouts) => {
|
||||
if (selectedLayouts.length === 1 && selectedLayouts[0] === val) return;
|
||||
onChange({
|
||||
...value,
|
||||
[val]: !value?.[val],
|
||||
});
|
||||
}}
|
||||
buttonClassName="border-none"
|
||||
placement="bottom-end"
|
||||
>
|
||||
{VIEW_OPTIONS.map((option) => (
|
||||
<CustomSelect.Option
|
||||
key={option.key}
|
||||
value={option.key}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
{option.label}
|
||||
{selectedLayouts.includes(option.key) && <Check className="size-3.5 flex-shrink-0" />}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex items-center justify-between gap-2">
|
||||
<div className="text-sm">Allow comments</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="is_comments_enabled"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<ToggleSwitch value={!!value} onChange={onChange} size="sm" />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex items-center justify-between gap-2">
|
||||
<div className="text-sm">Allow reactions</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="is_reactions_enabled"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<ToggleSwitch value={!!value} onChange={onChange} size="sm" />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex items-center justify-between gap-2">
|
||||
<div className="text-sm">Allow voting</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="is_votes_enabled"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<ToggleSwitch value={!!value} onChange={onChange} size="sm" />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* modal handlers */}
|
||||
<div className="relative flex items-center justify-between border-t border-custom-border-200 px-5 py-4 mt-4">
|
||||
<div className="flex items-center gap-1 text-sm text-custom-text-400">
|
||||
<Globe2 className="size-3.5" />
|
||||
<div className="text-sm">Anyone with the link can access</div>
|
||||
</div>
|
||||
{!fetchSettingsLoader && (
|
||||
<div className="relative flex items-center gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{project.anchor ? (
|
||||
isDirty && (
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Updating" : "Update settings"}
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Publishing" : "Publish"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
import React, { Fragment } from "react";
|
||||
|
||||
// headless ui
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
|
||||
export const CustomPopover = ({
|
||||
children,
|
||||
label,
|
||||
placeholder = "Select",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
}) => (
|
||||
<div className="relative">
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button className={`${open ? "" : ""} relative flex items-center gap-1 outline-none ring-0`}>
|
||||
<div className="text-sm">{label ?? placeholder}</div>
|
||||
<div className="grid h-5 w-5 place-items-center">
|
||||
{!open ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||
</div>
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-10 mt-1 min-w-[150px]">
|
||||
<div className="mt-1 overflow-hidden overflow-y-auto rounded border border-custom-border-300 bg-custom-background-90 shadow-custom-shadow-2xs focus:outline-none">
|
||||
{children}
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -396,7 +396,7 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
|
|||
<div className="flex h-4 w-4 cursor-pointer items-center justify-center rounded text-custom-sidebar-text-200 transition-all duration-300 hover:bg-custom-sidebar-background-80">
|
||||
<Share2 className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
</div>
|
||||
<div>{project.is_deployed ? "Publish settings" : "Publish"}</div>
|
||||
<div>{project.anchor ? "Publish settings" : "Publish"}</div>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Fragment, useState } from "react";
|
||||
import { Fragment, Ref, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
|
@ -263,8 +263,8 @@ export const WorkspaceSidebarDropdown = observer(() => {
|
|||
>
|
||||
<Menu.Items
|
||||
className="absolute left-0 z-20 mt-1 flex w-52 origin-top-left flex-col divide-y
|
||||
divide-custom-sidebar-border-200 rounded-md border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-1 py-2 text-xs shadow-lg outline-none"
|
||||
ref={setPopperElement}
|
||||
divide-custom-sidebar-border-200 rounded-md border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-1 py-2 text-xs shadow-lg outline-none"
|
||||
ref={setPopperElement as Ref<HTMLDivElement>}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue