Merge branch 'develop' of https://github.com/makeplane/plane into style/project_settings

This commit is contained in:
Aaryan Khandelwal 2023-03-03 13:32:42 +05:30
commit ad7b691b2b
6 changed files with 300 additions and 287 deletions

View File

@ -0,0 +1,152 @@
import React, { useEffect, useState, useRef } from "react";
// next
import Image from "next/image";
// swr
import useSWR from "swr";
// headless ui
import { Tab, Transition, Popover } from "@headlessui/react";
// services
import fileService from "services/file.service";
// components
import { Input, Spinner } from "components/ui";
import { PrimaryButton } from "components/ui/button/primary-button";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
const tabOptions = [
{
key: "unsplash",
title: "Unsplash",
},
{
key: "upload",
title: "Upload",
},
];
type Props = {
label: string | React.ReactNode;
value: string | null;
onChange: (data: string) => void;
};
export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange }) => {
const ref = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [searchParams, setSearchParams] = useState("");
const [formData, setFormData] = useState({
search: "",
});
const { data: images } = useSWR(`UNSPLASH_IMAGES_${searchParams}`, () =>
fileService.getUnsplashImages(1, searchParams)
);
useOutsideClickDetector(ref, () => {
setIsOpen(false);
});
useEffect(() => {
if (!images || value !== null) return;
onChange(images[0].urls.regular);
}, [value, onChange, images]);
return (
<Popover className="relative z-[2]" ref={ref}>
<Popover.Button
className="rounded-md border border-gray-500 bg-white px-2 py-1 text-xs text-gray-700"
onClick={() => setIsOpen((prev) => !prev)}
>
{label}
</Popover.Button>
<Transition
show={isOpen}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md bg-white shadow-lg">
<div className="h-96 w-80 overflow-auto rounded border bg-white p-5 shadow-2xl sm:max-w-2xl md:w-96 lg:w-[40rem]">
<Tab.Group>
<Tab.List as="span" className="inline-block rounded bg-gray-200 p-1">
{tabOptions.map((tab) => (
<Tab
key={tab.key}
className={({ selected }) =>
`rounded py-1 px-4 text-center text-sm outline-none transition-colors ${
selected ? "bg-theme text-white" : "text-black"
}`
}
>
{tab.title}
</Tab>
))}
</Tab.List>
<Tab.Panels className="h-full w-full flex-1 overflow-y-auto overflow-x-hidden">
<Tab.Panel className="h-full w-full space-y-4">
<form
onSubmit={(e) => {
e.preventDefault();
setSearchParams(formData.search);
}}
className="flex gap-x-2 pt-7"
>
<Input
name="search"
className="text-sm"
id="search"
value={formData.search}
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
placeholder="Search for images"
/>
<PrimaryButton className="bg-indigo-600" size="sm">
Search
</PrimaryButton>
</form>
{images ? (
<div className="grid grid-cols-4 gap-4">
{images.map((image) => (
<div
key={image.id}
className="relative col-span-2 aspect-video md:col-span-1"
>
<Image
src={image.urls.small}
alt={image.alt_description}
layout="fill"
objectFit="cover"
className="cursor-pointer rounded"
onClick={() => {
setIsOpen(false);
onChange(image.urls.regular);
}}
/>
</div>
))}
</div>
) : (
<div className="flex justify-center pt-20">
<Spinner />
</div>
)}
</Tab.Panel>
<Tab.Panel className="flex h-full w-full flex-col items-center justify-center">
<p>Coming Soon...</p>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</Popover.Panel>
</Transition>
</Popover>
);
};

View File

@ -9,4 +9,4 @@ export * from "./issues-view";
export * from "./link-modal";
export * from "./not-authorized-view";
export * from "./multi-level-select";
export * from "./unsplash-modal";
export * from "./image-picker-popover";

View File

