[WEB-3788] improvement: enhance project properties related components modularity (#6882)

* improvement: work item modal data preload and parent work item details

* improvement: collapsible button title

* improvement: project creation form and modal

* improvement: emoji helper

* improvement: enhance labels component modularity

* improvement: enable state group and state list components modularity

* improvement: project settings feature list

* improvement: common utils
This commit is contained in:
Prateek Shourya 2025-04-09 14:50:43 +05:30 committed by GitHub
parent 670134562f
commit 1f9222065e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 622 additions and 381 deletions

View file

@ -8,3 +8,18 @@ export const ISSUE_REACTION_EMOJI_CODES = [
"9992", "9992",
"128064", "128064",
]; ];
export const RANDOM_EMOJI_CODES = [
"8986",
"9200",
"128204",
"127773",
"127891",
"128076",
"128077",
"128187",
"128188",
"128512",
"128522",
"128578",
];

View file

@ -29,3 +29,4 @@ export * from "./event-tracker";
export * from "./spreadsheet"; export * from "./spreadsheet";
export * from "./dashboard"; export * from "./dashboard";
export * from "./page"; export * from "./page";
export * from "./emoji";

View file

@ -1,5 +1,7 @@
// icons // plane imports
import { TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types"; import { IProject, TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types";
// local imports
import { RANDOM_EMOJI_CODES } from "./emoji";
export type TNetworkChoiceIconKey = "Lock" | "Globe2"; export type TNetworkChoiceIconKey = "Lock" | "Globe2";
@ -132,3 +134,18 @@ export const PROJECT_ERROR_MESSAGES = {
i18n_message: "workspace_projects.error.issue_delete", i18n_message: "workspace_projects.error.issue_delete",
}, },
}; };
export const DEFAULT_PROJECT_FORM_VALUES: Partial<IProject> = {
cover_image_url: PROJECT_UNSPLASH_COVERS[Math.floor(Math.random() * PROJECT_UNSPLASH_COVERS.length)],
description: "",
logo_props: {
in_use: "emoji",
emoji: {
value: RANDOM_EMOJI_CODES[Math.floor(Math.random() * RANDOM_EMOJI_CODES.length)],
},
},
identifier: "",
name: "",
network: 2,
project_lead: null,
};

View file

