plane/web/components/project/form.tsx

369 lines
13 KiB
TypeScript

import { FC, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
// icons
import { Info, Lock } from "lucide-react";
import { IProject, IWorkspace } from "@plane/types";
// ui
import {
Button,
CustomSelect,
Input,
TextArea,
TOAST_TYPE,
setToast,
CustomEmojiIconPicker,
EmojiIconPickerTypes,
Tooltip,
} from "@plane/ui";
// components
import { ImagePickerPopover } from "@/components/core";
// constants
import { E_PROJECT_GENERAL, PROJECT_UPDATED } from "@/constants/event-tracker";
import { NETWORK_CHOICES } from "@/constants/project";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
import { useEventTracker, useProject } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// services
import { ProjectService } from "@/services/project";
// types
import { ProjectLogo } from "./project-logo";
export interface IProjectDetailsForm {
project: IProject;
workspaceSlug: string;
projectId: string;
isAdmin: boolean;
}
const projectService = new ProjectService();
export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
const { project, workspaceSlug, projectId, isAdmin } = props;
// states
const [isLoading, setIsLoading] = useState(false);
// store hooks
const { captureProjectEvent } = useEventTracker();
const { updateProject } = useProject();
const { isMobile } = usePlatformOS();
// form info
const {
handleSubmit,
watch,
control,
setValue,
setError,
reset,
formState: { errors, dirtyFields },
getValues,
} = useForm<IProject>({
defaultValues: {
...project,
workspace: (project.workspace as IWorkspace).id,
},
});
useEffect(() => {
if (project && projectId !== getValues("id")) {
reset({
...project,
workspace: (project.workspace as IWorkspace).id,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project, projectId]);
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 handleUpdateChange = async (payload: Partial<IProject>) => {
if (!workspaceSlug || !project) return;
return updateProject(workspaceSlug.toString(), project.id, payload)
.then((res) => {
const changed_properties = Object.keys(dirtyFields);
captureProjectEvent({
eventName: PROJECT_UPDATED,
payload: {
...res,
changed_properties: changed_properties,
state: "SUCCESS",
element: E_PROJECT_GENERAL,
},
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Project updated successfully",
});
})
.catch((error) => {
captureProjectEvent({
eventName: PROJECT_UPDATED,
payload: { ...payload, state: "FAILED", element: E_PROJECT_GENERAL },
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error?.error ?? "Project could not be updated. Please try again.",
});
});
};
const onSubmit = async (formData: IProject) => {
if (!workspaceSlug) return;
setIsLoading(true);
const payload: Partial<IProject> = {
name: formData.name,
network: formData.network,
identifier: formData.identifier,
description: formData.description,
cover_image: formData.cover_image,
logo_props: formData.logo_props,
};
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 handleUpdateChange(payload);
});
else await handleUpdateChange(payload);
setTimeout(() => {
setIsLoading(false);
}, 300);
};
const currentNetwork = NETWORK_CHOICES.find((n) => n.key === project?.network);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="relative mt-6 h-44 w-full">
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
<img src={watch("cover_image")!} alt={watch("cover_image")!} className="h-44 w-full rounded-md object-cover" />
<div className="z-5 absolute bottom-4 flex w-full items-end justify-between gap-3 px-4">
<div className="flex flex-grow gap-3 truncate">
<div className="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-lg bg-custom-background-90">
<Controller
control={control}
name="logo_props"
render={({ field: { value, onChange } }) => (
<CustomEmojiIconPicker
label={
<span className="grid h-7 w-7 place-items-center">
<ProjectLogo logo={value} className="text-lg" />
</span>
}
onChange={(val) => {
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,
});
}}
defaultIconColor={value?.in_use && value.in_use === "icon" ? value?.icon?.color : undefined}
defaultOpen={
value.in_use && value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON
}
disabled={!isAdmin}
/>
)}
/>
</div>
<div className="flex flex-col gap-1 truncate text-white">
<span className="truncate text-lg font-semibold">{watch("name")}</span>
<span className="flex items-center gap-2 text-sm">
<span>{watch("identifier")} .</span>
<span className="flex items-center gap-1.5">
{project.network === 0 && <Lock className="h-2.5 w-2.5 text-white " />}
{currentNetwork?.label}
</span>
</span>
</div>
</div>
<div className="flex flex-shrink-0 justify-center">
<div>
<Controller
control={control}
name="cover_image"
render={({ field: { value, onChange } }) => (
<ImagePickerPopover
label={"Change cover"}
control={control}
onChange={onChange}
value={value}
disabled={!isAdmin}
/>
)}
/>
</div>
</div>
</div>
</div>
<div className="my-8 flex flex-col gap-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",
maxLength: {
value: 255,
message: "Project name should be less than 255 characters",
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="name"
name="name"
type="text"
ref={ref}
value={value}
onChange={onChange}
hasError={Boolean(errors.name)}
className="rounded-md !p-3 font-medium"
placeholder="Project Name"
disabled={!isAdmin}
/>
)}
/>
<span className="text-xs text-red-500">{errors?.name?.message}</span>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">Description</h4>
<Controller
name="description"
control={control}
render={({ field: { value, onChange } }) => (
<TextArea
id="description"
name="description"
value={value}
placeholder="Enter project description"
onChange={onChange}
className="min-h-[102px] text-sm font-medium"
hasError={Boolean(errors?.description)}
disabled={!isAdmin}
/>
)}
/>
</div>
<div className="flex w-full justify-between gap-10">
<div className="flex w-1/2 flex-col gap-1">
<h4 className="text-sm">Project ID</h4>
<div className="relative">
<Controller
control={control}
name="identifier"
rules={{
required: "Project ID is required",
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, ref } }) => (
<Input
id="identifier"
name="identifier"
type="text"
value={value}
onChange={handleIdentifierChange}
ref={ref}
hasError={Boolean(errors.identifier)}
placeholder="Enter Project ID"
className="w-full font-medium"
disabled={!isAdmin}
/>
)}
/>
<Tooltip
isMobile={isMobile}
tooltipContent="Helps you identify issues in the project uniquely, (e.g. APP-123). Max 5 characters."
className="text-sm"
position="right-top"
>
<Info className="absolute right-2 top-2.5 h-4 w-4 text-custom-text-400" />
</Tooltip>
</div>
<span className="text-xs text-red-500">
<>{errors?.identifier?.message}</>
</span>
</div>
<div className="flex w-1/2 flex-col gap-1">
<h4 className="text-sm">Network</h4>
<Controller
name="network"
control={control}
render={({ field: { value, onChange } }) => {
const selectedNetwork = NETWORK_CHOICES.find((n) => n.key === value);
return (
<CustomSelect
value={value}
onChange={onChange}
label={
<div className="flex items-center gap-1">
{selectedNetwork ? (
<>
<selectedNetwork.icon className="h-3.5 w-3.5" />
{selectedNetwork.label}
</>
) : (
<span className="text-custom-text-400">Select network</span>
)}
</div>
}
buttonClassName="!border-custom-border-200 !shadow-none font-medium rounded-md"
input
disabled={!isAdmin}
// optionsClassName="w-full"
>
{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>
</div>
<div className="flex items-center justify-between py-2">
<>
<Button variant="primary" type="submit" loading={isLoading} disabled={!isAdmin}>
{isLoading ? "Updating..." : "Update project"}
</Button>
<span className="text-sm italic text-custom-sidebar-text-400">
Created on {renderFormattedDate(project?.created_at)}
</span>
</>
</div>
</div>
</form>
);
};