chore: seperated project components for CE (#5324)

* chore: seperated project components for CE

* chore: splitted the code for project creation form

* fix: code structure optimization

* fix: project page root moved

* fix: synced with preview

* fix: component splitting and refactoring

* fix: build error
This commit is contained in:
Akshita Goyal 2024-08-12 18:24:42 +05:30 committed by GitHub
parent 3ffaa4f2ca
commit 91693b2269
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 799 additions and 596 deletions

View file

@ -0,0 +1,16 @@
"use client";
import { ReactNode } from "react";
// components
import { AppHeader, ContentWrapper } from "@/components/core";
// local components
import { ProjectsListHeader } from "@/plane-web/components/projects/header";
import { ProjectsListMobileHeader } from "@/plane-web/components/projects/mobile-header";
export default function ProjectListLayout({ children }: { children: ReactNode }) {
return (
<>
<AppHeader header={<ProjectsListHeader />} mobileHeader={<ProjectsListMobileHeader />} />
<ContentWrapper>{children}</ContentWrapper>
</>
);
}

View file

@ -0,0 +1,4 @@
import ProjectPageRoot from "@/plane-web/components/projects/page";
const ProjectsPage = () => <ProjectPageRoot />;
export default ProjectsPage;

View file

@ -4,9 +4,8 @@ import { ReactNode } from "react";
// components
import { AppHeader, ContentWrapper } from "@/components/core";
// local components
import { ProjectsListHeader } from "./header";
import { ProjectsListMobileHeader } from "./mobile-header";
import { ProjectsListHeader } from "@/plane-web/components/projects/header";
import { ProjectsListMobileHeader } from "@/plane-web/components/projects/mobile-header";
export default function ProjectListLayout({ children }: { children: ReactNode }) {
return (
<>

View file

@ -1,84 +1,4 @@
"use client";
import { useCallback } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// types
import { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types";
// components
import { PageHead } from "@/components/core";
import { ProjectAppliedFiltersList, ProjectCardList } from "@/components/project";
// helpers
import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks
import { useProject, useProjectFilter, useWorkspace } from "@/hooks/store";
const ProjectsPage = observer(() => {
// store
const { workspaceSlug } = useParams();
const { currentWorkspace } = useWorkspace();
const { totalProjectIds, filteredProjectIds } = useProject();
const {
currentWorkspaceFilters,
currentWorkspaceAppliedDisplayFilters,
clearAllFilters,
clearAllAppliedDisplayFilters,
updateFilters,
updateDisplayFilters,
} = useProjectFilter();
// derived values
const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Projects` : undefined;
const handleRemoveFilter = useCallback(
(key: keyof TProjectFilters, value: string | null) => {
if (!workspaceSlug) return;
let newValues = currentWorkspaceFilters?.[key] ?? [];
if (!value) newValues = [];
else newValues = newValues.filter((val) => val !== value);
updateFilters(workspaceSlug.toString(), { [key]: newValues });
},
[currentWorkspaceFilters, updateFilters, workspaceSlug]
);
const handleRemoveDisplayFilter = useCallback(
(key: TProjectAppliedDisplayFilterKeys) => {
if (!workspaceSlug) return;
updateDisplayFilters(workspaceSlug.toString(), { [key]: false });
},
[updateDisplayFilters, workspaceSlug]
);
const handleClearAllFilters = useCallback(() => {
if (!workspaceSlug) return;
clearAllFilters(workspaceSlug.toString());
clearAllAppliedDisplayFilters(workspaceSlug.toString());
}, [clearAllFilters, clearAllAppliedDisplayFilters, workspaceSlug]);
return (
<>
<PageHead title={pageTitle} />
<div className="flex h-full w-full flex-col">
{(calculateTotalFilters(currentWorkspaceFilters ?? {}) !== 0 ||
currentWorkspaceAppliedDisplayFilters?.length !== 0) && (
<div className="border-b border-custom-border-200 px-5 py-3">
<ProjectAppliedFiltersList
appliedFilters={currentWorkspaceFilters ?? {}}
appliedDisplayFilters={currentWorkspaceAppliedDisplayFilters ?? []}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
handleRemoveDisplayFilter={handleRemoveDisplayFilter}
filteredProjects={filteredProjectIds?.length ?? 0}
totalProjects={totalProjectIds?.length ?? 0}
alwaysAllowEditing
/>
</div>
)}
<ProjectCardList />
</div>
</>
);
});
import ProjectPageRoot from "@/plane-web/components/projects/page";
const ProjectsPage = () => <ProjectPageRoot />;
export default ProjectsPage;

View file

@ -0,0 +1,80 @@
import { Controller, useFormContext } from "react-hook-form";
import { IProject } from "@plane/types";
import { CustomSelect } from "@plane/ui";
import { MemberDropdown } from "@/components/dropdowns";
import { NETWORK_CHOICES } from "@/constants/project";
const ProjectAttributes = () => {
const { control } = useFormContext<IProject>();
return (
<div className="flex flex-wrap items-center gap-2">
<Controller
name="network"
control={control}
render={({ field: { onChange, value } }) => {
const currentNetwork = NETWORK_CHOICES.find((n) => n.key === value);
return (
<div className="flex-shrink-0 h-7" tabIndex={4}>
<CustomSelect
value={value}
onChange={onChange}
label={
<div className="flex items-center gap-1 h-full">
{currentNetwork ? (
<>
<currentNetwork.icon className="h-3 w-3" />
{currentNetwork.label}
</>
) : (
<span className="text-custom-text-400">Select network</span>
)}
</div>
}
placement="bottom-start"
className="h-full"
buttonClassName="h-full"
noChevron
tabIndex={4}
>
{NETWORK_CHOICES.map((network) => (
<CustomSelect.Option key={network.key} value={network.key}>
<div className="flex items-start gap-2">
<network.icon className="h-3.5 w-3.5" />
<div className="-mt-1">
<p>{network.label}</p>
<p className="text-xs text-custom-text-400">{network.description}</p>
</div>
</div>
</CustomSelect.Option>
))}
</CustomSelect>
</div>
);
}}
/>
<Controller
name="project_lead"
control={control}
render={({ field: { value, onChange } }) => {
if (value === undefined || value === null || typeof value === "string")
return (
<div className="flex-shrink-0 h-7" tabIndex={5}>
<MemberDropdown
value={value}
onChange={(lead) => onChange(lead === value ? null : lead)}
placeholder="Lead"
multiple={false}
buttonVariant="border-with-text"
tabIndex={5}
/>
</div>
);
else return <></>;
}}
/>
</div>
);
};
export default ProjectAttributes;

View file

@ -0,0 +1,139 @@
"use client";
import { useState, FC } from "react";
import { observer } from "mobx-react";
import { FormProvider, useForm } from "react-hook-form";
// ui
import { setToast, TOAST_TYPE } from "@plane/ui";
// constants
import ProjectCommonAttributes from "@/components/project/create/common-attributes";
import ProjectCreateHeader from "@/components/project/create/header";
import ProjectCreateButtons from "@/components/project/create/project-create-buttons";
import { PROJECT_CREATED } from "@/constants/event-tracker";
import { PROJECT_UNSPLASH_COVERS } from "@/constants/project";
// helpers
import { getRandomEmoji } from "@/helpers/emoji.helper";
// hooks
import { useEventTracker, useProject } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { TProject } from "@/plane-web/types/projects";
import ProjectAttributes from "./attributes";
type Props = {
setToFavorite?: boolean;
workspaceSlug: string;
onClose: () => void;
handleNextStep: (projectId: string) => void;
data?: Partial<TProject>;
};
const defaultValues: Partial<TProject> = {
cover_image: 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<Props> = observer((props) => {
const { setToFavorite, workspaceSlug, onClose, handleNextStep } = props;
// store
const { captureProjectEvent } = useEventTracker();
const { addProjectToFavorites, createProject } = useProject();
// states
const [isChangeInIdentifierRequired, setIsChangeInIdentifierRequired] = useState(true);
// form info
const methods = useForm<TProject>({
defaultValues,
reValidateMode: "onChange",
});
const { handleSubmit, reset, setValue } = methods;
const { isMobile } = usePlatformOS();
const handleAddToFavorites = (projectId: string) => {
if (!workspaceSlug) return;
addProjectToFavorites(workspaceSlug.toString(), projectId).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Couldn't remove the project from favorites. Please try again.",
});
});
};
const onSubmit = async (formData: Partial<TProject>) => {
// Upper case identifier
formData.identifier = formData.identifier?.toUpperCase();
return createProject(workspaceSlug.toString(), formData)
.then((res) => {
const newPayload = {
...res,
state: "SUCCESS",
};
captureProjectEvent({
eventName: PROJECT_CREATED,
payload: newPayload,
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Project created successfully.",
});
if (setToFavorite) {
handleAddToFavorites(res.id);
}
handleNextStep(res.id);
})
.catch((err) => {
Object.keys(err.data).map((key) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: err.data[key],
});
captureProjectEvent({
eventName: PROJECT_CREATED,
payload: {
...formData,
state: "FAILED",
},
});
});
});
};
const handleClose = () => {
onClose();
setIsChangeInIdentifierRequired(true);
setTimeout(() => {
reset();
}, 300);
};
return (
<FormProvider {...methods}>
<ProjectCreateHeader handleClose={handleClose} />
<form onSubmit={handleSubmit(onSubmit)} className="px-3">
<div className="mt-9 space-y-6 pb-5">
<ProjectCommonAttributes
setValue={setValue}
isMobile={isMobile}
isChangeInIdentifierRequired={isChangeInIdentifierRequired}
setIsChangeInIdentifierRequired={setIsChangeInIdentifierRequired}
/>
<ProjectAttributes />
</div>
<ProjectCreateButtons handleClose={handleClose} />
</form>
</FormProvider>
);
});

View file

@ -0,0 +1,5 @@
"use client";
import { ProjectsBaseHeader } from "@/components/project/header";
export const ProjectsListHeader = () => <ProjectsBaseHeader />;

View file

@ -1,3 +1,4 @@
"use client";
import { useCallback } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
@ -23,7 +24,6 @@ export const ProjectsListMobileHeader = observer(() => {
updateFilters,
} = useProjectFilter();
const {
workspace: { workspaceMemberIds },
} = useMember();

View file

@ -0,0 +1,5 @@
import Root from "@/components/project/root";
const ProjectPageRoot = () => <Root />;
export default ProjectPageRoot;

View file

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

View file

@ -0,0 +1,3 @@
import { IProject } from "@plane/types";
export type TProject = IProject;

View file

@ -14,10 +14,10 @@ export type GanttChartBlocksProps = {
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
blockToRender: (data: any) => React.ReactNode;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
enableBlockLeftResize: boolean;
enableBlockRightResize: boolean;
enableBlockMove: boolean;
enableAddBlock: boolean;
enableBlockLeftResize: boolean | ((blockId: string) => boolean);
enableBlockRightResize: boolean | ((blockId: string) => boolean);
enableBlockMove: boolean | ((blockId: string) => boolean);
enableAddBlock: boolean | ((blockId: string) => boolean);
ganttContainerRef: React.RefObject<HTMLDivElement>;
showAllBlocks: boolean;
selectionHelpers: TSelectionHelper;
@ -55,10 +55,14 @@ export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => {
showAllBlocks={showAllBlocks}
blockToRender={blockToRender}
blockUpdateHandler={blockUpdateHandler}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
enableAddBlock={enableAddBlock}
enableBlockLeftResize={
typeof enableBlockLeftResize === "function" ? enableBlockLeftResize(blockId) : enableBlockLeftResize
}
enableBlockRightResize={
typeof enableBlockRightResize === "function" ? enableBlockRightResize(blockId) : enableBlockRightResize
}
enableBlockMove={typeof enableBlockMove === "function" ? enableBlockMove(blockId) : enableBlockMove}
enableAddBlock={typeof enableAddBlock === "function" ? enableAddBlock(blockId) : enableAddBlock}
ganttContainerRef={ganttContainerRef}
selectionHelpers={selectionHelpers}
/>

View file

@ -16,10 +16,12 @@ type Props = {
handleToday: () => void;
loaderTitle: string;
toggleFullScreenMode: () => void;
showToday: boolean;
};
export const GanttChartHeader: React.FC<Props> = observer((props) => {
const { blockIds, fullScreenMode, handleChartView, handleToday, loaderTitle, toggleFullScreenMode } = props;
const { blockIds, fullScreenMode, handleChartView, handleToday, loaderTitle, toggleFullScreenMode, showToday } =
props;
// chart hook
const { currentView } = useGanttChart();
@ -46,9 +48,15 @@ export const GanttChartHeader: React.FC<Props> = observer((props) => {
))}
</div>
<button type="button" className="rounded-sm p-1 px-2 text-xs hover:bg-custom-background-80" onClick={handleToday}>
Today
</button>
{showToday && (
<button
type="button"
className="rounded-sm p-1 px-2 text-xs hover:bg-custom-background-80"
onClick={handleToday}
>
Today
</button>
)}
<button
type="button"

View file

@ -19,12 +19,12 @@ import {
WeekChartView,
YearChartView,
} from "@/components/gantt-chart";
// helpers
import { cn } from "@/helpers/common.helper";
// plane web components
import { IssueBulkOperationsRoot } from "@/plane-web/components/issues";
// plane web hooks
import { useBulkOperationStatus } from "@/plane-web/hooks/use-bulk-operation-status";
// helpers
// constants
import { GANTT_SELECT_GROUP } from "../constants";
// hooks
@ -38,12 +38,12 @@ type Props = {
blockToRender: (data: any) => React.ReactNode;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
bottomSpacing: boolean;
enableBlockLeftResize: boolean;
enableBlockMove: boolean;
enableBlockRightResize: boolean;
enableReorder: boolean;
enableSelection: boolean;
enableAddBlock: boolean;
enableBlockLeftResize: boolean | ((blockId: string) => boolean);
enableBlockMove: boolean | ((blockId: string) => boolean);
enableBlockRightResize: boolean | ((blockId: string) => boolean);
enableReorder: boolean | ((blockId: string) => boolean);
enableSelection: boolean | ((blockId: string) => boolean);
enableAddBlock: boolean | ((blockId: string) => boolean);
itemsContainerWidth: number;
showAllBlocks: boolean;
sidebarToRender: (props: any) => React.ReactNode;
@ -145,38 +145,38 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
onScroll={onScroll}
>
<GanttChartSidebar
blockIds={blockIds}
getBlockById={getBlockById}
loadMoreBlocks={loadMoreBlocks}
canLoadMoreBlocks={canLoadMoreBlocks}
ganttContainerRef={ganttContainerRef}
blockUpdateHandler={blockUpdateHandler}
enableReorder={enableReorder}
enableSelection={enableSelection}
sidebarToRender={sidebarToRender}
title={title}
quickAdd={quickAdd}
selectionHelpers={helpers}
/>
<div className="relative min-h-full h-max flex-shrink-0 flex-grow">
<ActiveChartView />
{currentViewData && (
<GanttChartBlocksList
itemsContainerWidth={itemsContainerWidth}
blockIds={blockIds}
getBlockById={getBlockById}
blockToRender={blockToRender}
blockUpdateHandler={blockUpdateHandler}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
enableAddBlock={enableAddBlock}
ganttContainerRef={ganttContainerRef}
showAllBlocks={showAllBlocks}
selectionHelpers={helpers}
/>
)}
</div>
blockIds={blockIds}
getBlockById={getBlockById}
loadMoreBlocks={loadMoreBlocks}
canLoadMoreBlocks={canLoadMoreBlocks}
ganttContainerRef={ganttContainerRef}
blockUpdateHandler={blockUpdateHandler}
enableReorder={enableReorder}
enableSelection={enableSelection}
sidebarToRender={sidebarToRender}
title={title}
quickAdd={quickAdd}
selectionHelpers={helpers}
/>
<div className="relative min-h-full h-max flex-shrink-0 flex-grow">
<ActiveChartView />
{currentViewData && (
<GanttChartBlocksList
itemsContainerWidth={itemsContainerWidth}
blockIds={blockIds}
getBlockById={getBlockById}
blockToRender={blockToRender}
blockUpdateHandler={blockUpdateHandler}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
enableAddBlock={enableAddBlock}
ganttContainerRef={ganttContainerRef}
showAllBlocks={showAllBlocks}
selectionHelpers={helpers}
/>
)}
</div>
</div>
<IssueBulkOperationsRoot selectionHelpers={helpers} />
</>

View file

@ -23,18 +23,19 @@ type ChartViewRootProps = {
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blockToRender: (data: any) => React.ReactNode;
sidebarToRender: (props: any) => React.ReactNode;
enableBlockLeftResize: boolean;
enableBlockRightResize: boolean;
enableBlockMove: boolean;
enableReorder: boolean;
enableAddBlock: boolean;
enableSelection: boolean;
enableBlockLeftResize: boolean | ((blockId: string) => boolean);
enableBlockRightResize: boolean | ((blockId: string) => boolean);
enableBlockMove: boolean | ((blockId: string) => boolean);
enableReorder: boolean | ((blockId: string) => boolean);
enableAddBlock: boolean | ((blockId: string) => boolean);
enableSelection: boolean | ((blockId: string) => boolean);
bottomSpacing: boolean;
showAllBlocks: boolean;
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
loadMoreBlocks?: () => void;
canLoadMoreBlocks?: boolean;
quickAdd?: React.JSX.Element | undefined;
showToday: boolean;
};
export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
@ -58,6 +59,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
bottomSpacing,
showAllBlocks,
quickAdd,
showToday,
} = props;
// states
const [itemsContainerWidth, setItemsContainerWidth] = useState(0);
@ -161,6 +163,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
handleChartView={(key) => updateCurrentViewRenderPayload(null, key)}
handleToday={handleToday}
loaderTitle={loaderTitle}
showToday={showToday}
/>
<GanttChartMainContent
blockIds={blockIds}

View file

@ -16,14 +16,15 @@ type GanttChartRootProps = {
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
canLoadMoreBlocks?: boolean;
loadMoreBlocks?: () => void;
enableBlockLeftResize?: boolean;
enableBlockRightResize?: boolean;
enableBlockMove?: boolean;
enableReorder?: boolean;
enableAddBlock?: boolean;
enableSelection?: boolean;
enableBlockLeftResize?: boolean | ((blockId: string) => boolean);
enableBlockRightResize?: boolean | ((blockId: string) => boolean);
enableBlockMove?: boolean | ((blockId: string) => boolean);
enableReorder?: boolean | ((blockId: string) => boolean);
enableAddBlock?: boolean | ((blockId: string) => boolean);
enableSelection?: boolean | ((blockId: string) => boolean);
bottomSpacing?: boolean;
showAllBlocks?: boolean;
showToday?: boolean;
};
export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
@ -46,6 +47,7 @@ export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
enableSelection = false,
bottomSpacing = false,
showAllBlocks = false,
showToday = true,
quickAdd,
} = props;
@ -71,6 +73,7 @@ export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
bottomSpacing={bottomSpacing}
showAllBlocks={showAllBlocks}
quickAdd={quickAdd}
showToday={showToday}
/>
</GanttStoreProvider>
);

View file

@ -16,8 +16,8 @@ type Props = {
canLoadMoreBlocks?: boolean;
loadMoreBlocks?: () => void;
ganttContainerRef: RefObject<HTMLDivElement>;
enableReorder: boolean;
enableSelection: boolean;
enableReorder: boolean | ((blockId: string) => boolean);
enableSelection: boolean | ((blockId: string) => boolean);
sidebarToRender: (props: any) => React.ReactNode;
title: string;
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
@ -94,7 +94,7 @@ export const GanttChartSidebar: React.FC<Props> = observer((props) => {
canLoadMoreBlocks,
ganttContainerRef,
loadMoreBlocks,
selectionHelpers
selectionHelpers,
})}
</div>
{quickAdd ? quickAdd : null}

View file

@ -1,405 +0,0 @@
"use client";
import { useState, FC, ChangeEvent } from "react";
import { observer } from "mobx-react";
import { useForm, Controller } from "react-hook-form";
import { Info, X } from "lucide-react";
import { IProject } from "@plane/types";
// ui
import {
Button,
CustomEmojiIconPicker,
CustomSelect,
EmojiIconPickerTypes,
Input,
setToast,
TextArea,
TOAST_TYPE,
Tooltip,
} from "@plane/ui";
// components
import { Logo } from "@/components/common";
import { ImagePickerPopover } from "@/components/core";
import { MemberDropdown } from "@/components/dropdowns";
// constants
import { PROJECT_CREATED } from "@/constants/event-tracker";
import { NETWORK_CHOICES, PROJECT_UNSPLASH_COVERS } from "@/constants/project";
// helpers
import { cn } from "@/helpers/common.helper";
import { convertHexEmojiToDecimal, getRandomEmoji } from "@/helpers/emoji.helper";
import { projectIdentifierSanitizer } from "@/helpers/project.helper";
// hooks
import { useEventTracker, useProject } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = {
setToFavorite?: boolean;
workspaceSlug: string;
onClose: () => void;
handleNextStep: (projectId: string) => void;
};
const defaultValues: Partial<IProject> = {
cover_image: 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<Props> = observer((props) => {
const { setToFavorite, workspaceSlug, onClose, handleNextStep } = props;
// store
const { captureProjectEvent } = useEventTracker();
const { addProjectToFavorites, createProject } = useProject();
// states
const [isOpen, setIsOpen] = useState(false);
const [isChangeInIdentifierRequired, setIsChangeInIdentifierRequired] = useState(true);
// form info
const {
formState: { errors, isSubmitting },
handleSubmit,
reset,
control,
watch,
setValue,
} = useForm<IProject>({
defaultValues,
reValidateMode: "onChange",
});
const { isMobile } = usePlatformOS();
const handleAddToFavorites = (projectId: string) => {
if (!workspaceSlug) return;
addProjectToFavorites(workspaceSlug.toString(), projectId).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Couldn't remove the project from favorites. Please try again.",
});
});
};
const onSubmit = async (formData: Partial<IProject>) => {
// Upper case identifier
formData.identifier = formData.identifier?.toUpperCase();
return createProject(workspaceSlug.toString(), formData)
.then((res) => {
const newPayload = {
...res,
state: "SUCCESS",
};
captureProjectEvent({
eventName: PROJECT_CREATED,
payload: newPayload,
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Project created successfully.",
});
if (setToFavorite) {
handleAddToFavorites(res.id);
}
handleNextStep(res.id);
})
.catch((err) => {
Object.keys(err.data).map((key) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: err.data[key],
});
captureProjectEvent({
eventName: PROJECT_CREATED,
payload: {
...formData,
state: "FAILED",
},
});
});
});
};
const handleNameChange = (onChange: any) => (e: ChangeEvent<HTMLInputElement>) => {
if (!isChangeInIdentifierRequired) {
onChange(e);
return;
}
if (e.target.value === "") setValue("identifier", "");
else setValue("identifier", projectIdentifierSanitizer(e.target.value).substring(0, 5));
onChange(e);
};
const handleIdentifierChange = (onChange: any) => (e: ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
const alphanumericValue = projectIdentifierSanitizer(value);
setIsChangeInIdentifierRequired(false);
onChange(alphanumericValue);
};
const handleClose = () => {
onClose();
setIsChangeInIdentifierRequired(true);
setTimeout(() => {
reset();
}, 300);
};
return (
<>
<div className="group relative h-44 w-full rounded-lg bg-custom-background-80">
{watch("cover_image") && (
<img
src={watch("cover_image")!}
className="absolute left-0 top-0 h-full w-full rounded-lg object-cover"
alt="Cover image"
/>
)}
<div className="absolute right-2 top-2 p-2">
<button data-posthog="PROJECT_MODAL_CLOSE" type="button" onClick={handleClose} tabIndex={8}>
<X className="h-5 w-5 text-white" />
</button>
</div>
<div className="absolute bottom-2 right-2">
<Controller
name="cover_image"
control={control}
render={({ field: { value, onChange } }) => (
<ImagePickerPopover
label="Change Cover"
onChange={onChange}
control={control}
value={value}
tabIndex={9}
/>
)}
/>
</div>
<div className="absolute -bottom-[22px] left-3">
<Controller
name="logo_props"
control={control}
render={({ field: { value, onChange } }) => (
<CustomEmojiIconPicker
isOpen={isOpen}
handleToggle={(val: boolean) => setIsOpen(val)}
className="flex items-center justify-center"
buttonClassName="flex items-center justify-center"
label={
<span className="grid h-11 w-11 place-items-center rounded-md bg-custom-background-80">
<Logo logo={value} size={20} />
</span>
}
onChange={(val: any) => {
let logoValue = {};
if (val?.type === "emoji")
logoValue = {
value: convertHexEmojiToDecimal(val.value.unified),
url: val.value.imageUrl,
};
else if (val?.type === "icon") logoValue = val.value;
onChange({
in_use: val?.type,
[val?.type]: logoValue,
});
setIsOpen(false);
}}
defaultIconColor={value.in_use && value.in_use === "icon" ? value.icon?.color : undefined}
defaultOpen={
value.in_use && value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON
}
/>
)}
/>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="px-3">
<div className="mt-9 space-y-6 pb-5">
<div className="grid grid-cols-1 gap-x-2 gap-y-3 md:grid-cols-4">
<div className="md:col-span-3">
<Controller
control={control}
name="name"
rules={{
required: "Name is required",
maxLength: {
value: 255,
message: "Title should be less than 255 characters",
},
}}
render={({ field: { value, onChange } }) => (
<Input
id="name"
name="name"
type="text"
value={value}
onChange={handleNameChange(onChange)}
hasError={Boolean(errors.name)}
placeholder="Project name"
className="w-full focus:border-blue-400"
tabIndex={1}
/>
)}
/>
<span className="text-xs text-red-500">
<>{errors?.name?.message}</>
</span>
</div>
<div className="relative">
<Controller
control={control}
name="identifier"
rules={{
required: "Project ID is required",
// allow only alphanumeric & non-latin characters
validate: (value) =>
/^[ÇŞĞIİÖÜA-Z0-9]+$/.test(value.toUpperCase()) ||
"Only Alphanumeric & Non-latin characters are allowed.",
minLength: {
value: 1,
message: "Project ID must at least be of 1 character",
},
maxLength: {
value: 5,
message: "Project ID must at most be of 5 characters",
},
}}
render={({ field: { value, onChange } }) => (
<Input
id="identifier"
name="identifier"
type="text"
value={value}
onChange={handleIdentifierChange(onChange)}
hasError={Boolean(errors.identifier)}
placeholder="Project ID"
className={cn("w-full text-xs focus:border-blue-400 pr-7", {
uppercase: value,
})}
tabIndex={2}
/>
)}
/>
<Tooltip
isMobile={isMobile}
tooltipContent="Helps you identify issues in the project uniquely. Max 5 characters."
className="text-sm"
position="right-top"
>
<Info className="absolute right-2 top-2.5 h-3 w-3 text-custom-text-400" />
</Tooltip>
<span className="text-xs text-red-500">{errors?.identifier?.message}</span>
</div>
<div className="md:col-span-4">
<Controller
name="description"
control={control}
render={({ field: { value, onChange } }) => (
<TextArea
id="description"
name="description"
value={value}
placeholder="Description..."
onChange={onChange}
className="!h-24 text-sm focus:border-blue-400"
hasError={Boolean(errors?.description)}
tabIndex={3}
/>
)}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Controller
name="network"
control={control}
render={({ field: { onChange, value } }) => {
const currentNetwork = NETWORK_CHOICES.find((n) => n.key === value);
return (
<div className="flex-shrink-0 h-7" tabIndex={4}>
<CustomSelect
value={value}
onChange={onChange}
label={
<div className="flex items-center gap-1 h-full">
{currentNetwork ? (
<>
<currentNetwork.icon className="h-3 w-3" />
{currentNetwork.label}
</>
) : (
<span className="text-custom-text-400">Select network</span>
)}
</div>
}
placement="bottom-start"
className="h-full"
buttonClassName="h-full"
noChevron
tabIndex={4}
>
{NETWORK_CHOICES.map((network) => (
<CustomSelect.Option key={network.key} value={network.key}>
<div className="flex items-start gap-2">
<network.icon className="h-3.5 w-3.5" />
<div className="-mt-1">
<p>{network.label}</p>
<p className="text-xs text-custom-text-400">{network.description}</p>
</div>
</div>
</CustomSelect.Option>
))}
</CustomSelect>
</div>
);
}}
/>
<Controller
name="project_lead"
control={control}
render={({ field: { value, onChange } }) => {
if (value === undefined || value === null || typeof value === "string")
return (
<div className="flex-shrink-0 h-7" tabIndex={5}>
<MemberDropdown
value={value}
onChange={(lead) => onChange(lead === value ? null : lead)}
placeholder="Lead"
multiple={false}
buttonVariant="border-with-text"
tabIndex={5}
/>
</div>
);
else return <></>;
}}
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-custom-border-100">
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={6}>
Cancel
</Button>
<Button variant="primary" type="submit" size="sm" loading={isSubmitting} tabIndex={7}>
{isSubmitting ? "Creating" : "Create project"}
</Button>
</div>
</form>
</>
);
});

View file

@ -1,7 +1,8 @@
import { useEffect, Fragment, FC, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
// components
import { CreateProjectForm } from "./create-project-form";
import { CreateProjectForm } from "@/plane-web/components/projects/create/root";
import { TProject } from "@/plane-web/types/projects";
import { ProjectFeatureUpdate } from "./project-feature-update";
type Props = {
@ -9,6 +10,7 @@ type Props = {
onClose: () => void;
setToFavorite?: boolean;
workspaceSlug: string;
data?: Partial<TProject>;
};
enum EProjectCreationSteps {
@ -17,7 +19,7 @@ enum EProjectCreationSteps {
}
export const CreateProjectModal: FC<Props> = (props) => {
const { isOpen, onClose, setToFavorite = false, workspaceSlug } = props;
const { isOpen, onClose, setToFavorite = false, workspaceSlug, data } = props;
// states
const [currentStep, setCurrentStep] = useState<EProjectCreationSteps>(EProjectCreationSteps.CREATE_PROJECT);
const [createdProjectId, setCreatedProjectId] = useState<string | null>(null);
@ -68,6 +70,7 @@ export const CreateProjectModal: FC<Props> = (props) => {
workspaceSlug={workspaceSlug}
onClose={onClose}
handleNextStep={handleNextStep}
data={data}
/>
)}
{currentStep === EProjectCreationSteps.FEATURE_SELECTION && (

View file

@ -0,0 +1,133 @@
import { ChangeEvent } from "react";
import { Controller, useFormContext, UseFormSetValue } from "react-hook-form";
import { Info } from "lucide-react";
import { cn } from "@plane/editor";
import { Input, TextArea, Tooltip } from "@plane/ui";
import { projectIdentifierSanitizer } from "@/helpers/project.helper";
import { TProject } from "@/plane-web/types/projects";
type Props = {
setValue: UseFormSetValue<TProject>;
isMobile: boolean;
isChangeInIdentifierRequired: boolean;
setIsChangeInIdentifierRequired: (value: boolean) => void;
};
const ProjectCommonAttributes: React.FC<Props> = (props) => {
const { setValue, isMobile, isChangeInIdentifierRequired, setIsChangeInIdentifierRequired } = props;
const {
formState: { errors },
control,
} = useFormContext<TProject>();
const handleNameChange = (onChange: (...event: any[]) => void) => (e: ChangeEvent<HTMLInputElement>) => {
if (!isChangeInIdentifierRequired) {
onChange(e);
return;
}
if (e.target.value === "") setValue("identifier", "");
else setValue("identifier", projectIdentifierSanitizer(e.target.value).substring(0, 5));
onChange(e);
};
const handleIdentifierChange = (onChange: any) => (e: ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
const alphanumericValue = projectIdentifierSanitizer(value);
setIsChangeInIdentifierRequired(false);
onChange(alphanumericValue);
};
return (
<div className="grid grid-cols-1 gap-x-2 gap-y-3 md:grid-cols-4">
<div className="md:col-span-3">
<Controller
control={control}
name="name"
rules={{
required: "Name is required",
maxLength: {
value: 255,
message: "Title should be less than 255 characters",
},
}}
render={({ field: { value, onChange } }) => (
<Input
id="name"
name="name"
type="text"
value={value}
onChange={handleNameChange(onChange)}
hasError={Boolean(errors.name)}
placeholder="Project name"
className="w-full focus:border-blue-400"
tabIndex={1}
/>
)}
/>
<span className="text-xs text-red-500">{errors?.name?.message}</span>
</div>
<div className="relative">
<Controller
control={control}
name="identifier"
rules={{
required: "Project ID is required",
// allow only alphanumeric & non-latin characters
validate: (value) =>
/^[ÇŞĞIİÖÜA-Z0-9]+$/.test(value.toUpperCase()) || "Only Alphanumeric & Non-latin characters are allowed.",
minLength: {
value: 1,
message: "Project ID must at least be of 1 character",
},
maxLength: {
value: 5,
message: "Project ID must at most be of 5 characters",
},
}}
render={({ field: { value, onChange } }) => (
<Input
id="identifier"
name="identifier"
type="text"
value={value}
onChange={handleIdentifierChange(onChange)}
hasError={Boolean(errors.identifier)}
placeholder="Project ID"
className={cn("w-full text-xs focus:border-blue-400 pr-7", {
uppercase: value,
})}
tabIndex={2}
/>
)}
/>
<Tooltip
isMobile={isMobile}
tooltipContent="Helps you identify issues in the project uniquely. Max 5 characters."
className="text-sm"
position="right-top"
>
<Info className="absolute right-2 top-2.5 h-3 w-3 text-custom-text-400" />
</Tooltip>
<span className="text-xs text-red-500">{errors?.identifier?.message}</span>
</div>
<div className="md:col-span-4">
<Controller
name="description"
control={control}
render={({ field: { value, onChange } }) => (
<TextArea
id="description"
name="description"
value={value}
placeholder="Description..."
onChange={onChange}
className="!h-24 text-sm focus:border-blue-400"
hasError={Boolean(errors?.description)}
tabIndex={3}
/>
)}
/>
</div>
</div>
);
};
export default ProjectCommonAttributes;

View file

@ -0,0 +1,85 @@
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { X } from "lucide-react";
import { IProject } from "@plane/types";
import { CustomEmojiIconPicker, EmojiIconPickerTypes, Logo } from "@plane/ui";
import { ImagePickerPopover } from "@/components/core";
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
type Props = {
handleClose: () => void;
};
const ProjectCreateHeader: React.FC<Props> = (props) => {
const { handleClose } = props;
const { watch, control } = useFormContext<IProject>();
const [isOpen, setIsOpen] = useState(false);
return (
<div className="group relative h-44 w-full rounded-lg bg-custom-background-80">
{watch("cover_image") && (
<img
src={watch("cover_image")!}
className="absolute left-0 top-0 h-full w-full rounded-lg object-cover"
alt="Cover image"
/>
)}
<div className="absolute right-2 top-2 p-2">
<button data-posthog="PROJECT_MODAL_CLOSE" type="button" onClick={handleClose} tabIndex={8}>
<X className="h-5 w-5 text-white" />
</button>
</div>
<div className="absolute bottom-2 right-2">
<Controller
name="cover_image"
control={control}
render={({ field: { value, onChange } }) => (
<ImagePickerPopover label="Change Cover" onChange={onChange} control={control} value={value} tabIndex={9} />
)}
/>
</div>
<div className="absolute -bottom-[22px] left-3">
<Controller
name="logo_props"
control={control}
render={({ field: { value, onChange } }) => (
<CustomEmojiIconPicker
isOpen={isOpen}
handleToggle={(val: boolean) => setIsOpen(val)}
className="flex items-center justify-center"
buttonClassName="flex items-center justify-center"
label={
<span className="grid h-11 w-11 place-items-center rounded-md bg-custom-background-80">
<Logo logo={value} size={20} />
</span>
}
onChange={(val: any) => {
let logoValue = {};
if (val?.type === "emoji")
logoValue = {
value: convertHexEmojiToDecimal(val.value.unified),
url: val.value.imageUrl,
};
else if (val?.type === "icon") logoValue = val.value;
onChange({
in_use: val?.type,
[val?.type]: logoValue,
});
setIsOpen(false);
}}
defaultIconColor={value.in_use && value.in_use === "icon" ? value.icon?.color : undefined}
defaultOpen={
value.in_use && value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON
}
/>
)}
/>
</div>
</div>
);
};
export default ProjectCreateHeader;

View file

@ -0,0 +1,26 @@
import { useFormContext } from "react-hook-form";
import { IProject } from "@plane/types";
import { Button } from "@plane/ui";
type Props = {
handleClose: () => void;
};
const ProjectCreateButtons: React.FC<Props> = (props) => {
const { handleClose } = props;
const {
formState: { isSubmitting },
} = useFormContext<IProject>();
return (
<div className="flex justify-end gap-2 pt-4 border-t border-custom-border-100">
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={6}>
Cancel
</Button>
<Button variant="primary" type="submit" size="sm" loading={isSubmitting} tabIndex={7}>
{isSubmitting ? "Creating" : "Create project"}
</Button>
</div>
);
};
export default ProjectCreateButtons;

View file

@ -2,7 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useParams, usePathname } from "next/navigation";
import { Search, Briefcase, X, ListFilter } from "lucide-react";
// types
import { TProjectFilters } from "@plane/types";
@ -21,7 +21,7 @@ import { calculateTotalFilters } from "@/helpers/filter.helper";
import { useCommandPalette, useEventTracker, useMember, useProjectFilter, useUser } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
export const ProjectsListHeader = observer(() => {
export const ProjectsBaseHeader = observer(() => {
// router
const { workspaceSlug } = useParams();
// states
@ -34,6 +34,8 @@ export const ProjectsListHeader = observer(() => {
const {
membership: { currentWorkspaceRole },
} = useUser();
const pathname = usePathname();
const {
currentWorkspaceDisplayFilters: displayFilters,
currentWorkspaceFilters: filters,
@ -51,6 +53,7 @@ export const ProjectsListHeader = observer(() => {
});
// auth
const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
const isArchived = pathname.includes("/archives");
const handleFilters = useCallback(
(key: keyof TProjectFilters, value: string | string[]) => {
@ -97,6 +100,7 @@ export const ProjectsListHeader = observer(() => {
type="text"
link={<BreadcrumbLink label="Projects" icon={<Briefcase className="h-4 w-4 text-custom-text-300" />} />}
/>
{isArchived && <Breadcrumbs.BreadcrumbItem type="text" link={<BreadcrumbLink label="Archived" />} />}
</Breadcrumbs>
</div>
</div>

View file

@ -5,7 +5,6 @@ export * from "./settings";
export * from "./card-list";
export * from "./card";
export * from "./create-project-modal";
export * from "./create-project-form";
export * from "./project-feature-update";
export * from "./delete-project-modal";
export * from "./form-loader";
@ -19,3 +18,4 @@ export * from "./member-list-item";
export * from "./project-settings-member-defaults";
export * from "./send-project-invitation-modal";
export * from "./confirm-project-member-remove";
export * from "@/plane-web/components/projects/create/root";

View file

@ -0,0 +1,90 @@
"use client";
import { useCallback, useEffect } from "react";
import { observer } from "mobx-react";
// types
import { useParams, usePathname } from "next/navigation";
import { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types";
// components
import { PageHead } from "@/components/core";
import { ProjectAppliedFiltersList, ProjectCardList } from "@/components/project";
// helpers
import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks
import { useProject, useProjectFilter, useWorkspace } from "@/hooks/store";
const Root = observer(() => {
const { currentWorkspace } = useWorkspace();
const { workspaceSlug } = useParams();
const pathname = usePathname();
// store
const { totalProjectIds, filteredProjectIds } = useProject();
const {
currentWorkspaceFilters,
currentWorkspaceAppliedDisplayFilters,
clearAllFilters,
clearAllAppliedDisplayFilters,
updateFilters,
updateDisplayFilters,
} = useProjectFilter();
// derived values
const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Projects` : undefined;
const handleRemoveFilter = useCallback(
(key: keyof TProjectFilters, value: string | null) => {
if (!workspaceSlug) return;
let newValues = currentWorkspaceFilters?.[key] ?? [];
if (!value) newValues = [];
else newValues = newValues.filter((val) => val !== value);
updateFilters(workspaceSlug.toString(), { [key]: newValues });
},
[currentWorkspaceFilters, updateFilters, workspaceSlug]
);
const handleRemoveDisplayFilter = useCallback(
(key: TProjectAppliedDisplayFilterKeys) => {
if (!workspaceSlug) return;
updateDisplayFilters(workspaceSlug.toString(), { [key]: false });
},
[updateDisplayFilters, workspaceSlug]
);
const handleClearAllFilters = useCallback(() => {
if (!workspaceSlug) return;
clearAllFilters(workspaceSlug.toString());
clearAllAppliedDisplayFilters(workspaceSlug.toString());
}, [clearAllFilters, clearAllAppliedDisplayFilters, workspaceSlug]);
useEffect(() => {
if (pathname.includes("/archives")) {
updateDisplayFilters(workspaceSlug.toString(), { archived_projects: true });
}
}, [pathname]);
return (
<>
<PageHead title={pageTitle} />
<div className="flex h-full w-full flex-col">
{(calculateTotalFilters(currentWorkspaceFilters ?? {}) !== 0 ||
currentWorkspaceAppliedDisplayFilters?.length !== 0) && (
<div className="border-b border-custom-border-200 px-5 py-3">
<ProjectAppliedFiltersList
appliedFilters={currentWorkspaceFilters ?? {}}
appliedDisplayFilters={currentWorkspaceAppliedDisplayFilters ?? []}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
handleRemoveDisplayFilter={handleRemoveDisplayFilter}
filteredProjects={filteredProjectIds?.length ?? 0}
totalProjects={totalProjectIds?.length ?? 0}
alwaysAllowEditing
/>
</div>
)}
<ProjectCardList />
</div>
</>
);
});
export default Root;

View file

@ -1,13 +1,13 @@
"use client";
import React, { useEffect } from "react";
import React, { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { ChevronRight } from "lucide-react";
import { ArchiveIcon, ChevronRight, MoreHorizontal, Settings } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// ui
import { Tooltip } from "@plane/ui";
import { CustomMenu, Tooltip } from "@plane/ui";
// components
import { SidebarNavItem } from "@/components/sidebar";
// constants
@ -19,11 +19,16 @@ import { cn } from "@/helpers/common.helper";
// hooks
import { useAppTheme, useEventTracker, useUser } from "@/hooks/store";
import useLocalStorage from "@/hooks/use-local-storage";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { UpgradeBadge } from "@/plane-web/components/workspace";
export const SidebarWorkspaceMenu = observer(() => {
// state
const [isMenuActive, setIsMenuActive] = useState(false);
// refs
const actionSectionRef = useRef<HTMLDivElement | null>(null);
// store hooks
const { toggleSidebar, sidebarCollapsed } = useAppTheme();
const { captureEvent } = useEventTracker();
@ -54,6 +59,7 @@ export const SidebarWorkspaceMenu = observer(() => {
useEffect(() => {
if (sidebarCollapsed) toggleWorkspaceMenu(true);
}, [sidebarCollapsed, toggleWorkspaceMenu]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
const indicatorElement = (
<div className="flex-shrink-0">
@ -64,20 +70,70 @@ export const SidebarWorkspaceMenu = observer(() => {
return (
<Disclosure as="div" defaultOpen>
{!sidebarCollapsed && (
<Disclosure.Button
as="button"
className="sticky top-0 bg-custom-sidebar-background-100 z-10 group/workspace-button w-full px-2 py-1.5 flex items-center justify-between gap-1 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90 rounded text-xs font-semibold"
onClick={() => toggleWorkspaceMenu(!isWorkspaceMenuOpen)}
>
<span>WORKSPACE</span>
<span className="flex-shrink-0 opacity-0 pointer-events-none group-hover/workspace-button:opacity-100 group-hover/workspace-button:pointer-events-auto rounded p-0.5 hover:bg-custom-sidebar-background-80">
<ChevronRight
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {
"rotate-90": isWorkspaceMenuOpen,
})}
/>
</span>
</Disclosure.Button>
<div className="flex px-2 bg-custom-sidebar-background-100 group/workspace-button hover:bg-custom-sidebar-background-90 rounded">
{" "}
<Disclosure.Button
as="button"
className="flex-1 sticky top-0 z-10 w-full py-1.5 flex items-center justify-between gap-1 text-custom-sidebar-text-400 text-xs font-semibold"
onClick={() => toggleWorkspaceMenu(!isWorkspaceMenuOpen)}
>
<span>WORKSPACE</span>
</Disclosure.Button>
<CustomMenu
customButton={
<span
ref={actionSectionRef}
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded my-auto"
onClick={() => {
console.log("ndkn");
setIsMenuActive(!isMenuActive);
}}
>
<MoreHorizontal className="size-4" />
</span>
}
className={cn(
"h-full flex items-center opacity-0 z-20 pointer-events-none flex-shrink-0 group-hover/workspace-button:opacity-100 group-hover/workspace-button:pointer-events-auto my-auto",
{
"opacity-100 pointer-events-auto": isMenuActive,
}
)}
customButtonClassName="grid place-items-center"
placement="bottom-start"
>
<CustomMenu.MenuItem>
<Link href={`/${workspaceSlug}/projects/archives`}>
<div className="flex items-center justify-start gap-2">
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Archives</span>
</div>
</Link>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem>
<Link href={`/${workspaceSlug}/settings`}>
<div className="flex items-center justify-start gap-2">
<Settings className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Settings</span>
</div>
</Link>
</CustomMenu.MenuItem>
</CustomMenu>
<Disclosure.Button
as="button"
className="sticky top-0 z-10 group/workspace-button px-0.5 py-1.5 flex items-center justify-between gap-1 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90 rounded text-xs font-semibold"
onClick={() => toggleWorkspaceMenu(!isWorkspaceMenuOpen)}
>
{" "}
<span className="flex-shrink-0 opacity-0 pointer-events-none group-hover/workspace-button:opacity-100 group-hover/workspace-button:pointer-events-auto rounded hover:bg-custom-sidebar-background-80">
<ChevronRight
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {
"rotate-90": isWorkspaceMenuOpen,
})}
/>
</span>
</Disclosure.Button>
</div>
)}
<Transition
show={isWorkspaceMenuOpen}

View file

@ -3,7 +3,13 @@ import sortBy from "lodash/sortBy";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// types
import { IProjectBulkAddFormData, IProjectMember, IProjectMembership, IUserLite } from "@plane/types";
import {
IProjectBulkAddFormData,
IProjectMember,
IProjectMemberLite,
IProjectMembership,
IUserLite,
} from "@plane/types";
// constants
import { EUserProjectRoles } from "@/constants/project";
// services
@ -12,6 +18,7 @@ import { ProjectMemberService } from "@/services/project";
import { IRouterStore } from "@/store/router.store";
import { IUserStore } from "@/store/user";
// store
import { IProjectStore } from "../project/project.store";
import { CoreRootStore } from "../root.store";
import { IMemberRootStore } from ".";
@ -58,6 +65,7 @@ export class ProjectMemberStore implements IProjectMemberStore {
routerStore: IRouterStore;
userStore: IUserStore;
memberRoot: IMemberRootStore;
projectRoot: IProjectStore;
// services
projectMemberService;
@ -78,6 +86,7 @@ export class ProjectMemberStore implements IProjectMemberStore {
this.routerStore = _rootStore.router;
this.userStore = _rootStore.user;
this.memberRoot = _memberRoot;
this.projectRoot = _rootStore.projectRoot.project;
// services
this.projectMemberService = new ProjectMemberService();
}
@ -159,6 +168,10 @@ export class ProjectMemberStore implements IProjectMemberStore {
set(this.projectMemberMap, [projectId, member.member], member);
});
});
this.projectRoot.projectMap[projectId].members = this.projectRoot.projectMap?.[projectId]?.members.concat(
data.members as unknown as IProjectMemberLite[]
);
return response;
});
@ -212,6 +225,9 @@ export class ProjectMemberStore implements IProjectMemberStore {
runInAction(() => {
delete this.projectMemberMap?.[projectId]?.[userId];
});
this.projectRoot.projectMap[projectId].members = this.projectRoot.projectMap?.[projectId]?.members.filter(
(member) => member.id !== userId
);
});
};
}

View file

@ -2,11 +2,10 @@ import set from "lodash/set";
import sortBy from "lodash/sortBy";
import { observable, action, computed, makeObservable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// types
import { IProject } from "@plane/types";
// helpers
import { orderProjects, shouldFilterProject } from "@/helpers/project.helper";
// services
import { TProject } from "@/plane-web/types/projects/projects";
import { IssueLabelService, IssueService } from "@/services/issue";
import { ProjectService, ProjectStateService, ProjectArchiveService } from "@/services/project";
// store
@ -16,7 +15,7 @@ export interface IProjectStore {
// observables
loader: boolean;
projectMap: {
[projectId: string]: IProject; // projectId: project Info
[projectId: string]: TProject; // projectId: project Info
};
// computed
filteredProjectIds: string[] | undefined;
@ -25,21 +24,21 @@ export interface IProjectStore {
totalProjectIds: string[] | undefined;
joinedProjectIds: string[];
favoriteProjectIds: string[];
currentProjectDetails: IProject | undefined;
currentProjectDetails: TProject | undefined;
// actions
getProjectById: (projectId: string | undefined | null) => IProject | undefined;
getProjectById: (projectId: string | undefined | null) => TProject | undefined;
getProjectIdentifierById: (projectId: string | undefined | null) => string;
// fetch actions
fetchProjects: (workspaceSlug: string) => Promise<IProject[]>;
fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise<IProject>;
fetchProjects: (workspaceSlug: string) => Promise<TProject[]>;
fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise<TProject>;
// favorites actions
addProjectToFavorites: (workspaceSlug: string, projectId: string) => Promise<any>;
removeProjectFromFavorites: (workspaceSlug: string, projectId: string) => Promise<any>;
// project-view action
updateProjectView: (workspaceSlug: string, projectId: string, viewProps: any) => Promise<any>;
// CRUD actions
createProject: (workspaceSlug: string, data: Partial<IProject>) => Promise<IProject>;
updateProject: (workspaceSlug: string, projectId: string, data: Partial<IProject>) => Promise<IProject>;
createProject: (workspaceSlug: string, data: Partial<TProject>) => Promise<TProject>;
updateProject: (workspaceSlug: string, projectId: string, data: Partial<TProject>) => Promise<TProject>;
deleteProject: (workspaceSlug: string, projectId: string) => Promise<void>;
// archive actions
archiveProject: (workspaceSlug: string, projectId: string) => Promise<void>;
@ -50,7 +49,7 @@ export class ProjectStore implements IProjectStore {
// observables
loader: boolean = false;
projectMap: {
[projectId: string]: IProject; // projectId: project Info
[projectId: string]: TProject; // projectId: project Info
} = {};
// root store
rootStore: CoreRootStore;
@ -206,7 +205,7 @@ export class ProjectStore implements IProjectStore {
/**
* get Workspace projects using workspace slug
* @param workspaceSlug
* @returns Promise<IProject[]>
* @returns Promise<TProject[]>
*
*/
fetchProjects = async (workspaceSlug: string) => {
@ -231,7 +230,7 @@ export class ProjectStore implements IProjectStore {
* Fetches project details using workspace slug and project id
* @param workspaceSlug
* @param projectId
* @returns Promise<IProject>
* @returns Promise<TProject>
*/
fetchProjectDetails = async (workspaceSlug: string, projectId: string) => {
try {
@ -249,7 +248,7 @@ export class ProjectStore implements IProjectStore {
/**
* Returns project details using project id
* @param projectId
* @returns IProject | null
* @returns TProject | null
*/
getProjectById = computedFn((projectId: string | undefined | null) => {
const projectInfo = this.projectMap[projectId ?? ""] || undefined;
@ -348,7 +347,7 @@ export class ProjectStore implements IProjectStore {
* Creates a project in the workspace and adds it to the store
* @param workspaceSlug
* @param data
* @returns Promise<IProject>
* @returns Promise<TProject>
*/
createProject = async (workspaceSlug: string, data: any) => {
try {
@ -369,9 +368,9 @@ export class ProjectStore implements IProjectStore {
* @param workspaceSlug
* @param projectId
* @param data
* @returns Promise<IProject>
* @returns Promise<TProject>
*/
updateProject = async (workspaceSlug: string, projectId: string, data: Partial<IProject>) => {
updateProject = async (workspaceSlug: string, projectId: string, data: Partial<TProject>) => {
try {
const projectDetails = this.getProjectById(projectId);
runInAction(() => {

View file

@ -0,0 +1 @@
export * from "ce/components/projects/create/attributes";

View file

@ -0,0 +1 @@
export * from "ce/components/projects/create/root";

View file

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

View file

@ -0,0 +1 @@
export * from "ce/components/projects/mobile-header";

View file

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

View file

@ -0,0 +1 @@
export * from "ce/types/projects/projects";