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 // components
import { AppHeader, ContentWrapper } from "@/components/core"; import { AppHeader, ContentWrapper } from "@/components/core";
// local components // local components
import { ProjectsListHeader } from "./header"; import { ProjectsListHeader } from "@/plane-web/components/projects/header";
import { ProjectsListMobileHeader } from "./mobile-header"; import { ProjectsListMobileHeader } from "@/plane-web/components/projects/mobile-header";
export default function ProjectListLayout({ children }: { children: ReactNode }) { export default function ProjectListLayout({ children }: { children: ReactNode }) {
return ( return (
<> <>

View file

@ -1,84 +1,4 @@
"use client"; import ProjectPageRoot from "@/plane-web/components/projects/page";
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>
</>
);
});
const ProjectsPage = () => <ProjectPageRoot />;
export default ProjectsPage; 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 { useCallback } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
@ -23,7 +24,6 @@ export const ProjectsListMobileHeader = observer(() => {
updateFilters, updateFilters,
} = useProjectFilter(); } = useProjectFilter();
const { const {
workspace: { workspaceMemberIds }, workspace: { workspaceMemberIds },
} = useMember(); } = 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; getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
blockToRender: (data: any) => React.ReactNode; blockToRender: (data: any) => React.ReactNode;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
enableBlockLeftResize: boolean; enableBlockLeftResize: boolean | ((blockId: string) => boolean);
enableBlockRightResize: boolean; enableBlockRightResize: boolean | ((blockId: string) => boolean);
enableBlockMove: boolean; enableBlockMove: boolean | ((blockId: string) => boolean);
enableAddBlock: boolean; enableAddBlock: boolean | ((blockId: string) => boolean);
ganttContainerRef: React.RefObject<HTMLDivElement>; ganttContainerRef: React.RefObject<HTMLDivElement>;
showAllBlocks: boolean; showAllBlocks: boolean;
selectionHelpers: TSelectionHelper; selectionHelpers: TSelectionHelper;
@ -55,10 +55,14 @@ export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => {
showAllBlocks={showAllBlocks} showAllBlocks={showAllBlocks}
blockToRender={blockToRender} blockToRender={blockToRender}
blockUpdateHandler={blockUpdateHandler} blockUpdateHandler={blockUpdateHandler}
enableBlockLeftResize={enableBlockLeftResize} enableBlockLeftResize={
enableBlockRightResize={enableBlockRightResize} typeof enableBlockLeftResize === "function" ? enableBlockLeftResize(blockId) : enableBlockLeftResize
enableBlockMove={enableBlockMove} }
enableAddBlock={enableAddBlock} enableBlockRightResize={
typeof enableBlockRightResize === "function" ? enableBlockRightResize(blockId) : enableBlockRightResize
}
enableBlockMove={typeof enableBlockMove === "function" ? enableBlockMove(blockId) : enableBlockMove}
enableAddBlock={typeof enableAddBlock === "function" ? enableAddBlock(blockId) : enableAddBlock}
ganttContainerRef={ganttContainerRef} ganttContainerRef={ganttContainerRef}
selectionHelpers={selectionHelpers} selectionHelpers={selectionHelpers}
/> />

View file

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

View file

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

View file

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

View file

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

View file

@ -16,8 +16,8 @@ type Props = {
canLoadMoreBlocks?: boolean; canLoadMoreBlocks?: boolean;
loadMoreBlocks?: () => void; loadMoreBlocks?: () => void;
ganttContainerRef: RefObject<HTMLDivElement>; ganttContainerRef: RefObject<HTMLDivElement>;
enableReorder: boolean; enableReorder: boolean | ((blockId: string) => boolean);
enableSelection: boolean; enableSelection: boolean | ((blockId: string) => boolean);
sidebarToRender: (props: any) => React.ReactNode; sidebarToRender: (props: any) => React.ReactNode;
title: string; title: string;
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock; getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
@ -94,7 +94,7 @@ export const GanttChartSidebar: React.FC<Props> = observer((props) => {
canLoadMoreBlocks, canLoadMoreBlocks,
ganttContainerRef, ganttContainerRef,
loadMoreBlocks, loadMoreBlocks,
selectionHelpers selectionHelpers,
})} })}
</div> </div>
{quickAdd ? quickAdd : null} {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 { useEffect, Fragment, FC, useState } from "react";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// components // 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"; import { ProjectFeatureUpdate } from "./project-feature-update";
type Props = { type Props = {
@ -9,6 +10,7 @@ type Props = {
onClose: () => void; onClose: () => void;
setToFavorite?: boolean; setToFavorite?: boolean;
workspaceSlug: string; workspaceSlug: string;
data?: Partial<TProject>;
}; };
enum EProjectCreationSteps { enum EProjectCreationSteps {
@ -17,7 +19,7 @@ enum EProjectCreationSteps {
} }
export const CreateProjectModal: FC<Props> = (props) => { export const CreateProjectModal: FC<Props> = (props) => {
const { isOpen, onClose, setToFavorite = false, workspaceSlug } = props; const { isOpen, onClose, setToFavorite = false, workspaceSlug, data } = props;
// states // states
const [currentStep, setCurrentStep] = useState<EProjectCreationSteps>(EProjectCreationSteps.CREATE_PROJECT); const [currentStep, setCurrentStep] = useState<EProjectCreationSteps>(EProjectCreationSteps.CREATE_PROJECT);
const [createdProjectId, setCreatedProjectId] = useState<string | null>(null); const [createdProjectId, setCreatedProjectId] = useState<string | null>(null);
@ -68,6 +70,7 @@ export const CreateProjectModal: FC<Props> = (props) => {
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
onClose={onClose} onClose={onClose}
handleNextStep={handleNextStep} handleNextStep={handleNextStep}
data={data}
/> />
)} )}
{currentStep === EProjectCreationSteps.FEATURE_SELECTION && ( {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 { useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-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"; import { Search, Briefcase, X, ListFilter } from "lucide-react";
// types // types
import { TProjectFilters } from "@plane/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 { useCommandPalette, useEventTracker, useMember, useProjectFilter, useUser } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
export const ProjectsListHeader = observer(() => { export const ProjectsBaseHeader = observer(() => {
// router // router
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
// states // states
@ -34,6 +34,8 @@ export const ProjectsListHeader = observer(() => {
const { const {
membership: { currentWorkspaceRole }, membership: { currentWorkspaceRole },
} = useUser(); } = useUser();
const pathname = usePathname();
const { const {
currentWorkspaceDisplayFilters: displayFilters, currentWorkspaceDisplayFilters: displayFilters,
currentWorkspaceFilters: filters, currentWorkspaceFilters: filters,
@ -51,6 +53,7 @@ export const ProjectsListHeader = observer(() => {
}); });
// auth // auth
const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
const isArchived = pathname.includes("/archives");
const handleFilters = useCallback( const handleFilters = useCallback(
(key: keyof TProjectFilters, value: string | string[]) => { (key: keyof TProjectFilters, value: string | string[]) => {
@ -97,6 +100,7 @@ export const ProjectsListHeader = observer(() => {
type="text" type="text"
link={<BreadcrumbLink label="Projects" icon={<Briefcase className="h-4 w-4 text-custom-text-300" />} />} 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> </Breadcrumbs>
</div> </div>
</div> </div>

View file

@ -5,7 +5,6 @@ export * from "./settings";
export * from "./card-list"; export * from "./card-list";
export * from "./card"; export * from "./card";
export * from "./create-project-modal"; export * from "./create-project-modal";
export * from "./create-project-form";
export * from "./project-feature-update"; export * from "./project-feature-update";
export * from "./delete-project-modal"; export * from "./delete-project-modal";
export * from "./form-loader"; export * from "./form-loader";
@ -19,3 +18,4 @@ export * from "./member-list-item";
export * from "./project-settings-member-defaults"; export * from "./project-settings-member-defaults";
export * from "./send-project-invitation-modal"; export * from "./send-project-invitation-modal";
export * from "./confirm-project-member-remove"; 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"; "use client";
import React, { useEffect } from "react"; import React, { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useParams, usePathname } from "next/navigation"; 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"; import { Disclosure, Transition } from "@headlessui/react";
// ui // ui
import { Tooltip } from "@plane/ui"; import { CustomMenu, Tooltip } from "@plane/ui";
// components // components
import { SidebarNavItem } from "@/components/sidebar"; import { SidebarNavItem } from "@/components/sidebar";
// constants // constants
@ -19,11 +19,16 @@ import { cn } from "@/helpers/common.helper";
// hooks // hooks
import { useAppTheme, useEventTracker, useUser } from "@/hooks/store"; import { useAppTheme, useEventTracker, useUser } from "@/hooks/store";
import useLocalStorage from "@/hooks/use-local-storage"; import useLocalStorage from "@/hooks/use-local-storage";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components // plane web components
import { UpgradeBadge } from "@/plane-web/components/workspace"; import { UpgradeBadge } from "@/plane-web/components/workspace";
export const SidebarWorkspaceMenu = observer(() => { export const SidebarWorkspaceMenu = observer(() => {
// state
const [isMenuActive, setIsMenuActive] = useState(false);
// refs
const actionSectionRef = useRef<HTMLDivElement | null>(null);
// store hooks // store hooks
const { toggleSidebar, sidebarCollapsed } = useAppTheme(); const { toggleSidebar, sidebarCollapsed } = useAppTheme();
const { captureEvent } = useEventTracker(); const { captureEvent } = useEventTracker();
@ -54,6 +59,7 @@ export const SidebarWorkspaceMenu = observer(() => {
useEffect(() => { useEffect(() => {
if (sidebarCollapsed) toggleWorkspaceMenu(true); if (sidebarCollapsed) toggleWorkspaceMenu(true);
}, [sidebarCollapsed, toggleWorkspaceMenu]); }, [sidebarCollapsed, toggleWorkspaceMenu]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
const indicatorElement = ( const indicatorElement = (
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@ -64,20 +70,70 @@ export const SidebarWorkspaceMenu = observer(() => {
return ( return (
<Disclosure as="div" defaultOpen> <Disclosure as="div" defaultOpen>
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<Disclosure.Button <div className="flex px-2 bg-custom-sidebar-background-100 group/workspace-button hover:bg-custom-sidebar-background-90 rounded">
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" <Disclosure.Button
onClick={() => toggleWorkspaceMenu(!isWorkspaceMenuOpen)} 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"
<span>WORKSPACE</span> 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 p-0.5 hover:bg-custom-sidebar-background-80"> >
<ChevronRight <span>WORKSPACE</span>
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", { </Disclosure.Button>
"rotate-90": isWorkspaceMenuOpen, <CustomMenu
})} customButton={
/> <span
</span> ref={actionSectionRef}
</Disclosure.Button> 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 <Transition
show={isWorkspaceMenuOpen} show={isWorkspaceMenuOpen}

View file

@ -3,7 +3,13 @@ import sortBy from "lodash/sortBy";
import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils"; import { computedFn } from "mobx-utils";
// types // types
import { IProjectBulkAddFormData, IProjectMember, IProjectMembership, IUserLite } from "@plane/types"; import {
IProjectBulkAddFormData,
IProjectMember,
IProjectMemberLite,
IProjectMembership,
IUserLite,
} from "@plane/types";
// constants // constants
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
// services // services
@ -12,6 +18,7 @@ import { ProjectMemberService } from "@/services/project";
import { IRouterStore } from "@/store/router.store"; import { IRouterStore } from "@/store/router.store";
import { IUserStore } from "@/store/user"; import { IUserStore } from "@/store/user";
// store // store
import { IProjectStore } from "../project/project.store";
import { CoreRootStore } from "../root.store"; import { CoreRootStore } from "../root.store";
import { IMemberRootStore } from "."; import { IMemberRootStore } from ".";
@ -58,6 +65,7 @@ export class ProjectMemberStore implements IProjectMemberStore {
routerStore: IRouterStore; routerStore: IRouterStore;
userStore: IUserStore; userStore: IUserStore;
memberRoot: IMemberRootStore; memberRoot: IMemberRootStore;
projectRoot: IProjectStore;
// services // services
projectMemberService; projectMemberService;
@ -78,6 +86,7 @@ export class ProjectMemberStore implements IProjectMemberStore {
this.routerStore = _rootStore.router; this.routerStore = _rootStore.router;
this.userStore = _rootStore.user; this.userStore = _rootStore.user;
this.memberRoot = _memberRoot; this.memberRoot = _memberRoot;
this.projectRoot = _rootStore.projectRoot.project;
// services // services
this.projectMemberService = new ProjectMemberService(); this.projectMemberService = new ProjectMemberService();
} }
@ -159,6 +168,10 @@ export class ProjectMemberStore implements IProjectMemberStore {
set(this.projectMemberMap, [projectId, member.member], member); 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; return response;
}); });
@ -212,6 +225,9 @@ export class ProjectMemberStore implements IProjectMemberStore {
runInAction(() => { runInAction(() => {
delete this.projectMemberMap?.[projectId]?.[userId]; 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 sortBy from "lodash/sortBy";
import { observable, action, computed, makeObservable, runInAction } from "mobx"; import { observable, action, computed, makeObservable, runInAction } from "mobx";
import { computedFn } from "mobx-utils"; import { computedFn } from "mobx-utils";
// types
import { IProject } from "@plane/types";
// helpers // helpers
import { orderProjects, shouldFilterProject } from "@/helpers/project.helper"; import { orderProjects, shouldFilterProject } from "@/helpers/project.helper";
// services // services
import { TProject } from "@/plane-web/types/projects/projects";
import { IssueLabelService, IssueService } from "@/services/issue"; import { IssueLabelService, IssueService } from "@/services/issue";
import { ProjectService, ProjectStateService, ProjectArchiveService } from "@/services/project"; import { ProjectService, ProjectStateService, ProjectArchiveService } from "@/services/project";
// store // store
@ -16,7 +15,7 @@ export interface IProjectStore {
// observables // observables
loader: boolean; loader: boolean;
projectMap: { projectMap: {
[projectId: string]: IProject; // projectId: project Info [projectId: string]: TProject; // projectId: project Info
}; };
// computed // computed
filteredProjectIds: string[] | undefined; filteredProjectIds: string[] | undefined;
@ -25,21 +24,21 @@ export interface IProjectStore {
totalProjectIds: string[] | undefined; totalProjectIds: string[] | undefined;
joinedProjectIds: string[]; joinedProjectIds: string[];
favoriteProjectIds: string[]; favoriteProjectIds: string[];
currentProjectDetails: IProject | undefined; currentProjectDetails: TProject | undefined;
// actions // actions
getProjectById: (projectId: string | undefined | null) => IProject | undefined; getProjectById: (projectId: string | undefined | null) => TProject | undefined;
getProjectIdentifierById: (projectId: string | undefined | null) => string; getProjectIdentifierById: (projectId: string | undefined | null) => string;
// fetch actions // fetch actions
fetchProjects: (workspaceSlug: string) => Promise<IProject[]>; fetchProjects: (workspaceSlug: string) => Promise<TProject[]>;
fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise<IProject>; fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise<TProject>;
// favorites actions // favorites actions
addProjectToFavorites: (workspaceSlug: string, projectId: string) => Promise<any>; addProjectToFavorites: (workspaceSlug: string, projectId: string) => Promise<any>;
removeProjectFromFavorites: (workspaceSlug: string, projectId: string) => Promise<any>; removeProjectFromFavorites: (workspaceSlug: string, projectId: string) => Promise<any>;
// project-view action // project-view action
updateProjectView: (workspaceSlug: string, projectId: string, viewProps: any) => Promise<any>; updateProjectView: (workspaceSlug: string, projectId: string, viewProps: any) => Promise<any>;
// CRUD actions // CRUD actions
createProject: (workspaceSlug: string, data: Partial<IProject>) => Promise<IProject>; createProject: (workspaceSlug: string, data: Partial<TProject>) => Promise<TProject>;
updateProject: (workspaceSlug: string, projectId: string, data: Partial<IProject>) => Promise<IProject>; updateProject: (workspaceSlug: string, projectId: string, data: Partial<TProject>) => Promise<TProject>;
deleteProject: (workspaceSlug: string, projectId: string) => Promise<void>; deleteProject: (workspaceSlug: string, projectId: string) => Promise<void>;
// archive actions // archive actions
archiveProject: (workspaceSlug: string, projectId: string) => Promise<void>; archiveProject: (workspaceSlug: string, projectId: string) => Promise<void>;
@ -50,7 +49,7 @@ export class ProjectStore implements IProjectStore {
// observables // observables
loader: boolean = false; loader: boolean = false;
projectMap: { projectMap: {
[projectId: string]: IProject; // projectId: project Info [projectId: string]: TProject; // projectId: project Info
} = {}; } = {};
// root store // root store
rootStore: CoreRootStore; rootStore: CoreRootStore;
@ -206,7 +205,7 @@ export class ProjectStore implements IProjectStore {
/** /**
* get Workspace projects using workspace slug * get Workspace projects using workspace slug
* @param workspaceSlug * @param workspaceSlug
* @returns Promise<IProject[]> * @returns Promise<TProject[]>
* *
*/ */
fetchProjects = async (workspaceSlug: string) => { fetchProjects = async (workspaceSlug: string) => {
@ -231,7 +230,7 @@ export class ProjectStore implements IProjectStore {
* Fetches project details using workspace slug and project id * Fetches project details using workspace slug and project id
* @param workspaceSlug * @param workspaceSlug
* @param projectId * @param projectId
* @returns Promise<IProject> * @returns Promise<TProject>
*/ */
fetchProjectDetails = async (workspaceSlug: string, projectId: string) => { fetchProjectDetails = async (workspaceSlug: string, projectId: string) => {
try { try {
@ -249,7 +248,7 @@ export class ProjectStore implements IProjectStore {
/** /**
* Returns project details using project id * Returns project details using project id
* @param projectId * @param projectId
* @returns IProject | null * @returns TProject | null
*/ */
getProjectById = computedFn((projectId: string | undefined | null) => { getProjectById = computedFn((projectId: string | undefined | null) => {
const projectInfo = this.projectMap[projectId ?? ""] || undefined; 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 * Creates a project in the workspace and adds it to the store
* @param workspaceSlug * @param workspaceSlug
* @param data * @param data
* @returns Promise<IProject> * @returns Promise<TProject>
*/ */
createProject = async (workspaceSlug: string, data: any) => { createProject = async (workspaceSlug: string, data: any) => {
try { try {
@ -369,9 +368,9 @@ export class ProjectStore implements IProjectStore {
* @param workspaceSlug * @param workspaceSlug
* @param projectId * @param projectId
* @param data * @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 { try {
const projectDetails = this.getProjectById(projectId); const projectDetails = this.getProjectById(projectId);
runInAction(() => { 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";