[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
36 changed files with 622 additions and 381 deletions

View File

@@ -8,3 +8,18 @@ export const ISSUE_REACTION_EMOJI_CODES = [
"9992",
"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 "./dashboard";
export * from "./page";
export * from "./emoji";

View File

@@ -1,5 +1,7 @@
// icons
import { TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types";
// plane imports
import { IProject, TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types";
// local imports
import { RANDOM_EMOJI_CODES } from "./emoji";
export type TNetworkChoiceIconKey = "Lock" | "Globe2";
@@ -132,3 +134,18 @@ export const PROJECT_ERROR_MESSAGES = {
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 =
| "backlog"
| "unstarted"
| "started"
| "completed"
| "cancelled";
export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
export type TDraggableData = {
groupKey: TStateGroups;
@@ -14,40 +9,43 @@ export const STATE_GROUPS: {
[key in TStateGroups]: {
key: TStateGroups;
label: string;
defaultStateName: string;
color: string;
};
} = {
backlog: {
key: "backlog",
label: "Backlog",
defaultStateName: "Backlog",
color: "#d9d9d9",
},
unstarted: {
key: "unstarted",
label: "Unstarted",
defaultStateName: "Todo",
color: "#3f76ff",
},
started: {
key: "started",
label: "Started",
defaultStateName: "In Progress",
color: "#f59e0b",
},
completed: {
key: "completed",
label: "Completed",
defaultStateName: "Done",
color: "#16a34a",
},
cancelled: {
key: "cancelled",
label: "Canceled",
defaultStateName: "Cancelled",
color: "#dc2626",
},
};
export const ARCHIVABLE_STATE_GROUPS = [
STATE_GROUPS.completed.key,
STATE_GROUPS.cancelled.key,
];
export const ARCHIVABLE_STATE_GROUPS = [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key];
export const COMPLETED_STATE_GROUPS = [STATE_GROUPS.completed.key];
export const PENDING_STATE_GROUPS = [
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 { TIssuePriorities } from "./issues";
import { TIssue } from "./issues/base";
// filters
export type TInboxIssueFilterMemberKeys = "assignees" | "created_by";

View File

@@ -24,3 +24,11 @@ export interface IStateLite {
export interface IStateResponse {
[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 { DropdownIcon } from "../icons";
import { cn } from "../../helpers";
import { DropdownIcon } from "../icons";
type Props = {
isOpen: boolean;
title: string;
title: React.ReactNode;
hideChevron?: boolean;
indicatorElement?: React.ReactNode;
actionItemElement?: React.ReactNode;

View File

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

View File

@@ -1,5 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { CompleteOrEmpty } from "@plane/types";
// Support email can be configured by the application
export const getSupportEmail = (defaultEmail: string = ""): string => defaultEmail;
@@ -39,3 +40,21 @@ export const partitionValidIds = (ids: string[], validIds: string[]): { valid: s
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 { observer } from "mobx-react-lite";
// plane imports
import { ISearchIssueResponse } from "@plane/types";
import { ISearchIssueResponse, TIssue } from "@plane/types";
// components
import { IssueModalContext } from "@/components/issues";
export type TIssueModalProviderProps = {
templateId?: string;
dataForPreload?: Partial<TIssue>;
children: React.ReactNode;
};
@@ -32,7 +33,6 @@ export const IssueModalProvider = observer((props: TIssueModalProviderProps) =>
getActiveAdditionalPropertiesLength: () => 0,
handlePropertyValuesValidation: () => true,
handleCreateUpdatePropertyValues: () => Promise.resolve(),
handleParentWorkItemDetails: () => Promise.resolve(undefined),
handleProjectEntitiesFetch: () => Promise.resolve(),
handleTemplateChange: () => Promise.resolve(),
}}

View File

@@ -3,7 +3,7 @@
import { useState, FC } from "react";
import { observer } from "mobx-react";
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";
// 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 ProjectCreateHeader from "@/components/project/create/header";
import ProjectCreateButtons from "@/components/project/create/project-create-buttons";
// helpers
import { getRandomEmoji } from "@/helpers/emoji.helper";
// hooks
import { useEventTracker, useProject } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
@@ -26,26 +24,12 @@ export type TCreateProjectFormProps = {
onClose: () => void;
handleNextStep: (projectId: string) => void;
data?: Partial<TProject>;
templateId?: string;
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) => {
const { setToFavorite, workspaceSlug, onClose, handleNextStep, updateCoverImageStatus } = props;
const { setToFavorite, workspaceSlug, data, onClose, handleNextStep, updateCoverImageStatus } = props;
// store
const { t } = useTranslation();
const { captureProjectEvent } = useEventTracker();
@@ -54,7 +38,7 @@ export const CreateProjectForm: FC<TCreateProjectFormProps> = observer((props) =
const [isChangeInIdentifierRequired, setIsChangeInIdentifierRequired] = useState(true);
// form info
const methods = useForm<TProject>({
defaultValues,
defaultValues: { ...DEFAULT_PROJECT_FORM_VALUES, ...data },
reValidateMode: "onChange",
});
const { handleSubmit, reset, setValue } = methods;
@@ -105,7 +89,7 @@ export const CreateProjectForm: FC<TCreateProjectFormProps> = observer((props) =
handleNextStep(res.id);
})
.catch((err) => {
Object.keys(err.data).map((key) => {
Object.keys(err?.data ?? {}).map((key) => {
setToast({
type: TOAST_TYPE.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 { FileText, Layers, Timer } from "lucide-react";
// plane imports
import { IProject } from "@plane/types";
import { ContrastIcon, DiceIcon, Intake } from "@plane/ui";
@@ -13,16 +14,90 @@ export type TProperties = {
isEnabled: boolean;
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 = {
[key: string]: {
export const PROJECT_BASE_FEATURES_LIST: TBaseFeatureList = {
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;
title: 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",
title: "Projects and work items",
description: "Toggle these on or off this project.",
featureList: {
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,
},
},
featureList: PROJECT_BASE_FEATURES_LIST,
},
project_others: {
key: "work_management",
title: "Work management",
description: "Available only on some plans as indicated by the label next to the feature below.",
featureList: {
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,
},
},
featureList: PROJECT_OTHER_FEATURES_LIST,
},
};

View File

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

View File

@@ -2,6 +2,7 @@
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EIssuesStoreType } from "@plane/constants";
import type { TIssue } from "@plane/types";
@@ -30,11 +31,20 @@ export interface IssuesModalProps {
templateId?: string;
}
export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer(
(props) =>
props.isOpen && (
<IssueModalProvider templateId={props.templateId}>
<CreateUpdateIssueModalBase {...props} />
</IssueModalProvider>
)
);
export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((props) => {
// router params
const { cycleId, moduleId } = useParams();
// derived values
const dataForPreload = {
...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 { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { TwitterPicker } from "react-color";
import { Controller, SubmitHandler, useForm } from "react-hook-form";
import { Popover, Transition } from "@headlessui/react";
@@ -11,13 +10,17 @@ import { getRandomLabelColor, LABEL_COLOR_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IIssueLabel } from "@plane/types";
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;
setLabelForm: React.Dispatch<React.SetStateAction<boolean>>;
isUpdating: boolean;
labelOperationsCallbacks: TLabelOperationsCallbacks;
labelToUpdate?: IIssueLabel;
onClose?: () => void;
};
@@ -28,12 +31,8 @@ const defaultValues: Partial<IIssueLabel> = {
};
export const CreateUpdateLabelInline = observer(
forwardRef<HTMLFormElement, Props>(function CreateUpdateLabelInline(props, ref) {
const { labelForm, setLabelForm, isUpdating, labelToUpdate, onClose } = props;
// router
const { workspaceSlug, projectId } = useParams();
// store hooks
const { createLabel, updateLabel } = useLabel();
forwardRef<HTMLDivElement, TCreateUpdateLabelInlineProps>(function CreateUpdateLabelInline(props, ref) {
const { labelForm, setLabelForm, isUpdating, labelOperationsCallbacks, labelToUpdate, onClose } = props;
// form info
const {
handleSubmit,
@@ -56,9 +55,10 @@ export const CreateUpdateLabelInline = observer(
};
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(() => {
handleClose();
reset(defaultValues);
@@ -74,10 +74,10 @@ export const CreateUpdateLabelInline = observer(
};
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 updateLabel(workspaceSlug.toString(), projectId.toString(), labelToUpdate?.id!, formData)
await labelOperationsCallbacks
.updateLabel(labelToUpdate.id, formData)
.then(() => {
reset(defaultValues);
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
*/
@@ -117,12 +125,8 @@ export const CreateUpdateLabelInline = observer(
return (
<>
<form
<div
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"}`}
>
<div className="flex-shrink-0">
@@ -199,10 +203,18 @@ export const CreateUpdateLabelInline = observer(
<Button variant="neutral-primary" onClick={() => handleClose()} size="sm">
{t("cancel")}
</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")}
</Button>
</form>
</div>
{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;
dragHandleRef: MutableRefObject<HTMLButtonElement | null>;
disabled?: boolean;
draggable?: boolean;
}
export const LabelItemBlock = (props: ILabelItemBlock) => {
@@ -40,9 +41,10 @@ export const LabelItemBlock = (props: ILabelItemBlock) => {
isLabelGroup,
dragHandleRef,
disabled = false,
draggable = true,
} = props;
// states
const [isMenuActive, setIsMenuActive] = useState(false);
const [isMenuActive, setIsMenuActive] = useState(true);
// refs
const actionSectionRef = useRef<HTMLDivElement | null>(null);
@@ -51,7 +53,7 @@ export const LabelItemBlock = (props: ILabelItemBlock) => {
return (
<div className="group flex items-center">
<div className="flex items-center">
{!disabled && (
{!disabled && draggable && (
<DragHandle
className={cn("opacity-0 group-hover:opacity-100", {
"opacity-100": isDragging,
@@ -65,7 +67,7 @@ export const LabelItemBlock = (props: ILabelItemBlock) => {
{!disabled && (
<div
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
? "opacity-100"
: "opacity-0 group-hover:pointer-events-auto group-hover:opacity-100"
@@ -77,7 +79,7 @@ export const LabelItemBlock = (props: ILabelItemBlock) => {
isVisible && (
<CustomMenu.MenuItem key={key} onClick={() => onClick(label)}>
<span className="flex items-center justify-start gap-2">
<CustomIcon className="h-4 w-4" />
<CustomIcon className="size-4" />
<span>{text}</span>
</span>
</CustomMenu.MenuItem>
@@ -87,10 +89,10 @@ export const LabelItemBlock = (props: ILabelItemBlock) => {
{!isLabelGroup && (
<div className="py-0.5">
<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)}
>
<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>
</div>
)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,35 +4,38 @@ import { FC, useState, useRef } from "react";
import { observer } from "mobx-react";
import { ChevronDown, Plus } from "lucide-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IState, TStateGroups } from "@plane/types";
import { IState, TStateGroups, TStateOperationsCallbacks } from "@plane/types";
import { StateGroupIcon } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { StateList, StateCreate } from "@/components/project-states";
// hooks
import { useUserPermissions } from "@/hooks/store";
type TGroupItem = {
workspaceSlug: string;
projectId: string;
groupKey: TStateGroups;
groupsExpanded: Partial<TStateGroups>[];
handleGroupCollapse: (groupKey: TStateGroups) => void;
handleExpand: (groupKey: TStateGroups) => void;
groupedStates: Record<string, 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) => {
const {
workspaceSlug,
projectId,
groupKey,
groupedStates,
states,
groupsExpanded,
isEditable,
stateOperationsCallbacks,
shouldTrackEvents,
groupItemClassName,
stateItemClassName,
handleExpand,
handleGroupCollapse,
} = props;
@@ -40,18 +43,18 @@ export const GroupItem: FC<TGroupItem> = observer((props) => {
const dropElementRef = useRef<HTMLDivElement | null>(null);
// plane hooks
const { t } = useTranslation();
// store hooks
const { allowPermissions } = useUserPermissions();
// state
const [createState, setCreateState] = useState(false);
// derived values
const currentStateExpanded = groupsExpanded.includes(groupKey);
const isEditable = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
const shouldShowEmptyState = states.length === 0 && currentStateExpanded && !createState;
return (
<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}
>
<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>
<button
type="button"
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",
!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)}
disabled={!isEditable}
onClick={() => {
if (!createState) {
handleExpand(groupKey);
setCreateState(true);
}
}}
disabled={!isEditable || createState}
>
<Plus className="w-4 h-4" />
</button>
@@ -97,12 +106,13 @@ export const GroupItem: FC<TGroupItem> = observer((props) => {
{currentStateExpanded && (
<div id="group-droppable-container">
<StateList
workspaceSlug={workspaceSlug}
projectId={projectId}
groupKey={groupKey}
groupedStates={groupedStates}
states={states}
disabled={!isEditable}
stateOperationsCallbacks={stateOperationsCallbacks}
shouldTrackEvents={shouldTrackEvents}
stateItemClassName={stateItemClassName}
/>
</div>
)}
@@ -110,10 +120,10 @@ export const GroupItem: FC<TGroupItem> = observer((props) => {
{isEditable && createState && (
<div className="">
<StateCreate
workspaceSlug={workspaceSlug}
projectId={projectId}
groupKey={groupKey}
handleClose={() => setCreateState(false)}
createStateCallback={stateOperationsCallbacks.createState}
shouldTrackEvents={shouldTrackEvents}
/>
</div>
)}

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,14 @@
"use client";
import { FC } from "react";
import { FC, useMemo } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// components
import { EUserProjectRoles, EUserPermissionsLevel } from "@plane/constants";
import { IState, TStateOperationsCallbacks } from "@plane/types";
import { ProjectStateLoader, GroupList } from "@/components/project-states";
// hooks
import { useProjectState } from "@/hooks/store";
import { useProjectState, useUserPermissions } from "@/hooks/store";
type TProjectState = {
workspaceSlug: string;
@@ -16,20 +18,56 @@ type TProjectState = {
export const ProjectStateRoot: FC<TProjectState> = observer((props) => {
const { workspaceSlug, projectId } = props;
// 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(
workspaceSlug && projectId ? `PROJECT_STATES_${workspaceSlug}_${projectId}` : null,
workspaceSlug && projectId ? () => fetchProjectStates(workspaceSlug.toString(), projectId.toString()) : null,
{ 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
if (!groupedProjectStates) return <ProjectStateLoader />;
return (
<div className="py-3">
<GroupList workspaceSlug={workspaceSlug} projectId={projectId} groupedStates={groupedProjectStates} />
<GroupList
groupedStates={groupedProjectStates}
stateOperationsCallbacks={stateOperationsCallbacks}
isEditable={isEditable}
shouldTrackEvents
/>
</div>
);
});

View File

@@ -2,31 +2,33 @@ import { SetStateAction } from "react";
import { observer } from "mobx-react";
import { GripVertical, Pencil } from "lucide-react";
// plane imports
import { IState } from "@plane/types";
import { IState, TStateOperationsCallbacks } from "@plane/types";
import { StateGroupIcon } from "@plane/ui";
// local imports
import { StateDelete, StateMarksAsDefault } from "./options";
export type StateItemTitleProps = {
workspaceSlug: string;
projectId: string;
setUpdateStateModal: (value: SetStateAction<boolean>) => void;
type TBaseStateItemTitleProps = {
stateCount: number;
disabled: boolean;
state: IState;
shouldShowDescription?: boolean;
setUpdateStateModal: (value: SetStateAction<boolean>) => void;
};
export const StateItemTitle = observer((props: StateItemTitleProps) => {
const {
workspaceSlug,
projectId,
stateCount,
setUpdateStateModal,
disabled,
state,
shouldShowDescription = true,
} = props;
type TEnabledStateItemTitleProps = TBaseStateItemTitleProps & {
disabled: false;
stateOperationsCallbacks: Pick<TStateOperationsCallbacks, "markStateAsDefault" | "deleteState">;
shouldTrackEvents: boolean;
};
type TDisabledStateItemTitleProps = TBaseStateItemTitleProps & {
disabled: true;
};
export type TStateItemTitleProps = TEnabledStateItemTitleProps | TDisabledStateItemTitleProps;
export const StateItemTitle = observer((props: TStateItemTitleProps) => {
const { stateCount, setUpdateStateModal, disabled, state, shouldShowDescription = true } = props;
return (
<div className="flex items-center gap-2 w-full justify-between">
<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>}
</div>
</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}
markStateAsDefaultCallback={props.stateOperationsCallbacks.markStateAsDefault}
/>
</div>
{/* state edit options */}
<div className="flex items-center gap-1 transition-all">
<button
@@ -67,7 +66,12 @@ export const StateItemTitle = observer((props: StateItemTitleProps) => {
>
<Pencil className="w-3 h-3" />
</button>
<StateDelete workspaceSlug={workspaceSlug} projectId={projectId} totalStates={stateCount} state={state} />
<StateDelete
totalStates={stateCount}
state={state}
deleteStateCallback={props.stateOperationsCallbacks.deleteState}
shouldTrackEvents={props.shouldTrackEvents}
/>
</div>
</div>
)}

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,8 @@ import { ImagePickerPopover } from "@/components/core";
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
import { getFileURL } from "@/helpers/file.helper";
import { getTabIndex } from "@/helpers/tab-indices.helper";
// plane web imports
import { ProjectTemplateSelect } from "@/plane-web/components/projects/create/template-select";
type Props = {
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">
<button data-posthog="PROJECT_MODAL_CLOSE" type="button" onClick={handleClose} tabIndex={getIndex("close")}>
<X className="h-5 w-5 text-white" />

View File

@@ -59,58 +59,51 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
return (
<div className="space-y-6">
{Object.keys(PROJECT_FEATURES_LIST).map((featureSectionKey) => {
const feature = PROJECT_FEATURES_LIST[featureSectionKey];
return (
<div key={featureSectionKey} className="">
<div className="flex flex-col justify-center pb-2 border-b border-custom-border-100">
<h3 className="text-xl font-medium">{t(feature.key)}</h3>
<h4 className="text-sm leading-5 text-custom-text-200">{t(`${feature.key}_description`)}</h4>
</div>
{Object.keys(feature.featureList).map((featureItemKey) => {
const featureItem = feature.featureList[featureItemKey];
return (
<div
key={featureItemKey}
className="gap-x-8 gap-y-2 border-b border-custom-border-100 bg-custom-background-100 pb-2 pt-4"
>
<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"
/>
{Object.entries(PROJECT_FEATURES_LIST).map(([featureSectionKey, feature]) => (
<div key={featureSectionKey} className="">
<div className="flex flex-col justify-center pb-2 border-b border-custom-border-100">
<h3 className="text-xl font-medium">{t(feature.key)}</h3>
<h4 className="text-sm leading-5 text-custom-text-200">{t(`${feature.key}_description`)}</h4>
</div>
{Object.entries(feature.featureList).map(([featureItemKey, featureItem]) => (
<div
key={featureItemKey}
className="gap-x-8 gap-y-2 border-b border-custom-border-100 bg-custom-background-100 pb-2 pt-4"
>
<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 className="pl-14">
{currentProjectDetails?.[featureItem.property as keyof IProject] &&
featureItem.renderChildren?.(currentProjectDetails, workspaceSlug)}
<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>
);
})}
</div>
);
})}
<ToggleSwitch
value={Boolean(currentProjectDetails?.[featureItem.property as keyof IProject])}
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>
);
});

View File

@@ -37,6 +37,7 @@ export interface IProjectStore {
getPartialProjectById: (projectId: string | undefined | null) => TPartialProject | undefined;
getProjectIdentifierById: (projectId: string | undefined | null) => string;
getProjectAnalyticsCountById: (projectId: string | undefined | null) => TProjectAnalyticsCount | undefined;
getProjectByIdentifier: (projectIdentifier: string) => TProject | undefined;
// collapsible
openCollapsibleSection: ProjectOverviewCollapsible[];
lastCollapsibleAction: ProjectOverviewCollapsible | null;
@@ -45,6 +46,9 @@ export interface IProjectStore {
setLastCollapsibleAction: (section: ProjectOverviewCollapsible) => void;
toggleOpenCollapsibleSection: (section: ProjectOverviewCollapsible) => void;
// helper actions
processProjectAfterCreation: (workspaceSlug: string, data: TProject) => void;
// fetch actions
fetchPartialProjects: (workspaceSlug: string) => Promise<TPartialProject[]>;
fetchProjects: (workspaceSlug: string) => Promise<TProject[]>;
@@ -104,6 +108,8 @@ export class ProjectStore implements IProjectStore {
currentProjectDetails: computed,
joinedProjectIds: computed,
favoriteProjectIds: computed,
// helper actions
processProjectAfterCreation: action,
// fetch actions
fetchPartialProjects: action,
fetchProjects: action,
@@ -233,7 +239,10 @@ export class ProjectStore implements IProjectStore {
const projectIds = projects
.filter(
(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);
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
* @param workspaceSlug
@@ -363,6 +385,15 @@ export class ProjectStore implements IProjectStore {
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
* This method is used just for type safety
@@ -481,15 +512,7 @@ export class ProjectStore implements IProjectStore {
createProject = async (workspaceSlug: string, data: any) => {
try {
const response = await this.projectService.createProject(workspaceSlug, data);
runInAction(() => {
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
);
});
this.processProjectAfterCreation(workspaceSlug, response);
return response;
} catch (error) {
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";
export const getRandomEmoji = () => {
const emojis = [
"8986",
"9200",
"128204",
"127773",
"127891",
"128076",
"128077",
"128187",
"128188",
"128512",
"128522",
"128578",
];
return emojis[Math.floor(Math.random() * emojis.length)];
};
export const getRandomEmoji = () => RANDOM_EMOJI_CODES[Math.floor(Math.random() * RANDOM_EMOJI_CODES.length)];
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,
key: string
) => {
if (!Array.isArray(reactions)) {
console.error("Expected an array of reactions, but got:", reactions);
return {};
}
const groupedReactions = reactions.reduce(
(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]]) {
acc[reaction[key]] = [];
}