diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/states/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/states/page.tsx
index 6c030a1fd..50b0c0abc 100644
--- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/states/page.tsx
+++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/states/page.tsx
@@ -1,17 +1,20 @@
"use client";
import { observer } from "mobx-react";
+import { useParams } from "next/navigation";
// components
import { PageHead } from "@/components/core";
-import { ProjectSettingStateList } from "@/components/states";
+import { ProjectStateRoot } from "@/components/project-states";
// hook
import { useProject } from "@/hooks/store";
const StatesSettingsPage = observer(() => {
+ const { workspaceSlug, projectId } = useParams();
// store
const { currentProjectDetails } = useProject();
// derived values
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - States` : undefined;
+
return (
<>
@@ -19,7 +22,9 @@ const StatesSettingsPage = observer(() => {
States
-
+ {workspaceSlug && projectId && (
+
+ )}
>
);
diff --git a/web/core/components/project-states/create-update/create.tsx b/web/core/components/project-states/create-update/create.tsx
new file mode 100644
index 000000000..b581eacec
--- /dev/null
+++ b/web/core/components/project-states/create-update/create.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { FC, useState } from "react";
+import { observer } from "mobx-react";
+import { IState, TStateGroups } from "@plane/types";
+import { TOAST_TYPE, setToast } from "@plane/ui";
+// components
+import { StateForm } from "@/components/project-states";
+// constants
+import { STATE_CREATED } from "@/constants/event-tracker";
+import { STATE_GROUPS } from "@/constants/state";
+// hooks
+import { useEventTracker, useProjectState } from "@/hooks/store";
+
+type TStateCreate = {
+ workspaceSlug: string;
+ projectId: string;
+ groupKey: TStateGroups;
+ handleClose: () => void;
+};
+
+export const StateCreate: FC = observer((props) => {
+ const { workspaceSlug, projectId, groupKey, handleClose } = props;
+ // hooks
+ const { captureProjectStateEvent, setTrackElement } = useEventTracker();
+ const { createState } = useProjectState();
+ // states
+ const [loader, setLoader] = useState(false);
+
+ const onCancel = () => {
+ setLoader(false);
+ handleClose();
+ };
+
+ const onSubmit = async (formData: Partial) => {
+ if (!workspaceSlug || !projectId || !groupKey) return { status: "error" };
+
+ setTrackElement("PROJECT_SETTINGS_STATE_PAGE");
+ try {
+ const stateResponse = await createState(workspaceSlug, projectId, { ...formData, group: groupKey });
+ captureProjectStateEvent({
+ eventName: STATE_CREATED,
+ payload: {
+ ...stateResponse,
+ state: "SUCCESS",
+ element: "Project settings states page",
+ },
+ });
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success!",
+ message: "State created successfully.",
+ });
+ handleClose();
+ return { status: "success" };
+ } catch (error) {
+ const errorStatus = error as unknown as { status: number; data: { error: string } };
+ captureProjectStateEvent({
+ eventName: STATE_CREATED,
+ payload: {
+ ...formData,
+ state: "FAILED",
+ element: "Project settings states page",
+ },
+ });
+ if (errorStatus?.status === 400) {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: "State with that name already exists. Please try again with another name.",
+ });
+ return { status: "already_exists" };
+ } else {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: errorStatus.data.error ?? "State could not be created. Please try again.",
+ });
+ return { status: "error" };
+ }
+ }
+ };
+
+ return (
+
+ );
+});
diff --git a/web/core/components/project-states/create-update/form.tsx b/web/core/components/project-states/create-update/form.tsx
new file mode 100644
index 000000000..6a26d7cdd
--- /dev/null
+++ b/web/core/components/project-states/create-update/form.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import { FormEvent, FC, useEffect, useState, Fragment } from "react";
+import { TwitterPicker } from "react-color";
+import { Popover, Transition } from "@headlessui/react";
+import { IState } from "@plane/types";
+import { Button, Input } from "@plane/ui";
+
+type TStateForm = {
+ data: Partial;
+ onSubmit: (formData: Partial) => Promise<{ status: string }>;
+ onCancel: () => void;
+ buttonDisabled: boolean;
+ buttonTitle: string;
+};
+
+export const StateForm: FC = (props) => {
+ const { data, onSubmit, onCancel, buttonDisabled, buttonTitle } = props;
+ // states
+ const [formData, setFromData] = useState | undefined>(undefined);
+ const [errors, setErrors] = useState> | undefined>(undefined);
+
+ useEffect(() => {
+ if (data && !formData) setFromData(data);
+ }, [data, formData]);
+
+ const handleFormData = (key: T, value: IState[T]) => {
+ setFromData((prev) => ({ ...prev, [key]: value }));
+ setErrors((prev) => ({ ...prev, [key]: "" }));
+ };
+
+ const formSubmit = async (event: FormEvent) => {
+ event.preventDefault();
+
+ const name = formData?.name || undefined;
+ if (!formData || !name) {
+ let currentErrors: Partial> = {};
+ if (!name) currentErrors = { ...currentErrors, name: "Name is required" };
+ setErrors(currentErrors);
+ return;
+ }
+
+ try {
+ await onSubmit(formData);
+ } catch (error) {
+ console.log("error", error);
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/web/core/components/project-states/create-update/index.ts b/web/core/components/project-states/create-update/index.ts
new file mode 100644
index 000000000..a295e4a83
--- /dev/null
+++ b/web/core/components/project-states/create-update/index.ts
@@ -0,0 +1,3 @@
+export * from "./create";
+export * from "./update";
+export * from "./form";
diff --git a/web/core/components/project-states/create-update/update.tsx b/web/core/components/project-states/create-update/update.tsx
new file mode 100644
index 000000000..669a86516
--- /dev/null
+++ b/web/core/components/project-states/create-update/update.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { FC, useState } from "react";
+import { observer } from "mobx-react";
+import { IState } from "@plane/types";
+import { TOAST_TYPE, setToast } from "@plane/ui";
+// components
+import { StateForm } from "@/components/project-states";
+// constants
+import { STATE_UPDATED } from "@/constants/event-tracker";
+// hooks
+import { useEventTracker, useProjectState } from "@/hooks/store";
+
+type TStateUpdate = {
+ workspaceSlug: string;
+ projectId: string;
+ state: IState;
+ handleClose: () => void;
+};
+
+export const StateUpdate: FC = observer((props) => {
+ const { workspaceSlug, projectId, state, handleClose } = props;
+ // hooks
+ const { captureProjectStateEvent, setTrackElement } = useEventTracker();
+ const { updateState } = useProjectState();
+ // states
+ const [loader, setLoader] = useState(false);
+
+ const onCancel = () => {
+ setLoader(false);
+ handleClose();
+ };
+
+ const onSubmit = async (formData: Partial) => {
+ if (!workspaceSlug || !projectId || !state.id) return { status: "error" };
+
+ setTrackElement("PROJECT_SETTINGS_STATE_PAGE");
+ try {
+ const stateResponse = await updateState(workspaceSlug, projectId, state.id, formData);
+ captureProjectStateEvent({
+ eventName: STATE_UPDATED,
+ payload: {
+ ...stateResponse,
+ state: "SUCCESS",
+ element: "Project settings states page",
+ },
+ });
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success!",
+ message: "State updated successfully.",
+ });
+ handleClose();
+ return { status: "success" };
+ } catch (error) {
+ const errorStatus = error as unknown as { status: number };
+ if (errorStatus?.status === 400) {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: "Another state exists with the same name. Please try again with another name.",
+ });
+ return { status: "already_exists" };
+ } else {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: "State could not be updated. Please try again.",
+ });
+ captureProjectStateEvent({
+ eventName: STATE_UPDATED,
+ payload: {
+ ...formData,
+ state: "FAILED",
+ element: "Project settings states page",
+ },
+ });
+ return { status: "error" };
+ }
+ }
+ };
+
+ return (
+
+ );
+});
diff --git a/web/core/components/project-states/group-item.tsx b/web/core/components/project-states/group-item.tsx
new file mode 100644
index 000000000..5316871a1
--- /dev/null
+++ b/web/core/components/project-states/group-item.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { FC, useState } from "react";
+import { observer } from "mobx-react";
+import { Plus } from "lucide-react";
+import { IState, TStateGroups } from "@plane/types";
+// components
+import { StateList, StateCreate } from "@/components/project-states";
+
+type TGroupItem = {
+ workspaceSlug: string;
+ projectId: string;
+ groupKey: TStateGroups;
+ groupedStates: Record;
+ states: IState[];
+};
+
+export const GroupItem: FC = observer((props) => {
+ const { workspaceSlug, projectId, groupKey, groupedStates, states } = props;
+ // state
+ const [createState, setCreateState] = useState(false);
+
+ return (
+
+
+
{groupKey}
+
!createState && setCreateState(true)}
+ >
+
+
+
+
+ {createState && (
+
setCreateState(false)}
+ />
+ )}
+
+
+
+
+
+ );
+});
diff --git a/web/core/components/project-states/group-list.tsx b/web/core/components/project-states/group-list.tsx
new file mode 100644
index 000000000..59b5e66be
--- /dev/null
+++ b/web/core/components/project-states/group-list.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { FC } from "react";
+import { observer } from "mobx-react";
+import { IState, TStateGroups } from "@plane/types";
+// components
+import { GroupItem } from "@/components/project-states";
+
+type TGroupList = {
+ workspaceSlug: string;
+ projectId: string;
+ groupedStates: Record;
+};
+
+export const GroupList: FC = observer((props) => {
+ const { workspaceSlug, projectId, groupedStates } = props;
+
+ return (
+
+ {Object.entries(groupedStates).map(([key, value]) => {
+ const groupKey = key as TStateGroups;
+ const groupStates = value;
+ return (
+
+ );
+ })}
+
+ );
+});
diff --git a/web/core/components/project-states/index.ts b/web/core/components/project-states/index.ts
new file mode 100644
index 000000000..8c2901392
--- /dev/null
+++ b/web/core/components/project-states/index.ts
@@ -0,0 +1,12 @@
+export * from "./root";
+
+export * from "./group-list";
+export * from "./group-item";
+
+export * from "./state-list";
+export * from "./state-item";
+export * from "./options";
+
+export * from "./loader";
+export * from "./create-update";
+export * from "./state-delete-modal";
diff --git a/web/core/components/project-states/loader.tsx b/web/core/components/project-states/loader.tsx
new file mode 100644
index 000000000..958d0ea9e
--- /dev/null
+++ b/web/core/components/project-states/loader.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import { Loader } from "@plane/ui";
+
+export const ProjectStateLoader = () => (
+
+
+
+
+
+
+);
diff --git a/web/core/components/project-states/options/delete.tsx b/web/core/components/project-states/options/delete.tsx
new file mode 100644
index 000000000..00f88f034
--- /dev/null
+++ b/web/core/components/project-states/options/delete.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import { FC, useState } from "react";
+import { observer } from "mobx-react";
+import { Loader, X } from "lucide-react";
+import { IState } from "@plane/types";
+import { AlertModalCore, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
+// constants
+import { STATE_DELETED } from "@/constants/event-tracker";
+// helpers
+import { cn } from "@/helpers/common.helper";
+// hooks
+import { useEventTracker, useProjectState } from "@/hooks/store";
+import { usePlatformOS } from "@/hooks/use-platform-os";
+
+type TStateDelete = {
+ workspaceSlug: string;
+ projectId: string;
+ totalStates: number;
+ state: IState;
+};
+
+export const StateDelete: FC = observer((props) => {
+ const { workspaceSlug, projectId, totalStates, state } = props;
+ // hooks
+ const { isMobile } = usePlatformOS();
+ const { captureProjectStateEvent, setTrackElement } = useEventTracker();
+ const { deleteState } = useProjectState();
+ // states
+ const [isDeleteModal, setIsDeleteModal] = useState(false);
+ const [isDelete, setIsDelete] = useState(false);
+
+ // derived values
+ const isDeleteDisabled = state.default ? true : totalStates === 1 ? true : false;
+
+ const handleDeleteState = async () => {
+ if (!workspaceSlug || !projectId || isDeleteDisabled) return;
+
+ setTrackElement("PROJECT_SETTINGS_STATE_PAGE");
+ setIsDelete(true);
+
+ try {
+ await deleteState(workspaceSlug, projectId, state.id);
+ captureProjectStateEvent({
+ eventName: STATE_DELETED,
+ payload: {
+ ...state,
+ state: "SUCCESS",
+ },
+ });
+ setIsDelete(false);
+ } catch (error) {
+ const errorStatus = error as unknown as { status: number; data: { error: string } };
+ captureProjectStateEvent({
+ eventName: STATE_DELETED,
+ payload: {
+ ...state,
+ state: "FAILED",
+ },
+ });
+ if (errorStatus.status === 400) {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message:
+ "This state contains some issues within it, please move them to some other state to delete this state.",
+ });
+ } else {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: "State could not be deleted. Please try again.",
+ });
+ }
+ setIsDelete(false);
+ }
+ };
+
+ return (
+ <>
+ setIsDeleteModal(false)}
+ handleSubmit={handleDeleteState}
+ isSubmitting={isDelete}
+ isOpen={isDeleteModal}
+ title="Delete State"
+ content={
+ <>
+ Are you sure you want to delete state-{" "}
+ {state?.name}? All of the data related to the
+ state will be permanently removed. This action cannot be undone.
+ >
+ }
+ />
+
+
+ >
+ );
+});
diff --git a/web/core/components/project-states/options/index.ts b/web/core/components/project-states/options/index.ts
new file mode 100644
index 000000000..6aad9566c
--- /dev/null
+++ b/web/core/components/project-states/options/index.ts
@@ -0,0 +1,2 @@
+export * from "./mark-as-default";
+export * from "./delete";
diff --git a/web/core/components/project-states/options/mark-as-default.tsx b/web/core/components/project-states/options/mark-as-default.tsx
new file mode 100644
index 000000000..667b2063b
--- /dev/null
+++ b/web/core/components/project-states/options/mark-as-default.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { FC, useState } from "react";
+import { observer } from "mobx-react";
+// helpers
+import { cn } from "@/helpers/common.helper";
+// hooks
+import { useProjectState } from "@/hooks/store";
+
+type TStateMarksAsDefault = { workspaceSlug: string; projectId: string; stateId: string; isDefault: boolean };
+
+export const StateMarksAsDefault: FC = observer((props) => {
+ const { workspaceSlug, projectId, stateId, isDefault } = props;
+ // hooks
+ const { markStateAsDefault } = useProjectState();
+ // states
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleMarkAsDefault = async () => {
+ if (!workspaceSlug || !projectId || !stateId || isDefault) return;
+ setIsLoading(true);
+
+ try {
+ setIsLoading(false);
+ await markStateAsDefault(workspaceSlug, projectId, stateId);
+ setIsLoading(false);
+ } catch (error) {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+ );
+});
diff --git a/web/core/components/project-states/root.tsx b/web/core/components/project-states/root.tsx
new file mode 100644
index 000000000..aca642ef0
--- /dev/null
+++ b/web/core/components/project-states/root.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { FC } from "react";
+import { observer } from "mobx-react";
+import useSWR from "swr";
+// components
+import { ProjectStateLoader, GroupList } from "@/components/project-states";
+// hooks
+import { useProjectState } from "@/hooks/store";
+
+type TProjectState = {
+ workspaceSlug: string;
+ projectId: string;
+};
+
+export const ProjectStateRoot: FC = observer((props) => {
+ const { workspaceSlug, projectId } = props;
+ // hooks
+ const { groupedProjectStates, fetchProjectStates } = useProjectState();
+
+ useSWR(
+ workspaceSlug && projectId ? `PROJECT_STATES_${workspaceSlug}_${projectId}` : null,
+ workspaceSlug && projectId ? () => fetchProjectStates(workspaceSlug.toString(), projectId.toString()) : null
+ );
+
+ // Loader
+ if (!groupedProjectStates) return ;
+
+ return (
+
+
+
+ );
+});
diff --git a/web/core/components/states/delete-state-modal.tsx b/web/core/components/project-states/state-delete-modal.tsx
similarity index 95%
rename from web/core/components/states/delete-state-modal.tsx
rename to web/core/components/project-states/state-delete-modal.tsx
index de66c3b49..f36c4ca12 100644
--- a/web/core/components/states/delete-state-modal.tsx
+++ b/web/core/components/project-states/state-delete-modal.tsx
@@ -12,13 +12,13 @@ import { STATE_DELETED } from "@/constants/event-tracker";
// hooks
import { useEventTracker, useProjectState } from "@/hooks/store";
-type Props = {
+type TStateDeleteModal = {
isOpen: boolean;
onClose: () => void;
data: IState | null;
};
-export const DeleteStateModal: React.FC = observer((props) => {
+export const StateDeleteModal: React.FC = observer((props) => {
const { isOpen, onClose, data } = props;
// states
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
diff --git a/web/core/components/project-states/state-item.tsx b/web/core/components/project-states/state-item.tsx
new file mode 100644
index 000000000..e15a708d7
--- /dev/null
+++ b/web/core/components/project-states/state-item.tsx
@@ -0,0 +1,179 @@
+"use client";
+
+import { FC, Fragment, useCallback, useEffect, useRef, useState } from "react";
+import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
+import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
+import { attachClosestEdge, extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
+import { observer } from "mobx-react";
+import { GripVertical, Pencil } from "lucide-react";
+import { IState, TStateGroups } from "@plane/types";
+import { DropIndicator, StateGroupIcon } from "@plane/ui";
+// components
+import { StateUpdate, StateDelete, StateMarksAsDefault } from "@/components/project-states";
+// helpers
+import { TDraggableData } from "@/constants/state";
+import { cn } from "@/helpers/common.helper";
+import { getCurrentStateSequence } from "@/helpers/state.helper";
+// hooks
+import { useProjectState } from "@/hooks/store";
+
+type TStateItem = {
+ workspaceSlug: string;
+ projectId: string;
+ groupKey: TStateGroups;
+ groupedStates: Record;
+ totalStates: number;
+ state: IState;
+};
+
+export const StateItem: FC = observer((props) => {
+ const { workspaceSlug, projectId, groupKey, groupedStates, totalStates, state } = props;
+ // hooks
+ const { moveStatePosition } = useProjectState();
+ // states
+ const [updateStateModal, setUpdateStateModal] = useState(false);
+
+ const handleStateSequence = useCallback(
+ async (payload: Partial) => {
+ try {
+ if (!workspaceSlug || !projectId || !payload.id) return;
+ await moveStatePosition(workspaceSlug, projectId, payload.id, payload);
+ } catch (error) {
+ console.error("error", error);
+ }
+ },
+ [workspaceSlug, projectId, moveStatePosition]
+ );
+
+ // derived values
+ const isDraggable = totalStates === 1 ? false : true;
+
+ // DND starts
+ // ref
+ const draggableElementRef = useRef(null);
+ // states
+ const [isDragging, setIsDragging] = useState(false);
+ const [isDraggedOver, setIsDraggedOver] = useState(false);
+ const [closestEdge, setClosestEdge] = useState(null);
+ useEffect(() => {
+ const elementRef = draggableElementRef.current;
+ const initialData: TDraggableData = { groupKey: groupKey, id: state.id };
+
+ if (elementRef && state) {
+ combine(
+ draggable({
+ element: elementRef,
+ getInitialData: () => initialData,
+ onDragStart: () => setIsDragging(true),
+ onDrop: () => setIsDragging(false),
+ canDrag: () => isDraggable,
+ }),
+ dropTargetForElements({
+ element: elementRef,
+ getData: ({ input, element }) =>
+ attachClosestEdge(initialData, {
+ input,
+ element,
+ allowedEdges: ["top", "bottom"],
+ }),
+ onDragEnter: (args) => {
+ setIsDraggedOver(true);
+ setClosestEdge(extractClosestEdge(args.self.data));
+ },
+ onDragLeave: () => {
+ setIsDraggedOver(false);
+ setClosestEdge(null);
+ },
+ onDrop: (data) => {
+ setIsDraggedOver(false);
+ const { self, source } = data;
+ const sourceData = source.data as TDraggableData;
+ const destinationData = self.data as TDraggableData;
+
+ if (sourceData && destinationData && sourceData.id) {
+ const destinationGroupKey = destinationData.groupKey as TStateGroups;
+ const edge = extractClosestEdge(destinationData) || undefined;
+ const payload: Partial = {
+ id: sourceData.id as string,
+ group: destinationGroupKey,
+ sequence: getCurrentStateSequence(groupedStates[destinationGroupKey], destinationData, edge),
+ };
+ handleStateSequence(payload);
+ }
+ },
+ })
+ );
+ }
+ }, [draggableElementRef, state, groupKey, isDraggable, groupedStates, handleStateSequence]);
+ // DND ends
+
+ if (updateStateModal)
+ return (
+ setUpdateStateModal(false)}
+ />
+ );
+
+ return (
+
+ {/* draggable drop top indicator */}
+
+
+
+ {/* draggable indicator */}
+ {totalStates != 1 && (
+
+
+
+ )}
+
+ {/* state icon */}
+
+
+
+
+ {/* state title and description */}
+
+
{state.name}
+
{state.description}
+
+
+
+ {/* state mark as default option */}
+
+
+
+
+ {/* state edit options */}
+
+
+
+
+
+
+
+ {/* draggable drop bottom indicator */}
+
+
+ );
+});
diff --git a/web/core/components/project-states/state-list.tsx b/web/core/components/project-states/state-list.tsx
new file mode 100644
index 000000000..42ddb6955
--- /dev/null
+++ b/web/core/components/project-states/state-list.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import { FC } from "react";
+import { observer } from "mobx-react";
+import { IState, TStateGroups } from "@plane/types";
+// components
+import { StateItem } from "@/components/project-states";
+
+type TStateList = {
+ workspaceSlug: string;
+ projectId: string;
+ groupKey: TStateGroups;
+ groupedStates: Record;
+ states: IState[];
+};
+
+export const StateList: FC = observer((props) => {
+ const { workspaceSlug, projectId, groupKey, groupedStates, states } = props;
+
+ return (
+ <>
+ {states.map((state: IState) => (
+
+ ))}
+ >
+ );
+});
diff --git a/web/core/components/states/create-state-modal.tsx b/web/core/components/states/create-state-modal.tsx
deleted file mode 100644
index ccbcf8895..000000000
--- a/web/core/components/states/create-state-modal.tsx
+++ /dev/null
@@ -1,259 +0,0 @@
-"use client";
-
-import React from "react";
-import { observer } from "mobx-react";
-import { useParams } from "next/navigation";
-import { TwitterPicker } from "react-color";
-import { Controller, useForm } from "react-hook-form";
-import { ChevronDown } from "lucide-react";
-import { Dialog, Popover, Transition } from "@headlessui/react";
-// icons
-import type { IState } from "@plane/types";
-// ui
-import { Button, CustomSelect, Input, TextArea, TOAST_TYPE, setToast } from "@plane/ui";
-// constants
-import { GROUP_CHOICES } from "@/constants/project";
-// hooks
-import { useProjectState } from "@/hooks/store";
-// types
-
-// types
-type Props = {
- isOpen: boolean;
- projectId: string;
- handleClose: () => void;
-};
-
-const defaultValues: Partial = {
- name: "",
- description: "",
- color: "rgb(var(--color-text-200))",
- group: "backlog",
-};
-
-export const CreateStateModal: React.FC = observer((props) => {
- const { isOpen, projectId, handleClose } = props;
- // router
- const { workspaceSlug } = useParams();
- // store hooks
- const { createState } = useProjectState();
- // form info
- const {
- formState: { errors, isSubmitting },
- handleSubmit,
- watch,
- control,
- reset,
- } = useForm({
- defaultValues,
- });
-
- const onClose = () => {
- handleClose();
- reset(defaultValues);
- };
-
- const onSubmit = async (formData: IState) => {
- if (!workspaceSlug) return;
-
- const payload: IState = {
- ...formData,
- };
-
- await createState(workspaceSlug.toString(), projectId.toString(), payload)
- .then(() => {
- onClose();
- })
- .catch((err) => {
- const error = err.response;
-
- if (typeof error === "object") {
- Object.keys(error).forEach((key) => {
- setToast({
- type: TOAST_TYPE.ERROR,
- title: "Error!",
- message: Array.isArray(error[key]) ? error[key].join(", ") : error[key],
- });
- });
- } else {
- setToast({
- type: TOAST_TYPE.ERROR,
- title: "Error!",
- message:
- error ?? err.status === 400
- ? "Another state exists with the same name. Please try again with another name."
- : "State could not be created. Please try again.",
- });
- }
- });
- };
-
- return (
-
-
-
- );
-});
diff --git a/web/core/components/states/create-update-state-inline.tsx b/web/core/components/states/create-update-state-inline.tsx
deleted file mode 100644
index f218211a1..000000000
--- a/web/core/components/states/create-update-state-inline.tsx
+++ /dev/null
@@ -1,310 +0,0 @@
-"use client";
-
-import React, { useEffect } from "react";
-import { observer } from "mobx-react";
-import { useParams } from "next/navigation";
-import { TwitterPicker } from "react-color";
-import { useForm, Controller } from "react-hook-form";
-import { Popover, Transition } from "@headlessui/react";
-import type { IState } from "@plane/types";
-// ui
-import { Button, CustomSelect, Input, Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
-// constants
-import { STATE_CREATED, STATE_UPDATED } from "@/constants/event-tracker";
-import { GROUP_CHOICES } from "@/constants/project";
-// hooks
-import { useEventTracker, useProjectState } from "@/hooks/store";
-import { usePlatformOS } from "@/hooks/use-platform-os";
-// types
-
-type Props = {
- data: IState | null;
- onClose: () => void;
- groupLength: number;
- selectedGroup: StateGroup | null;
-};
-
-export type StateGroup = "backlog" | "unstarted" | "started" | "completed" | "cancelled" | null;
-
-const defaultValues: Partial = {
- name: "",
- description: "",
- color: "rgb(var(--color-text-200))",
- group: "backlog",
-};
-
-export const CreateUpdateStateInline: React.FC = observer((props) => {
- const { data, onClose, selectedGroup, groupLength } = props;
- // router
- const { workspaceSlug, projectId } = useParams();
- // store hooks
- const { captureProjectStateEvent, setTrackElement } = useEventTracker();
- const { createState, updateState } = useProjectState();
- const { isMobile } = usePlatformOS();
- // form info
- const {
- handleSubmit,
- formState: { errors, isSubmitting },
- watch,
- reset,
- control,
- } = useForm({
- defaultValues,
- });
-
- /**
- * @description pre-populate form with data if data is present
- */
- useEffect(() => {
- if (!data) return;
- reset(data);
- }, [data, reset]);
-
- /**
- * @description pre-populate form with default values if data is not present
- */
- useEffect(() => {
- if (data) return;
- reset({
- ...defaultValues,
- group: selectedGroup ?? "backlog",
- });
- }, [selectedGroup, data, reset]);
-
- const handleClose = () => {
- onClose();
- reset({ name: "", color: "#000000", group: "backlog" });
- };
-
- const handleCreate = async (formData: IState) => {
- if (!workspaceSlug || !projectId || isSubmitting) return;
-
- await createState(workspaceSlug.toString(), projectId.toString(), formData)
- .then((res) => {
- handleClose();
- setToast({
- type: TOAST_TYPE.SUCCESS,
- title: "Success!",
- message: "State created successfully.",
- });
- captureProjectStateEvent({
- eventName: STATE_CREATED,
- payload: {
- ...res,
- state: "SUCCESS",
- element: "Project settings states page",
- },
- });
- })
- .catch((error) => {
- if (error.status === 400)
- setToast({
- type: TOAST_TYPE.ERROR,
- title: "Error!",
- message: "State with that name already exists. Please try again with another name.",
- });
- else
- setToast({
- type: TOAST_TYPE.ERROR,
- title: "Error!",
- message: error.data.error ?? "State could not be created. Please try again.",
- });
- captureProjectStateEvent({
- eventName: STATE_CREATED,
- payload: {
- ...formData,
- state: "FAILED",
- element: "Project settings states page",
- },
- });
- });
- };
-
- const handleUpdate = async (formData: IState) => {
- if (!workspaceSlug || !projectId || !data || isSubmitting) return;
-
- await updateState(workspaceSlug.toString(), projectId.toString(), data.id, formData)
- .then((res) => {
- handleClose();
- captureProjectStateEvent({
- eventName: STATE_UPDATED,
- payload: {
- ...res,
- state: "SUCCESS",
- element: "Project settings states page",
- },
- });
- setToast({
- type: TOAST_TYPE.SUCCESS,
- title: "Success!",
- message: "State updated successfully.",
- });
- })
- .catch((error) => {
- if (error.status === 400)
- setToast({
- type: TOAST_TYPE.ERROR,
- title: "Error!",
- message: "Another state exists with the same name. Please try again with another name.",
- });
- else
- setToast({
- type: TOAST_TYPE.ERROR,
- title: "Error!",
- message: "State could not be updated. Please try again.",
- });
- captureProjectStateEvent({
- eventName: STATE_UPDATED,
- payload: {
- ...formData,
- state: "FAILED",
- element: "Project settings states page",
- },
- });
- });
- };
-
- const onSubmit = async (formData: IState) => {
- if (data) await handleUpdate(formData);
- else await handleCreate(formData);
- };
-
- return (
-
- );
-});
diff --git a/web/core/components/states/index.ts b/web/core/components/states/index.ts
deleted file mode 100644
index 1752fdd1f..000000000
--- a/web/core/components/states/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export * from "./create-update-state-inline";
-export * from "./create-state-modal";
-export * from "./delete-state-modal";
-export * from "./project-setting-state-list-item";
-export * from "./project-setting-state-list";
diff --git a/web/core/components/states/project-setting-state-list-item.tsx b/web/core/components/states/project-setting-state-list-item.tsx
deleted file mode 100644
index 1ab721a52..000000000
--- a/web/core/components/states/project-setting-state-list-item.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { observer } from "mobx-react";
-import { useParams } from "next/navigation";
-// icons
-import { Pencil, X, ArrowDown, ArrowUp } from "lucide-react";
-// types
-import { IState } from "@plane/types";
-// ui
-import { Tooltip, StateGroupIcon } from "@plane/ui";
-// hooks
-import { useEventTracker, useProjectState } from "@/hooks/store";
-import { usePlatformOS } from "@/hooks/use-platform-os";
-
-type Props = {
- index: number;
- state: IState;
- statesList: IState[];
- handleEditState: () => void;
- handleDeleteState: () => void;
-};
-
-export const StatesListItem: React.FC = observer((props) => {
- const { index, state, statesList, handleEditState, handleDeleteState } = props;
- // states
- const [isSubmitting, setIsSubmitting] = useState(false);
- // router
- const { workspaceSlug, projectId } = useParams();
- // store hooks
- const { setTrackElement } = useEventTracker();
- const { markStateAsDefault, moveStatePosition } = useProjectState();
- const { isMobile } = usePlatformOS();
- // derived values
- const groupStates = statesList.filter((s) => s.group === state.group);
- const groupLength = groupStates.length;
-
- const handleMakeDefault = () => {
- if (!workspaceSlug || !projectId) return;
- setIsSubmitting(true);
- markStateAsDefault(workspaceSlug.toString(), projectId.toString(), state.id).finally(() => {
- setIsSubmitting(false);
- });
- };
-
- const handleMove = (state: IState, direction: "up" | "down") => {
- if (!workspaceSlug || !projectId) return;
- moveStatePosition(workspaceSlug.toString(), projectId.toString(), state.id, direction, index);
- };
-
- return (
-
-
-
-
-
{state.name}
-
{state.description}
-
-
-
- {index !== 0 && (
-
- )}
- {!(index === groupLength - 1) && (
-
- )}
-
-
- {state.default ? (
-
Default
- ) : (
-
- )}
-
-
-
-
-
-
- );
-});
diff --git a/web/core/components/states/project-setting-state-list.tsx b/web/core/components/states/project-setting-state-list.tsx
deleted file mode 100644
index 2c5de926b..000000000
--- a/web/core/components/states/project-setting-state-list.tsx
+++ /dev/null
@@ -1,183 +0,0 @@
-"use client";
-
-import React, { useState } from "react";
-import { observer } from "mobx-react";
-import { useParams } from "next/navigation";
-import useSWR from "swr";
-// hooks
-import { Plus } from "lucide-react";
-import { Loader } from "@plane/ui";
-import { CreateUpdateStateInline, DeleteStateModal, StateGroup, StatesListItem } from "@/components/states";
-import { STATES_LIST } from "@/constants/fetch-keys";
-import { sortByField } from "@/helpers/array.helper";
-import { orderStateGroups } from "@/helpers/state.helper";
-import { useEventTracker, useProjectState } from "@/hooks/store";
-// components
-// ui
-// icons
-// helpers
-// fetch-keys
-
-export const ProjectSettingStateList: React.FC = observer(() => {
- // router
- const { workspaceSlug, projectId } = useParams();
- // store
- const { setTrackElement } = useEventTracker();
- const { groupedProjectStates, projectStates, fetchProjectStates } = useProjectState();
- // state
- const [activeGroup, setActiveGroup] = useState(null);
- const [selectedState, setSelectedState] = useState(null);
- const [selectDeleteState, setSelectDeleteState] = useState(null);
-
- useSWR(
- workspaceSlug && projectId ? STATES_LIST(projectId.toString()) : null,
- workspaceSlug && projectId ? () => fetchProjectStates(workspaceSlug.toString(), projectId.toString()) : null
- );
-
- // derived values
- const orderedStateGroups = orderStateGroups(groupedProjectStates!);
-
- return (
- <>
- setSelectDeleteState(null)}
- data={projectStates?.find((s) => s.id === selectDeleteState) ?? null}
- />
-
-
- {orderedStateGroups ? (
- <>
- {Object.keys(orderedStateGroups).map((group) => (
-
-
-
{group}
-
-
-
- {group === activeGroup && (
-
{
- setActiveGroup(null);
- setSelectedState(null);
- }}
- selectedGroup={group as keyof StateGroup}
- />
- )}
- {sortByField(orderedStateGroups[group], "sequence").map((state, index) =>
- state.id !== selectedState ? (
- setSelectedState(state.id)}
- handleDeleteState={() => setSelectDeleteState(state.id)}
- />
- ) : (
-
- {
- setActiveGroup(null);
- setSelectedState(null);
- }}
- groupLength={orderedStateGroups[group].length}
- data={projectStates?.find((state) => state.id === selectedState) ?? null}
- selectedGroup={group as keyof StateGroup}
- />
-
- )
- )}
-
-
- ))}
- >
- ) : (
-
-
-
-
-
-
- )}
-
-
- {/*
- {states && currentProjectDetails && orderedStateGroups ? (
- Object.keys(orderedStateGroups || {}).map((key) => {
- if (orderedStateGroups[key].length !== 0)
- return (
-
-
-
{key}
-
-
-
- {key === activeGroup && (
-
{
- setActiveGroup(null);
- setSelectedState(null);
- }}
- selectedGroup={key as keyof StateGroup}
- />
- )}
- {orderedStateGroups[key].map((state, index) =>
- state.id !== selectedState ? (
- setSelectedState(state.id)}
- handleDeleteState={() => setSelectDeleteState(state.id)}
- />
- ) : (
-
- {
- setActiveGroup(null);
- setSelectedState(null);
- }}
- groupLength={orderedStateGroups[key].length}
- data={statesList?.find((state) => state.id === selectedState) ?? null}
- selectedGroup={key as keyof StateGroup}
- />
-
- )
- )}
-
-
- );
- })
- ) : (
-
-
-
-
-
-
- )}
-
*/}
- >
- );
-});
diff --git a/web/core/constants/state.ts b/web/core/constants/state.ts
index b0fd622be..3b3dbcaf8 100644
--- a/web/core/constants/state.ts
+++ b/web/core/constants/state.ts
@@ -1,5 +1,10 @@
import { TStateGroups } from "@plane/types";
+export type TDraggableData = {
+ groupKey: TStateGroups;
+ id: string;
+};
+
export const STATE_GROUPS: {
[key in TStateGroups]: {
key: TStateGroups;
diff --git a/web/core/store/state.store.ts b/web/core/store/state.store.ts
index 90f957a4f..3b7abc1b6 100644
--- a/web/core/store/state.store.ts
+++ b/web/core/store/state.store.ts
@@ -40,8 +40,7 @@ export interface IStateStore {
workspaceSlug: string,
projectId: string,
stateId: string,
- direction: "up" | "down",
- groupIndex: number
+ payload: Partial
) => Promise;
}
@@ -245,33 +244,16 @@ export class StateStore implements IStateStore {
* @param direction
* @param groupIndex
*/
- moveStatePosition = async (
- workspaceSlug: string,
- projectId: string,
- stateId: string,
- direction: "up" | "down",
- groupIndex: number
- ) => {
- const SEQUENCE_GAP = 15000;
+ moveStatePosition = async (workspaceSlug: string, projectId: string, stateId: string, payload: Partial) => {
const originalStates = this.stateMap;
try {
- let newSequence = SEQUENCE_GAP;
- const stateMap = this.projectStates || [];
- const selectedState = stateMap?.find((state) => state.id === stateId);
- const groupStates = stateMap?.filter((state) => state.group === selectedState?.group);
- const groupLength = groupStates.length;
- if (direction === "up") {
- if (groupIndex === 1) newSequence = groupStates[0].sequence - SEQUENCE_GAP;
- else newSequence = (groupStates[groupIndex - 2].sequence + groupStates[groupIndex - 1].sequence) / 2;
- } else {
- if (groupIndex === groupLength - 2) newSequence = groupStates[groupLength - 1].sequence + SEQUENCE_GAP;
- else newSequence = (groupStates[groupIndex + 2].sequence + groupStates[groupIndex + 1].sequence) / 2;
- }
- runInAction(() => {
- set(this.stateMap, [stateId, "sequence"], newSequence);
+ Object.entries(payload).forEach(([key, value]) => {
+ runInAction(() => {
+ set(this.stateMap, [stateId, key], value);
+ });
});
// updating using api
- await this.stateService.patchState(workspaceSlug, projectId, stateId, { sequence: newSequence });
+ await this.stateService.patchState(workspaceSlug, projectId, stateId, payload);
} catch (err) {
// reverting back to old state group if api fails
runInAction(() => {
diff --git a/web/helpers/state.helper.ts b/web/helpers/state.helper.ts
index cf57b5237..4e6c633ba 100644
--- a/web/helpers/state.helper.ts
+++ b/web/helpers/state.helper.ts
@@ -1,6 +1,6 @@
// types
import { IState, IStateResponse } from "@plane/types";
-import { STATE_GROUPS } from "@/constants/state";
+import { STATE_GROUPS, TDraggableData } from "@/constants/state";
export const orderStateGroups = (unorderedStateGroups: IStateResponse | undefined): IStateResponse | undefined => {
if (!unorderedStateGroups) return undefined;
@@ -17,3 +17,33 @@ export const sortStates = (states: IState[]) => {
return Object.keys(STATE_GROUPS).indexOf(stateA.group) - Object.keys(STATE_GROUPS).indexOf(stateB.group);
});
};
+
+export const getCurrentStateSequence = (
+ groupSates: IState[],
+ destinationData: TDraggableData,
+ edge: string | undefined
+) => {
+ const defaultSequence = 65535;
+ if (!edge) return defaultSequence;
+
+ const currentStateIndex = groupSates.findIndex((state) => state.id === destinationData.id);
+ const currentStateSequence = groupSates[currentStateIndex]?.sequence || undefined;
+
+ if (!currentStateSequence) return defaultSequence;
+
+ if (edge === "top") {
+ const prevStateSequence = groupSates[currentStateIndex - 1]?.sequence || undefined;
+
+ if (prevStateSequence === undefined) {
+ return currentStateSequence - defaultSequence;
+ }
+ return (currentStateSequence + prevStateSequence) / 2;
+ } else if (edge === "bottom") {
+ const nextStateSequence = groupSates[currentStateIndex + 1]?.sequence || undefined;
+
+ if (nextStateSequence === undefined) {
+ return currentStateSequence + defaultSequence;
+ }
+ return (currentStateSequence + nextStateSequence) / 2;
+ }
+};