fix: ui package setup and project update form refactor

This commit is contained in:
sriramveeraghanta 2023-09-27 15:59:37 +05:30
parent 310a2ca904
commit c342ab302e
9 changed files with 529 additions and 481 deletions

View File

@ -1,13 +1,10 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
// react-hook-form
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// icons
import { XMarkIcon } from "@heroicons/react/24/outline";
// services // services
import projectServices from "services/project.service"; import projectServices from "services/project.service";
// hooks // hooks
@ -25,8 +22,6 @@ import {
Avatar, Avatar,
CustomSearchSelect, CustomSearchSelect,
} from "components/ui"; } from "components/ui";
// icons
import { XMarkIcon } from "@heroicons/react/24/outline";
// components // components
import { ImagePickerPopover } from "components/core"; import { ImagePickerPopover } from "components/core";
import EmojiIconPicker from "components/emoji-icon-picker"; import EmojiIconPicker from "components/emoji-icon-picker";
@ -38,6 +33,7 @@ import { ICurrentUserResponse, IProject } from "types";
import { PROJECTS_LIST } from "constants/fetch-keys"; import { PROJECTS_LIST } from "constants/fetch-keys";
// constants // constants
import { NETWORK_CHOICES } from "constants/project"; import { NETWORK_CHOICES } from "constants/project";
import { useMobxStore } from "lib/mobx/store-provider";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
@ -75,12 +71,11 @@ const IsGuestCondition: React.FC<{
return null; return null;
}; };
export const CreateProjectModal: React.FC<Props> = ({ export const CreateProjectModal: React.FC<Props> = (props) => {
isOpen, const { isOpen, setIsOpen, setToFavorite = false, user } = props;
setIsOpen, // store
setToFavorite = false, const { project: projectStore } = useMobxStore();
user, // states
}) => {
const [isChangeInIdentifierRequired, setIsChangeInIdentifierRequired] = useState(true); const [isChangeInIdentifierRequired, setIsChangeInIdentifierRequired] = useState(true);
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -113,24 +108,13 @@ export const CreateProjectModal: React.FC<Props> = ({
const handleAddToFavorites = (projectId: string) => { const handleAddToFavorites = (projectId: string) => {
if (!workspaceSlug) return; if (!workspaceSlug) return;
mutate<IProject[]>( projectStore.addProjectToFavorites(workspaceSlug.toString(), projectId).catch(() => {
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
(prevData) =>
(prevData ?? []).map((p) => (p.id === projectId ? { ...p, is_favorite: true } : p)),
false
);
projectServices
.addProjectToFavorites(workspaceSlug as string, {
project: projectId,
})
.catch(() =>
setToastAlert({ setToastAlert({
type: "error", type: "error",
title: "Error!", title: "Error!",
message: "Couldn't remove the project from favorites. Please try again.", message: "Couldn't remove the project from favorites. Please try again.",
}) });
); });
}; };
const onSubmit = async (formData: IProject) => { const onSubmit = async (formData: IProject) => {
@ -141,14 +125,9 @@ export const CreateProjectModal: React.FC<Props> = ({
if (typeof formData.emoji_and_icon === "object") payload.icon_prop = formData.emoji_and_icon; if (typeof formData.emoji_and_icon === "object") payload.icon_prop = formData.emoji_and_icon;
else payload.emoji = formData.emoji_and_icon; else payload.emoji = formData.emoji_and_icon;
await projectServices await projectStore
.createProject(workspaceSlug.toString(), payload, user) .createProject(workspaceSlug.toString(), payload)
.then((res) => { .then((res) => {
mutate<IProject[]>(
PROJECTS_LIST(workspaceSlug.toString(), { is_favorite: "all" }),
(prevData) => [res, ...(prevData ?? [])],
false
);
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Success!", title: "Success!",
@ -206,8 +185,7 @@ export const CreateProjectModal: React.FC<Props> = ({
const currentNetwork = NETWORK_CHOICES.find((n) => n.key === watch("network")); const currentNetwork = NETWORK_CHOICES.find((n) => n.key === watch("network"));
if (memberDetails && isOpen) if (memberDetails && isOpen) if (memberDetails.role <= 10) return <IsGuestCondition setIsOpen={setIsOpen} />;
if (memberDetails.role <= 10) return <IsGuestCondition setIsOpen={setIsOpen} />;
return ( return (
<Transition.Root show={isOpen} as={React.Fragment}> <Transition.Root show={isOpen} as={React.Fragment}>
@ -277,10 +255,7 @@ export const CreateProjectModal: React.FC<Props> = ({
/> />
</div> </div>
</div> </div>
<form <form onSubmit={handleSubmit(onSubmit)} className="divide-y-[0.5px] divide-custom-border-100 px-3">
onSubmit={handleSubmit(onSubmit)}
className="divide-y-[0.5px] divide-custom-border-100 px-3"
>
<div className="mt-9 space-y-6 pb-5"> <div className="mt-9 space-y-6 pb-5">
<div className="grid grid-cols-1 md:grid-cols-4 gap-y-3 gap-x-2"> <div className="grid grid-cols-1 md:grid-cols-4 gap-y-3 gap-x-2">
<div className="md:col-span-3"> <div className="md:col-span-3">
@ -316,8 +291,7 @@ export const CreateProjectModal: React.FC<Props> = ({
validations={{ validations={{
required: "Identifier is required", required: "Identifier is required",
validate: (value) => validate: (value) =>
/^[A-Z0-9]+$/.test(value.toUpperCase()) || /^[A-Z0-9]+$/.test(value.toUpperCase()) || "Identifier must be in uppercase.",
"Identifier must be in uppercase.",
minLength: { minLength: {
value: 1, value: 1,
message: "Identifier must at least be of 1 character", message: "Identifier must at least be of 1 character",
@ -384,9 +358,7 @@ export const CreateProjectModal: React.FC<Props> = ({
name="project_lead" name="project_lead"
control={control} control={control}
render={({ field: { value, onChange } }) => { render={({ field: { value, onChange } }) => {
const selectedMember = workspaceMembers?.find( const selectedMember = workspaceMembers?.find((m) => m.member.id === value);
(m) => m.member.id === value
);
return ( return (
<CustomSearchSelect <CustomSearchSelect
@ -409,10 +381,7 @@ export const CreateProjectModal: React.FC<Props> = ({
</> </>
) : ( ) : (
<> <>
<Icon <Icon iconName="group" className="!text-sm text-custom-text-400" />
iconName="group"
className="!text-sm text-custom-text-400"
/>
<span className="text-custom-text-400">Lead</span> <span className="text-custom-text-400">Lead</span>
</> </>
)} )}

View File

@ -0,0 +1,62 @@
import { FC } from "react";
// components
import { Loader } from "components/ui";
export interface IProjectDetailsFormLoader {}
export const ProjectDetailsFormLoader: FC<IProjectDetailsFormLoader> = () => (
<>
<div className="relative h-44 w-full mt-6">
<Loader>
<Loader.Item height="auto" width="46px" />
</Loader>
<div className="flex items-end justify-between gap-3 absolute bottom-4 w-full px-4">
<div className="flex gap-3 flex-grow truncate">
<div className="flex items-center justify-center flex-shrink-0 bg-custom-background-90 h-[52px] w-[52px] rounded-lg">
<Loader>
<Loader.Item height="46px" width="46px" />
</Loader>
</div>
</div>
<div className="flex justify-center flex-shrink-0">
<Loader>
<Loader.Item height="32px" width="108px" />
</Loader>
</div>
</div>
</div>
<div className="flex flex-col gap-8 my-8">
<div className="flex flex-col gap-1">
<h4 className="text-sm">Project Name</h4>
<Loader>
<Loader.Item height="46px" width="100%" />
</Loader>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">Description</h4>
<Loader className="w-full">
<Loader.Item height="102px" width="full" />
</Loader>
</div>
<div className="flex items-center justify-between gap-10 w-full">
<div className="flex flex-col gap-1 w-1/2">
<h4 className="text-sm">Identifier</h4>
<Loader>
<Loader.Item height="36px" width="100%" />
</Loader>
</div>
<div className="flex flex-col gap-1 w-1/2">
<h4 className="text-sm">Network</h4>
<Loader className="w-full">
<Loader.Item height="46px" width="100%" />
</Loader>
</div>
</div>
<div className="flex items-center justify-between py-2">
<Loader className="mt-2 w-full">
<Loader.Item height="34px" width="100px" />
</Loader>
</div>
</div>
</>
);

View File

@ -0,0 +1,262 @@
import { FC } from "react";
import { Controller, useForm } from "react-hook-form";
// components
import EmojiIconPicker from "components/emoji-icon-picker";
import { ImagePickerPopover } from "components/core";
import { Input, TextArea, CustomSelect, PrimaryButton } from "components/ui";
import { Input as InputElement } from "@plane/ui";
// types
import { IProject, IWorkspace } from "types";
// helpers
import { renderEmoji } from "helpers/emoji.helper";
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// constants
import { NETWORK_CHOICES } from "constants/project";
// services
import projectService from "services/project.service";
// hooks
import useToast from "hooks/use-toast";
import { useMobxStore } from "lib/mobx/store-provider";
export interface IProjectDetailsForm {
project: IProject;
workspaceSlug: string;
isAdmin: boolean;
}
export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
const { project, workspaceSlug, isAdmin } = props;
// store
const { project: projectStore } = useMobxStore();
// toast
const { setToastAlert } = useToast();
// form data
const {
register,
handleSubmit,
watch,
control,
setValue,
setError,
formState: { errors, isSubmitting },
} = useForm<IProject>({
defaultValues: {
...project,
emoji_and_icon: project.emoji ?? project.icon_prop,
workspace: (project.workspace as IWorkspace).id,
},
});
const handleIdentifierChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
const alphanumericValue = value.replace(/[^a-zA-Z0-9]/g, "");
const formattedValue = alphanumericValue.toUpperCase();
setValue("identifier", formattedValue);
};
const updateProject = async (payload: Partial<IProject>) => {
if (!workspaceSlug || !project) return;
return projectStore
.updateProject(workspaceSlug.toString(), project.id, payload)
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Project updated successfully",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Project could not be updated. Please try again.",
});
});
};
const onSubmit = async (formData: IProject) => {
if (!workspaceSlug) return;
const payload: Partial<IProject> = {
name: formData.name,
network: formData.network,
identifier: formData.identifier,
description: formData.description,
cover_image: formData.cover_image,
};
if (typeof formData.emoji_and_icon === "object") {
payload.emoji = null;
payload.icon_prop = formData.emoji_and_icon;
} else {
payload.emoji = formData.emoji_and_icon;
payload.icon_prop = null;
}
if (project.identifier !== formData.identifier)
await projectService
.checkProjectIdentifierAvailability(workspaceSlug as string, payload.identifier ?? "")
.then(async (res) => {
if (res.exists) setError("identifier", { message: "Identifier already exists" });
else await updateProject(payload);
});
else await updateProject(payload);
};
const currentNetwork = NETWORK_CHOICES.find((n) => n.key === project?.network);
const selectedNetwork = NETWORK_CHOICES.find((n) => n.key === watch("network"));
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="relative h-44 w-full mt-6">
<img src={watch("cover_image")!} alt={watch("cover_image")!} className="h-44 w-full rounded-md object-cover" />
<div className="flex items-end justify-between gap-3 absolute bottom-4 w-full px-4">
<div className="flex gap-3 flex-grow truncate">
<div className="flex items-center justify-center flex-shrink-0 bg-custom-background-90 h-[52px] w-[52px] rounded-lg">
<div className="h-7 w-7 grid place-items-center">
<Controller
control={control}
name="emoji_and_icon"
render={({ field: { value, onChange } }) => (
<EmojiIconPicker
label={value ? renderEmoji(value) : "Icon"}
value={value}
onChange={onChange}
disabled={!isAdmin}
/>
)}
/>
</div>
</div>
<div className="flex flex-col gap-1 text-white truncate">
<span className="text-lg font-semibold truncate">{watch("name")}</span>
<span className="flex items-center gap-2 text-sm">
<span>
{watch("identifier")} . {currentNetwork?.label}
</span>
</span>
</div>
</div>
<div className="flex justify-center flex-shrink-0">
<div>
<Controller
control={control}
name="cover_image"
render={({ field: { value, onChange } }) => (
<ImagePickerPopover label={"Change cover"} onChange={onChange} value={value} disabled={!isAdmin} />
)}
/>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-8 my-8">
<div className="flex flex-col gap-1">
<h4 className="text-sm">Project Name</h4>
<Controller
control={control}
name="name"
rules={{
required: "Name is required",
}}
render={({ field: { value, onChange, ref } }) => (
<InputElement
id="name"
name="name"
type="text"
ref={ref}
value={value}
onChange={onChange}
hasError={Boolean(errors.name)}
className="!p-3 rounded-md font-medium"
placeholder="Project Name"
disabled={!isAdmin}
/>
)}
/>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">Description</h4>
<TextArea
id="description"
name="description"
error={errors.description}
register={register}
placeholder="Enter project description"
validations={{}}
className="min-h-[102px] text-sm"
disabled={!isAdmin}
/>
</div>
<div className="flex items-center justify-between gap-10 w-full">
<div className="flex flex-col gap-1 w-1/2">
<h4 className="text-sm">Identifier</h4>
<Input
id="identifier"
name="identifier"
error={errors.identifier}
register={register}
placeholder="Enter identifier"
onChange={handleIdentifierChange}
validations={{
required: "Identifier is required",
validate: (value) => /^[A-Z0-9]+$/.test(value.toUpperCase()) || "Identifier must be in uppercase.",
minLength: {
value: 1,
message: "Identifier must at least be of 1 character",
},
maxLength: {
value: 12,
message: "Identifier must at most be of 5 characters",
},
}}
disabled={!isAdmin}
/>
</div>
<div className="flex flex-col gap-1 w-1/2">
<h4 className="text-sm">Network</h4>
<Controller
name="network"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={selectedNetwork?.label ?? "Select network"}
className="!border-custom-border-200 !shadow-none"
input
disabled={!isAdmin}
optionsClassName="w-full"
>
{NETWORK_CHOICES.map((network) => (
<CustomSelect.Option key={network.key} value={network.key}>
{network.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
</div>
<div className="flex items-center justify-between py-2">
<>
<PrimaryButton type="submit" loading={isSubmitting} disabled={!isAdmin}>
{isSubmitting ? "Updating Project..." : "Update Project"}
</PrimaryButton>
<span className="text-sm text-custom-sidebar-text-400 italic">
Created on {renderShortDateWithYearFormat(project?.created_at)}
</span>
</>
</div>
</div>
</form>
);
};

View File

@ -12,3 +12,5 @@ export * from "./priority-select";
export * from "./card-list"; export * from "./card-list";
export * from "./card"; export * from "./card";
export * from "./join-project-modal"; export * from "./join-project-modal";
export * from "./form";
export * from "./form-loader";

View File

@ -25,6 +25,7 @@
"@nivo/line": "0.80.0", "@nivo/line": "0.80.0",
"@nivo/pie": "0.80.0", "@nivo/pie": "0.80.0",
"@nivo/scatterplot": "0.80.0", "@nivo/scatterplot": "0.80.0",
"@plane/ui": "*",
"@sentry/nextjs": "^7.36.0", "@sentry/nextjs": "^7.36.0",
"@tiptap/extension-code-block-lowlight": "^2.0.4", "@tiptap/extension-code-block-lowlight": "^2.0.4",
"@tiptap/extension-color": "^2.0.4", "@tiptap/extension-color": "^2.0.4",

View File

@ -1,46 +1,27 @@
import { useEffect, useState } from "react"; import { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr";
import useSWR, { mutate } from "swr";
// headless ui
import { Disclosure, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// layouts // layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout"; import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// services // services
import projectService from "services/project.service"; import projectService from "services/project.service";
// components // components
import { DeleteProjectModal, SettingsSidebar } from "components/project"; import { DeleteProjectModal, ProjectDetailsForm, ProjectDetailsFormLoader, SettingsSidebar } from "components/project";
import { ImagePickerPopover } from "components/core";
import EmojiIconPicker from "components/emoji-icon-picker";
// hooks // hooks
import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth"; import useUserAuth from "hooks/use-user-auth";
// ui // components
import { import { Loader, DangerButton, Icon } from "components/ui";
Input,
TextArea,
Loader,
CustomSelect,
DangerButton,
Icon,
PrimaryButton,
} from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// helpers // helpers
import { renderEmoji } from "helpers/emoji.helper";
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// types // types
import { IProject, IWorkspace } from "types"; import { IProject } from "types";
import type { NextPage } from "next"; import type { NextPage } from "next";
// fetch-keys // fetch-keys
import { PROJECTS_LIST, PROJECT_DETAILS, USER_PROJECT_VIEW } from "constants/fetch-keys"; import { PROJECT_DETAILS, USER_PROJECT_VIEW } from "constants/fetch-keys";
// constants import { useMobxStore } from "lib/mobx/store-provider";
import { NETWORK_CHOICES } from "constants/project"; import { observer } from "mobx-react-lite";
const defaultValues: Partial<IProject> = { const defaultValues: Partial<IProject> = {
name: "", name: "",
@ -49,23 +30,28 @@ const defaultValues: Partial<IProject> = {
network: 0, network: 0,
}; };
const GeneralSettings: NextPage = () => { const GeneralSettings: NextPage = observer(() => {
const { project: projectStore } = useMobxStore();
// states
const [selectProject, setSelectedProject] = useState<string | null>(null); const [selectProject, setSelectedProject] = useState<string | null>(null);
// user info
const { user } = useUserAuth(); const { user } = useUserAuth();
// router
const { setToastAlert } = useToast();
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
// derived values
const { data: projectDetails } = useSWR<IProject>( const projectDetails = projectId ? projectStore.project_details[projectId.toString()] : null;
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null, console.log("projectDetails", projectDetails);
console.log("condition", workspaceSlug && projectId && !projectDetails);
console.log("wow", projectId);
// api call to fetch project details
useSWR(
workspaceSlug && projectId ? "PROJECT_DETAILS" : null,
workspaceSlug && projectId workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string) ? () => projectStore.fetchProjectDetails(workspaceSlug.toString(), projectId.toString())
: null : null
); );
// API call to fetch user privileges
const { data: memberDetails } = useSWR( const { data: memberDetails } = useSWR(
workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId.toString()) : null, workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId.toString()) : null,
workspaceSlug && projectId workspaceSlug && projectId
@ -73,101 +59,8 @@ const GeneralSettings: NextPage = () => {
: null : null
); );
const { // const currentNetwork = NETWORK_CHOICES.find((n) => n.key === projectDetails?.network);
register, // const selectedNetwork = NETWORK_CHOICES.find((n) => n.key === watch("network"));
handleSubmit,
reset,
watch,
control,
setValue,
setError,
formState: { errors, isSubmitting },
} = useForm<IProject>({
defaultValues,
});
useEffect(() => {
if (projectDetails)
reset({
...projectDetails,
emoji_and_icon: projectDetails.emoji ?? projectDetails.icon_prop,
workspace: (projectDetails.workspace as IWorkspace).id,
});
}, [projectDetails, reset]);
const updateProject = async (payload: Partial<IProject>) => {
if (!workspaceSlug || !projectDetails) return;
await projectService
.updateProject(workspaceSlug as string, projectDetails.id, payload, user)
.then((res) => {
mutate<IProject>(
PROJECT_DETAILS(projectDetails.id),
(prevData) => ({ ...prevData, ...res }),
false
);
mutate(
PROJECTS_LIST(workspaceSlug as string, {
is_favorite: "all",
})
);
setToastAlert({
type: "success",
title: "Success!",
message: "Project updated successfully",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Project could not be updated. Please try again.",
});
});
};
const onSubmit = async (formData: IProject) => {
if (!workspaceSlug || !projectDetails) return;
const payload: Partial<IProject> = {
name: formData.name,
network: formData.network,
identifier: formData.identifier,
description: formData.description,
cover_image: formData.cover_image,
};
if (typeof formData.emoji_and_icon === "object") {
payload.emoji = null;
payload.icon_prop = formData.emoji_and_icon;
} else {
payload.emoji = formData.emoji_and_icon;
payload.icon_prop = null;
}
if (projectDetails.identifier !== formData.identifier)
await projectService
.checkProjectIdentifierAvailability(workspaceSlug as string, payload.identifier ?? "")
.then(async (res) => {
if (res.exists) setError("identifier", { message: "Identifier already exists" });
else await updateProject(payload);
});
else await updateProject(payload);
};
const handleIdentifierChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
const alphanumericValue = value.replace(/[^a-zA-Z0-9]/g, "");
const formattedValue = alphanumericValue.toUpperCase();
setValue("identifier", formattedValue);
};
const currentNetwork = NETWORK_CHOICES.find((n) => n.key === projectDetails?.network);
const selectedNetwork = NETWORK_CHOICES.find((n) => n.key === watch("network"));
const isAdmin = memberDetails?.role === 20; const isAdmin = memberDetails?.role === 20;
@ -184,210 +77,25 @@ const GeneralSettings: NextPage = () => {
</Breadcrumbs> </Breadcrumbs>
} }
> >
{projectDetails && (
<DeleteProjectModal <DeleteProjectModal
data={projectDetails ?? null} project={projectDetails}
isOpen={Boolean(selectProject)} isOpen={Boolean(selectProject)}
onClose={() => setSelectedProject(null)} onClose={() => setSelectedProject(null)}
user={user}
/> />
)}
<div className="flex flex-row gap-2 h-full"> <div className="flex flex-row gap-2 h-full">
<div className="w-80 pt-8 overflow-y-hidden flex-shrink-0"> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<SettingsSidebar /> <SettingsSidebar />
</div> </div>
<div className={`pr-9 py-8 w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}> <div className={`pr-9 py-8 w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>
<form onSubmit={handleSubmit(onSubmit)}> {projectDetails && workspaceSlug ? (
<div className="relative h-44 w-full mt-6"> <ProjectDetailsForm project={projectDetails} workspaceSlug={workspaceSlug.toString()} isAdmin={isAdmin} />
<img
src={watch("cover_image")!}
alt={watch("cover_image")!}
className="h-44 w-full rounded-md object-cover"
/>
<div className="flex items-end justify-between gap-3 absolute bottom-4 w-full px-4">
<div className="flex gap-3 flex-grow truncate">
<div className="flex items-center justify-center flex-shrink-0 bg-custom-background-90 h-[52px] w-[52px] rounded-lg">
{projectDetails ? (
<div className="h-7 w-7 grid place-items-center">
<Controller
control={control}
name="emoji_and_icon"
render={({ field: { value, onChange } }) => (
<EmojiIconPicker
label={value ? renderEmoji(value) : "Icon"}
value={value}
onChange={onChange}
disabled={!isAdmin}
/>
)}
/>
</div>
) : ( ) : (
<Loader> <ProjectDetailsFormLoader />
<Loader.Item height="46px" width="46px" />
</Loader>
)} )}
</div>
<div className="flex flex-col gap-1 text-white truncate">
<span className="text-lg font-semibold truncate">{watch("name")}</span>
<span className="flex items-center gap-2 text-sm">
<span>
{watch("identifier")} . {currentNetwork?.label}
</span>
</span>
</div>
</div>
<div className="flex justify-center flex-shrink-0">
{projectDetails ? (
<div>
<Controller
control={control}
name="cover_image"
render={({ field: { value, onChange } }) => (
<ImagePickerPopover
label={"Change cover"}
onChange={(imageUrl) => {
setValue("cover_image", imageUrl);
}}
value={watch("cover_image")}
disabled={!isAdmin}
/>
)}
/>
</div>
) : (
<Loader>
<Loader.Item height="32px" width="108px" />
</Loader>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-8 my-8">
<div className="flex flex-col gap-1">
<h4 className="text-sm">Project Name</h4>
{projectDetails ? (
<Input
id="name"
name="name"
error={errors.name}
register={register}
className="!p-3 rounded-md font-medium"
placeholder="Project Name"
validations={{
required: "Name is required",
}}
disabled={!isAdmin}
/>
) : (
<Loader>
<Loader.Item height="46px" width="100%" />
</Loader>
)}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">Description</h4>
{projectDetails ? (
<TextArea
id="description"
name="description"
error={errors.description}
register={register}
placeholder="Enter project description"
validations={{}}
className="min-h-[102px] text-sm"
disabled={!isAdmin}
/>
) : (
<Loader className="w-full">
<Loader.Item height="102px" width="full" />
</Loader>
)}
</div>
<div className="flex items-center justify-between gap-10 w-full">
<div className="flex flex-col gap-1 w-1/2">
<h4 className="text-sm">Identifier</h4>
{projectDetails ? (
<Input
id="identifier"
name="identifier"
error={errors.identifier}
register={register}
placeholder="Enter identifier"
onChange={handleIdentifierChange}
validations={{
required: "Identifier is required",
validate: (value) =>
/^[A-Z0-9]+$/.test(value.toUpperCase()) ||
"Identifier must be in uppercase.",
minLength: {
value: 1,
message: "Identifier must at least be of 1 character",
},
maxLength: {
value: 5,
message: "Identifier must at most be of 5 characters",
},
}}
disabled={!isAdmin}
/>
) : (
<Loader>
<Loader.Item height="36px" width="100%" />
</Loader>
)}
</div>
<div className="flex flex-col gap-1 w-1/2">
<h4 className="text-sm">Network</h4>
{projectDetails ? (
<Controller
name="network"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={selectedNetwork?.label ?? "Select network"}
className="!border-custom-border-200 !shadow-none"
input
disabled={!isAdmin}
>
{NETWORK_CHOICES.map((network) => (
<CustomSelect.Option key={network.key} value={network.key}>
{network.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
) : (
<Loader className="w-full">
<Loader.Item height="46px" width="100%" />
</Loader>
)}
</div>
</div>
<div className="flex items-center justify-between py-2">
{projectDetails ? (
<>
<PrimaryButton type="submit" loading={isSubmitting} disabled={!isAdmin}>
{isSubmitting ? "Updating Project..." : "Update Project"}
</PrimaryButton>
<span className="text-sm text-custom-sidebar-text-400 italic">
Created on {renderShortDateWithYearFormat(projectDetails?.created_at)}
</span>
</>
) : (
<Loader className="mt-2 w-full">
<Loader.Item height="34px" width="100px" />
</Loader>
)}
</div>
</div>
{isAdmin && ( {isAdmin && (
<Disclosure as="div" className="border-t border-custom-border-400"> <Disclosure as="div" className="border-t border-custom-border-400">
{({ open }) => ( {({ open }) => (
@ -413,10 +121,9 @@ const GeneralSettings: NextPage = () => {
<Disclosure.Panel> <Disclosure.Panel>
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<span className="text-sm tracking-tight"> <span className="text-sm tracking-tight">
The danger zone of the project delete page is a critical area that The danger zone of the project delete page is a critical area that requires careful
requires careful consideration and attention. When deleting a project, consideration and attention. When deleting a project, all of the data and resources within
all of the data and resources within that project will be permanently that project will be permanently removed and cannot be recovered.
removed and cannot be recovered.
</span> </span>
<div> <div>
{projectDetails ? ( {projectDetails ? (
@ -442,11 +149,10 @@ const GeneralSettings: NextPage = () => {
)} )}
</Disclosure> </Disclosure>
)} )}
</form>
</div> </div>
</div> </div>
</ProjectAuthorizationWrapper> </ProjectAuthorizationWrapper>
); );
}; });
export default GeneralSettings; export default GeneralSettings;

View File

@ -4,8 +4,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
const unsplashKey = process.env.UNSPLASH_ACCESS_KEY; const unsplashKey = process.env.UNSPLASH_ACCESS_KEY;
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const { query, page, per_page = 20 } = req.query; const { query, page, per_page = 20 } = req.query;
const url = query const url = query
? `https://api.unsplash.com/search/photos/?client_id=${unsplashKey}&query=${query}&page=${page}&per_page=${per_page}` ? `https://api.unsplash.com/search/photos/?client_id=${unsplashKey}&query=${query}&page=${page}&per_page=${per_page}`
: `https://api.unsplash.com/photos/?client_id=${unsplashKey}&page=${page}&per_page=${per_page}`; : `https://api.unsplash.com/photos/?client_id=${unsplashKey}&page=${page}&per_page=${per_page}`;
@ -17,6 +17,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); });
res.status(200).json(response.data);
res.status(200).json(response); } catch (error) {
console.log("unsplash failed", error);
res.status(500).json({ message: "failed to fetch unsplash images", error });
}
} }

View File

@ -21,11 +21,7 @@ export class ProjectService extends APIService {
super(API_BASE_URL); super(API_BASE_URL);
} }
async createProject( async createProject(workspaceSlug: string, data: Partial<IProject>, user: any): Promise<IProject> {
workspaceSlug: string,
data: Partial<IProject>,
user: ICurrentUserResponse | undefined
): Promise<IProject> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/`, data) return this.post(`/api/workspaces/${workspaceSlug}/projects/`, data)
.then((response) => { .then((response) => {
trackEventServices.trackProjectEvent(response.data, "CREATE_PROJECT", user); trackEventServices.trackProjectEvent(response.data, "CREATE_PROJECT", user);
@ -71,12 +67,7 @@ export class ProjectService extends APIService {
}); });
} }
async updateProject( async updateProject(workspaceSlug: string, projectId: string, data: Partial<IProject>, user: any): Promise<IProject> {
workspaceSlug: string,
projectId: string,
data: Partial<IProject>,
user: ICurrentUserResponse | undefined
): Promise<IProject> {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/`, data) return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/`, data)
.then((response) => { .then((response) => {
trackEventServices.trackProjectEvent(response.data, "UPDATE_PROJECT", user); trackEventServices.trackProjectEvent(response.data, "UPDATE_PROJECT", user);

View File

@ -20,7 +20,7 @@ export interface IProjectStore {
projects: { [key: string]: IProject[] }; projects: { [key: string]: IProject[] };
project_details: { project_details: {
[projectId: string]: IProject; // projectId: project Info [projectId: string]: IProject; // projectId: project Info
} | null; };
states: { states: {
[projectId: string]: IStateResponse; // project_id: states [projectId: string]: IStateResponse; // project_id: states
} | null; } | null;
@ -50,19 +50,22 @@ export interface IProjectStore {
getProjectMemberById: (memberId: string) => IProjectMember | null; getProjectMemberById: (memberId: string) => IProjectMember | null;
fetchProjects: (workspaceSlug: string) => Promise<void>; fetchProjects: (workspaceSlug: string) => Promise<void>;
fetchProjectStates: (workspaceSlug: string, projectSlug: string) => Promise<void>; fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise<any>;
fetchProjectLabels: (workspaceSlug: string, projectSlug: string) => Promise<void>; fetchProjectStates: (workspaceSlug: string, projectId: string) => Promise<void>;
fetchProjectMembers: (workspaceSlug: string, projectSlug: string) => Promise<void>; fetchProjectLabels: (workspaceSlug: string, projectId: string) => Promise<void>;
fetchProjectMembers: (workspaceSlug: string, projectId: string) => Promise<void>;
addProjectToFavorites: (workspaceSlug: string, projectSlug: string) => Promise<any>; addProjectToFavorites: (workspaceSlug: string, projectId: string) => Promise<any>;
removeProjectFromFavorites: (workspaceSlug: string, projectSlug: string) => Promise<any>; removeProjectFromFavorites: (workspaceSlug: string, projectId: string) => Promise<any>;
orderProjectsWithSortOrder: (sourceIndex: number, destinationIndex: number, projectId: string) => number; orderProjectsWithSortOrder: (sourceIndex: number, destinationIndex: number, projectId: string) => number;
updateProjectView: (workspaceSlug: string, projectId: string, viewProps: any) => Promise<any>; updateProjectView: (workspaceSlug: string, projectId: string, viewProps: any) => Promise<any>;
joinProject: (workspaceSlug: string, projectIds: string[]) => Promise<void>; joinProject: (workspaceSlug: string, projectIds: string[]) => Promise<void>;
leaveProject: (workspaceSlug: string, projectSlug: string) => Promise<void>; leaveProject: (workspaceSlug: string, projectId: string) => Promise<void>;
deleteProject: (workspaceSlug: string, projectSlug: string) => Promise<void>; createProject: (workspaceSlug: string, data: any) => Promise<any>;
updateProject: (workspaceSlug: string, projectId: string, data: any) => Promise<any>;
deleteProject: (workspaceSlug: string, projectId: string) => Promise<void>;
} }
class ProjectStore implements IProjectStore { class ProjectStore implements IProjectStore {
@ -74,7 +77,7 @@ class ProjectStore implements IProjectStore {
projects: { [workspaceSlug: string]: IProject[] } = {}; // workspace_id: project[] projects: { [workspaceSlug: string]: IProject[] } = {}; // workspace_id: project[]
project_details: { project_details: {
[key: string]: IProject; // project_id: project [key: string]: IProject; // project_id: project
} | null = {}; } = {};
states: { states: {
[key: string]: IStateResponse; // project_id: states [key: string]: IStateResponse; // project_id: states
} | null = {}; } | null = {};
@ -91,10 +94,6 @@ class ProjectStore implements IProjectStore {
projectService; projectService;
issueService; issueService;
stateService; stateService;
moduleService;
viewService;
pageService;
cycleService;
constructor(_rootStore: RootStore) { constructor(_rootStore: RootStore) {
makeObservable(this, { makeObservable(this, {
@ -123,6 +122,8 @@ class ProjectStore implements IProjectStore {
// action // action
setProjectId: action, setProjectId: action,
setSearchQuery: action, setSearchQuery: action,
fetchProjects: action,
fetchProjectDetails: action,
getProjectStateById: action, getProjectStateById: action,
getProjectLabelById: action, getProjectLabelById: action,
@ -137,6 +138,8 @@ class ProjectStore implements IProjectStore {
orderProjectsWithSortOrder: action, orderProjectsWithSortOrder: action,
updateProjectView: action, updateProjectView: action,
createProject: action,
updateProject: action,
leaveProject: action, leaveProject: action,
}); });
@ -144,10 +147,6 @@ class ProjectStore implements IProjectStore {
this.projectService = new ProjectService(); this.projectService = new ProjectService();
this.issueService = new IssueService(); this.issueService = new IssueService();
this.stateService = new ProjectStateServices(); this.stateService = new ProjectStateServices();
this.moduleService = new ModuleService();
this.viewService = new ViewService();
this.pageService = new PageService();
this.cycleService = new CycleService();
} }
get searchedProjects() { get searchedProjects() {
@ -230,6 +229,22 @@ class ProjectStore implements IProjectStore {
} }
}; };
fetchProjectDetails = async (workspaceSlug: string, projectId: string) => {
try {
const response = await this.projectService.getProject(workspaceSlug, projectId);
runInAction(() => {
this.project_details = {
...this.project_details,
[projectId]: response,
};
});
return response;
} catch (error) {
console.log("Error while fetching project details", error);
throw error;
}
};
getProjectStateById = (stateId: string) => { getProjectStateById = (stateId: string) => {
if (!this.projectId) return null; if (!this.projectId) return null;
const states = this.projectStates; const states = this.projectStates;
@ -438,6 +453,43 @@ class ProjectStore implements IProjectStore {
} }
}; };
createProject = async (workspaceSlug: string, data: any) => {
try {
const response = await this.projectService.createProject(workspaceSlug, data, this.rootStore.user.currentUser);
runInAction(() => {
this.projects = {
...this.projects,
[workspaceSlug]: [...this.projects[workspaceSlug], response],
};
this.project_details = {
...this.project_details,
[response.id]: response,
};
});
return response;
} catch (error) {
console.log("Failed to create project from project store");
throw error;
}
};
updateProject = async (workspaceSlug: string, projectId: string, data: any) => {
try {
const response = await this.projectService.updateProject(
workspaceSlug,
projectId,
data,
this.rootStore.user.currentUser
);
await this.fetchProjectDetails(workspaceSlug, projectId);
await this.fetchProjects(workspaceSlug);
return response;
} catch (error) {
console.log("Failed to create project from project store");
throw error;
}
};
deleteProject = async (workspaceSlug: string, projectId: string) => { deleteProject = async (workspaceSlug: string, projectId: string) => {
try { try {
await this.projectService.deleteProject(workspaceSlug, projectId, this.rootStore.user.currentUser); await this.projectService.deleteProject(workspaceSlug, projectId, this.rootStore.user.currentUser);