@ -1,9 +1,4 @@
export type TStateGroups = export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
| "backlog"
| "unstarted"
| "started"
| "completed"
| "cancelled";
export type TDraggableData = { export type TDraggableData = {
groupKey: TStateGroups; groupKey: TStateGroups;
@ -14,40 +9,43 @@ export const STATE_GROUPS: {
[key in TStateGroups]: { [key in TStateGroups]: {
key: TStateGroups; key: TStateGroups;
label: string; label: string;
defaultStateName: string;
color: string; color: string;
}; };
} = { } = {
backlog: { backlog: {
key: "backlog", key: "backlog",
label: "Backlog", label: "Backlog",
defaultStateName: "Backlog",
color: "#d9d9d9", color: "#d9d9d9",
}, },
unstarted: { unstarted: {
key: "unstarted", key: "unstarted",
label: "Unstarted", label: "Unstarted",
defaultStateName: "Todo",
color: "#3f76ff", color: "#3f76ff",
}, },
started: { started: {
key: "started", key: "started",
label: "Started", label: "Started",
defaultStateName: "In Progress",
color: "#f59e0b", color: "#f59e0b",
}, },
completed: { completed: {
key: "completed", key: "completed",
label: "Completed", label: "Completed",
defaultStateName: "Done",
color: "#16a34a", color: "#16a34a",
}, },
cancelled: { cancelled: {
key: "cancelled", key: "cancelled",
label: "Canceled", label: "Canceled",
defaultStateName: "Cancelled",
color: "#dc2626", color: "#dc2626",
}, },
}; };
export const ARCHIVABLE_STATE_GROUPS = [ export const ARCHIVABLE_STATE_GROUPS = [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key];
STATE_GROUPS.completed.key,
STATE_GROUPS.cancelled.key,
];
export const COMPLETED_STATE_GROUPS = [STATE_GROUPS.completed.key]; export const COMPLETED_STATE_GROUPS = [STATE_GROUPS.completed.key];
export const PENDING_STATE_GROUPS = [ export const PENDING_STATE_GROUPS = [
STATE_GROUPS.backlog.key, STATE_GROUPS.backlog.key,

View file

@ -1,6 +1,8 @@
// plane constants
import { TInboxIssue, TInboxIssueStatus } from "@plane/constants";
// plane types
import { TPaginationInfo } from "./common"; import { TPaginationInfo } from "./common";
import { TIssuePriorities } from "./issues"; import { TIssuePriorities } from "./issues";
import { TIssue } from "./issues/base";
// filters // filters
export type TInboxIssueFilterMemberKeys = "assignees" | "created_by"; export type TInboxIssueFilterMemberKeys = "assignees" | "created_by";

View file

@ -24,3 +24,11 @@ export interface IStateLite {
export interface IStateResponse { export interface IStateResponse {
[key: string]: IState[]; [key: string]: IState[];
} }
export type TStateOperationsCallbacks = {
createState: (data: Partial<IState>) => Promise<IState>;
updateState: (stateId: string, data: Partial<IState>) => Promise<IState | undefined>;
deleteState: (stateId: string) => Promise<void>;
moveStatePosition: (stateId: string, data: Partial<IState>) => Promise<void>;
markStateAsDefault: (stateId: string) => Promise<void>;
};

View file

@ -1,10 +1,10 @@
import React, { FC } from "react"; import React, { FC } from "react";
import { DropdownIcon } from "../icons";
import { cn } from "../../helpers"; import { cn } from "../../helpers";
import { DropdownIcon } from "../icons";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
title: string; title: React.ReactNode;
hideChevron?: boolean; hideChevron?: boolean;
indicatorElement?: React.ReactNode; indicatorElement?: React.ReactNode;
actionItemElement?: React.ReactNode; actionItemElement?: React.ReactNode;

View file

@ -45,14 +45,14 @@ interface CustomSearchSelectProps {
onClose?: () => void; onClose?: () => void;
noResultsMessage?: string; noResultsMessage?: string;
options: options:
| { | {
value: any; value: any;
query: string; query: string;
content: React.ReactNode; content: React.ReactNode;
disabled?: boolean; disabled?: boolean;
tooltip?: string | React.ReactNode; tooltip?: string | React.ReactNode;
}[] }[]
| undefined; | undefined;
} }
interface SingleValueProps { interface SingleValueProps {

View file

@ -1,5 +1,6 @@
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { CompleteOrEmpty } from "@plane/types";
// Support email can be configured by the application // Support email can be configured by the application
export const getSupportEmail = (defaultEmail: string = ""): string => defaultEmail; export const getSupportEmail = (defaultEmail: string = ""): string => defaultEmail;
@ -39,3 +40,21 @@ export const partitionValidIds = (ids: string[], validIds: string[]): { valid: s
return { valid, invalid }; return { valid, invalid };
}; };
/**
* Checks if an object is complete (has properties) rather than empty.
* This helps TypeScript narrow the type from CompleteOrEmpty<T> to T.
*
* @param obj The object to check, typed as CompleteOrEmpty<T>
* @returns A boolean indicating if the object is complete (true) or empty (false)
*/
export const isComplete = <T>(obj: CompleteOrEmpty<T>): obj is T => {
// Check if object is not null or undefined
if (obj == null) return false;
// Check if it's an object
if (typeof obj !== "object") return false;
// Check if it has any own properties
return Object.keys(obj).length > 0;
};

View file

@ -1,12 +1,13 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// plane imports // plane imports
import { ISearchIssueResponse } from "@plane/types"; import { ISearchIssueResponse, TIssue } from "@plane/types";
// components // components
import { IssueModalContext } from "@/components/issues"; import { IssueModalContext } from "@/components/issues";
export type TIssueModalProviderProps = { export type TIssueModalProviderProps = {
templateId?: string; templateId?: string;
dataForPreload?: Partial<TIssue>;
children: React.ReactNode; children: React.ReactNode;
}; };
@ -32,7 +33,6 @@ export const IssueModalProvider = observer((props: TIssueModalProviderProps) =>
getActiveAdditionalPropertiesLength: () => 0, getActiveAdditionalPropertiesLength: () => 0,
handlePropertyValuesValidation: () => true, handlePropertyValuesValidation: () => true,
handleCreateUpdatePropertyValues: () => Promise.resolve(), handleCreateUpdatePropertyValues: () => Promise.resolve(),
handleParentWorkItemDetails: () => Promise.resolve(undefined),
handleProjectEntitiesFetch: () => Promise.resolve(), handleProjectEntitiesFetch: () => Promise.resolve(),
handleTemplateChange: () => Promise.resolve(), handleTemplateChange: () => Promise.resolve(),
}} }}

View file

@ -3,7 +3,7 @@
import { useState, FC } from "react"; import { useState, FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { PROJECT_UNSPLASH_COVERS, PROJECT_CREATED } from "@plane/constants"; import { PROJECT_CREATED, DEFAULT_PROJECT_FORM_VALUES } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
// ui // ui
import { setToast, TOAST_TYPE } from "@plane/ui"; import { setToast, TOAST_TYPE } from "@plane/ui";
@ -11,8 +11,6 @@ import { setToast, TOAST_TYPE } from "@plane/ui";
import ProjectCommonAttributes from "@/components/project/create/common-attributes"; import ProjectCommonAttributes from "@/components/project/create/common-attributes";
import ProjectCreateHeader from "@/components/project/create/header"; import ProjectCreateHeader from "@/components/project/create/header";
import ProjectCreateButtons from "@/components/project/create/project-create-buttons"; import ProjectCreateButtons from "@/components/project/create/project-create-buttons";
// helpers
import { getRandomEmoji } from "@/helpers/emoji.helper";
// hooks // hooks
import { useEventTracker, useProject } from "@/hooks/store"; import { useEventTracker, useProject } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
@ -26,26 +24,12 @@ export type TCreateProjectFormProps = {
onClose: () => void; onClose: () => void;
handleNextStep: (projectId: string) => void; handleNextStep: (projectId: string) => void;
data?: Partial<TProject>; data?: Partial<TProject>;
templateId?: string;
updateCoverImageStatus: (projectId: string, coverImage: string) => Promise<void>; updateCoverImageStatus: (projectId: string, coverImage: string) => Promise<void>;
}; };
const defaultValues: Partial<TProject> = {
cover_image_url: PROJECT_UNSPLASH_COVERS[Math.floor(Math.random() * PROJECT_UNSPLASH_COVERS.length)],
description: "",
logo_props: {
in_use: "emoji",
emoji: {
value: getRandomEmoji(),
},
},
identifier: "",
name: "",
network: 2,
project_lead: null,
};
export const CreateProjectForm: FC<TCreateProjectFormProps> = observer((props) => { export const CreateProjectForm: FC<TCreateProjectFormProps> = observer((props) => {
const { setToFavorite, workspaceSlug, onClose, handleNextStep, updateCoverImageStatus } = props; const { setToFavorite, workspaceSlug, data, onClose, handleNextStep, updateCoverImageStatus } = props;
// store // store
const { t } = useTranslation(); const { t } = useTranslation();
const { captureProjectEvent } = useEventTracker(); const { captureProjectEvent } = useEventTracker();
@ -54,7 +38,7 @@ export const CreateProjectForm: FC<TCreateProjectFormProps> = observer((props) =
const [isChangeInIdentifierRequired, setIsChangeInIdentifierRequired] = useState(true); const [isChangeInIdentifierRequired, setIsChangeInIdentifierRequired] = useState(true);
// form info // form info
const methods = useForm<TProject>({ const methods = useForm<TProject>({
defaultValues, defaultValues: { ...DEFAULT_PROJECT_FORM_VALUES, ...data },
reValidateMode: "onChange", reValidateMode: "onChange",
}); });
const { handleSubmit, reset, setValue } = methods; const { handleSubmit, reset, setValue } = methods;
@ -105,7 +89,7 @@ export const CreateProjectForm: FC<TCreateProjectFormProps> = observer((props) =
handleNextStep(res.id); handleNextStep(res.id);
}) })
.catch((err) => { .catch((err) => {
Object.keys(err.data).map((key) => { Object.keys(err?.data ?? {}).map((key) => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: t("error"), title: t("error"),

View file

@ -0,0 +1,12 @@
type TProjectTemplateDropdownSize = "xs" | "sm";
export type TProjectTemplateSelect = {
disabled?: boolean;
size?: TProjectTemplateDropdownSize;
placeholder?: string;
dropDownContainerClassName?: string;
handleModalClose: () => void;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const ProjectTemplateSelect = (props: TProjectTemplateSelect) => <></>;

View file

@ -1,5 +1,6 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { FileText, Layers, Timer } from "lucide-react"; import { FileText, Layers, Timer } from "lucide-react";
// plane imports
import { IProject } from "@plane/types"; import { IProject } from "@plane/types";
import { ContrastIcon, DiceIcon, Intake } from "@plane/ui"; import { ContrastIcon, DiceIcon, Intake } from "@plane/ui";
@ -13,16 +14,90 @@ export type TProperties = {
isEnabled: boolean; isEnabled: boolean;
renderChildren?: (currentProjectDetails: IProject, workspaceSlug: string) => ReactNode; renderChildren?: (currentProjectDetails: IProject, workspaceSlug: string) => ReactNode;
}; };
export type TFeatureList = {
[key: string]: TProperties; type TProjectBaseFeatureKeys = "cycles" | "modules" | "views" | "pages" | "inbox";
type TProjectOtherFeatureKeys = "is_time_tracking_enabled";
type TBaseFeatureList = {
[key in TProjectBaseFeatureKeys]: TProperties;
}; };
export type TProjectFeatures = { export const PROJECT_BASE_FEATURES_LIST: TBaseFeatureList = {
[key: string]: { cycles: {
key: "cycles",
property: "cycle_view",
title: "Cycles",
description: "Timebox work as you see fit per project and change frequency from one period to the next.",
icon: <ContrastIcon className="h-5 w-5 flex-shrink-0 rotate-180 text-custom-text-300" />,
isPro: false,
isEnabled: true,
},
modules: {
key: "modules",
property: "module_view",
title: "Modules",
description: "Group work into sub-project-like set-ups with their own leads and assignees.",
icon: <DiceIcon width={20} height={20} className="flex-shrink-0 text-custom-text-300" />,
isPro: false,
isEnabled: true,
},
views: {
key: "views",
property: "issue_views_view",
title: "Views",
description: "Save sorts, filters, and display options for later or share them.",
icon: <Layers className="h-5 w-5 flex-shrink-0 text-custom-text-300" />,
isPro: false,
isEnabled: true,
},
pages: {
key: "pages",
property: "page_view",
title: "Pages",
description: "Write anything like you write anything.",
icon: <FileText className="h-5 w-5 flex-shrink-0 text-custom-text-300" />,
isPro: false,
isEnabled: true,
},
inbox: {
key: "intake",
property: "inbox_view",
title: "Intake",
description: "Consider and discuss work items before you add them to your project.",
icon: <Intake className="h-5 w-5 flex-shrink-0 text-custom-text-300" />,
isPro: false,
isEnabled: true,
},
};
type TOtherFeatureList = {
[key in TProjectOtherFeatureKeys]: TProperties;
};
export const PROJECT_OTHER_FEATURES_LIST: TOtherFeatureList = {
is_time_tracking_enabled: {
key: "time_tracking",
property: "is_time_tracking_enabled",
title: "Time Tracking",
description: "Log time, see timesheets, and download full CSVs for your entire workspace.",
icon: <Timer className="h-5 w-5 flex-shrink-0 text-custom-text-300" />,
isPro: true,
isEnabled: false,
},
};
type TProjectFeatures = {
project_features: {
key: string; key: string;
title: string; title: string;
description: string; description: string;
featureList: TFeatureList; featureList: TBaseFeatureList;
};
project_others: {
key: string;
title: string;
description: string;
featureList: TOtherFeatureList;
}; };
}; };
@ -31,68 +106,12 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = {
key: "projects_and_issues", key: "projects_and_issues",
title: "Projects and work items", title: "Projects and work items",
description: "Toggle these on or off this project.", description: "Toggle these on or off this project.",
featureList: { featureList: PROJECT_BASE_FEATURES_LIST,
cycles: {
key: "cycles",
property: "cycle_view",
title: "Cycles",
description: "Timebox work as you see fit per project and change frequency from one period to the next.",
icon: <ContrastIcon className="h-5 w-5 flex-shrink-0 rotate-180 text-custom-text-300" />,
isPro: false,
isEnabled: true,
},
modules: {
key: "modules",
property: "module_view",
title: "Modules",
description: "Group work into sub-project-like set-ups with their own leads and assignees.",
icon: <DiceIcon width={20} height={20} className="flex-shrink-0 text-custom-text-300" />,
isPro: false,
isEnabled: true,
},
views: {
key: "views",
property: "issue_views_view",
title: "Views",
description: "Save sorts, filters, and display options for later or share them.",
icon: <Layers className="h-5 w-5 flex-shrink-0 text-custom-text-300" />,
isPro: false,
isEnabled: true,
},
pages: {
key: "pages",
property: "page_view",
title: "Pages",
description: "Write anything like you write anything.",
icon: <FileText className="h-5 w-5 flex-shrink-0 text-custom-text-300" />,
isPro: false,
isEnabled: true,
},
inbox: {
key: "intake",
property: "inbox_view",
title: "Intake",
description: "Consider and discuss work items before you add them to your project.",
icon: <Intake className="h-5 w-5 flex-shrink-0 text-custom-text-300" />,
isPro: false,
isEnabled: true,
},
},
}, },
project_others: { project_others: {
key: "work_management", key: "work_management",
title: "Work management", title: "Work management",
description: "Available only on some plans as indicated by the label next to the feature below.", description: "Available only on some plans as indicated by the label next to the feature below.",
featureList: { featureList: PROJECT_OTHER_FEATURES_LIST,
is_time_tracking_enabled: {
key: "time_tracking",
property: "is_time_tracking_enabled",
title: "Time Tracking",
description: "Log time, see timesheets, and download full CSVs for your entire workspace.",
icon: <Timer className="h-5 w-5 flex-shrink-0 text-custom-text-300" />,
isPro: true,
isEnabled: false,
},
},
}, },
}; };

View file

@ -61,7 +61,6 @@ export type TIssueModalContext = {
getActiveAdditionalPropertiesLength: (props: TActiveAdditionalPropertiesProps) => number; getActiveAdditionalPropertiesLength: (props: TActiveAdditionalPropertiesProps) => number;
handlePropertyValuesValidation: (props: TPropertyValuesValidationProps) => boolean; handlePropertyValuesValidation: (props: TPropertyValuesValidationProps) => boolean;
handleCreateUpdatePropertyValues: (props: TCreateUpdatePropertyValuesProps) => Promise<void>; handleCreateUpdatePropertyValues: (props: TCreateUpdatePropertyValuesProps) => Promise<void>;
handleParentWorkItemDetails: (props: THandleParentWorkItemDetailsProps) => Promise<ISearchIssueResponse | undefined>;
handleProjectEntitiesFetch: (props: THandleProjectEntitiesFetchProps) => Promise<void>; handleProjectEntitiesFetch: (props: THandleProjectEntitiesFetchProps) => Promise<void>;
handleTemplateChange: (props: THandleTemplateChangeProps) => Promise<void>; handleTemplateChange: (props: THandleTemplateChangeProps) => Promise<void>;
}; };

View file

@ -2,6 +2,7 @@
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports // plane imports
import { EIssuesStoreType } from "@plane/constants"; import { EIssuesStoreType } from "@plane/constants";
import type { TIssue } from "@plane/types"; import type { TIssue } from "@plane/types";
@ -30,11 +31,20 @@ export interface IssuesModalProps {
templateId?: string; templateId?: string;
} }
export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer( export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((props) => {
(props) => // router params
props.isOpen && ( const { cycleId, moduleId } = useParams();
<IssueModalProvider templateId={props.templateId}> // derived values
<CreateUpdateIssueModalBase {...props} /> const dataForPreload = {
</IssueModalProvider> ...props.data,
) cycle_id: props.data?.cycle_id ? props.data?.cycle_id : cycleId ? cycleId.toString() : null,
); module_ids: props.data?.module_ids ? props.data?.module_ids : moduleId ? [moduleId.toString()] : null,
};
if (!props.isOpen) return null;
return (
<IssueModalProvider templateId={props.templateId} dataForPreload={dataForPreload}>
<CreateUpdateIssueModalBase {...props} />
</IssueModalProvider>
);
});

View file

@ -2,7 +2,6 @@
import React, { forwardRef, useEffect } from "react"; import React, { forwardRef, useEffect } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { TwitterPicker } from "react-color"; import { TwitterPicker } from "react-color";
import { Controller, SubmitHandler, useForm } from "react-hook-form"; import { Controller, SubmitHandler, useForm } from "react-hook-form";
import { Popover, Transition } from "@headlessui/react"; import { Popover, Transition } from "@headlessui/react";
@ -11,13 +10,17 @@ import { getRandomLabelColor, LABEL_COLOR_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { IIssueLabel } from "@plane/types"; import { IIssueLabel } from "@plane/types";
import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
// hooks
import { useLabel } from "@/hooks/store";
type Props = { export type TLabelOperationsCallbacks = {
createLabel: (data: Partial<IIssueLabel>) => Promise<IIssueLabel>;
updateLabel: (labelId: string, data: Partial<IIssueLabel>) => Promise<IIssueLabel>;
};
type TCreateUpdateLabelInlineProps = {
labelForm: boolean; labelForm: boolean;
setLabelForm: React.Dispatch<React.SetStateAction<boolean>>; setLabelForm: React.Dispatch<React.SetStateAction<boolean>>;
isUpdating: boolean; isUpdating: boolean;
labelOperationsCallbacks: TLabelOperationsCallbacks;
labelToUpdate?: IIssueLabel; labelToUpdate?: IIssueLabel;
onClose?: () => void; onClose?: () => void;
}; };
@ -28,12 +31,8 @@ const defaultValues: Partial<IIssueLabel> = {
}; };
export const CreateUpdateLabelInline = observer( export const CreateUpdateLabelInline = observer(
forwardRef<HTMLFormElement, Props>(function CreateUpdateLabelInline(props, ref) { forwardRef<HTMLDivElement, TCreateUpdateLabelInlineProps>(function CreateUpdateLabelInline(props, ref) {
const { labelForm, setLabelForm, isUpdating, labelToUpdate, onClose } = props; const { labelForm, setLabelForm, isUpdating, labelOperationsCallbacks, labelToUpdate, onClose } = props;
// router
const { workspaceSlug, projectId } = useParams();
// store hooks
const { createLabel, updateLabel } = useLabel();
// form info // form info
const { const {
handleSubmit, handleSubmit,
@ -56,9 +55,10 @@ export const CreateUpdateLabelInline = observer(
}; };
const handleLabelCreate: SubmitHandler<IIssueLabel> = async (formData) => { const handleLabelCreate: SubmitHandler<IIssueLabel> = async (formData) => {
if (!workspaceSlug || !projectId || isSubmitting) return; if (isSubmitting) return;
await createLabel(workspaceSlug.toString(), projectId.toString(), formData) await labelOperationsCallbacks
.createLabel(formData)
.then(() => { .then(() => {
handleClose(); handleClose();
reset(defaultValues); reset(defaultValues);
@ -74,10 +74,10 @@ export const CreateUpdateLabelInline = observer(
}; };
const handleLabelUpdate: SubmitHandler<IIssueLabel> = async (formData) => { const handleLabelUpdate: SubmitHandler<IIssueLabel> = async (formData) => {
if (!workspaceSlug || !projectId || isSubmitting) return; if (!labelToUpdate?.id || isSubmitting) return;
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain await labelOperationsCallbacks
await updateLabel(workspaceSlug.toString(), projectId.toString(), labelToUpdate?.id!, formData) .updateLabel(labelToUpdate.id, formData)
.then(() => { .then(() => {
reset(defaultValues); reset(defaultValues);
handleClose(); handleClose();
@ -92,6 +92,14 @@ export const CreateUpdateLabelInline = observer(
}); });
}; };
const handleFormSubmit = (formData: IIssueLabel) => {
if (isUpdating) {
handleLabelUpdate(formData);
} else {
handleLabelCreate(formData);
}
};
/** /**
* For settings focus on name input * For settings focus on name input
*/ */
@ -117,12 +125,8 @@ export const CreateUpdateLabelInline = observer(
return ( return (
<> <>
<form <div
ref={ref} ref={ref}
onSubmit={(e) => {
e.preventDefault();
handleSubmit(isUpdating ? handleLabelUpdate : handleLabelCreate)();
}}
className={`flex w-full scroll-m-8 items-center gap-2 bg-custom-background-100 ${labelForm ? "" : "hidden"}`} className={`flex w-full scroll-m-8 items-center gap-2 bg-custom-background-100 ${labelForm ? "" : "hidden"}`}
> >
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@ -199,10 +203,18 @@ export const CreateUpdateLabelInline = observer(
<Button variant="neutral-primary" onClick={() => handleClose()} size="sm"> <Button variant="neutral-primary" onClick={() => handleClose()} size="sm">
{t("cancel")} {t("cancel")}
</Button> </Button>
<Button variant="primary" type="submit" size="sm" loading={isSubmitting}> <Button
variant="primary"
onClick={(e) => {
e.preventDefault();
handleSubmit(handleFormSubmit)();
}}
size="sm"
loading={isSubmitting}
>
{isUpdating ? (isSubmitting ? t("updating") : t("update")) : isSubmitting ? t("adding") : t("add")} {isUpdating ? (isSubmitting ? t("updating") : t("update")) : isSubmitting ? t("adding") : t("add")}
</Button> </Button>
</form> </div>
{errors.name?.message && <p className="p-0.5 pl-8 text-sm text-red-500">{errors.name?.message}</p>} {errors.name?.message && <p className="p-0.5 pl-8 text-sm text-red-500">{errors.name?.message}</p>}
</> </>
); );

View file

@ -29,6 +29,7 @@ interface ILabelItemBlock {
isLabelGroup?: boolean; isLabelGroup?: boolean;
dragHandleRef: MutableRefObject<HTMLButtonElement | null>; dragHandleRef: MutableRefObject<HTMLButtonElement | null>;
disabled?: boolean; disabled?: boolean;
draggable?: boolean;
} }
export const LabelItemBlock = (props: ILabelItemBlock) => { export const LabelItemBlock = (props: ILabelItemBlock) => {
@ -40,9 +41,10 @@ export const LabelItemBlock = (props: ILabelItemBlock) => {
isLabelGroup, isLabelGroup,
dragHandleRef, dragHandleRef,
disabled = false, disabled = false,
draggable = true,
} = props; } = props;
// states // states
const [isMenuActive, setIsMenuActive] = useState(false); const [isMenuActive, setIsMenuActive] = useState(true);
// refs // refs
const actionSectionRef = useRef<HTMLDivElement | null>(null); const actionSectionRef = useRef<HTMLDivElement | null>(null);
@ -51,7 +53,7 @@ export const LabelItemBlock = (props: ILabelItemBlock) => {
return ( return (
<div className="group flex items-center"> <div className="group flex items-center">
<div className="flex items-center"> <div className="flex items-center">
{!disabled && ( {!disabled && draggable && (
<DragHandle <DragHandle
className={cn("opacity-0 group-hover:opacity-100", { className={cn("opacity-0 group-hover:opacity-100", {
"opacity-100": isDragging, "opacity-100": isDragging,
@ -65,7 +67,7 @@ export const LabelItemBlock = (props: ILabelItemBlock) => {
{!disabled && ( {!disabled && (
<div <div
ref={actionSectionRef} ref={actionSectionRef}
className={`absolute right-2.5 flex items-start gap-3.5 px-4 ${ className={`absolute right-2.5 flex items-center gap-2 px-4 ${
isMenuActive || isLabelGroup isMenuActive || isLabelGroup
? "opacity-100" ? "opacity-100"
: "opacity-0 group-hover:pointer-events-auto group-hover:opacity-100" : "opacity-0 group-hover:pointer-events-auto group-hover:opacity-100"
@ -77,7 +79,7 @@ export const LabelItemBlock = (props: ILabelItemBlock) => {
isVisible && ( isVisible && (
<CustomMenu.MenuItem key={key} onClick={() => onClick(label)}> <CustomMenu.MenuItem key={key} onClick={() => onClick(label)}>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<CustomIcon className="h-4 w-4" /> <CustomIcon className="size-4" />
<span>{text}</span> <span>{text}</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
@ -87,10 +89,10 @@ export const LabelItemBlock = (props: ILabelItemBlock) => {
{!isLabelGroup && ( {!isLabelGroup && (
<div className="py-0.5"> <div className="py-0.5">
<button <button
className="flex h-4 w-4 items-center justify-start gap-2" className="flex size-5 items-center justify-center rounded hover:bg-custom-background-80"
onClick={() => handleLabelDelete(label)} onClick={() => handleLabelDelete(label)}
> >
<X className="h-4 w-4 flex-shrink-0 text-custom-sidebar-text-400" /> <X className="size-3.5 flex-shrink-0 text-custom-sidebar-text-300" />
</button> </button>
</div> </div>
)} )}

View file

@ -7,7 +7,7 @@ import { Disclosure, Transition } from "@headlessui/react";
// types // types
import { IIssueLabel } from "@plane/types"; import { IIssueLabel } from "@plane/types";
// components // components
import { CreateUpdateLabelInline } from "./create-update-label-inline"; import { CreateUpdateLabelInline, TLabelOperationsCallbacks } from "./create-update-label-inline";
import { ICustomMenuItem, LabelItemBlock } from "./label-block/label-item-block"; import { ICustomMenuItem, LabelItemBlock } from "./label-block/label-item-block";
import { LabelDndHOC } from "./label-drag-n-drop-HOC"; import { LabelDndHOC } from "./label-drag-n-drop-HOC";
import { ProjectSettingLabelItem } from "./project-setting-label-item"; import { ProjectSettingLabelItem } from "./project-setting-label-item";
@ -25,6 +25,7 @@ type Props = {
droppedLabelId: string | undefined, droppedLabelId: string | undefined,
dropAtEndOfList: boolean dropAtEndOfList: boolean
) => void; ) => void;
labelOperationsCallbacks: TLabelOperationsCallbacks;
isEditable?: boolean; isEditable?: boolean;
}; };
@ -38,6 +39,7 @@ export const ProjectSettingLabelGroup: React.FC<Props> = observer((props) => {
isLastChild, isLastChild,
onDrop, onDrop,
isEditable = false, isEditable = false,
labelOperationsCallbacks,
} = props; } = props;
// states // states
@ -87,6 +89,7 @@ export const ProjectSettingLabelGroup: React.FC<Props> = observer((props) => {
setLabelForm={setEditLabelForm} setLabelForm={setEditLabelForm}
isUpdating isUpdating
labelToUpdate={label} labelToUpdate={label}
labelOperationsCallbacks={labelOperationsCallbacks}
onClose={() => { onClose={() => {
setEditLabelForm(false); setEditLabelForm(false);
setIsUpdating(false); setIsUpdating(false);
@ -134,6 +137,7 @@ export const ProjectSettingLabelGroup: React.FC<Props> = observer((props) => {
isLastChild={index === labelChildren.length - 1} isLastChild={index === labelChildren.length - 1}
onDrop={onDrop} onDrop={onDrop}
isEditable={isEditable} isEditable={isEditable}
labelOperationsCallbacks={labelOperationsCallbacks}
/> />
</div> </div>
</div> </div>

View file

@ -6,7 +6,7 @@ import { IIssueLabel } from "@plane/types";
// hooks // hooks
import { useLabel } from "@/hooks/store"; import { useLabel } from "@/hooks/store";
// components // components
import { CreateUpdateLabelInline } from "./create-update-label-inline"; import { CreateUpdateLabelInline, TLabelOperationsCallbacks } from "./create-update-label-inline";
import { ICustomMenuItem, LabelItemBlock } from "./label-block/label-item-block"; import { ICustomMenuItem, LabelItemBlock } from "./label-block/label-item-block";
import { LabelDndHOC } from "./label-drag-n-drop-HOC"; import { LabelDndHOC } from "./label-drag-n-drop-HOC";
@ -23,6 +23,7 @@ type Props = {
droppedLabelId: string | undefined, droppedLabelId: string | undefined,
dropAtEndOfList: boolean dropAtEndOfList: boolean
) => void; ) => void;
labelOperationsCallbacks: TLabelOperationsCallbacks;
isEditable?: boolean; isEditable?: boolean;
}; };
@ -35,6 +36,7 @@ export const ProjectSettingLabelItem: React.FC<Props> = (props) => {
isLastChild, isLastChild,
isParentDragging = false, isParentDragging = false,
onDrop, onDrop,
labelOperationsCallbacks,
isEditable = false, isEditable = false,
} = props; } = props;
// states // states
@ -89,6 +91,7 @@ export const ProjectSettingLabelItem: React.FC<Props> = (props) => {
setLabelForm={setEditLabelForm} setLabelForm={setEditLabelForm}
isUpdating isUpdating
labelToUpdate={label} labelToUpdate={label}
labelOperationsCallbacks={labelOperationsCallbacks}
onClose={() => { onClose={() => {
setEditLabelForm(false); setEditLabelForm(false);
setIsUpdating(false); setIsUpdating(false);

View file

@ -14,6 +14,7 @@ import {
DeleteLabelModal, DeleteLabelModal,
ProjectSettingLabelGroup, ProjectSettingLabelGroup,
ProjectSettingLabelItem, ProjectSettingLabelItem,
TLabelOperationsCallbacks,
} from "@/components/labels"; } from "@/components/labels";
// hooks // hooks
import { useLabel, useUserPermissions } from "@/hooks/store"; import { useLabel, useUserPermissions } from "@/hooks/store";
@ -24,7 +25,7 @@ export const ProjectSettingsLabelList: React.FC = observer(() => {
// router // router
const { workspaceSlug, projectId } = useParams(); const { workspaceSlug, projectId } = useParams();
// refs // refs
const scrollToRef = useRef<HTMLFormElement>(null); const scrollToRef = useRef<HTMLDivElement>(null);
// states // states
const [showLabelForm, setLabelForm] = useState(false); const [showLabelForm, setLabelForm] = useState(false);
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
@ -32,11 +33,16 @@ export const ProjectSettingsLabelList: React.FC = observer(() => {
// plane hooks // plane hooks
const { t } = useTranslation(); const { t } = useTranslation();
// store hooks // store hooks
const { projectLabels, updateLabelPosition, projectLabelsTree } = useLabel(); const { projectLabels, updateLabelPosition, projectLabelsTree, createLabel, updateLabel } = useLabel();
const { allowPermissions } = useUserPermissions(); const { allowPermissions } = useUserPermissions();
// derived values // derived values
const isEditable = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); const isEditable = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/project-settings/labels" }); const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/project-settings/labels" });
const labelOperationsCallbacks: TLabelOperationsCallbacks = {
createLabel: (data: Partial<IIssueLabel>) => createLabel(workspaceSlug?.toString(), projectId?.toString(), data),
updateLabel: (labelId: string, data: Partial<IIssueLabel>) =>
updateLabel(workspaceSlug?.toString(), projectId?.toString(), labelId, data),
};
const newLabel = () => { const newLabel = () => {
setIsUpdating(false); setIsUpdating(false);
@ -84,6 +90,7 @@ export const ProjectSettingsLabelList: React.FC = observer(() => {
labelForm={showLabelForm} labelForm={showLabelForm}
setLabelForm={setLabelForm} setLabelForm={setLabelForm}
isUpdating={isUpdating} isUpdating={isUpdating}
labelOperationsCallbacks={labelOperationsCallbacks}
ref={scrollToRef} ref={scrollToRef}
onClose={() => { onClose={() => {
setLabelForm(false); setLabelForm(false);
@ -117,6 +124,7 @@ export const ProjectSettingsLabelList: React.FC = observer(() => {
isLastChild={index === projectLabelsTree.length - 1} isLastChild={index === projectLabelsTree.length - 1}
onDrop={onDrop} onDrop={onDrop}
isEditable={isEditable} isEditable={isEditable}
labelOperationsCallbacks={labelOperationsCallbacks}
/> />
); );
} }
@ -130,6 +138,7 @@ export const ProjectSettingsLabelList: React.FC = observer(() => {
isLastChild={index === projectLabelsTree.length - 1} isLastChild={index === projectLabelsTree.length - 1}
onDrop={onDrop} onDrop={onDrop}
isEditable={isEditable} isEditable={isEditable}
labelOperationsCallbacks={labelOperationsCallbacks}
/> />
); );
})} })}

View file

@ -2,41 +2,48 @@
import { FC, useState } from "react"; import { FC, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { STATE_CREATED, STATE_GROUPS } from "@plane/constants"; import { EventProps, STATE_CREATED, STATE_GROUPS } from "@plane/constants";
import { IState, TStateGroups } from "@plane/types"; import { IState, TStateGroups, TStateOperationsCallbacks } from "@plane/types";
import { TOAST_TYPE, setToast } from "@plane/ui"; import { TOAST_TYPE, setToast } from "@plane/ui";
// components // components
import { StateForm } from "@/components/project-states"; import { StateForm } from "@/components/project-states";
// hooks // hooks
import { useEventTracker, useProjectState } from "@/hooks/store"; import { useEventTracker } from "@/hooks/store";
type TStateCreate = { type TStateCreate = {
workspaceSlug: string;
projectId: string;
groupKey: TStateGroups; groupKey: TStateGroups;
shouldTrackEvents: boolean;
createStateCallback: TStateOperationsCallbacks["createState"];
handleClose: () => void; handleClose: () => void;
}; };
export const StateCreate: FC<TStateCreate> = observer((props) => { export const StateCreate: FC<TStateCreate> = observer((props) => {
const { workspaceSlug, projectId, groupKey, handleClose } = props; const { groupKey, shouldTrackEvents, createStateCallback, handleClose } = props;
// hooks // hooks
const { captureProjectStateEvent, setTrackElement } = useEventTracker(); const { captureProjectStateEvent, setTrackElement } = useEventTracker();
const { createState } = useProjectState();
// states // states
const [loader, setLoader] = useState(false); const [loader, setLoader] = useState(false);
const captureEventIfEnabled = (props: EventProps) => {
if (shouldTrackEvents) {
captureProjectStateEvent(props);
}
};
const onCancel = () => { const onCancel = () => {
setLoader(false); setLoader(false);
handleClose(); handleClose();
}; };
const onSubmit = async (formData: Partial<IState>) => { const onSubmit = async (formData: Partial<IState>) => {
if (!workspaceSlug || !projectId || !groupKey) return { status: "error" }; if (!groupKey) return { status: "error" };
setTrackElement("PROJECT_SETTINGS_STATE_PAGE"); if (shouldTrackEvents) {
setTrackElement("PROJECT_SETTINGS_STATE_PAGE");
}
try { try {
const stateResponse = await createState(workspaceSlug, projectId, { ...formData, group: groupKey }); const stateResponse = await createStateCallback({ ...formData, group: groupKey });
captureProjectStateEvent({ captureEventIfEnabled({
eventName: STATE_CREATED, eventName: STATE_CREATED,
payload: { payload: {
...stateResponse, ...stateResponse,
@ -53,7 +60,7 @@ export const StateCreate: FC<TStateCreate> = observer((props) => {
return { status: "success" }; return { status: "success" };
} catch (error) { } catch (error) {
const errorStatus = error as unknown as { status: number; data: { error: string } }; const errorStatus = error as unknown as { status: number; data: { error: string } };
captureProjectStateEvent({ captureEventIfEnabled({
eventName: STATE_CREATED, eventName: STATE_CREATED,
payload: { payload: {
...formData, ...formData,

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { FormEvent, FC, useEffect, useState, useMemo } from "react"; import { FC, useEffect, useState, useMemo } from "react";
import { TwitterPicker } from "react-color"; import { TwitterPicker } from "react-color";
import { IState } from "@plane/types"; import { IState } from "@plane/types";
import { Button, Popover, Input, TextArea } from "@plane/ui"; import { Button, Popover, Input, TextArea } from "@plane/ui";
@ -28,7 +28,7 @@ export const StateForm: FC<TStateForm> = (props) => {
setErrors((prev) => ({ ...prev, [key]: "" })); setErrors((prev) => ({ ...prev, [key]: "" }));
}; };
const formSubmit = async (event: FormEvent<HTMLFormElement>) => { const formSubmit = async (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.preventDefault(); event.preventDefault();
const name = formData?.name || undefined; const name = formData?.name || undefined;
@ -59,7 +59,7 @@ export const StateForm: FC<TStateForm> = (props) => {
); );
return ( return (
<form onSubmit={formSubmit} className="relative flex space-x-2 bg-custom-background-100 p-3 rounded"> <div className="relative flex space-x-2 bg-custom-background-100 p-3 rounded">
{/* color */} {/* color */}
<div className="flex-shrink-0 h-full mt-2"> <div className="flex-shrink-0 h-full mt-2">
<Popover button={PopoverButton} panelClassName="mt-4 -ml-3"> <Popover button={PopoverButton} panelClassName="mt-4 -ml-3">
@ -94,7 +94,7 @@ export const StateForm: FC<TStateForm> = (props) => {
/> />
<div className="flex space-x-2 items-center"> <div className="flex space-x-2 items-center">
<Button type="submit" variant="primary" size="sm" disabled={buttonDisabled}> <Button onClick={formSubmit} variant="primary" size="sm" disabled={buttonDisabled}>
{buttonTitle} {buttonTitle}
</Button> </Button>
<Button type="button" variant="neutral-primary" size="sm" disabled={buttonDisabled} onClick={onCancel}> <Button type="button" variant="neutral-primary" size="sm" disabled={buttonDisabled} onClick={onCancel}>
@ -102,6 +102,6 @@ export const StateForm: FC<TStateForm> = (props) => {
</Button> </Button>
</div> </div>
</div> </div>
</form> </div>
); );
}; };

View file

@ -2,27 +2,25 @@
import { FC, useState } from "react"; import { FC, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { STATE_UPDATED } from "@plane/constants"; import { EventProps, STATE_UPDATED } from "@plane/constants";
import { IState } from "@plane/types"; import { IState, TStateOperationsCallbacks } from "@plane/types";
import { TOAST_TYPE, setToast } from "@plane/ui"; import { TOAST_TYPE, setToast } from "@plane/ui";
// components // components
import { StateForm } from "@/components/project-states"; import { StateForm } from "@/components/project-states";
// constants
// hooks // hooks
import { useEventTracker, useProjectState } from "@/hooks/store"; import { useEventTracker } from "@/hooks/store";
type TStateUpdate = { type TStateUpdate = {
workspaceSlug: string;
projectId: string;
state: IState; state: IState;
updateStateCallback: TStateOperationsCallbacks["updateState"];
shouldTrackEvents: boolean;
handleClose: () => void; handleClose: () => void;
}; };
export const StateUpdate: FC<TStateUpdate> = observer((props) => { export const StateUpdate: FC<TStateUpdate> = observer((props) => {
const { workspaceSlug, projectId, state, handleClose } = props; const { state, updateStateCallback, shouldTrackEvents, handleClose } = props;
// hooks // hooks
const { captureProjectStateEvent, setTrackElement } = useEventTracker(); const { captureProjectStateEvent, setTrackElement } = useEventTracker();
const { updateState } = useProjectState();
// states // states
const [loader, setLoader] = useState(false); const [loader, setLoader] = useState(false);
@ -31,13 +29,21 @@ export const StateUpdate: FC<TStateUpdate> = observer((props) => {
handleClose(); handleClose();
}; };
const onSubmit = async (formData: Partial<IState>) => { const captureEventIfEnabled = (props: EventProps) => {
if (!workspaceSlug || !projectId || !state.id) return { status: "error" }; if (shouldTrackEvents) {
captureProjectStateEvent(props);
}
};
setTrackElement("PROJECT_SETTINGS_STATE_PAGE"); const onSubmit = async (formData: Partial<IState>) => {
if (!state.id) return { status: "error" };
if (shouldTrackEvents) {
setTrackElement("PROJECT_SETTINGS_STATE_PAGE");
}
try { try {
const stateResponse = await updateState(workspaceSlug, projectId, state.id, formData); const stateResponse = await updateStateCallback(state.id, formData);
captureProjectStateEvent({ captureEventIfEnabled({
eventName: STATE_UPDATED, eventName: STATE_UPDATED,
payload: { payload: {
...stateResponse, ...stateResponse,
@ -67,7 +73,7 @@ export const StateUpdate: FC<TStateUpdate> = observer((props) => {
title: "Error!", title: "Error!",
message: "State could not be updated. Please try again.", message: "State could not be updated. Please try again.",
}); });
captureProjectStateEvent({ captureEventIfEnabled({
eventName: STATE_UPDATED, eventName: STATE_UPDATED,
payload: { payload: {
...formData, ...formData,

View file

@ -4,35 +4,38 @@ import { FC, useState, useRef } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { ChevronDown, Plus } from "lucide-react"; import { ChevronDown, Plus } from "lucide-react";
// plane imports // plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { IState, TStateGroups } from "@plane/types"; import { IState, TStateGroups, TStateOperationsCallbacks } from "@plane/types";
import { StateGroupIcon } from "@plane/ui"; import { StateGroupIcon } from "@plane/ui";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// components // components
import { StateList, StateCreate } from "@/components/project-states"; import { StateList, StateCreate } from "@/components/project-states";
// hooks
import { useUserPermissions } from "@/hooks/store";
type TGroupItem = { type TGroupItem = {
workspaceSlug: string;
projectId: string;
groupKey: TStateGroups; groupKey: TStateGroups;
groupsExpanded: Partial<TStateGroups>[]; groupsExpanded: Partial<TStateGroups>[];
handleGroupCollapse: (groupKey: TStateGroups) => void;
handleExpand: (groupKey: TStateGroups) => void;
groupedStates: Record<string, IState[]>; groupedStates: Record<string, IState[]>;
states: IState[]; states: IState[];
stateOperationsCallbacks: TStateOperationsCallbacks;
isEditable: boolean;
shouldTrackEvents: boolean;
groupItemClassName?: string;
stateItemClassName?: string;
handleGroupCollapse: (groupKey: TStateGroups) => void;
handleExpand: (groupKey: TStateGroups) => void;
}; };
export const GroupItem: FC<TGroupItem> = observer((props) => { export const GroupItem: FC<TGroupItem> = observer((props) => {
const { const {
workspaceSlug,
projectId,
groupKey, groupKey,
groupedStates, groupedStates,
states, states,
groupsExpanded, groupsExpanded,
isEditable,
stateOperationsCallbacks,
shouldTrackEvents,
groupItemClassName,
stateItemClassName,
handleExpand, handleExpand,
handleGroupCollapse, handleGroupCollapse,
} = props; } = props;
@ -40,18 +43,18 @@ export const GroupItem: FC<TGroupItem> = observer((props) => {
const dropElementRef = useRef<HTMLDivElement | null>(null); const dropElementRef = useRef<HTMLDivElement | null>(null);
// plane hooks // plane hooks
const { t } = useTranslation(); const { t } = useTranslation();
// store hooks
const { allowPermissions } = useUserPermissions();
// state // state
const [createState, setCreateState] = useState(false); const [createState, setCreateState] = useState(false);
// derived values // derived values
const currentStateExpanded = groupsExpanded.includes(groupKey); const currentStateExpanded = groupsExpanded.includes(groupKey);
const isEditable = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
const shouldShowEmptyState = states.length === 0 && currentStateExpanded && !createState; const shouldShowEmptyState = states.length === 0 && currentStateExpanded && !createState;
return ( return (
<div <div
className="space-y-1 border border-custom-border-200 rounded bg-custom-background-90 transition-all p-2" className={cn(
"space-y-1 border border-custom-border-200 rounded bg-custom-background-90 transition-all p-2",
groupItemClassName
)}
ref={dropElementRef} ref={dropElementRef}
> >
<div className="flex justify-between items-center gap-2"> <div className="flex justify-between items-center gap-2">
@ -76,12 +79,18 @@ export const GroupItem: FC<TGroupItem> = observer((props) => {
<div className="text-base font-medium text-custom-text-200 capitalize px-1">{groupKey}</div> <div className="text-base font-medium text-custom-text-200 capitalize px-1">{groupKey}</div>
</div> </div>
<button <button
type="button"
className={cn( className={cn(
"flex-shrink-0 w-6 h-6 rounded flex justify-center items-center overflow-hidden transition-colors hover:bg-custom-background-80 cursor-pointer text-custom-primary-100/80 hover:text-custom-primary-100", "flex-shrink-0 w-6 h-6 rounded flex justify-center items-center overflow-hidden transition-colors hover:bg-custom-background-80 cursor-pointer text-custom-primary-100/80 hover:text-custom-primary-100",
!isEditable && "cursor-not-allowed text-custom-text-400 hover:text-custom-text-400" (!isEditable || createState) && "cursor-not-allowed text-custom-text-400 hover:text-custom-text-400"
)} )}
onClick={() => !createState && setCreateState(true)} onClick={() => {
disabled={!isEditable} if (!createState) {
handleExpand(groupKey);
setCreateState(true);
}
}}
disabled={!isEditable || createState}
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
</button> </button>
@ -97,12 +106,13 @@ export const GroupItem: FC<TGroupItem> = observer((props) => {
{currentStateExpanded && ( {currentStateExpanded && (
<div id="group-droppable-container"> <div id="group-droppable-container">
<StateList <StateList
workspaceSlug={workspaceSlug}
projectId={projectId}
groupKey={groupKey} groupKey={groupKey}
groupedStates={groupedStates} groupedStates={groupedStates}
states={states} states={states}
disabled={!isEditable} disabled={!isEditable}
stateOperationsCallbacks={stateOperationsCallbacks}
shouldTrackEvents={shouldTrackEvents}
stateItemClassName={stateItemClassName}
/> />
</div> </div>
)} )}
@ -110,10 +120,10 @@ export const GroupItem: FC<TGroupItem> = observer((props) => {
{isEditable && createState && ( {isEditable && createState && (
<div className=""> <div className="">
<StateCreate <StateCreate
workspaceSlug={workspaceSlug}
projectId={projectId}
groupKey={groupKey} groupKey={groupKey}
handleClose={() => setCreateState(false)} handleClose={() => setCreateState(false)}
createStateCallback={stateOperationsCallbacks.createState}
shouldTrackEvents={shouldTrackEvents}
/> />
</div> </div>
)} )}

View file

@ -2,18 +2,32 @@
import { FC, useState } from "react"; import { FC, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { IState, TStateGroups } from "@plane/types"; // plane imports
import { IState, TStateGroups, TStateOperationsCallbacks } from "@plane/types";
import { cn } from "@plane/utils";
// components // components
import { GroupItem } from "@/components/project-states"; import { GroupItem } from "@/components/project-states";
type TGroupList = { type TGroupList = {
workspaceSlug: string;
projectId: string;
groupedStates: Record<string, IState[]>; groupedStates: Record<string, IState[]>;
stateOperationsCallbacks: TStateOperationsCallbacks;
isEditable: boolean;
shouldTrackEvents: boolean;
groupListClassName?: string;
groupItemClassName?: string;
stateItemClassName?: string;
}; };
export const GroupList: FC<TGroupList> = observer((props) => { export const GroupList: FC<TGroupList> = observer((props) => {
const { workspaceSlug, projectId, groupedStates } = props; const {
groupedStates,
stateOperationsCallbacks,
isEditable,
shouldTrackEvents,
groupListClassName,
groupItemClassName,
stateItemClassName,
} = props;
// states // states
const [groupsExpanded, setGroupsExpanded] = useState<Partial<TStateGroups>[]>([ const [groupsExpanded, setGroupsExpanded] = useState<Partial<TStateGroups>[]>([
"backlog", "backlog",
@ -41,21 +55,24 @@ export const GroupList: FC<TGroupList> = observer((props) => {
}); });
}; };
return ( return (
<div className="space-y-5"> <div className={cn("space-y-5", groupListClassName)}>
{Object.entries(groupedStates).map(([key, value]) => { {Object.entries(groupedStates).map(([key, value]) => {
const groupKey = key as TStateGroups; const groupKey = key as TStateGroups;
const groupStates = value; const groupStates = value;
return ( return (
<GroupItem <GroupItem
key={groupKey} key={groupKey}
workspaceSlug={workspaceSlug}
projectId={projectId}
groupKey={groupKey} groupKey={groupKey}
states={groupStates} states={groupStates}
groupedStates={groupedStates} groupedStates={groupedStates}
groupsExpanded={groupsExpanded} groupsExpanded={groupsExpanded}
stateOperationsCallbacks={stateOperationsCallbacks}
isEditable={isEditable}
shouldTrackEvents={shouldTrackEvents}
handleGroupCollapse={handleGroupCollapse} handleGroupCollapse={handleGroupCollapse}
handleExpand={handleExpand} handleExpand={handleExpand}
groupItemClassName={groupItemClassName}
stateItemClassName={stateItemClassName}
/> />
); );
})} })}

View file

@ -3,45 +3,49 @@
import { FC, useState } from "react"; import { FC, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Loader, X } from "lucide-react"; import { Loader, X } from "lucide-react";
import { STATE_DELETED } from "@plane/constants"; // plane imports
import { IState } from "@plane/types"; import { EventProps, STATE_DELETED } from "@plane/constants";
import { IState, TStateOperationsCallbacks } from "@plane/types";
import { AlertModalCore, TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; import { AlertModalCore, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
// constants import { cn } from "@plane/utils";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks // hooks
import { useEventTracker, useProjectState } from "@/hooks/store"; import { useEventTracker } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
type TStateDelete = { type TStateDelete = {
workspaceSlug: string;
projectId: string;
totalStates: number; totalStates: number;
state: IState; state: IState;
deleteStateCallback: TStateOperationsCallbacks["deleteState"];
shouldTrackEvents: boolean;
}; };
export const StateDelete: FC<TStateDelete> = observer((props) => { export const StateDelete: FC<TStateDelete> = observer((props) => {
const { workspaceSlug, projectId, totalStates, state } = props; const { totalStates, state, deleteStateCallback, shouldTrackEvents } = props;
// hooks // hooks
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const { captureProjectStateEvent, setTrackElement } = useEventTracker(); const { captureProjectStateEvent, setTrackElement } = useEventTracker();
const { deleteState } = useProjectState();
// states // states
const [isDeleteModal, setIsDeleteModal] = useState(false); const [isDeleteModal, setIsDeleteModal] = useState(false);
const [isDelete, setIsDelete] = useState(false); const [isDelete, setIsDelete] = useState(false);
// derived values // derived values
const isDeleteDisabled = state.default ? true : totalStates === 1 ? true : false; const isDeleteDisabled = state.default ? true : totalStates === 1 ? true : false;
const handleDeleteState = async () => { const captureEventIfEnabled = (props: EventProps) => {
if (!workspaceSlug || !projectId || isDeleteDisabled) return; if (shouldTrackEvents) {
captureProjectStateEvent(props);
}
};
setTrackElement("PROJECT_SETTINGS_STATE_PAGE"); const handleDeleteState = async () => {
if (isDeleteDisabled) return;
if (shouldTrackEvents) {
setTrackElement("PROJECT_SETTINGS_STATE_PAGE");
}
setIsDelete(true); setIsDelete(true);
try { try {
await deleteState(workspaceSlug, projectId, state.id); await deleteStateCallback(state.id);
captureProjectStateEvent({ captureEventIfEnabled({
eventName: STATE_DELETED, eventName: STATE_DELETED,
payload: { payload: {
...state, ...state,
@ -51,7 +55,7 @@ export const StateDelete: FC<TStateDelete> = observer((props) => {
setIsDelete(false); setIsDelete(false);
} catch (error) { } catch (error) {
const errorStatus = error as unknown as { status: number; data: { error: string } }; const errorStatus = error as unknown as { status: number; data: { error: string } };
captureProjectStateEvent({ captureEventIfEnabled({
eventName: STATE_DELETED, eventName: STATE_DELETED,
payload: { payload: {
...state, ...state,
@ -94,6 +98,7 @@ export const StateDelete: FC<TStateDelete> = observer((props) => {
/> />
<button <button
type="button"
className={cn( className={cn(
"flex-shrink-0 w-5 h-5 rounded flex justify-center items-center overflow-hidden transition-colors cursor-pointer focus:outline-none", "flex-shrink-0 w-5 h-5 rounded flex justify-center items-center overflow-hidden transition-colors cursor-pointer focus:outline-none",
isDeleteDisabled isDeleteDisabled

View file

@ -2,29 +2,30 @@
import { FC, useState } from "react"; import { FC, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// helpers // plane imports
import { cn } from "@/helpers/common.helper"; import { TStateOperationsCallbacks } from "@plane/types";
// hooks import { cn } from "@plane/utils";
import { useProjectState } from "@/hooks/store";
type TStateMarksAsDefault = { workspaceSlug: string; projectId: string; stateId: string; isDefault: boolean }; type TStateMarksAsDefault = {
stateId: string;
isDefault: boolean;
markStateAsDefaultCallback: TStateOperationsCallbacks["markStateAsDefault"];
};
export const StateMarksAsDefault: FC<TStateMarksAsDefault> = observer((props) => { export const StateMarksAsDefault: FC<TStateMarksAsDefault> = observer((props) => {
const { workspaceSlug, projectId, stateId, isDefault } = props; const { stateId, isDefault, markStateAsDefaultCallback } = props;
// hooks
const { markStateAsDefault } = useProjectState();
// states // states
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const handleMarkAsDefault = async () => { const handleMarkAsDefault = async () => {
if (!workspaceSlug || !projectId || !stateId || isDefault) return; if (!stateId || isDefault) return;
setIsLoading(true); setIsLoading(true);
try { try {
setIsLoading(false); setIsLoading(false);
await markStateAsDefault(workspaceSlug, projectId, stateId); await markStateAsDefaultCallback(stateId);
setIsLoading(false); setIsLoading(false);
} catch (error) { } catch {
setIsLoading(false); setIsLoading(false);
} }
}; };

View file

@ -1,12 +1,14 @@
"use client"; "use client";
import { FC } from "react"; import { FC, useMemo } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import useSWR from "swr"; import useSWR from "swr";
// components // components
import { EUserProjectRoles, EUserPermissionsLevel } from "@plane/constants";
import { IState, TStateOperationsCallbacks } from "@plane/types";
import { ProjectStateLoader, GroupList } from "@/components/project-states"; import { ProjectStateLoader, GroupList } from "@/components/project-states";
// hooks // hooks
import { useProjectState } from "@/hooks/store"; import { useProjectState, useUserPermissions } from "@/hooks/store";
type TProjectState = { type TProjectState = {
workspaceSlug: string; workspaceSlug: string;
@ -16,20 +18,56 @@ type TProjectState = {
export const ProjectStateRoot: FC<TProjectState> = observer((props) => { export const ProjectStateRoot: FC<TProjectState> = observer((props) => {
const { workspaceSlug, projectId } = props; const { workspaceSlug, projectId } = props;
// hooks // hooks
const { groupedProjectStates, fetchProjectStates } = useProjectState(); const {
groupedProjectStates,
fetchProjectStates,
createState,
moveStatePosition,
updateState,
deleteState,
markStateAsDefault,
} = useProjectState();
const { allowPermissions } = useUserPermissions();
// derived values
const isEditable = allowPermissions(
[EUserProjectRoles.ADMIN],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
projectId
);
// Fetching all project states
useSWR( useSWR(
workspaceSlug && projectId ? `PROJECT_STATES_${workspaceSlug}_${projectId}` : null, workspaceSlug && projectId ? `PROJECT_STATES_${workspaceSlug}_${projectId}` : null,
workspaceSlug && projectId ? () => fetchProjectStates(workspaceSlug.toString(), projectId.toString()) : null, workspaceSlug && projectId ? () => fetchProjectStates(workspaceSlug.toString(), projectId.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false } { revalidateIfStale: false, revalidateOnFocus: false }
); );
// State operations callbacks
const stateOperationsCallbacks: TStateOperationsCallbacks = useMemo(
() => ({
createState: async (data: Partial<IState>) => createState(workspaceSlug, projectId, data),
updateState: async (stateId: string, data: Partial<IState>) =>
updateState(workspaceSlug, projectId, stateId, data),
deleteState: async (stateId: string) => deleteState(workspaceSlug, projectId, stateId),
moveStatePosition: async (stateId: string, data: Partial<IState>) =>
moveStatePosition(workspaceSlug, projectId, stateId, data),
markStateAsDefault: async (stateId: string) => markStateAsDefault(workspaceSlug, projectId, stateId),
}),
[workspaceSlug, projectId, createState, moveStatePosition, updateState, deleteState, markStateAsDefault]
);
// Loader // Loader
if (!groupedProjectStates) return <ProjectStateLoader />; if (!groupedProjectStates) return <ProjectStateLoader />;
return ( return (
<div className="py-3"> <div className="py-3">
<GroupList workspaceSlug={workspaceSlug} projectId={projectId} groupedStates={groupedProjectStates} /> <GroupList
groupedStates={groupedProjectStates}
stateOperationsCallbacks={stateOperationsCallbacks}
isEditable={isEditable}
shouldTrackEvents
/>
</div> </div>
); );
}); });

View file

@ -2,31 +2,33 @@ import { SetStateAction } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { GripVertical, Pencil } from "lucide-react"; import { GripVertical, Pencil } from "lucide-react";
// plane imports // plane imports
import { IState } from "@plane/types"; import { IState, TStateOperationsCallbacks } from "@plane/types";
import { StateGroupIcon } from "@plane/ui"; import { StateGroupIcon } from "@plane/ui";
// local imports // local imports
import { StateDelete, StateMarksAsDefault } from "./options"; import { StateDelete, StateMarksAsDefault } from "./options";
export type StateItemTitleProps = { type TBaseStateItemTitleProps = {
workspaceSlug: string;
projectId: string;
setUpdateStateModal: (value: SetStateAction<boolean>) => void;
stateCount: number; stateCount: number;
disabled: boolean;
state: IState; state: IState;
shouldShowDescription?: boolean; shouldShowDescription?: boolean;
setUpdateStateModal: (value: SetStateAction<boolean>) => void;
}; };
export const StateItemTitle = observer((props: StateItemTitleProps) => { type TEnabledStateItemTitleProps = TBaseStateItemTitleProps & {
const { disabled: false;
workspaceSlug, stateOperationsCallbacks: Pick<TStateOperationsCallbacks, "markStateAsDefault" | "deleteState">;
projectId, shouldTrackEvents: boolean;
stateCount, };
setUpdateStateModal,
disabled, type TDisabledStateItemTitleProps = TBaseStateItemTitleProps & {
state, disabled: true;
shouldShowDescription = true, };
} = props;
export type TStateItemTitleProps = TEnabledStateItemTitleProps | TDisabledStateItemTitleProps;
export const StateItemTitle = observer((props: TStateItemTitleProps) => {
const { stateCount, setUpdateStateModal, disabled, state, shouldShowDescription = true } = props;
return ( return (
<div className="flex items-center gap-2 w-full justify-between"> <div className="flex items-center gap-2 w-full justify-between">
<div className="flex items-center gap-1 px-1"> <div className="flex items-center gap-1 px-1">
@ -46,19 +48,16 @@ export const StateItemTitle = observer((props: StateItemTitleProps) => {
{shouldShowDescription && <p className="text-xs text-custom-text-200">{state.description}</p>} {shouldShowDescription && <p className="text-xs text-custom-text-200">{state.description}</p>}
</div> </div>
</div> </div>
{!disabled && ( {!disabled && (
<div className="hidden group-hover:flex items-center gap-2"> <div className="hidden group-hover:flex items-center gap-2">
{/* state mark as default option */} {/* state mark as default option */}
<div className="flex-shrink-0 text-xs transition-all"> <div className="flex-shrink-0 text-xs transition-all">
<StateMarksAsDefault <StateMarksAsDefault
workspaceSlug={workspaceSlug}
projectId={projectId}
stateId={state.id} stateId={state.id}
isDefault={state.default ? true : false} isDefault={state.default ? true : false}
markStateAsDefaultCallback={props.stateOperationsCallbacks.markStateAsDefault}
/> />
</div> </div>
{/* state edit options */} {/* state edit options */}
<div className="flex items-center gap-1 transition-all"> <div className="flex items-center gap-1 transition-all">
<button <button
@ -67,7 +66,12 @@ export const StateItemTitle = observer((props: StateItemTitleProps) => {
> >
<Pencil className="w-3 h-3" /> <Pencil className="w-3 h-3" />
</button> </button>
<StateDelete workspaceSlug={workspaceSlug} projectId={projectId} totalStates={stateCount} state={state} /> <StateDelete
totalStates={stateCount}
state={state}
deleteStateCallback={props.stateOperationsCallbacks.deleteState}
shouldTrackEvents={props.shouldTrackEvents}
/>
</div> </div>
</div> </div>
)} )}

View file

@ -7,55 +7,63 @@ import { attachClosestEdge, extractClosestEdge } from "@atlaskit/pragmatic-drag-
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// Plane // Plane
import { TDraggableData } from "@plane/constants"; import { TDraggableData } from "@plane/constants";
import { IState, TStateGroups } from "@plane/types"; import { IState, TStateGroups, TStateOperationsCallbacks } from "@plane/types";
import { DropIndicator } from "@plane/ui"; import { DropIndicator } from "@plane/ui";
// components // components
import { StateItemTitle, StateUpdate } from "@/components/project-states"; import { StateItemTitle, StateUpdate } from "@/components/project-states";
// helpers // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
import { getCurrentStateSequence } from "@/helpers/state.helper"; import { getCurrentStateSequence } from "@/helpers/state.helper";
// hooks
import { useProjectState } from "@/hooks/store";
type TStateItem = { type TStateItem = {
workspaceSlug: string;
projectId: string;
groupKey: TStateGroups; groupKey: TStateGroups;
groupedStates: Record<string, IState[]>; groupedStates: Record<string, IState[]>;
totalStates: number; totalStates: number;
state: IState; state: IState;
stateOperationsCallbacks: TStateOperationsCallbacks;
shouldTrackEvents: boolean;
disabled?: boolean; disabled?: boolean;
stateItemClassName?: string;
}; };
export const StateItem: FC<TStateItem> = observer((props) => { export const StateItem: FC<TStateItem> = observer((props) => {
const { workspaceSlug, projectId, groupKey, groupedStates, totalStates, state, disabled = false } = props; const {
// hooks groupKey,
const { moveStatePosition } = useProjectState(); groupedStates,
totalStates,
state,
stateOperationsCallbacks,
shouldTrackEvents,
disabled = false,
stateItemClassName,
} = props;
// ref
const draggableElementRef = useRef<HTMLDivElement | null>(null);
// states // states
const [updateStateModal, setUpdateStateModal] = useState(false); const [updateStateModal, setUpdateStateModal] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [isDraggedOver, setIsDraggedOver] = useState(false);
const [closestEdge, setClosestEdge] = useState<string | null>(null);
// derived values
const isDraggable = totalStates === 1 ? false : true;
const commonStateItemListProps = {
stateCount: totalStates,
state: state,
setUpdateStateModal: setUpdateStateModal,
};
const handleStateSequence = useCallback( const handleStateSequence = useCallback(
async (payload: Partial<IState>) => { async (payload: Partial<IState>) => {
try { try {
if (!workspaceSlug || !projectId || !payload.id) return; if (!payload.id) return;
await moveStatePosition(workspaceSlug, projectId, payload.id, payload); await stateOperationsCallbacks.moveStatePosition(payload.id, payload);
} catch (error) { } catch (error) {
console.error("error", error); console.error("error", error);
} }
}, },
[workspaceSlug, projectId, moveStatePosition] [stateOperationsCallbacks]
); );
// derived values
const isDraggable = totalStates === 1 ? false : true;
// DND starts
// ref
const draggableElementRef = useRef<HTMLDivElement | null>(null);
// states
const [isDragging, setIsDragging] = useState(false);
const [isDraggedOver, setIsDraggedOver] = useState(false);
const [closestEdge, setClosestEdge] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const elementRef = draggableElementRef.current; const elementRef = draggableElementRef.current;
const initialData: TDraggableData = { groupKey: groupKey, id: state.id }; const initialData: TDraggableData = { groupKey: groupKey, id: state.id };
@ -111,9 +119,9 @@ export const StateItem: FC<TStateItem> = observer((props) => {
if (updateStateModal) if (updateStateModal)
return ( return (
<StateUpdate <StateUpdate
workspaceSlug={workspaceSlug}
projectId={projectId}
state={state} state={state}
updateStateCallback={stateOperationsCallbacks.updateState}
shouldTrackEvents={shouldTrackEvents}
handleClose={() => setUpdateStateModal(false)} handleClose={() => setUpdateStateModal(false)}
/> />
); );
@ -122,25 +130,29 @@ export const StateItem: FC<TStateItem> = observer((props) => {
<Fragment> <Fragment>
{/* draggable drop top indicator */} {/* draggable drop top indicator */}
<DropIndicator isVisible={isDraggedOver && closestEdge === "top"} /> <DropIndicator isVisible={isDraggedOver && closestEdge === "top"} />
<div <div
ref={draggableElementRef} ref={draggableElementRef}
className={cn( className={cn(
"relative border border-custom-border-100 bg-custom-background-100 py-3 px-3.5 rounded group", "relative border border-custom-border-100 bg-custom-background-100 py-3 px-3.5 rounded group",
isDragging ? `opacity-50` : `opacity-100`, isDragging ? `opacity-50` : `opacity-100`,
totalStates === 1 ? `cursor-auto` : `cursor-grab` totalStates === 1 ? `cursor-auto` : `cursor-grab`,
stateItemClassName
)} )}
> >
<StateItemTitle {disabled ? (
workspaceSlug={workspaceSlug} <StateItemTitle {...commonStateItemListProps} disabled />
projectId={projectId} ) : (
setUpdateStateModal={setUpdateStateModal} <StateItemTitle
stateCount={totalStates} {...commonStateItemListProps}
disabled={disabled} disabled={false}
state={state} stateOperationsCallbacks={{
/> markStateAsDefault: stateOperationsCallbacks.markStateAsDefault,
deleteState: stateOperationsCallbacks.deleteState,
}}
shouldTrackEvents={shouldTrackEvents}
/>
)}
</div> </div>
{/* draggable drop bottom indicator */} {/* draggable drop bottom indicator */}
<DropIndicator isVisible={isDraggedOver && closestEdge === "bottom"} /> <DropIndicator isVisible={isDraggedOver && closestEdge === "bottom"} />
</Fragment> </Fragment>

View file

@ -2,34 +2,44 @@
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { IState, TStateGroups } from "@plane/types"; import { IState, TStateGroups, TStateOperationsCallbacks } from "@plane/types";
// components // components
import { StateItem } from "@/components/project-states"; import { StateItem } from "@/components/project-states";
type TStateList = { type TStateList = {
workspaceSlug: string;
projectId: string;
groupKey: TStateGroups; groupKey: TStateGroups;
groupedStates: Record<string, IState[]>; groupedStates: Record<string, IState[]>;
states: IState[]; states: IState[];
stateOperationsCallbacks: TStateOperationsCallbacks;
shouldTrackEvents: boolean;
disabled?: boolean; disabled?: boolean;
stateItemClassName?: string;
}; };
export const StateList: FC<TStateList> = observer((props) => { export const StateList: FC<TStateList> = observer((props) => {
const { workspaceSlug, projectId, groupKey, groupedStates, states, disabled = false } = props; const {
groupKey,
groupedStates,
states,
stateOperationsCallbacks,
shouldTrackEvents,
disabled = false,
stateItemClassName,
} = props;
return ( return (
<> <>
{states.map((state: IState) => ( {states.map((state: IState) => (
<StateItem <StateItem
key={state?.name} key={state?.name}
workspaceSlug={workspaceSlug}
projectId={projectId}
groupKey={groupKey} groupKey={groupKey}
groupedStates={groupedStates} groupedStates={groupedStates}
totalStates={states.length || 0} totalStates={states.length || 0}
state={state} state={state}
disabled={disabled} disabled={disabled}
stateOperationsCallbacks={stateOperationsCallbacks}
shouldTrackEvents={shouldTrackEvents}
stateItemClassName={stateItemClassName}
/> />
))} ))}
</> </>

View file

@ -19,6 +19,7 @@ type Props = {
setToFavorite?: boolean; setToFavorite?: boolean;
workspaceSlug: string; workspaceSlug: string;
data?: Partial<TProject>; data?: Partial<TProject>;
templateId?: string;
}; };
enum EProjectCreationSteps { enum EProjectCreationSteps {
@ -27,7 +28,7 @@ enum EProjectCreationSteps {
} }
export const CreateProjectModal: FC<Props> = (props) => { export const CreateProjectModal: FC<Props> = (props) => {
const { isOpen, onClose, setToFavorite = false, workspaceSlug, data } = props; const { isOpen, onClose, setToFavorite = false, workspaceSlug, data, templateId } = props;
// states // states
const [currentStep, setCurrentStep] = useState<EProjectCreationSteps>(EProjectCreationSteps.CREATE_PROJECT); const [currentStep, setCurrentStep] = useState<EProjectCreationSteps>(EProjectCreationSteps.CREATE_PROJECT);
const [createdProjectId, setCreatedProjectId] = useState<string | null>(null); const [createdProjectId, setCreatedProjectId] = useState<string | null>(null);
@ -63,6 +64,7 @@ export const CreateProjectModal: FC<Props> = (props) => {
updateCoverImageStatus={handleCoverImageStatusUpdate} updateCoverImageStatus={handleCoverImageStatusUpdate}
handleNextStep={handleNextStep} handleNextStep={handleNextStep}
data={data} data={data}
templateId={templateId}
/> />
)} )}
{currentStep === EProjectCreationSteps.FEATURE_SELECTION && ( {currentStep === EProjectCreationSteps.FEATURE_SELECTION && (

View file

@ -14,6 +14,8 @@ import { ImagePickerPopover } from "@/components/core";
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper"; import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
import { getFileURL } from "@/helpers/file.helper"; import { getFileURL } from "@/helpers/file.helper";
import { getTabIndex } from "@/helpers/tab-indices.helper"; import { getTabIndex } from "@/helpers/tab-indices.helper";
// plane web imports
import { ProjectTemplateSelect } from "@/plane-web/components/projects/create/template-select";
type Props = { type Props = {
handleClose: () => void; handleClose: () => void;
@ -39,6 +41,9 @@ const ProjectCreateHeader: React.FC<Props> = (props) => {
/> />
)} )}
<div className="absolute left-2.5 top-2.5">
<ProjectTemplateSelect handleModalClose={handleClose} />
</div>
<div className="absolute right-2 top-2 p-2"> <div className="absolute right-2 top-2 p-2">
<button data-posthog="PROJECT_MODAL_CLOSE" type="button" onClick={handleClose} tabIndex={getIndex("close")}> <button data-posthog="PROJECT_MODAL_CLOSE" type="button" onClick={handleClose} tabIndex={getIndex("close")}>
<X className="h-5 w-5 text-white" /> <X className="h-5 w-5 text-white" />

View file

@ -59,58 +59,51 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{Object.keys(PROJECT_FEATURES_LIST).map((featureSectionKey) => { {Object.entries(PROJECT_FEATURES_LIST).map(([featureSectionKey, feature]) => (
const feature = PROJECT_FEATURES_LIST[featureSectionKey]; <div key={featureSectionKey} className="">
return ( <div className="flex flex-col justify-center pb-2 border-b border-custom-border-100">
<div key={featureSectionKey} className=""> <h3 className="text-xl font-medium">{t(feature.key)}</h3>
<div className="flex flex-col justify-center pb-2 border-b border-custom-border-100"> <h4 className="text-sm leading-5 text-custom-text-200">{t(`${feature.key}_description`)}</h4>
<h3 className="text-xl font-medium">{t(feature.key)}</h3> </div>
<h4 className="text-sm leading-5 text-custom-text-200">{t(`${feature.key}_description`)}</h4> {Object.entries(feature.featureList).map(([featureItemKey, featureItem]) => (
</div> <div
{Object.keys(feature.featureList).map((featureItemKey) => { key={featureItemKey}
const featureItem = feature.featureList[featureItemKey]; className="gap-x-8 gap-y-2 border-b border-custom-border-100 bg-custom-background-100 pb-2 pt-4"
return ( >
<div <div key={featureItemKey} className="flex items-center justify-between">
key={featureItemKey} <div className="flex items-start gap-3">
className="gap-x-8 gap-y-2 border-b border-custom-border-100 bg-custom-background-100 pb-2 pt-4" <div className="flex items-center justify-center rounded bg-custom-background-90 p-3">
> {featureItem.icon}
<div key={featureItemKey} className="flex items-center justify-between">
<div className="flex items-start gap-3">
<div className="flex items-center justify-center rounded bg-custom-background-90 p-3">
{featureItem.icon}
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium leading-5">{t(featureItem.key)}</h4>
{featureItem.isPro && (
<Tooltip tooltipContent="Pro feature" position="top">
<UpgradeBadge className="rounded" />
</Tooltip>
)}
</div>
<p className="text-sm leading-5 tracking-tight text-custom-text-300">
{t(`${featureItem.key}_description`)}
</p>
</div>
</div>
<ToggleSwitch
value={Boolean(currentProjectDetails?.[featureItem.property as keyof IProject])}
onChange={() => handleSubmit(featureItemKey, featureItem.property)}
disabled={!featureItem.isEnabled || !isAdmin}
size="sm"
/>
</div> </div>
<div className="pl-14"> <div>
{currentProjectDetails?.[featureItem.property as keyof IProject] && <div className="flex items-center gap-2">
featureItem.renderChildren?.(currentProjectDetails, workspaceSlug)} <h4 className="text-sm font-medium leading-5">{t(featureItem.key)}</h4>
{featureItem.isPro && (
<Tooltip tooltipContent="Pro feature" position="top">
<UpgradeBadge className="rounded" />
</Tooltip>
)}
</div>
<p className="text-sm leading-5 tracking-tight text-custom-text-300">
{t(`${featureItem.key}_description`)}
</p>
</div> </div>
</div> </div>
); <ToggleSwitch
})} value={Boolean(currentProjectDetails?.[featureItem.property as keyof IProject])}
</div> onChange={() => handleSubmit(featureItemKey, featureItem.property)}
); disabled={!featureItem.isEnabled || !isAdmin}
})} size="sm"
/>
</div>
<div className="pl-14">
{currentProjectDetails?.[featureItem.property as keyof IProject] &&
featureItem.renderChildren?.(currentProjectDetails, workspaceSlug)}
</div>
</div>
))}
</div>
))}
</div> </div>
); );
}); });

View file

@ -37,6 +37,7 @@ export interface IProjectStore {
getPartialProjectById: (projectId: string | undefined | null) => TPartialProject | undefined; getPartialProjectById: (projectId: string | undefined | null) => TPartialProject | undefined;
getProjectIdentifierById: (projectId: string | undefined | null) => string; getProjectIdentifierById: (projectId: string | undefined | null) => string;
getProjectAnalyticsCountById: (projectId: string | undefined | null) => TProjectAnalyticsCount | undefined; getProjectAnalyticsCountById: (projectId: string | undefined | null) => TProjectAnalyticsCount | undefined;
getProjectByIdentifier: (projectIdentifier: string) => TProject | undefined;
// collapsible // collapsible
openCollapsibleSection: ProjectOverviewCollapsible[]; openCollapsibleSection: ProjectOverviewCollapsible[];
lastCollapsibleAction: ProjectOverviewCollapsible | null; lastCollapsibleAction: ProjectOverviewCollapsible | null;
@ -45,6 +46,9 @@ export interface IProjectStore {
setLastCollapsibleAction: (section: ProjectOverviewCollapsible) => void; setLastCollapsibleAction: (section: ProjectOverviewCollapsible) => void;
toggleOpenCollapsibleSection: (section: ProjectOverviewCollapsible) => void; toggleOpenCollapsibleSection: (section: ProjectOverviewCollapsible) => void;
// helper actions
processProjectAfterCreation: (workspaceSlug: string, data: TProject) => void;
// fetch actions // fetch actions
fetchPartialProjects: (workspaceSlug: string) => Promise<TPartialProject[]>; fetchPartialProjects: (workspaceSlug: string) => Promise<TPartialProject[]>;
fetchProjects: (workspaceSlug: string) => Promise<TProject[]>; fetchProjects: (workspaceSlug: string) => Promise<TProject[]>;
@ -104,6 +108,8 @@ export class ProjectStore implements IProjectStore {
currentProjectDetails: computed, currentProjectDetails: computed,
joinedProjectIds: computed, joinedProjectIds: computed,
favoriteProjectIds: computed, favoriteProjectIds: computed,
// helper actions
processProjectAfterCreation: action,
// fetch actions // fetch actions
fetchPartialProjects: action, fetchPartialProjects: action,
fetchProjects: action, fetchProjects: action,
@ -233,7 +239,10 @@ export class ProjectStore implements IProjectStore {
const projectIds = projects const projectIds = projects
.filter( .filter(
(project) => (project) =>
project.workspace === currentWorkspace.id && !!project.member_role && project.is_favorite && !project.archived_at project.workspace === currentWorkspace.id &&
!!project.member_role &&
project.is_favorite &&
!project.archived_at
) )
.map((project) => project.id); .map((project) => project.id);
return projectIds; return projectIds;
@ -256,6 +265,19 @@ export class ProjectStore implements IProjectStore {
} }
}; };
/**
* @description process project after creation
* @param workspaceSlug
* @param data
*/
processProjectAfterCreation = (workspaceSlug: string, data: TProject) => {
runInAction(() => {
set(this.projectMap, [data.id], data);
// updating the user project role in workspaceProjectsPermissions
set(this.rootStore.user.permission.workspaceProjectsPermissions, [workspaceSlug, data.id], data.member_role);
});
};
/** /**
* get Workspace projects partial data using workspace slug * get Workspace projects partial data using workspace slug
* @param workspaceSlug * @param workspaceSlug
@ -363,6 +385,15 @@ export class ProjectStore implements IProjectStore {
return projectInfo; return projectInfo;
}); });
/**
* Returns project details using project identifier
* @param projectIdentifier
* @returns TProject | undefined
*/
getProjectByIdentifier = computedFn((projectIdentifier: string) =>
Object.values(this.projectMap).find((project) => project.identifier === projectIdentifier)
);
/** /**
* Returns project lite using project id * Returns project lite using project id
* This method is used just for type safety * This method is used just for type safety
@ -481,15 +512,7 @@ export class ProjectStore implements IProjectStore {
createProject = async (workspaceSlug: string, data: any) => { createProject = async (workspaceSlug: string, data: any) => {
try { try {
const response = await this.projectService.createProject(workspaceSlug, data); const response = await this.projectService.createProject(workspaceSlug, data);
runInAction(() => { this.processProjectAfterCreation(workspaceSlug, response);
set(this.projectMap, [response.id], response);
// updating the user project role in workspaceProjectsPermissions
set(
this.rootStore.user.permission.workspaceProjectsPermissions,
[workspaceSlug, response.id],
response.member_role
);
});
return response; return response;
} catch (error) { } catch (error) {
console.log("Failed to create project from project store"); console.log("Failed to create project from project store");

View file

@ -1,24 +1,8 @@
// ui // plane imports
import { RANDOM_EMOJI_CODES } from "@plane/constants";
import { LUCIDE_ICONS_LIST } from "@plane/ui"; import { LUCIDE_ICONS_LIST } from "@plane/ui";
export const getRandomEmoji = () => { export const getRandomEmoji = () => RANDOM_EMOJI_CODES[Math.floor(Math.random() * RANDOM_EMOJI_CODES.length)];
const emojis = [
"8986",
"9200",
"128204",
"127773",
"127891",
"128076",
"128077",
"128187",
"128188",
"128512",
"128522",
"128578",
];
return emojis[Math.floor(Math.random() * emojis.length)];
};
export const getRandomIconName = () => LUCIDE_ICONS_LIST[Math.floor(Math.random() * LUCIDE_ICONS_LIST.length)].name; export const getRandomIconName = () => LUCIDE_ICONS_LIST[Math.floor(Math.random() * LUCIDE_ICONS_LIST.length)].name;
@ -45,8 +29,18 @@ export const groupReactions: (reactions: any[], key: string) => { [key: string]:
reactions: any, reactions: any,
key: string key: string
) => { ) => {
if (!Array.isArray(reactions)) {
console.error("Expected an array of reactions, but got:", reactions);
return {};
}
const groupedReactions = reactions.reduce( const groupedReactions = reactions.reduce(
(acc: any, reaction: any) => { (acc: any, reaction: any) => {
if (!reaction || typeof reaction !== "object" || !Object.prototype.hasOwnProperty.call(reaction, key)) {
console.warn("Skipping undefined reaction or missing key:", reaction);
return acc; // Skip undefined reactions or those without the specified key
}
if (!acc[reaction[key]]) { if (!acc[reaction[key]]) {
acc[reaction[key]] = []; acc[reaction[key]] = [];
} }