@ -1,108 +0,0 @@
import React, { useState } from "react";
import Image from "next/image";
import useSWR from "swr";
import { Dialog, Transition } from "@headlessui/react";
// services
import fileService from "services/file.service";
// ui
import { Button, Input, Spinner } from "components/ui";
type Props = {
isOpen: boolean;
handleClose: () => void;
onSelect: (url: string) => void;
};
export const UnsplashImageModal: React.FC<Props> = (props) => {
const { isOpen, handleClose, onSelect } = props;
const [searchQuery, setSearchQuery] = useState<string>("");
const [formData, setFormData] = useState({
search: "",
});
const { data: images } = useSWR(`UNSPLASH_IMAGES_${searchQuery.toUpperCase()}`, () =>
fileService.getUnsplashImages(1, searchQuery)
);
const onClose = () => {
handleClose();
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative h-[35rem] space-y-5 transform overflow-y-auto rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl sm:p-6">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
Select an image
</Dialog.Title>
<form
onSubmit={(e) => {
e.preventDefault();
setSearchQuery(formData.search);
}}
className="flex items-center"
>
<Input
name="search"
value={formData.search}
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
placeholder="Search for images"
/>
<Button type="submit">Search</Button>
</form>
{images ? (
<div className="grid grid-cols-3 gap-5">
{images.map((image) => (
<button
key={image.id}
className="w-full relative h-32 bg-gray-200 rounded-md overflow-hidden"
type="button"
onClick={() => onSelect(image.urls.small)}
>
<Image src={image.urls.small} layout="fill" objectFit="cover" />
</button>
))}
</div>
) : (
<div className="flex h-96 w-full items-center justify-center">
<Spinner />
</div>
)}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -44,7 +44,7 @@ const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange }) => {
return (
<Popover className="relative z-[1]" ref={ref}>
<Popover.Button
className="rounded-md border border-gray-300 p-2 outline-none sm:text-sm"
className="rounded-full bg-gray-100 p-2 outline-none sm:text-sm"
onClick={() => setIsOpen((prev) => !prev)}
>
{label}

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect } from "react";
import Image from "next/image";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
@ -7,17 +8,19 @@ import useSWR, { mutate } from "swr";
import { useForm, Controller } from "react-hook-form";
import { Dialog, Transition } from "@headlessui/react";
// services
import projectServices from "services/project.service";
import workspaceService from "services/workspace.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { PrimaryButton } from "components/ui/button/primary-button";
import { Button, Input, TextArea, CustomSelect } from "components/ui";
// icons
import { PhotoIcon } from "@heroicons/react/24/outline";
import { XMarkIcon } from "@heroicons/react/24/outline";
// components
import { UnsplashImageModal } from "components/core";
import { ImagePickerPopover } from "components/core";
import EmojiIconPicker from "components/emoji-icon-picker";
// helpers
import { getRandomEmoji } from "helpers/common.helper";
@ -62,7 +65,6 @@ const IsGuestCondition: React.FC<{
export const CreateProjectModal: React.FC<Props> = (props) => {
const { isOpen, setIsOpen } = props;
const [isUnsplashModalOpen, setIsUnsplashModalOpen] = useState(false);
const [isChangeIdentifierRequired, setIsChangeIdentifierRequired] = useState(true);
const { setToastAlert } = useToast();
@ -177,150 +179,149 @@ export const CreateProjectModal: React.FC<Props> = (props) => {
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl sm:p-6">
<div className="absolute border-2 rounded-md p-2 bg-red-a500 right-4 top-4">
<button
type="button"
className="w-full h-full flex items-center justify-center outline-none"
onClick={() => setIsUnsplashModalOpen(true)}
>
<PhotoIcon className="w-5 h-5 text-gray-700" />
</button>
<Dialog.Panel className="transform rounded-lg bg-white text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
<div className="relative h-36 w-full rounded-t-lg bg-gray-300">
{watch("cover_image") !== null && (
<Image
src={watch("cover_image")!}
layout="fill"
alt="cover image"
objectFit="cover"
className="rounded-t-lg"
/>
)}
<div className="absolute right-2 top-2 p-2">
<button type="button" onClick={handleClose}>
<XMarkIcon className="h-5 w-5 text-white" />
</button>
</div>
<div className="absolute bottom-0 left-0 flex w-full justify-between px-6 py-5">
<div className="absolute left-0 bottom-0 h-16 w-full bg-gradient-to-t from-black opacity-60" />
<h3 className="z-[1] text-xl text-white">Create Project</h3>
<div>
<ImagePickerPopover
label="Change Cover"
onChange={(image) => {
setValue("cover_image", image);
}}
value={watch("cover_image")}
/>
</div>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
Create Project
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Create a new project to start working on it.
</p>
</div>
<div className="space-y-3">
<div className="flex gap-3">
<div className="flex-shrink-0">
<label htmlFor="icon" className="mb-2 text-gray-500">
Icon
</label>
<Controller
control={control}
name="icon"
render={({ field: { value, onChange } }) => (
<EmojiIconPicker
label={
value ? String.fromCodePoint(parseInt(value)) : "Select Icon"
}
value={value}
onChange={onChange}
/>
)}
/>
</div>
<div className="w-full">
<Input
id="name"
label="Name"
name="name"
type="name"
placeholder="Enter name"
error={errors.name}
register={register}
validations={{
required: "Name is required",
maxLength: {
value: 255,
message: "Name should be less than 255 characters",
},
}}
/>
</div>
</div>
<div className="mt-5 space-y-4 px-4 py-3">
<div className="flex items-center gap-x-2">
<div>
<h6 className="text-gray-500">Network</h6>
<Controller
name="network"
control={control}
render={({ field: { onChange, value } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={
Object.keys(NETWORK_CHOICES).find((k) => k === value.toString())
? NETWORK_CHOICES[
value.toString() as keyof typeof NETWORK_CHOICES
]
: "Select network"
}
input
>
{Object.keys(NETWORK_CHOICES).map((key) => (
<CustomSelect.Option key={key} value={key}>
{NETWORK_CHOICES[key as keyof typeof NETWORK_CHOICES]}
</CustomSelect.Option>
))}
</CustomSelect>
)}
<EmojiIconPicker
label={String.fromCodePoint(parseInt(watch("icon")))}
onChange={(emoji) => {
setValue("icon", emoji);
}}
value={watch("icon")}
/>
</div>
<div>
<TextArea
id="description"
name="description"
label="Description"
placeholder="Enter description"
error={errors.description}
register={register}
/>
</div>
<div>
<div className="flex-shrink-0 flex-grow">
<Input
id="identifier"
label="Identifier"
name="identifier"
type="text"
placeholder="Enter Project Identifier"
error={errors.identifier}
id="name"
name="name"
type="name"
placeholder="Enter name"
error={errors.name}
register={register}
onChange={() => setIsChangeIdentifierRequired(false)}
className="text-xl"
mode="transparent"
validations={{
required: "Identifier is required",
validate: (value) =>
/^[A-Z]+$/.test(value) || "Identifier must be uppercase text.",
minLength: {
value: 1,
message: "Identifier must at least be of 1 character",
},
required: "Name is required",
maxLength: {
value: 5,
message: "Identifier must at most be of 5 characters",
value: 255,
message: "Name should be less than 255 characters",
},
}}
/>
</div>
</div>
<div>
<Input
id="identifier"
name="identifier"
type="text"
mode="transparent"
className="text-sm"
placeholder="Enter Project Identifier"
error={errors.identifier}
register={register}
onChange={() => setIsChangeIdentifierRequired(false)}
validations={{
required: "Identifier is required",
validate: (value) =>
/^[A-Z]+$/.test(value) || "Identifier must be uppercase text.",
minLength: {
value: 1,
message: "Identifier must at least be of 1 character",
},
maxLength: {
value: 5,
message: "Identifier must at most be of 5 characters",
},
}}
/>
</div>
<div>
<TextArea
id="description"
name="description"
mode="transparent"
className="text-sm"
placeholder="Enter description"
error={errors.description}
register={register}
/>
</div>
<div className="w-40">
<Controller
name="network"
control={control}
render={({ field: { onChange, value } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={
Object.keys(NETWORK_CHOICES).find((k) => k === value.toString())
? NETWORK_CHOICES[value.toString() as keyof typeof NETWORK_CHOICES]
: "Select network"
}
input
>
{Object.keys(NETWORK_CHOICES).map((key) => (
<CustomSelect.Option key={key} value={key}>
{NETWORK_CHOICES[key as keyof typeof NETWORK_CHOICES]}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<div className="mt-5 flex justify-end gap-2 border-t-2 px-4 py-3">
<Button theme="secondary" onClick={handleClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating Project..." : "Create Project"}
</Button>
<PrimaryButton type="submit" size="sm" loading={isSubmitting}>
{isSubmitting ? "Adding project..." : "Add Project"}
</PrimaryButton>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
<UnsplashImageModal
handleClose={() => setIsUnsplashModalOpen(false)}
isOpen={isUnsplashModalOpen}
onSelect={(imageUrl) => {
setValue("cover_image", imageUrl);
setIsUnsplashModalOpen(false);
}}
/>
</Dialog>
</Transition.Root>
);

View File

@ -7,15 +7,13 @@ import useSWR, { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// react-dropzone
import Dropzone from "react-dropzone";
// icons
import { LinkIcon } from "@heroicons/react/24/outline";
// lib
import { requiredWorkspaceAdmin } from "lib/auth";
// services
import workspaceService from "services/workspace.service";
import fileServices from "services/file.service";
// layouts
import AppLayout from "layouts/app-layout";
// hooks
@ -52,7 +50,6 @@ type TWorkspaceSettingsProps = {
const WorkspaceSettings: NextPage<TWorkspaceSettingsProps> = (props) => {
const [isOpen, setIsOpen] = useState(false);
const [image, setImage] = useState<File | null>(null);
const [isImageUploading, setIsImageUploading] = useState(false);
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
@ -122,9 +119,11 @@ const WorkspaceSettings: NextPage<TWorkspaceSettingsProps> = (props) => {
<ImageUploadModal
isOpen={isImageUploadModalOpen}
onClose={() => setIsImageUploadModalOpen(false)}
onSuccess={() => {
onSuccess={(imageUrl) => {
setIsImageUploading(true);
setValue("logo", imageUrl);
setIsImageUploadModalOpen(false);
handleSubmit(onSubmit)();
handleSubmit(onSubmit)().then(() => setIsImageUploading(false));
}}
value={watch("logo")}
/>
@ -147,62 +146,31 @@ const WorkspaceSettings: NextPage<TWorkspaceSettingsProps> = (props) => {
<div className="col-span lg:col-span-5">
<h4 className="text-md mb-1 leading-6 text-gray-900">Logo</h4>
<div className="flex w-full gap-2">
<Dropzone
multiple={false}
accept={{
"image/*": [],
}}
onDrop={(files) => {
setImage(files[0]);
}}
>
{({ getRootProps, getInputProps }) => (
<div>
<input {...getInputProps()} />
<div {...getRootProps()}>
{(watch("logo") && watch("logo") !== null && watch("logo") !== "") ||
(image && image !== null) ? (
<div className="relative mx-auto flex h-12 w-12">
<Image
src={image ? URL.createObjectURL(image) : watch("logo") ?? ""}
alt="Workspace Logo"
objectFit="cover"
layout="fill"
className="rounded-md"
priority
/>
</div>
) : (
<div className="relative flex h-12 w-12 items-center justify-center rounded bg-gray-700 p-4 uppercase text-white">
{activeWorkspace?.name?.charAt(0) ?? "N"}
</div>
)}
</div>
<button type="button" onClick={() => setIsImageUploadModalOpen(true)}>
{watch("logo") && watch("logo") !== null && watch("logo") !== "" ? (
<div className="relative mx-auto flex h-12 w-12">
<Image
src={watch("logo")!}
alt="Workspace Logo"
objectFit="cover"
layout="fill"
className="rounded-md"
priority
/>
</div>
) : (
<div className="relative flex h-12 w-12 items-center justify-center rounded bg-gray-700 p-4 uppercase text-white">
{activeWorkspace?.name?.charAt(0) ?? "N"}
</div>
)}
</Dropzone>
</button>
<div>
<p className="mb-2 text-sm text-gray-500">
Max file size is 5MB. Supported file types are .jpg and .png.
</p>
<Button
onClick={() => {
if (image === null) return;
setIsImageUploading(true);
const formData = new FormData();
formData.append("asset", image);
formData.append("attributes", JSON.stringify({}));
fileServices
.uploadFile(workspaceSlug as string, formData)
.then((response) => {
const imageUrl = response.asset;
setValue("logo", imageUrl);
handleSubmit(onSubmit)();
setIsImageUploading(false);
})
.catch(() => {
setIsImageUploading(false);
});
setIsImageUploadModalOpen(true);
}}
>
{isImageUploading ? "Uploading..." : "Upload"}