mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
[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:
@@ -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",
|
||||
];
|
||||
|
||||
@@ -29,3 +29,4 @@ export * from "./event-tracker";
|
||||
export * from "./spreadsheet";
|
||||
export * from "./dashboard";
|
||||
export * from "./page";
|
||||
export * from "./emoji";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
4
packages/types/src/inbox.d.ts
vendored
4
packages/types/src/inbox.d.ts
vendored
@@ -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";
|
||||
|
||||
8
packages/types/src/state.d.ts
vendored
8
packages/types/src/state.d.ts
vendored
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
}}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
12
web/ce/components/projects/create/template-select.tsx
Normal file
12
web/ce/components/projects/create/template-select.tsx
Normal 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) => <></>;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]] = [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user