refactor project states to ake way for new features (#6156)

This commit is contained in:
rahulramesha 2024-12-05 12:46:51 +05:30 committed by GitHub
parent 3bccda0c86
commit 66652a5d71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 462 additions and 161 deletions

View file

@ -23,6 +23,7 @@ export type TPosition =
interface ITooltipProps {
tooltipHeading?: string;
tooltipContent: string | React.ReactNode;
jsxContent?: string | React.ReactNode;
position?: TPosition;
children: JSX.Element;
disabled?: boolean;
@ -38,13 +39,14 @@ export const Tooltip: React.FC<ITooltipProps> = ({
tooltipContent,
position = "top",
children,
jsxContent,
disabled = false,
className = "",
openDelay = 200,
closeDelay,
isMobile = false,
renderByDefault = true, //FIXME: tooltip should always render on hover and not by default, this is a temporary fix
}) => {
}: ITooltipProps) => {
const toolTipRef = useRef<HTMLDivElement | null>(null);
const [shouldRender, setShouldRender] = useState(renderByDefault);
@ -79,6 +81,9 @@ export const Tooltip: React.FC<ITooltipProps> = ({
hoverOpenDelay={openDelay}
hoverCloseDelay={closeDelay}
content={
jsxContent ? (
<>{jsxContent}</>
) : (
<div
className={cn(
"relative block z-50 max-w-xs gap-1 overflow-hidden break-words rounded-md bg-custom-background-100 p-2 text-xs text-custom-text-200 shadow-md",
@ -91,6 +96,7 @@ export const Tooltip: React.FC<ITooltipProps> = ({
{tooltipHeading && <h5 className="font-medium text-custom-text-100">{tooltipHeading}</h5>}
{tooltipContent}
</div>
)
}
position={position}
renderTarget={({

View file

@ -0,0 +1,20 @@
import { Plus } from "lucide-react";
// Plane
import { cn } from "@plane/editor";
type Props = {
workspaceSlug: string;
projectId: string;
parentStateId: string;
onTransitionAdd?: () => void;
};
export const AddStateTransition = (props: Props) => (
<div className={cn("flex w-full px-3 h-6 items-center justify-start gap-2 text-sm bg-custom-background-90")}>
<>
<Plus className="h-4 w-4" color="#8591AD" />
<span className="text-custom-text-400 font-medium"> Add Transition</span>
<div className="text-white bg-custom-background-80 font-semibold px-2 rounded-lg">Pro</div>
</>
</div>
);

View file

@ -0,0 +1,6 @@
export * from "./state-option";
export * from "./state-item-child";
export * from "./state-transition-count";
export * from "./use-workflow-drag-n-drop";
export * from "./workflow-disabled-message";
export * from "./workflow-group-tree";

View file

@ -0,0 +1,35 @@
import { SetStateAction } from "react";
import { observer } from "mobx-react";
// Plane
import { IState } from "@plane/types";
// components
import { StateItemTitle } from "@/components/project-states/state-item-title";
//
import { AddStateTransition } from "./add-state-transition";
export type StateItemChildProps = {
workspaceSlug: string;
projectId: string;
stateCount: number;
disabled: boolean;
state: IState;
setUpdateStateModal: (value: SetStateAction<boolean>) => void;
};
export const StateItemChild = observer((props: StateItemChildProps) => {
const { workspaceSlug, projectId, stateCount, setUpdateStateModal, disabled, state } = props;
return (
<div className="flex flex-col w-full items-center justify-between">
<StateItemTitle
workspaceSlug={workspaceSlug}
projectId={projectId}
setUpdateStateModal={setUpdateStateModal}
stateCount={stateCount}
disabled={disabled}
state={state}
/>
<AddStateTransition workspaceSlug={workspaceSlug} projectId={projectId} parentStateId={state.id} />
</div>
);
});

View file

@ -0,0 +1,36 @@
import { observer } from "mobx-react";
import { Check } from "lucide-react";
import { Combobox } from "@headlessui/react";
type Props = {
projectId: string | null | undefined;
option: {
value: string | undefined;
query: string;
content: JSX.Element;
};
filterAvailableStateIds: boolean;
selectedValue: string | null | undefined;
className?: string;
};
export const StateOption = observer((props: Props) => {
const { option, className = "" } = props;
return (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`${className} ${active ? "bg-custom-background-80" : ""} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
);
});

View file

@ -0,0 +1,7 @@
import { IStateWorkFlow } from "@/plane-web/types";
type Props = {
currentTransitionMap?: IStateWorkFlow;
};
export const StateTransitionCount = (props: Props) => <></>;

View file

@ -0,0 +1,15 @@
import { TIssueGroupByOptions } from "@plane/types";
export const useWorkFlowFDragNDrop = (
groupBy: TIssueGroupByOptions | undefined,
subGroupBy?: TIssueGroupByOptions
) => ({
workflowDisabledSource: undefined,
isWorkflowDropDisabled: false,
handleWorkFlowState: (
sourceGroupId: string,
destinationGroupId: string,
sourceSubGroupId?: string,
destinationSubGroupId?: string
) => {},
});

View file

@ -0,0 +1,6 @@
type Props = {
parentStateId: string;
className?: string;
};
export const WorkFlowDisabledMessage = (props: Props) => <></>;

View file

@ -0,0 +1,8 @@
import { TIssueGroupByOptions } from "@plane/types";
type Props = {
groupBy?: TIssueGroupByOptions;
groupId: string | undefined;
};
export const WorkFlowGroupTree = (props: Props) => <></>;

View file

@ -0,0 +1 @@
export * from "@/services/project/project-state.service";

View file

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

View file

@ -1,3 +1,4 @@
export * from "./projects";
export * from "./issue-types";
export * from "./gantt-chart";
export * from "./state.d";

8
web/ce/types/state.d.ts vendored Normal file
View file

@ -0,0 +1,8 @@
export interface IStateTransition {
transition_state_id: string;
actors: string[];
}
export interface IStateWorkFlow {
[transitionId: string]: IStateTransition;
}

View file

@ -1,10 +1,10 @@
"use client";
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
import { ReactNode, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { usePopper } from "react-popper";
import { Check, ChevronDown, Search } from "lucide-react";
import { ChevronDown, Search } from "lucide-react";
import { Combobox } from "@headlessui/react";
// ui
import { ComboDropDown, Spinner, StateGroupIcon } from "@plane/ui";
@ -13,6 +13,8 @@ import { cn } from "@/helpers/common.helper";
// hooks
import { useProjectState } from "@/hooks/store";
import { useDropdown } from "@/hooks/use-dropdown";
// Plane-web
import { StateOption } from "@/plane-web/components/workflow";
// components
import { DropdownButton } from "./buttons";
// constants
@ -30,6 +32,8 @@ type Props = TDropdownProps & {
showDefaultState?: boolean;
value: string | undefined | null;
renderByDefault?: boolean;
stateIds?: string[];
filterAvailableStateIds?: boolean;
};
export const StateDropdown: React.FC<Props> = observer((props) => {
@ -52,6 +56,8 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
tabIndex,
value,
renderByDefault = true,
stateIds,
filterAvailableStateIds = true,
} = props;
// states
const [query, setQuery] = useState("");
@ -78,16 +84,18 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
// store hooks
const { workspaceSlug } = useParams();
const { fetchProjectStates, getProjectStates, getStateById } = useProjectState();
const statesList = getProjectStates(projectId);
const defaultState = statesList?.find((state) => state.default);
const statesList = stateIds
? stateIds.map((stateId) => getStateById(stateId)).filter((state) => !!state)
: getProjectStates(projectId);
const defaultState = statesList?.find((state) => state?.default);
const stateValue = !!value ? value : showDefaultState ? defaultState?.id : undefined;
const options = statesList?.map((state) => ({
value: state.id,
value: state?.id,
query: `${state?.name}`,
content: (
<div className="flex items-center gap-2">
<StateGroupIcon stateGroup={state.group} color={state.color} className="h-3 w-3 flex-shrink-0" />
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} className="h-3 w-3 flex-shrink-0" />
<span className="flex-grow truncate">{state?.name}</span>
</div>
),
@ -226,22 +234,14 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
<StateOption
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 }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
option={option}
projectId={projectId}
filterAvailableStateIds={filterAvailableStateIds}
selectedValue={value}
className="flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5"
/>
))
) : (
<p className="px-1.5 py-1 italic text-custom-text-400">No matches found</p>

View file

@ -1,10 +1,16 @@
import { AlertCircle } from "lucide-react";
// Plane
import { TIssueOrderByOptions } from "@plane/types";
// constants
import { ISSUE_ORDER_BY_OPTIONS } from "@/constants/issue";
// helpers
import { cn } from "@/helpers/common.helper";
// Plane-web
import { WorkFlowDisabledMessage } from "@/plane-web/components/workflow";
type Props = {
dragColumnOrientation: "justify-start" | "justify-center" | "justify-end";
workflowDisabledSource?: string;
canOverlayBeVisible: boolean;
isDropDisabled: boolean;
dropErrorMessage?: string;
@ -16,6 +22,7 @@ export const GroupDragOverlay = (props: Props) => {
const {
dragColumnOrientation,
canOverlayBeVisible,
workflowDisabledSource,
isDropDisabled,
dropErrorMessage,
orderBy,
@ -35,6 +42,9 @@ export const GroupDragOverlay = (props: Props) => {
{ hidden: !shouldOverlayBeVisible }
)}
>
{workflowDisabledSource ? (
<WorkFlowDisabledMessage parentStateId={workflowDisabledSource} className="my-2" />
) : (
<div
className={cn(
"p-3 my-8 flex flex-col rounded items-center",
@ -62,6 +72,7 @@ export const GroupDragOverlay = (props: Props) => {
</>
)}
</div>
)}
</div>
);
};

View file

@ -15,7 +15,8 @@ import { CreateUpdateIssueModal } from "@/components/issues";
// hooks
import { useEventTracker } from "@/hooks/store";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
// types
// Plane-web
import { WorkFlowGroupTree } from "@/plane-web/components/workflow";
interface IHeaderGroupByCard {
sub_group_by: TIssueGroupByOptions | undefined;
@ -33,6 +34,7 @@ interface IHeaderGroupByCard {
export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
const {
group_by,
sub_group_by,
column_id,
icon,
@ -130,6 +132,8 @@ export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
</div>
</div>
<WorkFlowGroupTree groupBy={group_by} groupId={column_id} />
{sub_group_by === null && (
<div
className="flex h-[20px] w-[20px] flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-sm transition-all hover:bg-custom-background-80"

View file

@ -1,7 +1,10 @@
import React, { FC } from "react";
import { observer } from "mobx-react";
import { Circle, ChevronDown, ChevronUp } from "lucide-react";
import { TIssueKanbanFilters } from "@plane/types";
// Plane
import { TIssueGroupByOptions, TIssueKanbanFilters } from "@plane/types";
// Plane-web
import { WorkFlowGroupTree } from "@/plane-web/components/workflow";
// mobx
interface IHeaderSubGroupByCard {
@ -10,11 +13,12 @@ interface IHeaderSubGroupByCard {
count: number;
column_id: string;
collapsedGroups: TIssueKanbanFilters;
sub_group_by: TIssueGroupByOptions | undefined;
handleCollapsedGroups: (toggle: "group_by" | "sub_group_by", value: string) => void;
}
export const HeaderSubGroupByCard: FC<IHeaderSubGroupByCard> = observer((props) => {
const { icon, title, count, column_id, collapsedGroups, handleCollapsedGroups } = props;
const { icon, title, count, column_id, collapsedGroups, sub_group_by, handleCollapsedGroups } = props;
return (
<div
className={`relative flex w-full flex-shrink-0 flex-row items-center gap-1 rounded-sm py-1.5 cursor-pointer`}
@ -36,6 +40,8 @@ export const HeaderSubGroupByCard: FC<IHeaderSubGroupByCard> = observer((props)
<div className="line-clamp-1 text-custom-text-100">{title}</div>
<div className="pl-2 text-sm font-medium text-custom-text-300">{count || 0}</div>
</div>
<WorkFlowGroupTree groupBy={sub_group_by} groupId={column_id} />
</div>
);
});

View file

@ -26,6 +26,9 @@ import { cn } from "@/helpers/common.helper";
import { useProjectState } from "@/hooks/store";
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
import { useIssuesStore } from "@/hooks/use-issue-layout-store";
// Plane-web
import { useWorkFlowFDragNDrop } from "@/plane-web/components/workflow";
//
import { GroupDragOverlay } from "../group-drag-overlay";
import { TRenderQuickActions } from "../list/list-view-types";
import { GroupDropLocation, getSourceFromDropPayload, getDestinationFromDropPayload, getIssueBlockId } from "../utils";
@ -103,6 +106,11 @@ export const KanbanGroup = observer((props: IKanbanGroup) => {
);
const [isDraggingOverColumn, setIsDraggingOverColumn] = useState(false);
const { workflowDisabledSource, isWorkflowDropDisabled, handleWorkFlowState } = useWorkFlowFDragNDrop(
group_by,
sub_group_by
);
// Enable Kanban Columns as Drop Targets
useEffect(() => {
const element = columnRef.current;
@ -113,14 +121,24 @@ export const KanbanGroup = observer((props: IKanbanGroup) => {
dropTargetForElements({
element,
getData: () => ({ groupId, subGroupId: sub_group_id, columnId: `${groupId}__${sub_group_id}`, type: "COLUMN" }),
onDragEnter: () => {
onDragEnter: (payload) => {
const source = getSourceFromDropPayload(payload);
setIsDraggingOverColumn(true);
// handle if dragging a workflowState
if (source) {
handleWorkFlowState(source?.groupId, groupId, source?.subGroupId, sub_group_id);
}
},
onDragLeave: () => {
setIsDraggingOverColumn(false);
},
onDragStart: () => {
onDragStart: (payload) => {
const source = getSourceFromDropPayload(payload);
setIsDraggingOverColumn(true);
// handle if dragging a workflowState
if (source) {
handleWorkFlowState(source?.groupId, groupId, source?.subGroupId, sub_group_id);
}
},
onDrop: (payload) => {
setIsDraggingOverColumn(false);
@ -129,7 +147,7 @@ export const KanbanGroup = observer((props: IKanbanGroup) => {
if (!source || !destination) return;
if (isDropDisabled) {
if (isWorkflowDropDisabled || isDropDisabled) {
dropErrorMessage &&
setToast({
type: TOAST_TYPE.WARNING,
@ -158,6 +176,7 @@ export const KanbanGroup = observer((props: IKanbanGroup) => {
setIsDraggingOverColumn,
orderBy,
isDropDisabled,
isWorkflowDropDisabled,
dropErrorMessage,
handleOnDrop,
]);
@ -237,7 +256,7 @@ export const KanbanGroup = observer((props: IKanbanGroup) => {
);
const shouldLoadMore = nextPageResults === undefined ? issueIds?.length < groupIssueCount : !!nextPageResults;
const canOverlayBeVisible = orderBy !== "sort_order" || isDropDisabled;
const canOverlayBeVisible = isWorkflowDropDisabled || orderBy !== "sort_order" || isDropDisabled;
const shouldOverlayBeVisible = isDraggingOverColumn && canOverlayBeVisible;
return (
@ -253,7 +272,8 @@ export const KanbanGroup = observer((props: IKanbanGroup) => {
<GroupDragOverlay
dragColumnOrientation={sub_group_by ? "justify-start" : "justify-center"}
canOverlayBeVisible={canOverlayBeVisible}
isDropDisabled={isDropDisabled}
isDropDisabled={isWorkflowDropDisabled || isDropDisabled}
workflowDisabledSource={workflowDisabledSource}
dropErrorMessage={dropErrorMessage}
orderBy={orderBy}
isDraggingOverColumn={isDraggingOverColumn}

View file

@ -170,6 +170,7 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
count={issueCount}
collapsedGroups={collapsedGroups}
handleCollapsedGroups={handleCollapsedGroups}
sub_group_by={sub_group_by}
/>
</Row>
</div>

View file

@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { CircleDashed, Plus } from "lucide-react";
// types
import { TIssue, ISearchIssueResponse } from "@plane/types";
import { TIssue, ISearchIssueResponse, TIssueGroupByOptions } from "@plane/types";
// ui
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
// components
@ -18,9 +18,12 @@ import { cn } from "@/helpers/common.helper";
import { useEventTracker } from "@/hooks/store";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import { TSelectionHelper } from "@/hooks/use-multiple-select";
// Plane-web
import { WorkFlowGroupTree } from "@/plane-web/components/workflow";
interface IHeaderGroupByCard {
groupID: string;
groupBy: TIssueGroupByOptions;
icon?: React.ReactNode;
title: string;
count: number;
@ -35,6 +38,7 @@ interface IHeaderGroupByCard {
export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => {
const {
groupID,
groupBy,
icon,
title,
count,
@ -43,7 +47,7 @@ export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => {
disableIssueCreation,
addIssuesToView,
selectionHelpers,
handleCollapsedGroups
handleCollapsedGroups,
} = props;
// states
const [isOpen, setIsOpen] = useState(false);
@ -112,6 +116,7 @@ export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => {
>
<div className="inline-block line-clamp-1 truncate font-medium text-custom-text-100">{title}</div>
<div className="pl-2 text-sm font-medium text-custom-text-300">{count || 0}</div>
<WorkFlowGroupTree groupBy={groupBy} groupId={groupID} />
</div>
{!disableIssueCreation &&

View file

@ -26,7 +26,9 @@ import { useProjectState } from "@/hooks/store";
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
import { useIssuesStore } from "@/hooks/use-issue-layout-store";
import { TSelectionHelper } from "@/hooks/use-multiple-select";
// components
// Plane-web
import { useWorkFlowFDragNDrop } from "@/plane-web/components/workflow";
//
import { GroupDragOverlay } from "../group-drag-overlay";
import { ListQuickAddIssueButton, QuickAddIssueRoot } from "../quick-add";
import {
@ -93,7 +95,7 @@ export const ListGroup = observer((props: Props) => {
const [isDraggingOverColumn, setIsDraggingOverColumn] = useState(false);
const [dragColumnOrientation, setDragColumnOrientation] = useState<"justify-start" | "justify-end">("justify-start");
const isExpanded = !(collapsedGroups?.group_by.includes(group.id))
const isExpanded = !collapsedGroups?.group_by.includes(group.id);
const groupRef = useRef<HTMLDivElement | null>(null);
const { projectId } = useParams();
@ -105,6 +107,8 @@ export const ListGroup = observer((props: Props) => {
const [intersectionElement, setIntersectionElement] = useState<HTMLDivElement | null>(null);
const { workflowDisabledSource, isWorkflowDropDisabled, handleWorkFlowState } = useWorkFlowFDragNDrop(group_by);
const groupIssueCount = getGroupIssueCount(group.id, undefined, false) ?? 0;
const nextPageResults = getPaginationData(group.id, undefined)?.nextPageResults;
const isPaginating = !!getIssueLoader(group.id);
@ -185,6 +189,8 @@ export const ListGroup = observer((props: Props) => {
const sourceGroupId = source?.data?.groupId as string | undefined;
const currentGroupId = group.id;
sourceGroupId && handleWorkFlowState(sourceGroupId, currentGroupId);
const sourceIndex = getGroupIndex(sourceGroupId);
const currentIndex = getGroupIndex(currentGroupId);
@ -201,7 +207,7 @@ export const ListGroup = observer((props: Props) => {
if (!source || !destination) return;
if (group.isDropDisabled) {
if (isWorkflowDropDisabled || group.isDropDisabled) {
group.dropErrorMessage &&
setToast({
type: TOAST_TYPE.WARNING,
@ -215,17 +221,25 @@ export const ListGroup = observer((props: Props) => {
highlightIssueOnDrop(getIssueBlockId(source.id, destination?.groupId), orderBy !== "sort_order");
if(!isExpanded){
handleCollapsedGroups(group.id)
if (!isExpanded) {
handleCollapsedGroups(group.id);
}
},
})
);
}, [groupRef?.current, group, orderBy, getGroupIndex, setDragColumnOrientation, setIsDraggingOverColumn]);
}, [
groupRef?.current,
group,
orderBy,
getGroupIndex,
setDragColumnOrientation,
setIsDraggingOverColumn,
isWorkflowDropDisabled,
]);
const isDragAllowed =
!!group_by && DRAG_ALLOWED_GROUPS.includes(group_by) && canEditProperties(projectId?.toString());
const canOverlayBeVisible = orderBy !== "sort_order" || !!group.isDropDisabled;
const canOverlayBeVisible = isWorkflowDropDisabled || orderBy !== "sort_order" || !!group.isDropDisabled;
const isGroupByCreatedBy = group_by === "created_by";
const shouldExpand = (!!groupIssueCount && isExpanded) || !group_by;
@ -245,6 +259,7 @@ export const ListGroup = observer((props: Props) => {
>
<HeaderGroupByCard
groupID={group.id}
groupBy={group_by}
icon={group.icon}
title={group.name || ""}
count={groupIssueCount}
@ -261,7 +276,8 @@ export const ListGroup = observer((props: Props) => {
<GroupDragOverlay
dragColumnOrientation={dragColumnOrientation}
canOverlayBeVisible={canOverlayBeVisible}
isDropDisabled={!!group.isDropDisabled}
isDropDisabled={isWorkflowDropDisabled || !!group.isDropDisabled}
workflowDisabledSource={workflowDisabledSource}
dropErrorMessage={group.dropErrorMessage}
orderBy={orderBy}
isDraggingOverColumn={isDraggingOverColumn}

View file

@ -10,3 +10,4 @@ export * from "./options";
export * from "./loader";
export * from "./create-update";
export * from "./state-delete-modal";
export * from "./state-item-title";

View file

@ -16,11 +16,17 @@ type TProjectState = {
export const ProjectStateRoot: FC<TProjectState> = observer((props) => {
const { workspaceSlug, projectId } = props;
// hooks
const { groupedProjectStates, fetchProjectStates } = useProjectState();
const { groupedProjectStates, fetchProjectStates, fetchProjectStateTransitions } = useProjectState();
useSWR(
workspaceSlug && projectId ? `PROJECT_STATES_${workspaceSlug}_${projectId}` : null,
workspaceSlug && projectId ? () => fetchProjectStates(workspaceSlug.toString(), projectId.toString()) : null
workspaceSlug && projectId
? () => {
fetchProjectStates(workspaceSlug.toString(), projectId.toString());
fetchProjectStateTransitions(workspaceSlug.toString(), projectId.toString());
}
: null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// Loader

View file

@ -0,0 +1,73 @@
import { SetStateAction } from "react";
import { observer } from "mobx-react";
import { GripVertical, Pencil } from "lucide-react";
// Plane
import { IState } from "@plane/types";
import { StateGroupIcon } from "@plane/ui";
// Plane-web
import { StateTransitionCount } from "@/plane-web/components/workflow";
import { IStateWorkFlow } from "@/plane-web/types";
//
import { StateDelete, StateMarksAsDefault } from "./options";
export type StateItemTitleProps = {
workspaceSlug: string;
projectId: string;
setUpdateStateModal: (value: SetStateAction<boolean>) => void;
stateCount: number;
disabled: boolean;
state: IState;
currentTransitionMap?: IStateWorkFlow;
};
export const StateItemTitle = observer((props: StateItemTitleProps) => {
const { workspaceSlug, projectId, stateCount, setUpdateStateModal, disabled, state, currentTransitionMap } = props;
return (
<div className="py-4 px-2 flex items-center gap-2 w-full justify-between">
<div className="flex items-center gap-2">
{/* draggable indicator */}
{!disabled && stateCount != 1 && (
<div className="flex-shrink-0 w-3 h-3 rounded-sm absolute left-0 hidden group-hover:flex justify-center items-center transition-colors bg-custom-background-90 cursor-pointer text-custom-text-200 hover:text-custom-text-100">
<GripVertical className="w-3 h-3" />
</div>
)}
{/* state icon */}
<div className="flex-shrink-0">
<StateGroupIcon stateGroup={state.group} color={state.color} height="16px" width="16px" />
</div>
{/* state title and description */}
<div className="text-sm px-2 min-h-5">
<h6 className="text-sm font-medium">{state.name}</h6>
<p className="text-xs text-custom-text-200">{state.description}</p>
</div>
{/* Transition count */}
<StateTransitionCount currentTransitionMap={currentTransitionMap} />
</div>
{!disabled && (
<div className="hidden group-hover:flex items-center gap-2">
{/* state mark as default option */}
<div className="flex-shrink-0 text-xs transition-all">
<StateMarksAsDefault
workspaceSlug={workspaceSlug}
projectId={projectId}
stateId={state.id}
isDefault={state.default ? true : false}
/>
</div>
{/* state edit options */}
<div className="flex items-center gap-1 transition-all">
<button
className="flex-shrink-0 w-5 h-5 rounded flex justify-center items-center overflow-hidden transition-colors hover:bg-custom-background-80 cursor-pointer text-custom-text-200 hover:text-custom-text-100"
onClick={() => setUpdateStateModal(true)}
>
<Pencil className="w-3 h-3" />
</button>
<StateDelete workspaceSlug={workspaceSlug} projectId={projectId} totalStates={stateCount} state={state} />
</div>
</div>
)}
</div>
);
});

View file

@ -5,17 +5,19 @@ 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";
// Plane
import { IState, TStateGroups } from "@plane/types";
import { DropIndicator, StateGroupIcon } from "@plane/ui";
import { DropIndicator } from "@plane/ui";
// components
import { StateUpdate, StateDelete, StateMarksAsDefault } from "@/components/project-states";
import { StateUpdate } 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";
// Plane-web
import { StateItemChild } from "@/plane-web/components/workflow";
type TStateItem = {
workspaceSlug: string;
@ -126,59 +128,20 @@ export const StateItem: FC<TStateItem> = observer((props) => {
<div
ref={draggableElementRef}
className={cn(
"relative border border-custom-border-100 rounded p-3 px-3.5 flex items-center gap-2 group my-1",
"relative border border-custom-border-100 rounded group",
isDragging ? `opacity-50` : `opacity-100`,
totalStates === 1 ? `cursor-auto` : `cursor-grab`
)}
>
{/* draggable indicator */}
{!disabled && totalStates != 1 && (
<div className="flex-shrink-0 w-3 h-3 rounded-sm absolute left-0 hidden group-hover:flex justify-center items-center transition-colors bg-custom-background-90 cursor-pointer text-custom-text-200 hover:text-custom-text-100">
<GripVertical className="w-3 h-3" />
</div>
)}
{/* state icon */}
<div className="flex-shrink-0">
<StateGroupIcon stateGroup={state.group} color={state.color} height="16px" width="16px" />
</div>
{/* state title and description */}
<div className="w-full text-sm px-2 min-h-5">
<h6 className="text-sm font-medium">{state.name}</h6>
<p className="text-xs text-custom-text-200">{state.description}</p>
</div>
{!disabled && (
<div className="hidden group-hover:flex items-center gap-2">
{/* state mark as default option */}
<div className="flex-shrink-0 text-xs transition-all">
<StateMarksAsDefault
<StateItemChild
workspaceSlug={workspaceSlug}
projectId={projectId}
stateId={state.id}
isDefault={state.default ? true : false}
/>
</div>
{/* state edit options */}
<div className="flex items-center gap-1 transition-all">
<button
className="flex-shrink-0 w-5 h-5 rounded flex justify-center items-center overflow-hidden transition-colors hover:bg-custom-background-80 cursor-pointer text-custom-text-200 hover:text-custom-text-100"
onClick={() => setUpdateStateModal(true)}
>
<Pencil className="w-3 h-3" />
</button>
<StateDelete
workspaceSlug={workspaceSlug}
projectId={projectId}
totalStates={totalStates}
setUpdateStateModal={setUpdateStateModal}
stateCount={totalStates}
disabled={disabled}
state={state}
/>
</div>
</div>
)}
</div>
{/* draggable drop bottom indicator */}
<DropIndicator isVisible={isDraggedOver && closestEdge === "bottom"} />

View file

@ -1,8 +1,8 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import { IStateStore } from "@/store/state.store";
// Plane-web
import { IStateStore } from "@/plane-web/store/state.store";
export const useProjectState = (): IStateStore => {
const context = useContext(StoreContext);

View file

@ -51,7 +51,7 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
const {
project: { fetchProjectMembers },
} = useMember();
const { fetchProjectStates } = useProjectState();
const { fetchProjectStates, fetchProjectStateTransitions } = useProjectState();
const { fetchProjectLabels } = useLabel();
const { getProjectEstimates } = useProjectEstimates();
// router
@ -105,7 +105,12 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
// fetching project states
useSWR(
workspaceSlug && projectId ? `PROJECT_STATES_${workspaceSlug}_${projectId}` : null,
workspaceSlug && projectId ? () => fetchProjectStates(workspaceSlug.toString(), projectId.toString()) : null,
workspaceSlug && projectId
? () => {
fetchProjectStates(workspaceSlug.toString(), projectId.toString());
fetchProjectStateTransitions(workspaceSlug.toString(), projectId.toString());
}
: null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching project estimates
@ -169,7 +174,7 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
layout="screen-detailed"
primaryButtonOnClick={() => {
setTrackElement("Projects page empty state");
toggleCreateProjectModal(true)
toggleCreateProjectModal(true);
}}
/>
</div>

View file

@ -15,7 +15,7 @@ export interface IIssueStoreActions {
workspaceSlug: string,
projectId: string,
issueId: string,
issueStatus?: "DEFAULT" | "DRAFT",
issueStatus?: "DEFAULT" | "DRAFT"
) => Promise<TIssue>;
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
@ -146,7 +146,7 @@ export class IssueStore implements IIssueStore {
// fetching states
// TODO: check if this function is required
this.rootIssueDetailStore.rootIssueStore.state.fetchProjectStates(workspaceSlug, projectId);
this.rootIssueDetailStore.rootIssueStore.rootStore.state.fetchProjectStates(workspaceSlug, projectId);
return issue;
};

View file

@ -218,12 +218,12 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
let issueStateGroup: string | undefined = undefined;
if (oldIssue.state_id) {
const state = this.rootIssueDetailStore.rootIssueStore.state.getStateById(oldIssue.state_id);
const state = this.rootIssueDetailStore.rootIssueStore.rootStore.state.getStateById(oldIssue.state_id);
if (state?.group) oldIssueStateGroup = state.group;
}
if (issueData.state_id) {
const state = this.rootIssueDetailStore.rootIssueStore.state.getStateById(issueData.state_id);
const state = this.rootIssueDetailStore.rootIssueStore.rootStore.state.getStateById(issueData.state_id);
if (state?.group) issueStateGroup = state.group;
}
@ -255,7 +255,7 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
const issue = this.rootIssueDetailStore.issue.getIssueById(issueId);
if (issue && issue.state_id) {
let issueStateGroup: string | undefined = undefined;
const state = this.rootIssueDetailStore.rootIssueStore.state.getStateById(issue.state_id);
const state = this.rootIssueDetailStore.rootIssueStore.rootStore.state.getStateById(issue.state_id);
if (state?.group) issueStateGroup = state.group;
if (issueStateGroup) {
@ -290,7 +290,7 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
const issue = this.rootIssueDetailStore.issue.getIssueById(issueId);
if (issue && issue.state_id) {
let issueStateGroup: string | undefined = undefined;
const state = this.rootIssueDetailStore.rootIssueStore.state.getStateById(issue.state_id);
const state = this.rootIssueDetailStore.rootIssueStore.rootStore.state.getStateById(issue.state_id);
if (state?.group) issueStateGroup = state.group;
if (issueStateGroup) {

View file

@ -53,8 +53,6 @@ export interface IIssueRootStore {
issues: IIssueStore;
state: IStateStore;
issueDetail: IIssueDetail;
workspaceIssuesFilter: IWorkspaceIssuesFilter;
@ -111,8 +109,6 @@ export class IssueRootStore implements IIssueRootStore {
issues: IIssueStore;
state: IStateStore;
issueDetail: IIssueDetail;
workspaceIssuesFilter: IWorkspaceIssuesFilter;
@ -191,8 +187,6 @@ export class IssueRootStore implements IIssueRootStore {
this.issues = new IssueStore();
this.state = new StateStore(rootStore);
this.issueDetail = new IssueDetail(this);
this.workspaceIssuesFilter = new WorkspaceIssuesFilter(this);

View file

@ -1,4 +1,7 @@
import { enableStaticRendering } from "mobx-react";
// plane web store
import { RootStore } from "@/plane-web/store/root.store";
import { IStateStore, StateStore } from "@/plane-web/store/state.store";
// stores
import { CommandPaletteStore, ICommandPaletteStore } from "./command-palette.store";
import { CycleStore, ICycleStore } from "./cycle.store";
@ -21,7 +24,6 @@ import { IProjectPageStore, ProjectPageStore } from "./pages/project-page.store"
import { IProjectRootStore, ProjectRootStore } from "./project";
import { IProjectViewStore, ProjectViewStore } from "./project-view.store";
import { RouterStore, IRouterStore } from "./router.store";
import { IStateStore, StateStore } from "./state.store";
import { ThemeStore, IThemeStore } from "./theme.store";
import { ITransientStore, TransientStore } from "./transient.store";
import { IUserStore, UserStore } from "./user";
@ -72,8 +74,8 @@ export class CoreRootStore {
this.moduleFilter = new ModuleFilterStore(this);
this.projectView = new ProjectViewStore(this);
this.globalView = new GlobalViewStore(this);
this.issue = new IssueRootStore(this);
this.state = new StateStore(this);
this.issue = new IssueRootStore(this as unknown as RootStore);
this.state = new StateStore(this as unknown as RootStore);
this.label = new LabelStore(this);
this.dashboard = new DashboardStore(this);
this.eventTracker = new EventTrackerStore(this);
@ -103,8 +105,8 @@ export class CoreRootStore {
this.moduleFilter = new ModuleFilterStore(this);
this.projectView = new ProjectViewStore(this);
this.globalView = new GlobalViewStore(this);
this.issue = new IssueRootStore(this);
this.state = new StateStore(this);
this.issue = new IssueRootStore(this as unknown as RootStore);
this.state = new StateStore(this as unknown as RootStore);
this.label = new LabelStore(this);
this.dashboard = new DashboardStore(this);
this.eventTracker = new EventTrackerStore(this);

View file

@ -5,11 +5,11 @@ import { computedFn } from "mobx-utils";
// types
import { IState } from "@plane/types";
// helpers
import { convertStringArrayToBooleanObject } from "@/helpers/array.helper";
import { sortStates } from "@/helpers/state.helper";
// services
import { ProjectStateService } from "@/services/project";
// plane web store
import { CoreRootStore } from "./root.store";
// plane web
import { ProjectStateService } from "@/plane-web/services/project/project-state.service";
import { RootStore } from "@/plane-web/store/root.store";
export interface IStateStore {
//Loaders
@ -23,6 +23,10 @@ export interface IStateStore {
// computed actions
getStateById: (stateId: string | null | undefined) => IState | undefined;
getProjectStates: (projectId: string | null | undefined) => IState[] | undefined;
getAvailableProjectStateIdMap: (
projectId: string | null | undefined,
currStateId: string | null | undefined
) => { [key: string]: boolean };
// fetch actions
fetchProjectStates: (workspaceSlug: string, projectId: string) => Promise<IState[]>;
fetchWorkspaceStates: (workspaceSlug: string) => Promise<IState[]>;
@ -42,16 +46,19 @@ export interface IStateStore {
stateId: string,
payload: Partial<IState>
) => Promise<void>;
//Dummy method
fetchProjectStateTransitions: (workspaceSlug: string, projectId: string) => void;
}
export class StateStore implements IStateStore {
stateMap: Record<string, IState> = {};
//loaders
fetchedMap: Record<string, boolean> = {};
rootStore: RootStore;
router;
stateService;
stateService: ProjectStateService;
constructor(_rootStore: CoreRootStore) {
constructor(_rootStore: RootStore) {
makeObservable(this, {
// observables
stateMap: observable,
@ -71,6 +78,7 @@ export class StateStore implements IStateStore {
});
this.stateService = new ProjectStateService();
this.router = _rootStore.router;
this.rootStore = _rootStore;
}
/**
@ -120,6 +128,20 @@ export class StateStore implements IStateStore {
return sortStates(Object.values(this.stateMap).filter((state) => state.project_id === projectId));
});
/**
* Returns an object linking state permissions as boolean values
* @param projectId
*/
getAvailableProjectStateIdMap = computedFn(
(projectId: string | null | undefined, currStateId: string | null | undefined) => {
const projectStates = this.getProjectStates(projectId);
if (!projectStates) return {};
return convertStringArrayToBooleanObject(projectStates.map((projectState) => projectState.id));
}
);
/**
* fetches the stateMap of a project
* @param workspaceSlug
@ -261,4 +283,7 @@ export class StateStore implements IStateStore {
});
}
};
// Dummy method
fetchProjectStateTransitions = (workspaceSlug: string, projectId: string) => {};
}

View file

@ -0,0 +1 @@
export * from "ce/components/workflow";

View file

@ -0,0 +1 @@
export * from "@/services/project/project-state.service";

View file

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

View file

@ -1,2 +1,3 @@
export * from "./projects";
export * from "./issue-types";
export * from "ce/types/state.d";

View file

@ -102,3 +102,18 @@ export const getValidKeysFromObject = (obj: any) => {
return Object.keys(obj).filter((key) => !!obj[key]);
};
/**
* Convert an array into an object of keys and boolean strue
* @param arrayStrings
* @returns
*/
export const convertStringArrayToBooleanObject = (arrayStrings: string[]) => {
const obj: { [key: string]: boolean } = {};
for (const arrayString of arrayStrings) {
obj[arrayString] = true;
}
return obj;
};