Compare commits

...

52 Commits

Author SHA1 Message Date
Aaryan Khandelwal
dd934e63a2 refactor: fetch object details logic 2023-10-03 18:55:53 +05:30
Aaryan Khandelwal
22d659cd4c refactor: services 2023-10-03 18:03:53 +05:30
Aaryan Khandelwal
639a30abb5 chore: integrate react-popper 2023-10-03 16:51:03 +05:30
Aaryan Khandelwal
2d3c1f93c1 chore: added outside click detector for custom attribute forms 2023-10-03 14:57:17 +05:30
Aaryan Khandelwal
4d158a6d8f refactor: attribute form components and object modal 2023-10-02 17:29:48 +05:30
Aaryan Khandelwal
4f33c4bea3 fix: merge conflicts 2023-10-02 13:09:59 +05:30
Aaryan Khandelwal
3ae292a2da refactor: remove unnecessary props 2023-09-21 18:32:45 +05:30
Aaryan Khandelwal
c4143a1a64 chore: url and email redirection added 2023-09-21 18:12:23 +05:30
Aaryan Khandelwal
998dc1bbae fix: delete option mutation 2023-09-21 02:23:27 +05:30
Aaryan Khandelwal
d57e99ed30 chore: random color generator for options and number fields 2023-09-21 02:12:06 +05:30
Aaryan Khandelwal
1428e6b555 chore: rename entities to objects throughout 2023-09-20 21:47:29 +05:30
Aaryan Khandelwal
95ae0f065a refactor: mobx store usage 2023-09-20 21:30:12 +05:30
Aaryan Khandelwal
3c3f0f7581 chore: edit/delete option 2023-09-20 18:52:38 +05:30
Aaryan Khandelwal
cd4d56d071 refactor: objects folder structure 2023-09-20 18:33:12 +05:30
Aaryan Khandelwal
4a5ca4d51b fix: modals layout 2023-09-20 17:46:17 +05:30
Aaryan Khandelwal
8ff1e8dd87 chore: set default values on the issue modal 2023-09-20 17:16:01 +05:30
Aaryan Khandelwal
4a13b30874 fix: edit object modal 2023-09-20 16:33:33 +05:30
Aaryan Khandelwal
c714b5e50c fix: merge conflicts 2023-09-20 15:40:01 +05:30
Aaryan Khandelwal
4c016c85f1 chore: file upload in the issue modal 2023-09-20 15:16:13 +05:30
Aaryan Khandelwal
220c572461 Merge branch 'develop' of https://github.com/makeplane/plane into feat/custom_attributes 2023-09-20 12:48:25 +05:30
Aaryan Khandelwal
4842fc8e58 refactor: issue modal custom attributes 2023-09-20 12:47:48 +05:30
Aaryan Khandelwal
d04eac30b0 Merge branch 'develop' of https://github.com/makeplane/plane into feat/custom_attributes 2023-09-19 22:38:05 +05:30
Aaryan Khandelwal
e94fb40602 chore: added icon field for objects 2023-09-19 19:27:19 +05:30
Aaryan Khandelwal
5dc9e00c3d style: issue modal select attributes 2023-09-19 19:04:54 +05:30
Aaryan Khandelwal
9ef30b4fbf chore: revert issue modal select designs 2023-09-19 18:11:38 +05:30
Aaryan Khandelwal
dc908d21bb chore: update entity option added 2023-09-19 18:09:36 +05:30
Aaryan Khandelwal
3b682c2124 chore: clear custom attributes on modal close 2023-09-19 17:53:16 +05:30
Aaryan Khandelwal
121d4cb4eb fix: issue display properties endpoint 2023-09-19 17:41:48 +05:30
Aaryan Khandelwal
47866fb511 fix: merge conflicts 2023-09-19 17:25:04 +05:30
Aaryan Khandelwal
6b2a5a97ac chore: separate description fields in the create issue modal 2023-09-19 17:09:50 +05:30
Aaryan Khandelwal
7cf263ecd4 refactor: display components 2023-09-19 13:07:33 +05:30
Aaryan Khandelwal
14be78564a style: create issue modal inputs 2023-09-18 23:14:40 +05:30
Aaryan Khandelwal
a014564d11 chore: date time attribute 2023-09-18 21:33:27 +05:30
Aaryan Khandelwal
2169ba35a9 Merge branch 'develop' of https://github.com/makeplane/plane into feat/custom_attributes 2023-09-16 01:38:18 +05:30
Aaryan Khandelwal
fb87bfc140 style: attributes empty state design 2023-09-16 01:38:02 +05:30
Aaryan Khandelwal
d36a8b1325 fix: merge conflicts 2023-09-15 17:49:39 +05:30
Aaryan Khandelwal
501a704108 fix: merge conflicts 2023-09-15 17:48:47 +05:30
Aaryan Khandelwal
ef77ca6524 chore: custom attributes on the create issue modal 2023-09-15 17:47:51 +05:30
Aaryan Khandelwal
57f4941ee2 refactor: attribute form component 2023-09-15 16:24:20 +05:30
Aaryan Khandelwal
e713db48b3 chore: file type attribute added 2023-09-15 14:14:08 +05:30
Aaryan Khandelwal
057ddf1310 style: loader for issue details sidebar 2023-09-15 12:40:55 +05:30
Aaryan Khandelwal
ed25e09557 chore: create color picker dropdown 2023-09-15 12:36:51 +05:30
Aaryan Khandelwal
fff07a2353 chore: number field validations 2023-09-15 12:03:17 +05:30
Aaryan Khandelwal
529a286954 chore: render custom attributes on the issue details sidebar 2023-09-15 11:24:51 +05:30
Aaryan Khandelwal
cf384d3a4d chore: single select options 2023-09-14 13:09:21 +05:30
Aaryan Khandelwal
02d18e9edd fix: merge conflicts 2023-09-13 23:59:17 +05:30
Aaryan Khandelwal
43659631cf chore: object select dropdown for the create issue modal 2023-09-13 23:14:32 +05:30
Aaryan Khandelwal
9b6efa2ed3 chore: set up mobx store and configured all crud operations 2023-09-13 22:25:47 +05:30
Aaryan Khandelwal
2e2cace5de Merge branch 'develop' of https://github.com/makeplane/plane into feat/custom_attributes 2023-09-13 22:21:31 +05:30
Aaryan Khandelwal
1d57f686e2 Merge branch 'develop' of https://github.com/makeplane/plane into feat/custom_attributes 2023-09-12 22:51:26 +05:30
Aaryan Khandelwal
04bf575011 fix: merge conflicts 2023-09-12 22:38:41 +05:30
Aaryan Khandelwal
e5b466a3c4 chore: all attribute forms 2023-09-12 19:36:35 +05:30
86 changed files with 6529 additions and 782 deletions

View File

@ -21,6 +21,8 @@ import { Input, Spinner, PrimaryButton, SecondaryButton } from "components/ui";
// hooks
import useWorkspaceDetails from "hooks/use-workspace-details";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// constants
import { MAX_FILE_SIZE } from "constants/workspace";
const unsplashEnabled =
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" ||
@ -81,7 +83,7 @@ export const ImagePickerPopover: React.FC<Props> = ({
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"],
},
maxSize: 5 * 1024 * 1024,
maxSize: MAX_FILE_SIZE,
});
const handleSubmit = async () => {

View File

@ -14,6 +14,8 @@ import useWorkspaceDetails from "hooks/use-workspace-details";
import { DangerButton, PrimaryButton, SecondaryButton } from "components/ui";
// icons
import { UserCircleIcon } from "components/icons";
// constants
import { MAX_FILE_SIZE } from "constants/workspace";
type Props = {
value?: string | null;
@ -51,7 +53,7 @@ export const ImageUploadModal: React.FC<Props> = ({
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"],
},
maxSize: 5 * 1024 * 1024,
maxSize: MAX_FILE_SIZE,
});
const handleSubmit = async () => {

View File

@ -0,0 +1,34 @@
// ui
import { ToggleSwitch } from "components/ui";
// types
import { ICustomAttribute } from "types";
type Props = {
attributeDetails: ICustomAttribute;
value: boolean;
onChange: (val: boolean) => void;
};
export const CustomCheckboxAttribute: React.FC<Props & { value: boolean }> = ({
attributeDetails,
onChange,
value,
}) => {
const handleUpdateCheckbox = (val: boolean) => onChange(val);
return (
<div className="text-xs truncate">
{attributeDetails.extra_settings.representation === "toggle_switch" ? (
<ToggleSwitch value={value ?? false} onChange={handleUpdateCheckbox} />
) : (
<div className="flex-shrink-0 flex items-center">
<input
type="checkbox"
defaultChecked={value}
onChange={(e) => handleUpdateCheckbox(e.target.checked)}
/>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,49 @@
// react-datepicker
import ReactDatePicker from "react-datepicker";
// types
import { ICustomAttribute } from "types";
type Props = {
attributeDetails: ICustomAttribute;
className?: string;
value: Date | undefined;
onChange: (val: Date | null) => void;
};
const DATE_FORMATS: { [key: string]: string } = {
"MM-DD-YYYY": "MM-dd-yyyy",
"DD-MM-YYYY": "dd-MM-yyyy",
"YYYY-MM-DD": "yyyy-MM-dd",
};
const TIME_FORMATS: { [key: string]: string } = {
"12": "hh:mm aa",
"24": "HH:mm",
};
export const CustomDateTimeAttribute: React.FC<Props> = (props) => {
const { attributeDetails, className = "", onChange, value } = props;
return (
<div className="flex-shrink-0">
<ReactDatePicker
selected={value}
onChange={onChange}
className={`bg-custom-background-80 rounded text-xs px-2.5 py-0.5 outline-none truncate ${className}`}
calendarClassName="!bg-custom-background-80"
dateFormat={`${
attributeDetails.extra_settings.hide_date
? ""
: DATE_FORMATS[attributeDetails.extra_settings.date_format] ?? "dd-MM-yyyy"
} ${
attributeDetails.extra_settings.hide_time
? ""
: TIME_FORMATS[attributeDetails.extra_settings.time_format] ?? "HH:mm"
}`}
showTimeInput={!attributeDetails.extra_settings.hide_time}
isClearable={!attributeDetails.is_required}
placeholderText={`Select ${attributeDetails.display_name}`}
/>
</div>
);
};

View File

@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// types
import { ICustomAttribute } from "types";
type Props = {
attributeDetails: ICustomAttribute;
value: string | undefined;
onChange: (val: string) => void;
};
export const CustomEmailAttribute: React.FC<Props> = ({ attributeDetails, onChange, value }) => {
const [isEditing, setIsEditing] = useState(false);
const formRef = useRef(null);
const { control, handleSubmit, reset, setFocus } = useForm({ defaultValues: { email: "" } });
const handleFormSubmit = (data: { email: string }) => {
setIsEditing(false);
onChange(data.email);
};
useEffect(() => {
if (isEditing) setFocus("email");
}, [isEditing, setFocus]);
useEffect(() => {
reset({ email: value?.toString() });
}, [reset, value]);
useEffect(() => {
const handleEscKeyPress = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsEditing(false);
};
document.addEventListener("keydown", handleEscKeyPress);
return () => {
document.removeEventListener("keydown", handleEscKeyPress);
};
}, []);
useOutsideClickDetector(formRef, () => {
setIsEditing(false);
});
return (
<div className="flex-shrink-0 flex">
{!isEditing && (
<div
className="cursor-pointer text-xs truncate bg-custom-background-80 px-2.5 py-0.5 rounded"
onClick={() => setIsEditing(true)}
>
{value && value !== "" ? (
<a href={`mailto:${value}`} target="_blank" rel="noopener noreferrer">
{value}
</a>
) : (
"Empty"
)}
</div>
)}
{isEditing && (
<form
onSubmit={handleSubmit(handleFormSubmit)}
className="flex items-center flex-grow"
ref={formRef}
>
<Controller
control={control}
name="email"
render={({ field }) => (
<input
type="email"
className="text-xs px-2 py-0.5 bg-custom-background-80 rounded w-full outline-none"
required={attributeDetails.is_required}
placeholder={attributeDetails.display_name}
{...field}
/>
)}
/>
</form>
)}
</div>
);
};

View File

@ -0,0 +1,153 @@
import { useCallback, useState } from "react";
import { useRouter } from "next/router";
// react-dropzone
import { useDropzone } from "react-dropzone";
// services
import fileService from "services/file.service";
// hooks
import useToast from "hooks/use-toast";
import useWorkspaceDetails from "hooks/use-workspace-details";
// icons
import { getFileIcon } from "components/icons";
import { X } from "lucide-react";
// helpers
import { getFileExtension } from "helpers/attachment.helper";
// types
import { ICustomAttribute } from "types";
// constants
import { MAX_FILE_SIZE } from "constants/workspace";
type Props = {
attributeDetails: ICustomAttribute;
className?: string;
value: string | undefined;
onChange: (val: string | undefined) => void;
};
export const CustomFileAttribute: React.FC<Props> = (props) => {
const { attributeDetails, className = "", onChange, value } = props;
const [isUploading, setIsUploading] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { workspaceDetails } = useWorkspaceDetails();
const { setToastAlert } = useToast();
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (!acceptedFiles[0] || !workspaceSlug) return;
const extension = getFileExtension(acceptedFiles[0].name);
if (!attributeDetails.extra_settings?.file_formats?.includes(`.${extension}`)) {
setToastAlert({
type: "error",
title: "Error!",
message: `File format not accepted. Accepted file formats- ${attributeDetails.extra_settings?.file_formats?.join(
", "
)}`,
});
return;
}
const formData = new FormData();
formData.append("asset", acceptedFiles[0]);
formData.append(
"attributes",
JSON.stringify({
name: acceptedFiles[0].name,
size: acceptedFiles[0].size,
})
);
setIsUploading(true);
fileService
.uploadFile(workspaceSlug.toString(), formData)
.then((res) => {
const imageUrl = res.asset;
onChange(imageUrl);
if (value && value !== "" && workspaceDetails)
fileService.deleteFile(workspaceDetails.id, value);
})
.finally(() => setIsUploading(false));
},
[
attributeDetails.extra_settings?.file_formats,
onChange,
setToastAlert,
value,
workspaceDetails,
workspaceSlug,
]
);
const handleRemoveFile = () => {
if (!workspaceDetails || !value || value === "") return;
onChange(undefined);
fileService.deleteFile(workspaceDetails.id, value);
};
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
onDrop,
maxSize: MAX_FILE_SIZE,
multiple: false,
disabled: isUploading,
});
const fileError =
fileRejections.length > 0
? `Invalid file type or size (max ${MAX_FILE_SIZE / 1024 / 1024} MB)`
: null;
return (
<div className="flex-shrink-0 truncate space-y-1">
{value && value !== "" && (
<div className="group flex items-center justify-between gap-2 p-1 rounded border border-custom-border-200 text-xs truncate w-min max-w-full whitespace-nowrap">
<a
href={value}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 flex-grow truncate"
>
<span className="flex-shrink-0 h-5 w-5">{getFileIcon(getFileExtension(value))}</span>
<span className="truncate">
{value.split("/")[value.split("/").length - 1].split("-")[1]}
</span>
</a>
<button
type="button"
className="opacity-0 group-hover:opacity-100 grid place-items-center flex-shrink-0"
onClick={handleRemoveFile}
>
<X size={12} strokeWidth={1.5} />
</button>
</div>
)}
<div
{...getRootProps()}
className={`flex items-center bg-custom-background-80 text-xs rounded px-2.5 py-0.5 cursor-pointer truncate w-min max-w-full whitespace-nowrap ${
isDragActive ? "bg-custom-primary-100/10" : ""
} ${isDragReject ? "bg-red-500/10" : ""} ${className}`}
>
<input className="flex-shrink-0" {...getInputProps()} />
<span className={`flex-grow truncate text-left ${fileError ? "text-red-500" : ""}`}>
{isDragActive
? "Drop here..."
: fileError
? fileError
: isUploading
? "Uploading..."
: `Upload ${value && value !== "" ? "new " : ""}file`}
</span>
</div>
</div>
);
};

View File

@ -0,0 +1,9 @@
export * from "./checkbox";
export * from "./date-time";
export * from "./email";
export * from "./file";
export * from "./number";
export * from "./relation";
export * from "./select";
export * from "./text";
export * from "./url";

View File

@ -0,0 +1,138 @@
import { useEffect, useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// ui
import { ProgressBar } from "components/ui";
// types
import { ICustomAttribute } from "types";
type Props = {
attributeDetails: ICustomAttribute;
value: number | undefined;
onChange: (val: number | undefined) => void;
};
export const CustomNumberAttribute: React.FC<Props> = ({ attributeDetails, onChange, value }) => {
const [isEditing, setIsEditing] = useState(false);
const formRef = useRef(null);
const { control, handleSubmit, reset, setFocus } = useForm({ defaultValues: { number: "" } });
const handleFormSubmit = (data: { number: string }) => {
setIsEditing(false);
const number = parseInt(data.number, 10);
if (isNaN(number)) onChange(undefined);
else onChange(number);
};
useEffect(() => {
if (isEditing) {
setFocus("number");
}
}, [isEditing, setFocus]);
useEffect(() => {
reset({ number: value?.toString() });
}, [reset, value]);
useEffect(() => {
const handleEscKeyPress = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsEditing(false);
};
document.addEventListener("keydown", handleEscKeyPress);
return () => {
document.removeEventListener("keydown", handleEscKeyPress);
};
}, []);
useOutsideClickDetector(formRef, () => {
setIsEditing(false);
});
const extraSettings = attributeDetails.extra_settings;
return (
<div className="flex-shrink-0 flex">
{!isEditing && (
<div
className="cursor-pointer text-xs truncate flex w-full"
onClick={() => setIsEditing(true)}
>
{value ? (
<>
{extraSettings?.representation === "bar" ? (
<div className="flex items-center gap-2 w-full">
{extraSettings?.show_number && (
<span className="flex-shrink-0 font-medium">{value}</span>
)}
<div className="relative h-1.5 bg-custom-background-80 flex-grow w-full rounded-full overflow-hidden">
<div
className="absolute top-0 left-0 h-full"
style={{
backgroundColor: attributeDetails.color ?? "rgb(var(--color-primary-100))",
width: `${(value / parseInt(extraSettings.divided_by ?? 100, 10)) * 100}%`,
}}
/>
</div>
</div>
) : extraSettings?.representation === "ring" ? (
<div className="flex items-center gap-2 w-full">
{extraSettings?.show_number && (
<span className="flex-shrink-0 font-medium">{value}</span>
)}
<ProgressBar
activeStrokeColor={attributeDetails.color ?? "rgb(var(--color-primary-100))"}
value={value}
maxValue={parseInt(extraSettings.divided_by ?? 100, 10)}
/>
</div>
) : (
<span className="font-medium truncate bg-custom-background-80 px-2.5 py-0.5 rounded">
{value}
</span>
)}
</>
) : (
<div
className="text-xs truncate bg-custom-background-80 px-2.5 py-0.5"
onClick={() => setIsEditing(true)}
>
{value ?? "Empty"}
</div>
)}
</div>
)}
{isEditing && (
<form
onSubmit={handleSubmit(handleFormSubmit)}
className="flex items-center flex-grow"
ref={formRef}
>
<Controller
control={control}
name="number"
render={({ field }) => (
<input
type="number"
className="hide-arrows text-xs px-2 py-0.5 bg-custom-background-80 rounded w-full outline-none"
step={1}
min={extraSettings.divided_by ? 0 : undefined}
max={extraSettings.divided_by ?? undefined}
required={attributeDetails.is_required}
placeholder={attributeDetails.display_name}
{...field}
/>
)}
/>
</form>
)}
</div>
);
};

View File

@ -0,0 +1,150 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
import { usePopper } from "react-popper";
import { Combobox } from "@headlessui/react";
// services
import cyclesService from "services/cycles.service";
import modulesService from "services/modules.service";
// hooks
import useProjectMembers from "hooks/use-project-members";
// ui
import { Avatar } from "components/ui";
// icons
import { Search } from "lucide-react";
// types
import { ICustomAttribute } from "types";
// fetch-keys
import { CYCLES_LIST, MODULE_LIST } from "constants/fetch-keys";
type Props = {
attributeDetails: ICustomAttribute;
className?: string;
projectId: string;
value: string | undefined;
onChange: (val: string | undefined) => void;
};
export const CustomRelationAttribute: React.FC<Props> = ({
attributeDetails,
className = "",
onChange,
projectId,
value,
}) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const [query, setQuery] = useState("");
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "bottom-start",
});
const { data: cycles } = useSWR(
workspaceSlug && projectId && attributeDetails.unit === "cycle"
? CYCLES_LIST(projectId.toString())
: null,
workspaceSlug && projectId && attributeDetails.unit === "cycle"
? () =>
cyclesService.getCyclesWithParams(workspaceSlug.toString(), projectId.toString(), "all")
: null
);
const { data: modules } = useSWR(
workspaceSlug && projectId && attributeDetails.unit === "module"
? MODULE_LIST(projectId as string)
: null,
workspaceSlug && projectId && attributeDetails.unit === "module"
? () => modulesService.getModules(workspaceSlug as string, projectId as string)
: null
);
const { members } = useProjectMembers(workspaceSlug?.toString(), projectId);
const optionsList =
attributeDetails.unit === "cycle"
? cycles?.map((c) => ({ id: c.id, query: c.name, label: c.name }))
: attributeDetails.unit === "module"
? modules?.map((m) => ({ id: m.id, query: m.name, label: m.name }))
: attributeDetails.unit === "user"
? members?.map((m) => ({
id: m.member.id,
query: m.member.display_name,
label: (
<div className="flex items-center gap-2">
<Avatar user={m.member} />
{m.member.is_bot ? m.member.first_name : m.member.display_name}
</div>
),
}))
: [];
const selectedOption = (optionsList ?? []).find((option) => option.id === value);
const options = (optionsList ?? []).filter((option) =>
option.query.toLowerCase().includes(query.toLowerCase())
);
return (
<Combobox
as="div"
value={value}
onChange={(val) => onChange(val)}
className="flex-shrink-0 text-left flex items-center"
>
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
className={`flex items-center text-xs rounded px-2.5 py-0.5 truncate w-min max-w-full text-left bg-custom-background-80 ${className}`}
>
{selectedOption?.label ?? `Select ${attributeDetails.unit}`}
</button>
</Combobox.Button>
<Combobox.Options>
<div
className="fixed z-10 border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-custom-shadow-rg focus:outline-none w-48 whitespace-nowrap mt-1"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded-sm border-[0.6px] border-custom-border-200 bg-custom-background-90 px-2 mb-1">
<Search className="text-custom-text-400" size={12} strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 px-2 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Type to search..."
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-1 overflow-y-auto">
{options ? (
options.length > 0 ? (
options.map((option) => (
<Combobox.Option
key={option.id}
value={option.id}
className="flex items-center gap-1 cursor-pointer select-none truncate rounded px-1 py-1.5 hover:bg-custom-background-80 w-full"
>
{option.label}
</Combobox.Option>
))
) : (
<p className="text-custom-text-300 text-center py-1">
No {attributeDetails.unit}s found
</p>
)
) : (
<p className="text-custom-text-300 text-center py-1">Loading...</p>
)}
</div>
</div>
</Combobox.Options>
</Combobox>
);
};

View File

@ -0,0 +1,166 @@
import React, { useState } from "react";
import { usePopper } from "react-popper";
import { Combobox } from "@headlessui/react";
// icons
import { Check, Search, XIcon } from "lucide-react";
// types
import { ICustomAttribute } from "types";
type Props = {
attributeDetails: ICustomAttribute;
className?: string;
onChange: (val: string | string[] | undefined) => void;
} & (
| {
multiple?: false;
value: string | undefined;
}
| { multiple?: true; value: string[] | undefined }
);
export const CustomSelectAttribute: React.FC<Props> = (props) => {
const { attributeDetails, className = "", multiple = false, onChange, value } = props;
const [query, setQuery] = useState("");
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "bottom-start",
});
const options = attributeDetails.children.filter((option) =>
option.display_name.toLowerCase().includes(query.toLowerCase())
);
const comboboxProps: any = {
onChange,
value,
};
if (multiple) comboboxProps.multiple = true;
return (
<Combobox as="div" className="flex-shrink-0 text-left flex items-center" {...comboboxProps}>
<Combobox.Button as={React.Fragment}>
<button type="button" ref={setReferenceElement}>
{value ? (
Array.isArray(value) ? (
value.length > 0 ? (
<div className="flex items-center gap-1 flex-wrap">
{value.map((val) => {
const optionDetails = options.find((o) => o.id === val);
return (
<div
key={val}
className="px-2.5 py-0.5 rounded text-xs flex items-center justify-between gap-1"
style={{
backgroundColor: `${optionDetails?.color}40`,
}}
>
{optionDetails?.display_name}
{((attributeDetails.is_required && value.length > 1) ||
!attributeDetails.is_required) && (
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onChange(value.filter((v) => v !== val));
}}
>
<XIcon size={10} strokeWidth={1.5} />
</span>
)}
</div>
);
})}
</div>
) : (
<div
className={`text-xs px-2.5 py-0.5 rounded bg-custom-background-80 ${className}`}
>
Select {attributeDetails.display_name}
</div>
)
) : (
<div
className="px-2.5 py-0.5 rounded text-xs flex items-center justify-between gap-1"
style={{
backgroundColor: `${options.find((o) => o.id === value)?.color}40`,
}}
>
{options.find((o) => o.id === value)?.display_name}
{!attributeDetails.is_required && (
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onChange(undefined);
}}
>
<XIcon size={10} strokeWidth={1.5} />
</span>
)}
</div>
)
) : (
<div
className={`cursor-pointer text-xs truncate bg-custom-background-80 px-2.5 py-0.5 rounded ${className}`}
>
Select {attributeDetails.display_name}
</div>
)}
</button>
</Combobox.Button>
<Combobox.Options>
<div
className="fixed z-10 border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-custom-shadow-rg focus:outline-none w-48 whitespace-nowrap mt-1"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded-sm border-[0.5px] border-custom-border-200 bg-custom-background-90 px-2 mb-1">
<Search className="text-custom-text-400" size={12} strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 px-2 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Type to search..."
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-1 overflow-y-auto">
{(options ?? []).map((option) => (
<Combobox.Option
key={option.id}
value={option.id}
className={({ active }) =>
`flex items-center justify-between gap-1 cursor-pointer select-none truncate rounded px-1 py-1.5 w-full ${
active ? "bg-custom-background-80" : ""
}`
}
>
{({ selected }) => (
<>
<span
className="px-1 rounded-sm truncate"
style={{ backgroundColor: `${option.color}40` }}
>
{option.display_name}
</span>
{selected && <Check size={14} strokeWidth={1.5} />}
</>
)}
</Combobox.Option>
))}
</div>
</div>
</Combobox.Options>
</Combobox>
);
};

View File

@ -0,0 +1,92 @@
import { useEffect, useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// types
import { ICustomAttribute } from "types";
type Props = {
attributeDetails: ICustomAttribute;
value: string | undefined;
onChange: (val: string) => void;
};
export const CustomTextAttribute: React.FC<Props & { value: string | undefined }> = ({
attributeDetails,
onChange,
value,
}) => {
const [isEditing, setIsEditing] = useState(false);
const formRef = useRef(null);
const { control, handleSubmit, reset, setFocus } = useForm({ defaultValues: { text: "" } });
const handleFormSubmit = (data: { text: string }) => {
setIsEditing(false);
onChange(data.text);
};
useEffect(() => {
if (isEditing) {
setFocus("text");
}
}, [isEditing, setFocus]);
useEffect(() => {
reset({ text: value ?? "" });
}, [reset, value]);
useEffect(() => {
const handleEscKeyPress = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsEditing(false);
};
document.addEventListener("keydown", handleEscKeyPress);
return () => {
document.removeEventListener("keydown", handleEscKeyPress);
};
}, []);
useOutsideClickDetector(formRef, () => {
setIsEditing(false);
});
return (
<div className="flex-shrink-0 flex">
{!isEditing && (
<button
type="button"
onClick={() => setIsEditing(true)}
className="cursor-pointer text-xs truncate bg-custom-background-80 px-2.5 py-0.5 rounded"
>
{value && value !== "" ? value : "Empty"}
</button>
)}
{isEditing && (
<form
onSubmit={handleSubmit(handleFormSubmit)}
className="flex items-center flex-grow"
ref={formRef}
>
<Controller
control={control}
name="text"
render={({ field }) => (
<input
type="text"
className="text-xs px-2 py-0.5 bg-custom-background-80 rounded w-full outline-none"
required={attributeDetails.is_required}
placeholder={attributeDetails.display_name}
{...field}
/>
)}
/>
</form>
)}
</div>
);
};

View File

@ -0,0 +1,95 @@
import { useEffect, useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// types
import { ICustomAttribute } from "types";
type Props = {
attributeDetails: ICustomAttribute;
value: string | undefined;
onChange: (val: string) => void;
};
export const CustomUrlAttribute: React.FC<Props & { value: string | undefined }> = ({
attributeDetails,
onChange,
value,
}) => {
const [isEditing, setIsEditing] = useState(false);
const formRef = useRef(null);
const { control, handleSubmit, reset, setFocus } = useForm({ defaultValues: { url: "" } });
const handleFormSubmit = (data: { url: string }) => {
setIsEditing(false);
onChange(data.url);
};
useEffect(() => {
if (isEditing) setFocus("url");
}, [isEditing, setFocus]);
useEffect(() => {
reset({ url: value?.toString() });
}, [reset, value]);
useEffect(() => {
const handleEscKeyPress = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsEditing(false);
};
document.addEventListener("keydown", handleEscKeyPress);
return () => {
document.removeEventListener("keydown", handleEscKeyPress);
};
}, []);
useOutsideClickDetector(formRef, () => {
setIsEditing(false);
});
return (
<div className="flex-shrink-0 flex">
{!isEditing && (
<div
className="cursor-pointer text-xs truncate bg-custom-background-80 px-2.5 py-0.5 rounded"
onClick={() => setIsEditing(true)}
>
{value && value !== "" ? (
<a href={value} target="_blank" rel="noopener noreferrer">
{value}
</a>
) : (
"Empty"
)}
</div>
)}
{isEditing && (
<form
onSubmit={handleSubmit(handleFormSubmit)}
className="flex items-center flex-grow"
ref={formRef}
>
<Controller
control={control}
name="url"
render={({ field }) => (
<input
type="url"
className="text-xs px-2 py-0.5 bg-custom-background-80 rounded w-full outline-none"
required={attributeDetails.is_required}
placeholder={attributeDetails.display_name}
{...field}
/>
)}
/>
</form>
)}
</div>
);
};

View File

@ -0,0 +1,141 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import {
CheckboxAttributeForm,
DateTimeAttributeForm,
EmailAttributeForm,
FileAttributeForm,
NumberAttributeForm,
RelationAttributeForm,
SelectAttributeForm,
TextAttributeForm,
UrlAttributeForm,
} from "components/custom-attributes";
// types
import { ICustomAttribute, TCustomAttributeTypes } from "types";
type Props = {
attributeDetails: ICustomAttribute;
objectId: string;
type: TCustomAttributeTypes;
};
export const AttributeForm: React.FC<Props> = observer((props) => {
const { attributeDetails, objectId, type } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const { customAttributes } = useMobxStore();
const handleUpdateAttribute = async (data: Partial<ICustomAttribute>) => {
if (!workspaceSlug || !attributeDetails.id || !objectId) return;
await customAttributes.updateObjectAttribute(
workspaceSlug.toString(),
objectId,
attributeDetails.id,
data
);
};
const handleDeleteAttribute = async () => {
if (!workspaceSlug || !attributeDetails.id || !objectId) return;
await customAttributes.deleteObjectAttribute(
workspaceSlug.toString(),
objectId,
attributeDetails.id
);
};
switch (type) {
case "checkbox":
return (
<CheckboxAttributeForm
attributeDetails={attributeDetails}
handleDeleteAttribute={handleDeleteAttribute}
handleUpdateAttribute={handleUpdateAttribute}
/>
);
case "datetime":
return (
<DateTimeAttributeForm
attributeDetails={attributeDetails}
handleDeleteAttribute={handleDeleteAttribute}
handleUpdateAttribute={handleUpdateAttribute}
/>
);
case "email":
return (
<EmailAttributeForm
attributeDetails={attributeDetails}
handleDeleteAttribute={handleDeleteAttribute}
handleUpdateAttribute={handleUpdateAttribute}
/>
);
case "file":
return (
<FileAttributeForm
attributeDetails={attributeDetails}
handleDeleteAttribute={handleDeleteAttribute}
handleUpdateAttribute={handleUpdateAttribute}
/>
);
case "multi_select":
return (
<SelectAttributeForm
attributeDetails={attributeDetails}
handleDeleteAttribute={handleDeleteAttribute}
handleUpdateAttribute={handleUpdateAttribute}
multiple
/>
);
case "number":
return (
<NumberAttributeForm
attributeDetails={attributeDetails}
handleDeleteAttribute={handleDeleteAttribute}
handleUpdateAttribute={handleUpdateAttribute}
/>
);
case "relation":
return (
<RelationAttributeForm
attributeDetails={attributeDetails}
handleDeleteAttribute={handleDeleteAttribute}
handleUpdateAttribute={handleUpdateAttribute}
/>
);
case "select":
return (
<SelectAttributeForm
attributeDetails={attributeDetails}
handleDeleteAttribute={handleDeleteAttribute}
handleUpdateAttribute={handleUpdateAttribute}
/>
);
case "text":
return (
<TextAttributeForm
attributeDetails={attributeDetails}
handleDeleteAttribute={handleDeleteAttribute}
handleUpdateAttribute={handleUpdateAttribute}
/>
);
case "url":
return (
<UrlAttributeForm
attributeDetails={attributeDetails}
handleDeleteAttribute={handleDeleteAttribute}
handleUpdateAttribute={handleUpdateAttribute}
/>
);
default:
return null;
}
});

View File

@ -0,0 +1,176 @@
import { useState } from "react";
import Image from "next/image";
import { Controller, useForm } from "react-hook-form";
import { Disclosure } from "@headlessui/react";
// components
import { Input } from "components/custom-attributes";
// ui
import { PrimaryButton, SecondaryButton } from "components/ui";
// icons
import { CheckCircle2, ChevronDown } from "lucide-react";
// assets
import CheckRepresentation from "public/custom-attributes/checkbox/check.svg";
import ToggleSwitchRepresentation from "public/custom-attributes/checkbox/toggle-switch.svg";
// types
import { ICustomAttribute } from "types";
// constants
import { CUSTOM_ATTRIBUTES_LIST } from "constants/custom-attributes";
type Props = {
attributeDetails: ICustomAttribute;
handleDeleteAttribute: () => Promise<void>;
handleUpdateAttribute: (data: Partial<ICustomAttribute>) => Promise<void>;
};
const checkboxAttributeRepresentations = [
{
image: CheckRepresentation,
key: "check",
label: "Check",
},
{
image: ToggleSwitchRepresentation,
key: "toggle_switch",
label: "Toggle Switch",
},
];
const typeMetaData = CUSTOM_ATTRIBUTES_LIST.checkbox;
export const CheckboxAttributeForm: React.FC<Props> = (props) => {
const { attributeDetails, handleDeleteAttribute, handleUpdateAttribute } = props;
const [isRemoving, setIsRemoving] = useState(false);
const {
control,
formState: { isSubmitting },
handleSubmit,
} = useForm({ defaultValues: typeMetaData.defaultFormValues });
const handleDelete = async () => {
setIsRemoving(true);
await handleDeleteAttribute().finally(() => setIsRemoving(false));
};
return (
<Disclosure
as="div"
className="bg-custom-background-90 border border-custom-border-200 rounded"
>
{({ open }) => (
<>
<Disclosure.Button className="p-3 flex items-center justify-between gap-1 w-full">
<div className="flex items-center gap-2.5">
<typeMetaData.icon size={14} strokeWidth={1.5} />
<h6 className="text-sm">{attributeDetails.display_name ?? typeMetaData.label}</h6>
</div>
<div className={`${open ? "-rotate-180" : ""} transition-all`}>
<ChevronDown size={16} strokeWidth={1.5} rotate="180deg" />
</div>
</Disclosure.Button>
<Disclosure.Panel>
<form onSubmit={handleSubmit(handleUpdateAttribute)} className="p-3 pl-9 pt-0">
<div className="space-y-3">
<Controller
control={control}
name="display_name"
render={({ field: { onChange, value } }) => (
<Input placeholder="Enter field title" value={value} onChange={onChange} />
)}
/>
<div>
<p className="text-xs">Default value</p>
<Controller
control={control}
name="default_value"
render={({ field: { onChange, value } }) => (
<div className="mt-2 flex items-center gap-6 accent-custom-primary-100">
<div className="flex items-center gap-1 text-xs">
<input
type="radio"
name="default_value"
value="true"
id="checked"
className="scale-75"
defaultChecked={value === "true"}
onChange={(e) => onChange(e.target.value)}
/>
<label htmlFor="checked">Checked</label>
</div>
<div className="flex items-center gap-1 text-xs">
<input
type="radio"
name="default_value"
value="false"
id="unchecked"
className="scale-75"
defaultChecked={value === "false"}
onChange={(e) => onChange(e.target.value)}
/>
<label htmlFor="unchecked">Unchecked</label>
</div>
</div>
)}
/>
</div>
</div>
<div className="my-3 border-t-[0.5px] border-custom-border-200" />
<div>
<h6 className="text-xs">Show as</h6>
<div className="mt-2 flex items-center gap-4 flex-wrap">
<Controller
control={control}
name="extra_settings.representation"
render={({ field: { onChange, value } }) => (
<>
{checkboxAttributeRepresentations.map((representation) => (
<div
key={representation.key}
className={`rounded divide-y w-32 cursor-pointer border ${
value === representation.key
? "border-custom-primary-100 divide-custom-primary-100"
: "border-custom-border-200 divide-custom-border-200"
}`}
onClick={() => onChange(representation.key)}
>
<div className="h-24 p-2.5 grid place-items-center">
<Image src={representation.image} alt={representation.label} />
</div>
<div className="h-9 text-xs font-medium p-2.5 bg-custom-background-100 rounded-b flex items-center justify-between gap-2">
{representation.label}
{value === representation.key && (
<CheckCircle2
size={14}
strokeWidth={1.5}
className="text-custom-primary-100"
/>
)}
</div>
</div>
))}
</>
)}
/>
</div>
</div>
<div className="mt-8 flex items-center justify-end">
<div className="flex items-center gap-2">
<SecondaryButton type="button" onClick={handleDelete} loading={isRemoving}>
{isRemoving ? "Removing..." : "Remove"}
</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</PrimaryButton>
</div>
</div>
</form>
</Disclosure.Panel>
</>
)}
</Disclosure>
);
};

View File

@ -0,0 +1,191 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Disclosure } from "@headlessui/react";
// components
import { Input } from "components/custom-attributes";
// ui
import { CustomSelect, PrimaryButton, SecondaryButton, ToggleSwitch, Tooltip } from "components/ui";
// icons
import { ChevronDown } from "lucide-react";
// types
import { ICustomAttribute } from "types";
// constants
import { CUSTOM_ATTRIBUTES_LIST, DATE_FORMATS, TIME_FORMATS } from "constants/custom-attributes";
type Props = {
attributeDetails: ICustomAttribute;
handleDeleteAttribute: () => Promise<void>;
handleUpdateAttribute: (data: Partial<ICustomAttribute>) => Promise<void>;
};
const typeMetaData = CUSTOM_ATTRIBUTES_LIST.datetime;
export const DateTimeAttributeForm: React.FC<Props> = (props) => {
const { attributeDetails, handleDeleteAttribute, handleUpdateAttribute } = props;
const [isRemoving, setIsRemoving] = useState(false);
const {
control,
formState: { isSubmitting },
handleSubmit,
watch,
} = useForm({ defaultValues: typeMetaData.defaultFormValues });
const handleDelete = async () => {
setIsRemoving(true);
await handleDeleteAttribute().finally(() => setIsRemoving(false));
};
return (
<Disclosure
as="div"
className="bg-custom-background-90 border border-custom-border-200 rounded"
>
{({ open }) => (
<>
<Disclosure.Button className="p-3 flex items-center justify-between gap-1 w-full">
<div className="flex items-center gap-2.5">
<typeMetaData.icon size={14} strokeWidth={1.5} />
<h6 className="text-sm">{attributeDetails.display_name ?? typeMetaData.label}</h6>
</div>
<div className={`${open ? "-rotate-180" : ""} transition-all`}>
<ChevronDown size={16} strokeWidth={1.5} rotate="180deg" />
</div>
</Disclosure.Button>
<Disclosure.Panel>
<form onSubmit={handleSubmit(handleUpdateAttribute)} className="p-3 pl-9 pt-0">
<div className="space-y-3">
<Controller
control={control}
name="display_name"
render={({ field: { onChange, value } }) => (
<Input placeholder="Enter field title" value={value} onChange={onChange} />
)}
/>
<div>
<Controller
control={control}
name="extra_settings.date_format"
render={({ field: { onChange, value } }) => (
<CustomSelect
label={
<span className="text-xs">
{DATE_FORMATS.find((f) => f.value === value)?.label}
</span>
}
value={value}
onChange={onChange}
buttonClassName="bg-custom-background-100 !px-3 !py-2 !border-custom-border-200 !rounded"
optionsClassName="w-full"
input
>
{DATE_FORMATS.map((format) => (
<CustomSelect.Option key={format.value} value={format.value}>
{format.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
<Controller
control={control}
name="extra_settings.hide_date"
render={({ field: { onChange, value } }) => (
<div className="flex items-center justify-end gap-1 mt-2">
<Tooltip
tooltipContent="Cannot disable both, date and time"
disabled={!watch("extra_settings.hide_time")}
>
<div className="flex items-center gap-1">
<ToggleSwitch
value={value ?? false}
onChange={onChange}
size="sm"
disabled={watch("extra_settings.hide_time")}
/>
<span className="text-xs">Don{"'"}t show date</span>
</div>
</Tooltip>
</div>
)}
/>
</div>
<div>
<Controller
control={control}
name="extra_settings.time_format"
render={({ field: { onChange, value } }) => (
<CustomSelect
label={
<span className="text-xs">
{TIME_FORMATS.find((f) => f.value === value)?.label}
</span>
}
value={value}
onChange={onChange}
buttonClassName="bg-custom-background-100 !px-3 !py-2 !border-custom-border-200 !rounded"
optionsClassName="w-full"
input
>
{TIME_FORMATS.map((format) => (
<CustomSelect.Option key={format.value} value={format.value}>
{format.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
<Controller
control={control}
name="extra_settings.hide_time"
render={({ field: { onChange, value } }) => (
<div className="flex items-center justify-end gap-1 mt-2">
<Tooltip
tooltipContent="Cannot disable both, date and time"
disabled={!watch("extra_settings.hide_date")}
>
<div className="flex items-center gap-1">
<ToggleSwitch
value={value ?? false}
onChange={onChange}
size="sm"
disabled={watch("extra_settings.hide_date")}
/>
<span className="text-xs">Don{"'"}t show time</span>
</div>
</Tooltip>
</div>
)}
/>
</div>
</div>
<div className="mt-8 flex items-center justify-between">
<div className="flex-shrink-0 flex items-center gap-2">
<Controller
control={control}
name="is_required"
render={({ field: { onChange, value } }) => (
<ToggleSwitch value={value ?? false} onChange={onChange} />
)}
/>
<span className="text-xs">Mandatory field</span>
</div>
<div className="flex items-center gap-2">
<SecondaryButton type="button" onClick={handleDelete} loading={isRemoving}>
{isRemoving ? "Removing..." : "Remove"}
</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</PrimaryButton>
</div>
</div>
</form>
</Disclosure.Panel>
</>
)}
</Disclosure>
);
};

View File

@ -0,0 +1,106 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Disclosure } from "@headlessui/react";
// components
import { Input } from "components/custom-attributes";
// ui
import { PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui";
// icons
import { ChevronDown } from "lucide-react";
// types
import { ICustomAttribute } from "types";
// constants
import { CUSTOM_ATTRIBUTES_LIST } from "constants/custom-attributes";
type Props = {
attributeDetails: ICustomAttribute;
handleDeleteAttribute: () => Promise<void>;
handleUpdateAttribute: (data: Partial<ICustomAttribute>) => Promise<void>;
};
const typeMetaData = CUSTOM_ATTRIBUTES_LIST.email;
export const EmailAttributeForm: React.FC<Props> = (props) => {
const { attributeDetails, handleDeleteAttribute, handleUpdateAttribute } = props;
const [isRemoving, setIsRemoving] = useState(false);
const {
control,
formState: { isSubmitting },
handleSubmit,
} = useForm({ defaultValues: typeMetaData.defaultFormValues });
const handleDelete = async () => {
setIsRemoving(true);
await handleDeleteAttribute().finally(() => setIsRemoving(false));
};
return (
<Disclosure
as="div"
className="bg-custom-background-90 border border-custom-border-200 rounded"
>
{({ open }) => (
<>
<Disclosure.Button className="p-3 flex items-center justify-between gap-1 w-full">
<div className="flex items-center gap-2.5">
<typeMetaData.icon size={14} strokeWidth={1.5} />
<h6 className="text-sm">{attributeDetails.display_name ?? typeMetaData.label}</h6>
</div>
<div className={`${open ? "-rotate-180" : ""} transition-all`}>
<ChevronDown size={16} strokeWidth={1.5} rotate="180deg" />
</div>
</Disclosure.Button>
<Disclosure.Panel>
<form onSubmit={handleSubmit(handleUpdateAttribute)} className="p-3 pl-9 pt-0">
<div className="space-y-3">
<Controller
control={control}
name="display_name"
render={({ field: { onChange, value } }) => (
<Input placeholder="Enter field title" value={value} onChange={onChange} />
)}
/>
<Controller
control={control}
name="default_value"
render={({ field: { onChange, value } }) => (
<Input
type="email"
placeholder="Enter default email"
value={value?.toString()}
onChange={onChange}
/>
)}
/>
</div>
<div className="mt-8 flex items-center justify-between">
<div className="flex-shrink-0 flex items-center gap-2">
<Controller
control={control}
name="is_required"
render={({ field: { onChange, value } }) => (
<ToggleSwitch value={value ?? false} onChange={onChange} />
)}
/>
<span className="text-xs">Mandatory field</span>
</div>
<div className="flex items-center gap-2">
<SecondaryButton type="button" onClick={handleDelete} loading={isRemoving}>
{isRemoving ? "Removing..." : "Remove"}
</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</PrimaryButton>
</div>
</div>
</form>
</Disclosure.Panel>
</>
)}
</Disclosure>
);
};

View File

@ -0,0 +1,91 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Disclosure } from "@headlessui/react";
// components
import { FileFormatsDropdown, Input } from "components/custom-attributes";
// ui
import { PrimaryButton, SecondaryButton } from "components/ui";
// icons
import { ChevronDown } from "lucide-react";
// types
import { ICustomAttribute } from "types";
// constants
import { CUSTOM_ATTRIBUTES_LIST } from "constants/custom-attributes";
type Props = {
attributeDetails: ICustomAttribute;
handleDeleteAttribute: () => Promise<void>;
handleUpdateAttribute: (data: Partial<ICustomAttribute>) => Promise<void>;
};
const typeMetaData = CUSTOM_ATTRIBUTES_LIST.file;
export const FileAttributeForm: React.FC<Props> = (props) => {
const { attributeDetails, handleDeleteAttribute, handleUpdateAttribute } = props;
const [isRemoving, setIsRemoving] = useState(false);
const {
control,
formState: { isSubmitting },
handleSubmit,
} = useForm({ defaultValues: typeMetaData.defaultFormValues });
const handleDelete = async () => {
setIsRemoving(true);
await handleDeleteAttribute().finally(() => setIsRemoving(false));
};
return (
<Disclosure
as="div"
className="bg-custom-background-90 border border-custom-border-200 rounded"
>
{({ open }) => (
<>
<Disclosure.Button className="p-3 flex items-center justify-between gap-1 w-full">
<div className="flex items-center gap-2.5">
<typeMetaData.icon size={14} strokeWidth={1.5} />
<h6 className="text-sm">{attributeDetails.display_name ?? typeMetaData.label}</h6>
</div>
<div className={`${open ? "-rotate-180" : ""} transition-all`}>
<ChevronDown size={16} strokeWidth={1.5} rotate="180deg" />
</div>
</Disclosure.Button>
<Disclosure.Panel>
<form onSubmit={handleSubmit(handleUpdateAttribute)} className="p-3 pl-9 pt-0">
<div className="space-y-3">
<Controller
control={control}
name="display_name"
render={({ field: { onChange, value } }) => (
<Input placeholder="Enter field title" value={value} onChange={onChange} />
)}
/>
<Controller
control={control}
name="extra_settings.file_formats"
render={({ field: { onChange, value } }) => (
<FileFormatsDropdown value={value} onChange={onChange} />
)}
/>
</div>
<div className="mt-8 flex items-center justify-end">
<div className="flex items-center gap-2">
<SecondaryButton type="button" onClick={handleDelete} loading={isRemoving}>
{isRemoving ? "Removing..." : "Remove"}
</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</PrimaryButton>
</div>
</div>
</form>
</Disclosure.Panel>
</>
)}
</Disclosure>
);
};

View File

@ -0,0 +1,10 @@
export * from "./select-attribute";
export * from "./attribute-form";
export * from "./checkbox-attribute-form";
export * from "./date-time-attribute-form";
export * from "./email-attribute-form";
export * from "./file-attribute-form";
export * from "./number-attribute-form";
export * from "./relation-attribute-form";
export * from "./text-attribute-form";
export * from "./url-attribute-form";

View File

@ -0,0 +1,226 @@
import { useState } from "react";
import Image from "next/image";
import { Controller, useForm } from "react-hook-form";
import { Disclosure } from "@headlessui/react";
// components
import { ColorPicker, Input } from "components/custom-attributes";
// ui
import { PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui";
// icons
import { CheckCircle2, ChevronDown } from "lucide-react";
// assets
import NumericalRepresentation from "public/custom-attributes/number/numerical.svg";
import BarRepresentation from "public/custom-attributes/number/bar.svg";
import RingRepresentation from "public/custom-attributes/number/ring.svg";
// types
import { ICustomAttribute } from "types";
// constants
import { CUSTOM_ATTRIBUTES_LIST } from "constants/custom-attributes";
type Props = {
attributeDetails: ICustomAttribute;
handleDeleteAttribute: () => Promise<void>;
handleUpdateAttribute: (data: Partial<ICustomAttribute>) => Promise<void>;
};
const numberAttributeRepresentations = [
{
image: NumericalRepresentation,
key: "numerical",
label: "Numerical",
},
{
image: BarRepresentation,
key: "bar",
label: "Bar",
},
{
image: RingRepresentation,
key: "ring",
label: "Ring",
},
];
const typeMetaData = CUSTOM_ATTRIBUTES_LIST.number;
export const NumberAttributeForm: React.FC<Props> = (props) => {
const { attributeDetails, handleDeleteAttribute, handleUpdateAttribute } = props;
const [isRemoving, setIsRemoving] = useState(false);
const {
control,
formState: { isSubmitting },
handleSubmit,
watch,
} = useForm({ defaultValues: typeMetaData.defaultFormValues });
const handleDelete = async () => {
setIsRemoving(true);
await handleDeleteAttribute().finally(() => setIsRemoving(false));
};
return (
<Disclosure
as="div"
className="bg-custom-background-90 border border-custom-border-200 rounded"
>
{({ open }) => (
<>
<Disclosure.Button className="p-3 flex items-center justify-between gap-1 w-full">
<div className="flex items-center gap-2.5">
<typeMetaData.icon size={14} strokeWidth={1.5} />
<h6 className="text-sm">{attributeDetails.display_name ?? typeMetaData.label}</h6>
</div>
<div className={`${open ? "-rotate-180" : ""} transition-all`}>
<ChevronDown size={16} strokeWidth={1.5} rotate="180deg" />
</div>
</Disclosure.Button>
<Disclosure.Panel>
<form onSubmit={handleSubmit(handleUpdateAttribute)} className="p-3 pl-9 pt-0">
<div className="space-y-3">
<Controller
control={control}
name="display_name"
render={({ field: { onChange, value } }) => (
<Input placeholder="Enter field title" value={value} onChange={onChange} />
)}
/>
<Controller
control={control}
name="default_value"
render={({ field: { onChange, value } }) => (
<Input
type="number"
placeholder="Enter default value"
value={value ?? ""}
onChange={onChange}
step={1}
/>
)}
/>
</div>
<div className="my-3 border-t-[0.5px] border-custom-border-200" />
<div>
<h6 className="text-xs">Show as</h6>
<div className="mt-2 flex items-center gap-4 flex-wrap">
<Controller
control={control}
name="extra_settings.representation"
render={({ field: { onChange, value } }) => (
<>
{numberAttributeRepresentations.map((representation) => (
<div
key={representation.key}
className={`rounded divide-y w-32 cursor-pointer border ${
value === representation.key
? "border-custom-primary-100 divide-custom-primary-100"
: "border-custom-border-200 divide-custom-border-200"
}`}
onClick={() => onChange(representation.key)}
>
<div className="h-24 p-2.5 grid place-items-center">
<Image src={representation.image} alt={representation.label} />
</div>
<div className="h-9 text-xs font-medium p-2.5 bg-custom-background-100 rounded-b flex items-center justify-between gap-2">
{representation.label}
{value === representation.key && (
<CheckCircle2
size={14}
strokeWidth={1.5}
className="text-custom-primary-100"
/>
)}
</div>
</div>
))}
</>
)}
/>
</div>
</div>
{watch &&
(watch("extra_settings.representation") === "bar" ||
watch("extra_settings.representation") === "ring") && (
<div className="mt-6 grid grid-cols-3 gap-x-2 gap-y-3 items-center">
<>
<div className="text-xs">Divided by</div>
<div className="col-span-2">
<Controller
control={control}
name="extra_settings.divided_by"
render={({ field: { onChange, value } }) => (
<Input
type="number"
placeholder="Maximum value"
value={value}
onChange={onChange}
className="hide-arrows"
min={0}
step={1}
defaultValue={100}
required
/>
)}
/>
</div>
</>
<>
<div className="text-xs">Color</div>
<div className="col-span-2">
<Controller
control={control}
name="color"
render={({ field: { onChange, value } }) => (
<ColorPicker
onChange={onChange}
selectedColor={value ?? "#000000"}
size={18}
/>
)}
/>
</div>
</>
<>
<div className="text-xs">Show number</div>
<div className="col-span-2">
<Controller
control={control}
name="extra_settings.show_number"
render={({ field: { onChange, value } }) => (
<ToggleSwitch value={value} onChange={onChange} />
)}
/>
</div>
</>
</div>
)}
<div className="mt-8 flex items-center justify-between">
<div className="flex-shrink-0 flex items-center gap-2">
<Controller
control={control}
name="is_required"
render={({ field: { onChange, value } }) => (
<ToggleSwitch value={value ?? false} onChange={onChange} />
)}
/>
<span className="text-xs">Mandatory field</span>
</div>
<div className="flex items-center gap-2">
<SecondaryButton type="button" onClick={handleDelete} loading={isRemoving}>
{isRemoving ? "Removing..." : "Remove"}
</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</PrimaryButton>
</div>
</div>
</form>
</Disclosure.Panel>
</>
)}
</Disclosure>
);
};

View File

@ -0,0 +1,135 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Disclosure } from "@headlessui/react";
// components
import { Input } from "components/custom-attributes";
// ui
import { CustomSelect, PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui";
// icons
import { ChevronDown } from "lucide-react";
// types
import { ICustomAttribute } from "types";
// constants
import { CUSTOM_ATTRIBUTES_LIST, CUSTOM_ATTRIBUTE_UNITS } from "constants/custom-attributes";
type Props = {
attributeDetails: ICustomAttribute;
handleDeleteAttribute: () => Promise<void>;
handleUpdateAttribute: (data: Partial<ICustomAttribute>) => Promise<void>;
};
const typeMetaData = CUSTOM_ATTRIBUTES_LIST.relation;
export const RelationAttributeForm: React.FC<Props> = (props) => {
const { attributeDetails, handleDeleteAttribute, handleUpdateAttribute } = props;
const [isRemoving, setIsRemoving] = useState(false);
const {
control,
formState: { isSubmitting },
handleSubmit,
} = useForm({ defaultValues: typeMetaData.defaultFormValues });
const handleDelete = async () => {
setIsRemoving(true);
await handleDeleteAttribute().finally(() => setIsRemoving(false));
};
return (
<Disclosure
as="div"
className="bg-custom-background-90 border border-custom-border-200 rounded"
>
{({ open }) => (
<>
<Disclosure.Button className="p-3 flex items-center justify-between gap-1 w-full">
<div className="flex items-center gap-2.5">
<typeMetaData.icon size={14} strokeWidth={1.5} />
<h6 className="text-sm">{attributeDetails.display_name ?? typeMetaData.label}</h6>
</div>
<div className={`${open ? "-rotate-180" : ""} transition-all`}>
<ChevronDown size={16} strokeWidth={1.5} rotate="180deg" />
</div>
</Disclosure.Button>
<Disclosure.Panel>
<form onSubmit={handleSubmit(handleUpdateAttribute)} className="p-3 pl-9 pt-0">
<div className="space-y-3">
<Controller
control={control}
name="display_name"
render={({ field: { onChange, value } }) => (
<Input placeholder="Enter field title" value={value} onChange={onChange} />
)}
/>
<Controller
control={control}
name="unit"
render={({ field: { onChange, value } }) => (
<CustomSelect
label={<span className="capitalize text-xs">{value}</span>}
value={value}
onChange={onChange}
buttonClassName="bg-custom-background-100 !px-3 !py-2 !border-custom-border-200 !rounded"
optionsClassName="w-full"
input
>
{CUSTOM_ATTRIBUTE_UNITS.map((unit) => (
<CustomSelect.Option key={unit.value} value={unit.value}>
{unit.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
{/* <div>
<p className="text-xs">Selection type</p>
<div className="mt-2 flex items-center gap-6 accent-custom-primary-100">
<div className="flex items-center gap-1 text-xs">
<input
type="radio"
name="is_multi"
value="false"
id="singleSelect"
className="scale-75"
defaultChecked
/>
<label htmlFor="singleSelect">Single Select</label>
</div>
<div className="flex items-center gap-1 text-xs">
<input type="radio" name="is_multi" value="true" id="multiSelect" className="scale-75" />
<label htmlFor="multiSelect">Multi select</label>
</div>
</div>
</div> */}
</div>
<div className="mt-8 flex items-center justify-between">
<div className="flex-shrink-0 flex items-center gap-2">
<Controller
control={control}
name="is_required"
render={({ field: { onChange, value } }) => (
<ToggleSwitch value={value ?? false} onChange={onChange} />
)}
/>
<span className="text-xs">Mandatory field</span>
</div>
<div className="flex items-center gap-2">
<SecondaryButton type="button" onClick={handleDelete} loading={isRemoving}>
{isRemoving ? "Removing..." : "Remove"}
</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</PrimaryButton>
</div>
</div>
</form>
</Disclosure.Panel>
</>
)}
</Disclosure>
);
};

View File

@ -0,0 +1,3 @@
export * from "./option-form";
export * from "./select-attribute-form";
export * from "./select-option";

View File

@ -0,0 +1,128 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { ColorPicker } from "components/custom-attributes";
// ui
import { PrimaryButton } from "components/ui";
// helpers
import { getRandomColor } from "helpers/color.helper";
// types
import { ICustomAttribute } from "types";
type Props = {
data: ICustomAttribute | null;
objectId: string;
onSubmit?: () => void;
parentId: string;
};
export const OptionForm: React.FC<Props> = observer((props) => {
const { data, objectId, onSubmit, parentId } = props;
const [option, setOption] = useState<Partial<ICustomAttribute>>({
display_name: "",
color: getRandomColor(),
});
const [isEditing, setIsEditing] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { customAttributes } = useMobxStore();
const handleCreateOption = async () => {
if (!workspaceSlug) return;
if (option.display_name === "") return;
const payload: Partial<ICustomAttribute> = {
color: option.color,
display_name: option.display_name,
type: "option",
};
await customAttributes.createAttributeOption(workspaceSlug.toString(), objectId, {
...payload,
parent: parentId,
});
};
const handleUpdateOption = async () => {
if (!workspaceSlug) return;
if (option.display_name === "" || !option.parent || !option.id) return;
setIsEditing(true);
const payload: Partial<ICustomAttribute> = {
color: option.color,
display_name: option.display_name,
};
await customAttributes
.updateAttributeOption(workspaceSlug.toString(), objectId, option.parent, option.id, payload)
.finally(() => setIsEditing(false));
};
const handleFormSubmit = async () => {
if (data) await handleUpdateOption();
else await handleCreateOption();
setOption({
display_name: "",
color: getRandomColor(),
});
if (onSubmit) onSubmit();
};
useEffect(() => {
if (!data) return;
setOption({ ...data });
}, [data]);
return (
<div className="flex items-center gap-2">
<div className="bg-custom-background-100 rounded border border-custom-border-200 flex items-center gap-2 px-3 py-2 flex-grow">
<input
type="text"
className="flex-grow border-none outline-none placeholder:text-custom-text-400 text-xs bg-transparent"
value={option.display_name}
onChange={(e) => setOption((prev) => ({ ...prev, display_name: e.target.value }))}
placeholder="Enter new option"
/>
<ColorPicker
onChange={(val) => setOption((prev) => ({ ...prev, color: val }))}
selectedColor={option.color ?? getRandomColor()}
/>
</div>
<div className="flex-shrink-0">
{data ? (
<PrimaryButton
onClick={handleFormSubmit}
size="sm"
className="!py-1.5 !px-2"
loading={isEditing}
>
{isEditing ? "Updating..." : "Update"}
</PrimaryButton>
) : (
<PrimaryButton
onClick={handleFormSubmit}
size="sm"
className="!py-1.5 !px-2"
loading={customAttributes.createAttributeOptionLoader}
>
{customAttributes.createAttributeOptionLoader ? "Adding..." : "Add"}
</PrimaryButton>
)}
</div>
</div>
);
});

View File

@ -0,0 +1,142 @@
import React, { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Controller, useForm } from "react-hook-form";
import { Disclosure } from "@headlessui/react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { Input, OptionForm, SelectOption } from "components/custom-attributes";
// ui
import { PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui";
// icons
import { ChevronDown } from "lucide-react";
// types
import { ICustomAttribute } from "types";
// constants
import { CUSTOM_ATTRIBUTES_LIST } from "constants/custom-attributes";
type Props = {
attributeDetails: ICustomAttribute;
handleDeleteAttribute: () => Promise<void>;
handleUpdateAttribute: (data: Partial<ICustomAttribute>) => Promise<void>;
};
const typeMetaData = CUSTOM_ATTRIBUTES_LIST.select;
export const SelectAttributeForm: React.FC<Props & { multiple?: boolean }> = observer((props) => {
const {
attributeDetails,
handleDeleteAttribute,
handleUpdateAttribute,
multiple = false,
} = props;
const [optionToEdit, setOptionToEdit] = useState<ICustomAttribute | null>(null);
const [isRemoving, setIsRemoving] = useState(false);
const { customAttributes } = useMobxStore();
const {
control,
formState: { isSubmitting },
handleSubmit,
reset,
watch,
} = useForm({ defaultValues: typeMetaData.defaultFormValues });
const options =
customAttributes.objectAttributes?.[attributeDetails.parent ?? ""]?.[watch("id") ?? ""]
?.children;
const handleDelete = async () => {
setIsRemoving(true);
await handleDeleteAttribute().finally(() => setIsRemoving(false));
};
useEffect(() => {
if (!attributeDetails) return;
reset({
...typeMetaData.defaultFormValues,
...attributeDetails,
});
}, [attributeDetails, reset]);
return (
<Disclosure
as="div"
className="bg-custom-background-90 border border-custom-border-200 rounded"
>
{({ open }) => (
<>
<Disclosure.Button className="p-3 flex items-center justify-between gap-1 w-full">
<div className="flex items-center gap-2.5">
<typeMetaData.icon size={14} strokeWidth={1.5} />
<h6 className="text-sm">{attributeDetails.display_name ?? typeMetaData.label}</h6>
</div>
<div className={`${open ? "-rotate-180" : ""} transition-all`}>
<ChevronDown size={16} strokeWidth={1.5} rotate="180deg" />
</div>
</Disclosure.Button>
<Disclosure.Panel>
<form onSubmit={handleSubmit(handleUpdateAttribute)} className="p-3 pl-9 pt-0">
<div className="space-y-3">
<Controller
control={control}
name="display_name"
render={({ field: { onChange, value } }) => (
<Input placeholder="Enter field title" value={value} onChange={onChange} />
)}
/>
<div>
<p className="text-xs">Options</p>
<div className="mt-3 space-y-2 w-3/5">
{options?.map((option) => (
<SelectOption
key={option.id}
handleEditOption={() => setOptionToEdit(option)}
objectId={attributeDetails.parent ?? ""}
option={option}
/>
))}
</div>
<div className="mt-2 w-3/5">
<OptionForm
data={optionToEdit}
objectId={attributeDetails.parent ?? ""}
onSubmit={() => setOptionToEdit(null)}
parentId={watch("id") ?? ""}
/>
</div>
</div>
</div>
<div className="mt-8 flex items-center justify-between">
<div className="flex-shrink-0 flex items-center gap-2">
<Controller
control={control}
name="is_required"
render={({ field: { onChange, value } }) => (
<ToggleSwitch value={value ?? false} onChange={onChange} />
)}
/>
<span className="text-xs">Mandatory field</span>
</div>
<div className="flex items-center gap-2">
<SecondaryButton type="button" onClick={handleDelete} loading={isRemoving}>
{isRemoving ? "Removing..." : "Remove"}
</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</PrimaryButton>
</div>
</div>
</form>
</Disclosure.Panel>
</>
)}
</Disclosure>
);
});

View File

@ -0,0 +1,94 @@
import { useRouter } from "next/router";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { CustomMenu, Tooltip } from "components/ui";
// icons
import { MoreHorizontal } from "lucide-react";
// types
import { ICustomAttribute } from "types";
type Props = {
handleEditOption: () => void;
objectId: string;
option: ICustomAttribute;
};
export const SelectOption: React.FC<Props> = observer((props) => {
const { handleEditOption, objectId, option } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const { customAttributes } = useMobxStore();
const handleSetAsDefault = async () => {
if (!workspaceSlug || !option.parent) return;
await customAttributes.updateAttributeOption(
workspaceSlug.toString(),
objectId,
option.parent,
option.id,
{
is_default: true,
}
);
};
const handleDeleteOption = async () => {
if (!workspaceSlug || !option.parent) return;
await customAttributes.deleteAttributeOption(
workspaceSlug.toString(),
objectId,
option.parent,
option.id
);
};
return (
<div className="group flex items-center justify-between gap-1 hover:bg-custom-background-80 px-2 py-1 rounded">
<div className="flex items-center gap-1 flex-grow truncate">
{/* <button type="button">
<GripVertical className="text-custom-text-400" size={14} />
</button> */}
<Tooltip tooltipContent={option.display_name}>
<p
className="text-custom-text-200 text-xs p-1 rounded inline truncate"
style={{
backgroundColor: `${option.color}40`,
}}
>
{option.display_name}
</p>
</Tooltip>
</div>
<div className="flex-shrink-0 flex items-center gap-2">
{option.is_default ? (
<span className="text-custom-text-300 text-xs">Default</span>
) : (
<button
type="button"
onClick={handleSetAsDefault}
className="hidden group-hover:inline-block text-custom-text-300 text-xs"
>
Set as default
</button>
)}
<CustomMenu
customButton={
<div className="grid place-items-center">
<MoreHorizontal className="text-custom-text-400" size={14} />
</div>
}
>
<CustomMenu.MenuItem onClick={handleEditOption}>Edit</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteOption}>Delete</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
);
});

View File

@ -0,0 +1,105 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Disclosure } from "@headlessui/react";
// components
import { Input } from "components/custom-attributes";
// ui
import { PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui";
// icons
import { ChevronDown } from "lucide-react";
// types
import { ICustomAttribute } from "types";
// constants
import { CUSTOM_ATTRIBUTES_LIST } from "constants/custom-attributes";
type Props = {
attributeDetails: ICustomAttribute;
handleDeleteAttribute: () => Promise<void>;
handleUpdateAttribute: (data: Partial<ICustomAttribute>) => Promise<void>;
};
const typeMetaData = CUSTOM_ATTRIBUTES_LIST.text;
export const TextAttributeForm: React.FC<Props> = (props) => {
const { attributeDetails, handleDeleteAttribute, handleUpdateAttribute } = props;
const [isRemoving, setIsRemoving] = useState(false);
const {
control,
formState: { isSubmitting },
handleSubmit,
} = useForm({ defaultValues: typeMetaData.defaultFormValues });
const handleDelete = async () => {
setIsRemoving(true);
await handleDeleteAttribute().finally(() => setIsRemoving(false));
};
return (
<Disclosure
as="div"
className="bg-custom-background-90 border border-custom-border-200 rounded"
>
{({ open }) => (
<>
<Disclosure.Button className="p-3 flex items-center justify-between gap-1 w-full">
<div className="flex items-center gap-2.5">
<typeMetaData.icon size={14} strokeWidth={1.5} />
<h6 className="text-sm">{attributeDetails.display_name ?? typeMetaData.label}</h6>
</div>
<div className={`${open ? "-rotate-180" : ""} transition-all`}>
<ChevronDown size={16} strokeWidth={1.5} rotate="180deg" />
</div>
</Disclosure.Button>
<Disclosure.Panel>
<form onSubmit={handleSubmit(handleUpdateAttribute)} className="p-3 pl-9 pt-0">
<div className="space-y-3">
<Controller
control={control}
name="display_name"
render={({ field: { onChange, value } }) => (
<Input placeholder="Enter field title" value={value} onChange={onChange} />
)}
/>
<Controller
control={control}
name="default_value"
render={({ field: { onChange, value } }) => (
<Input
placeholder="Enter default value"
value={value ?? ""}
onChange={onChange}
/>
)}
/>
</div>
<div className="mt-8 flex items-center justify-between">
<div className="flex-shrink-0 flex items-center gap-2">
<Controller
control={control}
name="is_required"
render={({ field: { onChange, value } }) => (
<ToggleSwitch value={value ?? false} onChange={onChange} />
)}
/>
<span className="text-xs">Mandatory field</span>
</div>
<div className="flex items-center gap-2">
<SecondaryButton type="button" onClick={handleDelete} loading={isRemoving}>
{isRemoving ? "Removing..." : "Remove"}
</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</PrimaryButton>
</div>
</div>
</form>
</Disclosure.Panel>
</>
)}
</Disclosure>
);
};

View File

@ -0,0 +1,106 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Disclosure } from "@headlessui/react";
// components
import { Input } from "components/custom-attributes";
// ui
import { PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui";
// icons
import { ChevronDown } from "lucide-react";
// types
import { ICustomAttribute } from "types";
// constants
import { CUSTOM_ATTRIBUTES_LIST } from "constants/custom-attributes";
type Props = {
attributeDetails: ICustomAttribute;
handleDeleteAttribute: () => Promise<void>;
handleUpdateAttribute: (data: Partial<ICustomAttribute>) => Promise<void>;
};
const typeMetaData = CUSTOM_ATTRIBUTES_LIST.url;
export const UrlAttributeForm: React.FC<Props> = (props) => {
const { attributeDetails, handleDeleteAttribute, handleUpdateAttribute } = props;
const [isRemoving, setIsRemoving] = useState(false);
const {
control,
formState: { isSubmitting },
handleSubmit,
} = useForm({ defaultValues: typeMetaData.defaultFormValues });
const handleDelete = async () => {
setIsRemoving(true);
await handleDeleteAttribute().finally(() => setIsRemoving(false));
};
return (
<Disclosure
as="div"
className="bg-custom-background-90 border border-custom-border-200 rounded"
>
{({ open }) => (
<>
<Disclosure.Button className="p-3 flex items-center justify-between gap-1 w-full">
<div className="flex items-center gap-2.5">
<typeMetaData.icon size={14} strokeWidth={1.5} />
<h6 className="text-sm">{attributeDetails.display_name ?? typeMetaData.label}</h6>
</div>
<div className={`${open ? "-rotate-180" : ""} transition-all`}>
<ChevronDown size={16} strokeWidth={1.5} rotate="180deg" />
</div>
</Disclosure.Button>
<Disclosure.Panel>
<form onSubmit={handleSubmit(handleUpdateAttribute)} className="p-3 pl-9 pt-0">
<div className="space-y-3">
<Controller
control={control}
name="display_name"
render={({ field: { onChange, value } }) => (
<Input placeholder="Enter field title" value={value} onChange={onChange} />
)}
/>
<Controller
control={control}
name="default_value"
render={({ field: { onChange, value } }) => (
<Input
type="url"
placeholder="Enter default URL"
value={value ?? ""}
onChange={onChange}
/>
)}
/>
</div>
<div className="mt-8 flex items-center justify-between">
<div className="flex-shrink-0 flex items-center gap-2">
<Controller
control={control}
name="is_required"
render={({ field: { onChange, value } }) => (
<ToggleSwitch value={value ?? false} onChange={onChange} />
)}
/>
<span className="text-xs">Mandatory field</span>
</div>
<div className="flex items-center gap-2">
<SecondaryButton type="button" onClick={handleDelete} loading={isRemoving}>
{isRemoving ? "Removing..." : "Remove"}
</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</PrimaryButton>
</div>
</div>
</form>
</Disclosure.Panel>
</>
)}
</Disclosure>
);
};

View File

@ -0,0 +1,3 @@
export * from "./issue-modal";
export * from "./peek-overview-custom-attributes-list";
export * from "./sidebar-custom-attributes-list";

View File

@ -0,0 +1,46 @@
// mobx
import { useMobxStore } from "lib/mobx/store-provider";
import { observer } from "mobx-react-lite";
// components
import { CustomCheckboxAttribute } from "components/custom-attributes";
// ui
import { Tooltip } from "components/ui";
type Props = {
objectId: string;
issueId: string;
onChange: (attributeId: string, val: string | string[] | undefined) => void;
projectId: string;
values: { [key: string]: string[] };
};
export const CustomAttributesCheckboxes: React.FC<Props> = observer((props) => {
const { objectId, issueId, onChange, projectId, values } = props;
const { customAttributes } = useMobxStore();
const attributes = customAttributes.objectAttributes[objectId] ?? {};
const checkboxFields = Object.values(attributes).filter((a) => a.type === "checkbox");
if (checkboxFields.length === 0) return null;
return (
<div className="space-y-4">
{Object.entries(checkboxFields).map(([attributeId, attribute]) => (
<div key={attributeId} className="flex items-center gap-2">
<Tooltip tooltipContent={attribute.display_name} position="top-left">
<p className="text-xs text-custom-text-300 w-2/5 truncate">{attribute.display_name}</p>
</Tooltip>
<div className="w-3/5 flex-shrink-0">
<CustomCheckboxAttribute
attributeDetails={attribute}
onChange={(val) => onChange(attribute.id, [`${val}`])}
value={values[attribute.id]?.[0] === "true" ? true : false}
/>
</div>
</div>
))}
</div>
);
});

View File

@ -0,0 +1,90 @@
import { useState } from "react";
// mobx
import { useMobxStore } from "lib/mobx/store-provider";
import { observer } from "mobx-react-lite";
// headless ui
import { Disclosure } from "@headlessui/react";
// ui
import { ToggleSwitch } from "components/ui";
// icons
import { ChevronDown } from "lucide-react";
// types
import { TCustomAttributeTypes } from "types";
type Props = {
objectId: string;
issueId: string;
onChange: (attributeId: string, val: string | string[] | undefined) => void;
projectId: string;
values: { [key: string]: string[] };
};
const DESCRIPTION_FIELDS: TCustomAttributeTypes[] = ["email", "number", "text", "url"];
export const CustomAttributesDescriptionFields: React.FC<Props> = observer((props) => {
const { objectId, onChange, values } = props;
const [hideOptionalFields, setHideOptionalFields] = useState(false);
const { customAttributes } = useMobxStore();
const attributes = customAttributes.objectAttributes[objectId] ?? {};
const descriptionFields = Object.values(attributes).filter((a) =>
DESCRIPTION_FIELDS.includes(a.type)
);
if (descriptionFields.length === 0) return null;
return (
<Disclosure as="div" defaultOpen>
{({ open }) => (
<>
<div className="flex items-center justify-between gap-2">
<Disclosure.Button className="font-medium flex items-center gap-2">
<ChevronDown
className={`transition-all ${open ? "" : "-rotate-90"}`}
size={14}
strokeWidth={1.5}
/>
Custom Attributes
</Disclosure.Button>
<div className={`flex items-center gap-1 ${open ? "" : "hidden"}`}>
<span className="text-xs">Hide optional fields</span>
<ToggleSwitch
value={hideOptionalFields}
onChange={() => setHideOptionalFields((prev) => !prev)}
/>
</div>
</div>
<Disclosure.Panel className="space-y-3.5 mt-2">
{Object.entries(descriptionFields).map(([attributeId, attribute]) => (
<div
key={attributeId}
className={hideOptionalFields && attribute.is_required ? "hidden" : ""}
>
<input
type={attribute.type}
className="border border-custom-border-200 rounded w-full px-2 py-1.5 text-xs placeholder:text-custom-text-400 focus:outline-none"
placeholder={attribute.display_name}
min={attribute.extra_settings.divided_by ? 0 : undefined}
max={attribute.extra_settings.divided_by ?? undefined}
value={values[attribute.id]?.[0]}
onChange={(e) => onChange(attribute.id, e.target.value)}
required={attribute.is_required}
/>
{attribute.type === "number" &&
attribute.extra_settings?.representation !== "numerical" && (
<span className="text-custom-text-400 text-[10px]">
Maximum value: {attribute.extra_settings?.divided_by}
</span>
)}
</div>
))}
</Disclosure.Panel>
</>
)}
</Disclosure>
);
});

View File

@ -0,0 +1,242 @@
import { useCallback, useState } from "react";
import { useRouter } from "next/router";
// mobx
import { useMobxStore } from "lib/mobx/store-provider";
import { observer } from "mobx-react-lite";
// react-dropzone
import { useDropzone } from "react-dropzone";
// headless ui
import { Disclosure } from "@headlessui/react";
// services
import fileService from "services/file.service";
// hooks
import useWorkspaceDetails from "hooks/use-workspace-details";
import useToast from "hooks/use-toast";
// components
// ui
import { Loader, Tooltip } from "components/ui";
// icons
import { ChevronDown, Plus, Trash2 } from "lucide-react";
import { getFileIcon } from "components/icons";
// helpers
import { getFileExtension } from "helpers/attachment.helper";
// types
import { ICustomAttribute } from "types";
// constants
import { MAX_FILE_SIZE } from "constants/workspace";
type Props = {
objectId: string;
issueId: string;
onChange: (attributeId: string, val: string | string[] | undefined) => void;
projectId: string;
values: { [key: string]: string[] };
};
type FileUploadProps = {
attributeDetails: ICustomAttribute;
className?: string;
issueId: string;
projectId: string;
value: string | undefined;
onChange: (val: string | undefined) => void;
};
const UploadFile: React.FC<FileUploadProps> = (props) => {
const { attributeDetails, className = "", onChange, value } = props;
const [isUploading, setIsUploading] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { workspaceDetails } = useWorkspaceDetails();
const { setToastAlert } = useToast();
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (!acceptedFiles[0] || !workspaceSlug) return;
const extension = getFileExtension(acceptedFiles[0].name);
if (!attributeDetails.extra_settings?.file_formats?.includes(`.${extension}`)) {
setToastAlert({
type: "error",
title: "Error!",
message: `File format not accepted. Accepted file formats- ${attributeDetails.extra_settings?.file_formats?.join(
", "
)}`,
});
return;
}
const formData = new FormData();
formData.append("asset", acceptedFiles[0]);
formData.append(
"attributes",
JSON.stringify({
name: acceptedFiles[0].name,
size: acceptedFiles[0].size,
})
);
setIsUploading(true);
fileService
.uploadFile(workspaceSlug.toString(), formData)
.then((res) => {
const imageUrl = res.asset;
onChange(imageUrl);
if (value && value !== "" && workspaceDetails)
fileService.deleteFile(workspaceDetails.id, value);
})
.finally(() => setIsUploading(false));
},
[
attributeDetails.extra_settings?.file_formats,
onChange,
setToastAlert,
value,
workspaceDetails,
workspaceSlug,
]
);
const handleRemoveFile = () => {
if (!workspaceDetails || !value || value === "") return;
onChange(undefined);
fileService.deleteFile(workspaceDetails.id, value);
};
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
onDrop,
maxSize: MAX_FILE_SIZE,
multiple: false,
disabled: isUploading,
});
const fileError =
fileRejections.length > 0
? `Invalid file type or size (max ${MAX_FILE_SIZE / 1024 / 1024} MB)`
: null;
return (
<div className="flex-shrink-0 truncate space-y-1">
{value && value !== "" ? (
<div className="group flex items-center justify-between gap-1.5 h-10 px-2.5 rounded border border-custom-border-200 text-xs truncate w-full">
<a
href={value}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 flex-grow truncate"
>
<span className="flex-shrink-0 h-6 w-6 grid place-items-center border border-custom-border-200 p-1 rounded-sm">
{getFileIcon(getFileExtension(value))}
</span>
<span className="truncate">
{value.split("/")[value.split("/").length - 1].split("-")[1]}
</span>
</a>
<button
type="button"
className="opacity-0 group-hover:opacity-100 grid place-items-center flex-shrink-0"
onClick={handleRemoveFile}
>
<Trash2 size={12} strokeWidth={1.5} />
</button>
</div>
) : (
<div
{...getRootProps()}
className={`flex items-center text-xs rounded cursor-pointer truncate w-full ${
isDragActive ? "bg-custom-primary-100/10" : ""
} ${isDragReject ? "bg-red-500/10" : ""} ${className}`}
>
<input className="flex-shrink-0" {...getInputProps()} />
<div
className={`border border-dashed flex items-center gap-1.5 h-10 px-2.5 rounded text-custom-text-400 w-full ${
fileError ? "border-red-500" : "border-custom-primary-100"
}`}
>
<span className="bg-custom-primary-100/10 rounded-sm text-custom-primary-100 h-4 w-4 grid place-items-center">
<Plus size={10} strokeWidth={1.5} />
</span>
{isDragActive ? (
<span>Drop here</span>
) : fileError ? (
<span>File error</span>
) : isUploading ? (
<span>Uploading...</span>
) : (
<span>Drag and drop files</span>
)}
</div>
</div>
)}
</div>
);
};
export const CustomAttributesFileUploads: React.FC<Props> = observer((props) => {
const { objectId, onChange, issueId, projectId, values } = props;
const { customAttributes } = useMobxStore();
const attributes = customAttributes.objectAttributes[objectId] ?? {};
const fileUploadFields = Object.values(attributes).filter((a) => a.type === "file");
if (fileUploadFields.length === 0) return null;
return (
<>
{customAttributes.fetchObjectDetailsLoader ? (
<Loader className="space-y-3.5">
<Loader.Item height="35px" />
<Loader.Item height="35px" />
<Loader.Item height="35px" />
</Loader>
) : (
<Disclosure as="div" defaultOpen>
{({ open }) => (
<>
<Disclosure.Button className="font-medium flex items-center gap-2">
<ChevronDown
className={`transition-all ${open ? "" : "-rotate-90"}`}
size={14}
strokeWidth={1.5}
/>
Attachment attributes
</Disclosure.Button>
<Disclosure.Panel className="grid grid-cols-3 gap-4 mt-2">
{Object.entries(fileUploadFields).map(([attributeId, attribute]) => (
<div key={attributeId}>
<Tooltip tooltipContent={attribute.display_name} position="top-left">
<p className="text-xs text-custom-text-300 truncate">
{attribute.display_name}
</p>
</Tooltip>
<div className="flex-shrink-0 mt-2">
<UploadFile
attributeDetails={attribute}
issueId={issueId}
onChange={(val) => onChange(attribute.id, val)}
projectId={projectId}
value={values[attribute.id]?.[0] ? values[attribute.id]?.[0] : undefined}
/>
</div>
</div>
))}
</Disclosure.Panel>
</>
)}
</Disclosure>
)}
</>
);
});

View File

@ -0,0 +1,4 @@
export * from "./checkboxes";
export * from "./description-fields";
export * from "./file-uploads";
export * from "./select-fields";

View File

@ -0,0 +1,94 @@
// mobx
import { useMobxStore } from "lib/mobx/store-provider";
import { observer } from "mobx-react-lite";
// components
import {
CustomDateTimeAttribute,
CustomFileAttribute,
CustomRelationAttribute,
CustomSelectAttribute,
} from "components/custom-attributes";
// ui
import { Loader } from "components/ui";
// types
import { TCustomAttributeTypes } from "types";
type Props = {
objectId: string;
issueId: string;
onChange: (attributeId: string, val: string | string[] | undefined) => void;
projectId: string;
values: { [key: string]: string[] };
};
const SELECT_FIELDS: TCustomAttributeTypes[] = ["datetime", "multi_select", "relation", "select"];
export const CustomAttributesSelectFields: React.FC<Props> = observer((props) => {
const { objectId, issueId, onChange, projectId, values } = props;
const { customAttributes } = useMobxStore();
const attributes = customAttributes.objectAttributes[objectId] ?? {};
const selectFields = Object.values(attributes).filter((a) => SELECT_FIELDS.includes(a.type));
return (
<>
{customAttributes.fetchObjectDetailsLoader ? (
<Loader className="flex items-center gap-2">
<Loader.Item height="27px" width="90px" />
<Loader.Item height="27px" width="90px" />
<Loader.Item height="27px" width="90px" />
</Loader>
) : (
Object.entries(selectFields).map(([attributeId, attribute]) => (
<div key={attributeId}>
{attribute.type === "datetime" && (
<CustomDateTimeAttribute
attributeDetails={attribute}
className="bg-transparent border border-custom-border-200 py-1 shadow-custom-shadow-2xs"
onChange={(val) => onChange(attribute.id, val ? [val.toISOString()] : undefined)}
value={values[attribute.id]?.[0] ? new Date(values[attribute.id]?.[0]) : undefined}
/>
)}
{attribute.type === "file" && (
<CustomFileAttribute
attributeDetails={attribute}
className="bg-transparent border border-custom-border-200 py-1 shadow-custom-shadow-2xs"
onChange={(val) => onChange(attribute.id, val)}
value={values[attribute.id]?.[0]}
/>
)}
{attribute.type === "multi_select" && (
<CustomSelectAttribute
attributeDetails={attribute}
className="bg-transparent border border-custom-border-200 py-1 shadow-custom-shadow-2xs"
onChange={(val) => onChange(attribute.id, val)}
value={values[attribute.id] ?? []}
multiple
/>
)}
{attribute.type === "relation" && (
<CustomRelationAttribute
attributeDetails={attribute}
className="bg-transparent border border-custom-border-200 py-1 shadow-custom-shadow-2xs"
onChange={(val) => onChange(attribute.id, val)}
projectId={projectId}
value={values[attribute.id]?.[0]}
/>
)}
{attribute.type === "select" && (
<CustomSelectAttribute
attributeDetails={attribute}
className="bg-transparent border border-custom-border-200 py-1 shadow-custom-shadow-2xs"
onChange={(val) => onChange(attribute.id, val)}
value={values[attribute.id]?.[0]}
multiple={false}
/>
)}
</div>
))
)}
</>
);
});

View File

@ -0,0 +1,223 @@
import { useEffect } from "react";
import { useRouter } from "next/router";
// mobx
import { useMobxStore } from "lib/mobx/store-provider";
import { observer } from "mobx-react-lite";
// components
import {
CustomCheckboxAttribute,
CustomDateTimeAttribute,
CustomEmailAttribute,
CustomFileAttribute,
CustomNumberAttribute,
CustomRelationAttribute,
CustomSelectAttribute,
CustomTextAttribute,
CustomUrlAttribute,
} from "components/custom-attributes";
// ui
import { Loader } from "components/ui";
// types
import { ICustomAttributeValueFormData, IIssue } from "types";
// constants
import { CUSTOM_ATTRIBUTES_LIST } from "constants/custom-attributes";
type Props = {
issue: IIssue | undefined;
projectId: string;
};
export const PeekOverviewCustomAttributesList: React.FC<Props> = observer(
({ issue, projectId }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { customAttributes, customAttributeValues } = useMobxStore();
const handleAttributeUpdate = (attributeId: string, value: string | string[] | undefined) => {
if (!issue || !workspaceSlug) return;
if (!value) {
customAttributeValues.deleteAttributeValue(
workspaceSlug.toString(),
projectId,
issue.id,
attributeId
);
return;
}
const payload: ICustomAttributeValueFormData = {
issue_properties: {
[attributeId]: Array.isArray(value) ? value : [value],
},
};
customAttributeValues.createAttributeValue(
workspaceSlug.toString(),
issue.project,
issue.id,
payload
);
};
// fetch the object details if object state has id
useEffect(() => {
if (!issue?.entity) return;
if (!customAttributes.objectAttributes[issue.entity]) {
if (!workspaceSlug) return;
customAttributes.fetchObjectDetails(workspaceSlug.toString(), issue.entity);
}
}, [customAttributes, issue?.entity, workspaceSlug]);
// fetch issue attribute values
useEffect(() => {
if (!issue) return;
if (
!customAttributeValues.issueAttributeValues ||
!customAttributeValues.issueAttributeValues[issue.id]
) {
if (!workspaceSlug) return;
customAttributeValues.fetchIssueAttributeValues(
workspaceSlug.toString(),
issue.project,
issue.id
);
}
}, [customAttributeValues, issue, workspaceSlug]);
if (!issue || !issue?.entity) return null;
if (
!customAttributes.objectAttributes[issue.entity] ||
!customAttributeValues.issueAttributeValues?.[issue.id]
)
return (
<Loader className="space-y-4">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</Loader>
);
return (
<>
{Object.values(customAttributes.objectAttributes?.[issue.entity] ?? {}).map((attribute) => {
const typeMetaData = CUSTOM_ATTRIBUTES_LIST[attribute.type];
const attributeValue = customAttributeValues.issueAttributeValues?.[issue.id].find(
(a) => a.id === attribute.id
)?.prop_value;
return (
<div key={attribute.id} className="flex items-center gap-2 text-sm">
<div className="flex-shrink-0 w-1/4 flex items-center gap-2 font-medium">
<typeMetaData.icon className="flex-shrink-0" size={16} strokeWidth={1.5} />
<p className="flex-grow truncate">{attribute.display_name}</p>
</div>
<div className="w-3/4 max-w-[20rem]">
{attribute.type === "checkbox" && (
<CustomCheckboxAttribute
attributeDetails={attribute}
onChange={(val) => handleAttributeUpdate(attribute.id, [`${val}`])}
value={
attributeValue
? attributeValue?.[0]?.value === "true"
? true
: false
: false
}
/>
)}
{attribute.type === "datetime" && (
<CustomDateTimeAttribute
attributeDetails={attribute}
onChange={(val) => {
handleAttributeUpdate(attribute.id, val ? [val.toISOString()] : undefined);
}}
value={attributeValue ? new Date(attributeValue?.[0]?.value ?? "") : undefined}
/>
)}
{attribute.type === "email" && (
<CustomEmailAttribute
attributeDetails={attribute}
onChange={(val) => {
handleAttributeUpdate(attribute.id, val && val !== "" ? [val] : undefined);
}}
value={attributeValue ? attributeValue?.[0]?.value : undefined}
/>
)}
{attribute.type === "file" && (
<CustomFileAttribute
attributeDetails={attribute}
onChange={(val) => handleAttributeUpdate(attribute.id, val)}
value={attributeValue ? attributeValue?.[0]?.value : undefined}
/>
)}
{attribute.type === "multi_select" && (
<CustomSelectAttribute
attributeDetails={attribute}
onChange={(val) => handleAttributeUpdate(attribute.id, val)}
value={Array.isArray(attributeValue) ? attributeValue.map((v) => v.value) : []}
multiple
/>
)}
{attribute.type === "number" && (
<CustomNumberAttribute
attributeDetails={attribute}
onChange={(val) => {
handleAttributeUpdate(attribute.id, val ? [val.toString()] : undefined);
}}
value={
attributeValue ? parseInt(attributeValue?.[0]?.value ?? "0", 10) : undefined
}
/>
)}
{attribute.type === "relation" && (
<CustomRelationAttribute
attributeDetails={attribute}
onChange={(val) => handleAttributeUpdate(attribute.id, val)}
projectId={issue.project}
value={attributeValue ? attributeValue?.[0]?.value : undefined}
/>
)}
{attribute.type === "select" && (
<CustomSelectAttribute
attributeDetails={attribute}
onChange={(val) => handleAttributeUpdate(attribute.id, val)}
value={attributeValue ? attributeValue?.[0]?.value : undefined}
multiple={false}
/>
)}
{attribute.type === "text" && (
<CustomTextAttribute
attributeDetails={attribute}
onChange={(val) =>
handleAttributeUpdate(attribute.id, val && val !== "" ? [val] : undefined)
}
value={attributeValue ? attributeValue?.[0].value : undefined}
/>
)}
{attribute.type === "url" && (
<CustomUrlAttribute
attributeDetails={attribute}
onChange={(val) =>
handleAttributeUpdate(attribute.id, val && val !== "" ? [val] : undefined)
}
value={attributeValue ? attributeValue?.[0]?.value : undefined}
/>
)}
</div>
</div>
);
})}
</>
);
}
);

View File

@ -0,0 +1,217 @@
import { useEffect } from "react";
import { useRouter } from "next/router";
// mobx
import { useMobxStore } from "lib/mobx/store-provider";
import { observer } from "mobx-react-lite";
// components
import {
CustomCheckboxAttribute,
CustomDateTimeAttribute,
CustomEmailAttribute,
CustomFileAttribute,
CustomNumberAttribute,
CustomRelationAttribute,
CustomSelectAttribute,
CustomTextAttribute,
CustomUrlAttribute,
} from "components/custom-attributes";
// ui
import { Loader } from "components/ui";
// types
import { ICustomAttributeValueFormData, IIssue } from "types";
// constants
import { CUSTOM_ATTRIBUTES_LIST } from "constants/custom-attributes";
type Props = {
issue: IIssue | undefined;
projectId: string;
};
export const SidebarCustomAttributesList: React.FC<Props> = observer(({ issue, projectId }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { customAttributes, customAttributeValues } = useMobxStore();
const handleAttributeUpdate = (attributeId: string, value: string | string[] | undefined) => {
if (!issue || !workspaceSlug) return;
if (!value) {
customAttributeValues.deleteAttributeValue(
workspaceSlug.toString(),
projectId,
issue.id,
attributeId
);
return;
}
const payload: ICustomAttributeValueFormData = {
issue_properties: {
[attributeId]: Array.isArray(value) ? value : [value],
},
};
customAttributeValues.createAttributeValue(
workspaceSlug.toString(),
issue.project,
issue.id,
payload
);
};
// fetch the object details if object state has id
useEffect(() => {
if (!issue?.entity) return;
if (!customAttributes.objectAttributes[issue.entity]) {
if (!workspaceSlug) return;
customAttributes.fetchObjectDetails(workspaceSlug.toString(), issue.entity);
}
}, [customAttributes, issue?.entity, workspaceSlug]);
// fetch issue attribute values
useEffect(() => {
if (!issue) return;
if (
!customAttributeValues.issueAttributeValues ||
!customAttributeValues.issueAttributeValues[issue.id]
) {
if (!workspaceSlug) return;
customAttributeValues.fetchIssueAttributeValues(
workspaceSlug.toString(),
issue.project,
issue.id
);
}
}, [customAttributeValues, issue, workspaceSlug]);
if (!issue || !issue?.entity) return null;
if (
!customAttributes.objectAttributes[issue.entity] ||
!customAttributeValues.issueAttributeValues?.[issue.id]
)
return (
<Loader className="space-y-4">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</Loader>
);
return (
<div>
{Object.values(customAttributes.objectAttributes?.[issue.entity] ?? {}).map((attribute) => {
const typeMetaData = CUSTOM_ATTRIBUTES_LIST[attribute.type];
const attributeValue = customAttributeValues.issueAttributeValues?.[issue.id].find(
(a) => a.id === attribute.id
)?.prop_value;
return (
<div key={attribute.id} className="flex items-center flex-wrap py-2">
<div className="flex-grow truncate flex items-center gap-x-2 text-sm text-custom-text-200 sm:w-1/2">
<typeMetaData.icon className="flex-shrink-0" size={16} strokeWidth={1.5} />
<p className="truncate">{attribute.display_name}</p>
</div>
<div className="flex-shrink-0 sm:w-1/2">
{attribute.type === "checkbox" && (
<CustomCheckboxAttribute
attributeDetails={attribute}
onChange={(val) => handleAttributeUpdate(attribute.id, [`${val}`])}
value={
attributeValue ? (attributeValue?.[0]?.value === "true" ? true : false) : false
}
/>
)}
{attribute.type === "datetime" && (
<CustomDateTimeAttribute
attributeDetails={attribute}
onChange={(val) => {
handleAttributeUpdate(attribute.id, val ? [val.toISOString()] : undefined);
}}
value={attributeValue ? new Date(attributeValue?.[0]?.value ?? "") : undefined}
/>
)}
{attribute.type === "email" && (
<CustomEmailAttribute
attributeDetails={attribute}
onChange={(val) => {
handleAttributeUpdate(attribute.id, val && val !== "" ? [val] : undefined);
}}
value={attributeValue ? attributeValue?.[0]?.value : undefined}
/>
)}
{attribute.type === "file" && (
<CustomFileAttribute
attributeDetails={attribute}
onChange={(val) => handleAttributeUpdate(attribute.id, val)}
value={attributeValue ? attributeValue?.[0]?.value : undefined}
/>
)}
{attribute.type === "multi_select" && (
<CustomSelectAttribute
attributeDetails={attribute}
onChange={(val) => handleAttributeUpdate(attribute.id, val)}
value={Array.isArray(attributeValue) ? attributeValue.map((v) => v.value) : []}
multiple
/>
)}
{attribute.type === "number" && (
<CustomNumberAttribute
attributeDetails={attribute}
onChange={(val) => {
handleAttributeUpdate(attribute.id, val ? [val.toString()] : undefined);
}}
value={
attributeValue ? parseInt(attributeValue?.[0]?.value ?? "0", 10) : undefined
}
/>
)}
{attribute.type === "relation" && (
<CustomRelationAttribute
attributeDetails={attribute}
onChange={(val) => handleAttributeUpdate(attribute.id, val)}
projectId={issue.project}
value={attributeValue ? attributeValue?.[0]?.value : undefined}
/>
)}
{attribute.type === "select" && (
<CustomSelectAttribute
attributeDetails={attribute}
onChange={(val) => handleAttributeUpdate(attribute.id, val)}
value={attributeValue ? attributeValue?.[0]?.value : undefined}
multiple={false}
/>
)}
{attribute.type === "text" && (
<CustomTextAttribute
attributeDetails={attribute}
onChange={(val) =>
handleAttributeUpdate(attribute.id, val && val !== "" ? [val] : undefined)
}
value={attributeValue ? attributeValue?.[0].value : undefined}
/>
)}
{attribute.type === "url" && (
<CustomUrlAttribute
attributeDetails={attribute}
onChange={(val) =>
handleAttributeUpdate(attribute.id, val && val !== "" ? [val] : undefined)
}
value={attributeValue ? attributeValue?.[0]?.value : undefined}
/>
)}
</div>
</div>
);
})}
</div>
);
});

View File

@ -0,0 +1,111 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
// mobx
import { useMobxStore } from "lib/mobx/store-provider";
import { observer } from "mobx-react-lite";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// ui
import { DangerButton, SecondaryButton } from "components/ui";
// icons
import { AlertTriangle } from "lucide-react";
// types
import { ICustomAttribute } from "types";
type Props = {
isOpen: boolean;
objectToDelete: ICustomAttribute | null;
onClose: () => void;
onSubmit?: () => Promise<void>;
};
export const DeleteObjectModal: React.FC<Props> = observer(
({ isOpen, objectToDelete, onClose, onSubmit }) => {
const [isDeleting, setIsDeleting] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { customAttributes } = useMobxStore();
const handleClose = () => {
onClose();
};
const handleDeleteObject = async () => {
if (!workspaceSlug || !objectToDelete) return;
setIsDeleting(true);
await customAttributes
.deleteObject(workspaceSlug.toString(), objectToDelete.id)
.then(async () => {
if (onSubmit) await onSubmit();
handleClose();
})
.finally(() => setIsDeleting(false));
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<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-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-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 transform overflow-hidden rounded-lg border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
<div className="flex flex-col gap-6 p-6">
<div className="flex w-full items-center justify-start gap-6">
<span className="place-items-center rounded-full bg-red-500/20 p-4">
<AlertTriangle className="text-red-500" size={24} strokeWidth={1.5} />
</span>
<span className="flex items-center justify-start">
<h3 className="text-xl font-medium 2xl:text-2xl">Delete Object</h3>
</span>
</div>
<span>
<p className="text-sm text-custom-text-200">
Are you sure you want to delete object{" "}
<span className="break-words font-medium text-custom-text-100">
{objectToDelete?.display_name}
</span>
? The object will be deleted permanently and cannot be recovered.
</p>
</span>
<div className="flex justify-end gap-2">
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
<DangerButton onClick={handleDeleteObject} loading={isDeleting}>
{isDeleting ? "Deleting..." : "Delete Object"}
</DangerButton>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}
);

View File

@ -0,0 +1,47 @@
import React from "react";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// react-color
import { TwitterPicker } from "react-color";
type Props = {
onChange: (hexValue: string) => void;
selectedColor: string;
size?: number;
};
export const ColorPicker: React.FC<Props> = ({ onChange, selectedColor, size = 14 }) => (
<Popover className="relative">
{({ close }) => (
<>
<Popover.Button className="grid place-items-center h-3.5 w-3.5 rounded-sm focus:outline-none">
<span
className="h-full w-full rounded-sm"
style={{ backgroundColor: selectedColor, height: `${size}px`, width: `${size}px` }}
/>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute bottom-full left-0 z-10 mb-1 px-2 sm:px-0">
<TwitterPicker
color={selectedColor}
onChange={(value) => {
onChange(value.hex);
close();
}}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
);

View File

@ -0,0 +1,124 @@
import React, { useState } from "react";
// headless ui
import { Combobox, Transition } from "@headlessui/react";
import { Search } from "lucide-react";
type Props = {
onChange: (value: string[]) => void;
value: string[];
};
const FILE_EXTENSIONS: {
[category: string]: string[];
} = {
image: [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tif", ".tiff", ".svg", ".eps", ".psd", ".ai"],
video: [".mp4", ".avi", ".mkv", ".mpg", ".mpeg", ".flv", ".wmv"],
audio: [".mp3", ".wav", ".ogg", ".flac", ".aac"],
document: [
".txt",
".doc",
".docx",
".pdf",
".ppt",
".pptx",
".xls",
".xlsx",
".html",
".htm",
".csv",
".xml",
],
};
const searchExtensions = (query: string) => {
query = query.toLowerCase();
const filteredExtensions: {
[category: string]: string[];
} = {};
for (const category in FILE_EXTENSIONS) {
const extensions = FILE_EXTENSIONS[category].filter((extension) =>
extension.toLowerCase().includes(query)
);
if (extensions.length > 0) {
filteredExtensions[category] = extensions;
}
}
return filteredExtensions;
};
export const FileFormatsDropdown: React.FC<Props> = ({ onChange, value }) => {
const [query, setQuery] = useState("");
const options = searchExtensions(query);
return (
<Combobox
as="div"
value={value}
onChange={(val) => onChange(val)}
className="relative flex-shrink-0 text-left"
multiple
>
{({ open }: { open: boolean }) => (
<>
<Combobox.Button className="px-3 py-2 bg-custom-background-100 rounded border border-custom-border-200 text-xs w-full text-left">
{value.length > 0 ? value.join(", ") : "Select file formats"}
</Combobox.Button>
<Transition
show={open}
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Combobox.Options className="absolute z-10 bottom-full mb-2 border-[0.5px] border-custom-border-300 p-1 w-full max-h-64 flex flex-col rounded bg-custom-background-100 text-xs shadow-custom-shadow-rg focus:outline-none overflow-hidden">
<div className="flex w-full items-center justify-start rounded-sm border-[0.6px] border-custom-border-200 bg-custom-background-90 px-2 mb-1">
<Search className="text-custom-text-400" size={12} />
<Combobox.Input
className="w-full bg-transparent py-1 px-2 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Type to search..."
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="h-full overflow-y-auto">
{Object.keys(options).map((category) =>
options[category].map((extension) => (
<Combobox.Option
key={extension}
value={extension}
className={({ active, selected }) =>
`flex items-center gap-1 cursor-pointer select-none truncate rounded px-1 py-1.5 accent-custom-primary-100 ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ active, selected }) => (
<>
<input
type="checkbox"
className="scale-75"
checked={value.includes(extension)}
readOnly
/>
{extension}
</>
)}
</Combobox.Option>
))
)}
</div>
</Combobox.Options>
</Transition>
</>
)}
</Combobox>
);
};

View File

@ -0,0 +1,3 @@
export * from "./color-picker";
export * from "./file-formats-dropdown";
export * from "./types-dropdown";

View File

@ -0,0 +1,63 @@
import React, { useState } from "react";
import { Menu } from "@headlessui/react";
import { usePopper } from "react-popper";
// icons
import { Plus } from "lucide-react";
// types
import { TCustomAttributeTypes } from "types";
// constants
import { CUSTOM_ATTRIBUTES_LIST } from "constants/custom-attributes";
type Props = {
onClick: (type: TCustomAttributeTypes) => void;
};
export const TypesDropdown: React.FC<Props> = ({ onClick }) => {
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "top",
});
return (
<Menu as="div" className="flex-shrink-0 text-left">
<Menu.Button as={React.Fragment}>
<button
type="button"
ref={setReferenceElement}
className="flex items-center gap-1 text-xs font-medium text-custom-primary-100"
>
<Plus size={14} strokeWidth={1.5} />
Add Attribute
</button>
</Menu.Button>
<Menu.Items>
<div
ref={setPopperElement}
className="fixed z-10 border-[0.5px] border-custom-border-300 p-1 min-w-[10rem] max-h-60 rounded bg-custom-background-100 text-xs shadow-custom-shadow-rg focus:outline-none overflow-y-auto"
style={styles.popper}
{...attributes.popper}
>
{Object.keys(CUSTOM_ATTRIBUTES_LIST).map((type) => {
const typeMetaData = CUSTOM_ATTRIBUTES_LIST[type as TCustomAttributeTypes];
return (
<Menu.Item
key={type}
as="button"
type="button"
onClick={() => onClick(type as TCustomAttributeTypes)}
className="flex items-center gap-1 cursor-pointer select-none truncate rounded px-1 py-1.5 hover:bg-custom-background-80 w-full"
>
<typeMetaData.icon size={14} strokeWidth={1.5} />
{typeMetaData.label}
</Menu.Item>
);
})}
</div>
</Menu.Items>
</Menu>
);
};

View File

@ -0,0 +1,10 @@
export * from "./attribute-display";
export * from "./attribute-forms";
export * from "./attributes-list";
export * from "./dropdowns";
export * from "./delete-object-modal";
export * from "./input";
export * from "./object-modal";
export * from "./objects-list";
export * from "./objects-select";
export * from "./single-object";

View File

@ -0,0 +1,18 @@
import { forwardRef } from "react";
export const Input = forwardRef(
(props: React.InputHTMLAttributes<HTMLInputElement>, ref: React.Ref<HTMLInputElement>) => {
const { className = "", type, ...rest } = props;
return (
<input
ref={ref}
type={type ?? "text"}
className={`placeholder:text-custom-text-400 text-xs px-3 py-2 rounded bg-custom-background-100 border border-custom-border-200 w-full focus:outline-none h-9 ${className}`}
{...rest}
/>
);
}
);
Input.displayName = "Input";

View File

@ -0,0 +1,278 @@
import React, { useEffect } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Controller, useForm } from "react-hook-form";
import { Dialog, Transition } from "@headlessui/react";
import useSWR from "swr";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { Input, TypesDropdown, AttributeForm } from "components/custom-attributes";
import EmojiIconPicker from "components/emoji-icon-picker";
// ui
import { Loader, PrimaryButton, SecondaryButton } from "components/ui";
// helpers
import { renderEmoji } from "helpers/emoji.helper";
// types
import { ICustomAttribute, TCustomAttributeTypes } from "types";
// fetch-keys
import { CUSTOM_ATTRIBUTE_DETAILS } from "constants/fetch-keys";
// constants
import { CUSTOM_ATTRIBUTES_LIST } from "constants/custom-attributes";
type Props = {
data?: ICustomAttribute;
isOpen: boolean;
onClose: () => void;
onSubmit?: () => Promise<void>;
};
const defaultValues: Partial<ICustomAttribute> = {
display_name: "",
description: "",
icon: "",
};
export const ObjectModal: React.FC<Props> = observer((props) => {
const { data, isOpen, onClose, onSubmit } = props;
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
control,
formState: { isSubmitting },
handleSubmit,
reset,
setValue,
watch,
} = useForm<ICustomAttribute>({ defaultValues });
const objectId = watch("id") && watch("id") !== "" ? watch("id") : null;
const { customAttributes: customAttributesStore } = useMobxStore();
const handleClose = () => {
onClose();
setTimeout(() => {
reset({ ...defaultValues });
}, 300);
};
const handleCreateObject = async (formData: Partial<ICustomAttribute>) => {
if (!workspaceSlug || !projectId) return;
const payload: Partial<ICustomAttribute> = {
description: formData.description ?? "",
display_name: formData.display_name ?? "",
icon: formData.icon ?? "",
project: projectId.toString(),
type: "entity",
};
await customAttributesStore
.createObject(workspaceSlug.toString(), payload)
.then((res) => setValue("id", res?.id ?? ""));
};
const handleUpdateObject = async (formData: Partial<ICustomAttribute>) => {
if (!workspaceSlug || !data || !data.id) return;
const payload: Partial<ICustomAttribute> = {
description: formData.description ?? "",
display_name: formData.display_name ?? "",
icon: formData.icon ?? "",
};
await customAttributesStore.updateObject(workspaceSlug.toString(), data.id, payload);
};
const handleObjectFormSubmit = async (formData: Partial<ICustomAttribute>) => {
if (data) await handleUpdateObject(formData);
else await handleCreateObject(formData);
if (onSubmit) onSubmit();
};
const handleCreateObjectAttribute = async (type: TCustomAttributeTypes) => {
if (!workspaceSlug || !objectId) return;
const typeMetaData = CUSTOM_ATTRIBUTES_LIST[type];
const payload: Partial<ICustomAttribute> = {
display_name: typeMetaData.label,
type,
...typeMetaData.initialPayload,
};
await customAttributesStore.createObjectAttribute(workspaceSlug.toString(), {
...payload,
parent: objectId,
});
};
useSWR(
workspaceSlug && objectId ? CUSTOM_ATTRIBUTE_DETAILS(objectId.toString()) : null,
workspaceSlug && objectId
? () =>
customAttributesStore.fetchObjectDetails(workspaceSlug.toString(), objectId.toString())
: null
);
// update the form if data is present
useEffect(() => {
if (!data) return;
reset({
...defaultValues,
...data,
});
}, [data, reset]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<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-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<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="fixed inset-0 h-full w-full z-20">
<div className="flex items-center justify-center h-full w-full p-4 sm:p-0 scale-90">
<div className="bg-custom-background-100 w-1/2 max-h-[85%] flex flex-col rounded-xl">
<h3 className="text-2xl font-semibold px-6 pt-5">New Object</h3>
<div className="mt-5 space-y-5 h-full overflow-y-auto">
<form
onSubmit={handleSubmit(handleObjectFormSubmit)}
className="space-y-4 px-6 pb-5"
>
<div className="flex items-center gap-2">
<div className="h-9 w-9 bg-custom-background-80 grid place-items-center rounded">
<Controller
control={control}
name="icon"
render={({ field: { onChange, value } }) => (
<EmojiIconPicker
label={value ? renderEmoji(value) : "Icon"}
onChange={(icon) => {
if (typeof icon === "string") onChange(icon);
}}
value={value}
showIconPicker={false}
/>
)}
/>
</div>
<Controller
control={control}
name="display_name"
render={({ field: { onChange, value } }) => (
<Input
placeholder="Enter Object Title"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
)}
/>
</div>
<Controller
control={control}
name="description"
render={({ field: { onChange, value } }) => (
<textarea
name="objectDescription"
id="objectDescription"
className="placeholder:text-custom-text-400 text-xs px-3 py-2 rounded bg-custom-background-100 border border-custom-border-200 w-full focus:outline-none"
cols={30}
rows={5}
placeholder="Enter Object Description"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
)}
/>
<div className="flex items-center justify-end gap-3">
<div className="flex items-center gap-3">
{!objectId && (
<SecondaryButton onClick={handleClose}>Close</SecondaryButton>
)}
<PrimaryButton type="submit" loading={isSubmitting}>
{objectId
? isSubmitting
? "Saving..."
: "Save changes"
: isSubmitting
? "Creating..."
: "Create Object"}
</PrimaryButton>
</div>
</div>
</form>
{objectId && (
<>
<div className="px-6">
<h4 className="font-medium">Attributes</h4>
<div className="mt-2 space-y-2">
{customAttributesStore.fetchObjectDetailsLoader ? (
<Loader>
<Loader.Item height="40px" />
</Loader>
) : (
Object.keys(
customAttributesStore.objectAttributes[objectId] ?? {}
)?.map((attributeId) => {
const attribute =
customAttributesStore.objectAttributes[objectId][attributeId];
return (
<AttributeForm
key={attributeId}
attributeDetails={attribute}
objectId={objectId}
type={attribute.type}
/>
);
})
)}
{customAttributesStore.createObjectAttributeLoader && (
<Loader>
<Loader.Item height="40px" />
</Loader>
)}
</div>
</div>
<div className="flex items-center justify-between gap-3 px-6 py-5 border-t border-custom-border-200">
<div className="flex-shrink-0">
<TypesDropdown onClick={handleCreateObjectAttribute} />
</div>
<SecondaryButton onClick={handleClose}>Close</SecondaryButton>
</div>
</>
)}
</div>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</Dialog>
</Transition.Root>
);
});

View File

@ -0,0 +1,59 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { SingleObject } from "components/custom-attributes";
// ui
import { EmptyState, Loader } from "components/ui";
// assets
import emptyCustomObjects from "public/empty-state/custom-objects.svg";
// fetch-keys
import { CUSTOM_OBJECTS_LIST } from "constants/fetch-keys";
type Props = {
projectId: string;
};
export const ObjectsList: React.FC<Props> = observer(({ projectId }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { customAttributes: customAttributesStore } = useMobxStore();
useSWR(
workspaceSlug && projectId ? CUSTOM_OBJECTS_LIST(projectId.toString()) : null,
workspaceSlug && projectId
? () => customAttributesStore.fetchObjects(workspaceSlug.toString(), projectId.toString())
: null
);
return (
<div className="divide-y divide-custom-border-100">
{customAttributesStore.objects ? (
customAttributesStore.objects.length > 0 ? (
customAttributesStore.objects.map((object) => (
<SingleObject key={object.id} object={object} />
))
) : (
<div className="bg-custom-background-90 border border-custom-border-100 rounded max-w-3xl mt-10 mx-auto">
<EmptyState
title="No custom objects yet"
description="You can think of Pages as an AI-powered notepad."
image={emptyCustomObjects}
isFullScreen={false}
/>
</div>
)
) : (
<Loader className="space-y-4">
<Loader.Item height="60px" />
<Loader.Item height="60px" />
<Loader.Item height="60px" />
</Loader>
)}
</div>
);
});

View File

@ -0,0 +1,85 @@
import { useEffect } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { CustomSearchSelect } from "components/ui";
// icons
import { TableProperties } from "lucide-react";
// helpers
import { renderEmoji } from "helpers/emoji.helper";
type Props = {
onChange: (val: string | null) => void;
projectId: string;
value: string | null;
};
export const ObjectsSelect: React.FC<Props> = observer(({ onChange, projectId, value }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { customAttributes } = useMobxStore();
const options:
| {
value: any;
query: string;
content: JSX.Element;
}[]
| undefined = customAttributes.objects?.map((object) => ({
value: object.id,
query: object.display_name,
content: (
<div className="flex items-center gap-2 text-xs">
{object.icon ? renderEmoji(object.icon) : <TableProperties size={14} strokeWidth={1.5} />}
<span>{object.display_name}</span>
</div>
),
}));
options?.unshift({
value: null,
query: "default",
content: (
<div className="flex items-center gap-2">
<TableProperties size={14} strokeWidth={1.5} />
<span>Default</span>
</div>
),
});
useEffect(() => {
if (!workspaceSlug) return;
if (!customAttributes.objects)
customAttributes.fetchObjects(workspaceSlug.toString(), projectId);
}, [customAttributes, projectId, workspaceSlug]);
const selectedObject = customAttributes.objects?.find((o) => o.id === value);
return (
<CustomSearchSelect
label={
<span className="flex items-center gap-2">
<div className="flex items-center gap-2 text-xs">
{selectedObject?.icon ? (
renderEmoji(selectedObject.icon)
) : (
<TableProperties size={14} strokeWidth={1.5} />
)}
<span>{selectedObject?.display_name ?? "Default"}</span>
</div>
</span>
}
value={value}
maxHeight="md"
optionsClassName="!min-w-[10rem]"
onChange={onChange}
options={options}
position="right"
noChevron
/>
);
});

View File

@ -0,0 +1,61 @@
import { useState } from "react";
// components
import { DeleteObjectModal, ObjectModal } from "components/custom-attributes";
// ui
import { CustomMenu } from "components/ui";
// icons
import { TableProperties } from "lucide-react";
// helpers
import { renderEmoji } from "helpers/emoji.helper";
// types
import { ICustomAttribute } from "types";
type Props = {
object: ICustomAttribute;
};
export const SingleObject: React.FC<Props> = (props) => {
const { object } = props;
const [isEditObjectModalOpen, setIsEditObjectModalOpen] = useState(false);
const [isDeleteObjectModalOpen, setIsDeleteObjectModalOpen] = useState(false);
return (
<>
<ObjectModal
data={object}
isOpen={isEditObjectModalOpen}
onClose={() => setIsEditObjectModalOpen(false)}
/>
<DeleteObjectModal
isOpen={isDeleteObjectModalOpen}
objectToDelete={object}
onClose={() => setIsDeleteObjectModalOpen(false)}
/>
<div className="flex items-center justify-between gap-4 py-4">
<div className={`flex gap-4 ${object.description === "" ? "items-center" : "items-start"}`}>
<div className="bg-custom-background-80 h-10 w-10 grid place-items-center rounded">
{object.icon ? (
renderEmoji(object.icon)
) : (
<TableProperties size={20} strokeWidth={1.5} />
)}
</div>
<div>
<h5 className="text-sm font-medium">{object.display_name}</h5>
<p className="text-custom-text-300 text-xs">{object.description}</p>
</div>
</div>
<CustomMenu ellipsis>
<CustomMenu.MenuItem renderAs="button" onClick={() => setIsEditObjectModalOpen(true)}>
Edit
</CustomMenu.MenuItem>
<CustomMenu.MenuItem renderAs="button" onClick={() => setIsDeleteObjectModalOpen(true)}>
Delete
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</>
);
};

View File

@ -1,18 +1,16 @@
import React, { useEffect, useState, useRef } from "react";
// headless ui
import { Tab, Transition, Popover } from "@headlessui/react";
// react colors
import React, { useEffect, useState } from "react";
import { Tab, Popover } from "@headlessui/react";
import { TwitterPicker } from "react-color";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// types
import { Props } from "./types";
import { usePopper } from "react-popper";
// emojis
import emojis from "./emojis.json";
import icons from "./icons.json";
// helpers
import { getRecentEmojis, saveRecentEmoji } from "./helpers";
import { getRandomEmoji, renderEmoji } from "helpers/emoji.helper";
// types
import { Props } from "./types";
const tabOptions = [
{
@ -31,6 +29,8 @@ const EmojiIconPicker: React.FC<Props> = ({
onChange,
onIconColorChange,
disabled = false,
showEmojiPicker = true,
showIconPicker = true,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [openColorPicker, setOpenColorPicker] = useState(false);
@ -38,7 +38,12 @@ const EmojiIconPicker: React.FC<Props> = ({
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
const emojiPickerRef = useRef<HTMLDivElement>(null);
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "bottom-start",
});
useEffect(() => {
setRecentEmojis(getRecentEmojis());
@ -48,34 +53,34 @@ const EmojiIconPicker: React.FC<Props> = ({
if (!value || value?.length === 0) onChange(getRandomEmoji());
}, [value, onChange]);
useOutsideClickDetector(emojiPickerRef, () => setIsOpen(false));
return (
<Popover className="relative z-[1]">
<Popover.Button
<Popover>
<Popover.Button as={React.Fragment}>
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className="outline-none"
ref={setReferenceElement}
disabled={disabled}
>
{label}
</button>
</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 z-10 mt-2 w-[250px] rounded-[4px] border border-custom-border-200 bg-custom-background-80 shadow-lg">
<Popover.Panel>
<div
ref={emojiPickerRef}
className="h-[230px] w-[250px] overflow-auto rounded-[4px] border border-custom-border-200 bg-custom-background-80 p-2 shadow-xl"
className="mt-2 w-[250px] rounded-[4px] border border-custom-border-200 bg-custom-background-80 shadow-lg"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="h-[230px] w-[250px] overflow-auto rounded-[4px] border border-custom-border-200 bg-custom-background-80 p-2 shadow-xl">
<Tab.Group as="div" className="flex h-full w-full flex-col">
<Tab.List className="flex-0 -mx-2 flex justify-around gap-1 p-1">
{tabOptions.map((tab) => (
{tabOptions.map((tab) => {
if (!showEmojiPicker && tab.key === "emoji") return null;
if (!showIconPicker && tab.key === "icon") return null;
return (
<Tab key={tab.key} as={React.Fragment}>
{({ selected }) => (
<button
@ -91,7 +96,8 @@ const EmojiIconPicker: React.FC<Props> = ({
</button>
)}
</Tab>
))}
);
})}
</Tab.List>
<Tab.Panels className="flex-1 overflow-y-auto">
<Tab.Panel>
@ -207,8 +213,8 @@ const EmojiIconPicker: React.FC<Props> = ({
</Tab.Panels>
</Tab.Group>
</div>
</div>
</Popover.Panel>
</Transition>
</Popover>
);
};

View File

@ -11,4 +11,6 @@ export type Props = {
) => void;
onIconColorChange?: (data: any) => void;
disabled?: boolean;
showEmojiPicker?: boolean;
showIconPicker?: boolean;
};

View File

@ -14,8 +14,8 @@ import useToast from "hooks/use-toast";
import { IIssueAttachment } from "types";
// fetch-keys
import { ISSUE_ATTACHMENTS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
const maxFileSize = 5 * 1024 * 1024; // 5 MB
// constants
import { MAX_FILE_SIZE } from "constants/workspace";
type Props = {
disabled?: boolean;
@ -64,7 +64,7 @@ export const IssueAttachmentUpload: React.FC<Props> = ({ disabled = false }) =>
});
setIsLoading(false);
})
.catch((err) => {
.catch(() => {
setIsLoading(false);
setToastAlert({
type: "error",
@ -77,14 +77,14 @@ export const IssueAttachmentUpload: React.FC<Props> = ({ disabled = false }) =>
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
onDrop,
maxSize: maxFileSize,
maxSize: MAX_FILE_SIZE,
multiple: false,
disabled: isLoading || disabled,
});
const fileError =
fileRejections.length > 0
? `Invalid file type or size (max ${maxFileSize / 1024 / 1024} MB)`
? `Invalid file type or size (max ${MAX_FILE_SIZE / 1024 / 1024} MB)`
: null;
return (

View File

@ -2,6 +2,9 @@ import React, { FC, useState, useEffect, useRef } from "react";
import { useRouter } from "next/router";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// services
@ -10,7 +13,7 @@ import aiService from "services/ai.service";
import useToast from "hooks/use-toast";
// components
import { GptAssistantModal } from "components/core";
import { ParentIssuesListModal } from "components/issues";
import { ParentIssuesListModal, TIssueFormAttributes } from "components/issues";
import {
IssueAssigneeSelect,
IssueDateSelect,
@ -22,8 +25,22 @@ import {
} from "components/issues/select";
import { CreateStateModal } from "components/states";
import { CreateLabelModal } from "components/labels";
import {
CustomAttributesCheckboxes,
CustomAttributesDescriptionFields,
CustomAttributesFileUploads,
CustomAttributesSelectFields,
ObjectsSelect,
} from "components/custom-attributes";
// ui
import { CustomMenu, Input, PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui";
import {
CustomMenu,
Input,
Loader,
PrimaryButton,
SecondaryButton,
ToggleSwitch,
} from "components/ui";
import { TipTapEditor } from "components/tiptap";
// icons
import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline";
@ -33,15 +50,8 @@ import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types";
const defaultValues: Partial<IIssue> = {
project: "",
name: "",
description: {
type: "doc",
content: [
{
type: "paragraph",
},
],
},
description_html: "<p></p>",
entity: null,
estimate_point: null,
state: "",
parent: null,
@ -65,23 +75,12 @@ export interface IssueFormProps {
status: boolean;
user: ICurrentUserResponse | undefined;
handleFormDirty: (payload: Partial<IIssue> | null) => void;
fieldsToShow: (
| "project"
| "name"
| "description"
| "state"
| "priority"
| "assignee"
| "label"
| "startDate"
| "dueDate"
| "estimate"
| "parent"
| "all"
)[];
customAttributesList: { [key: string]: string[] };
handleCustomAttributesChange: (attributeId: string, val: string | string[] | undefined) => void;
fieldsToShow: TIssueFormAttributes[];
}
export const IssueForm: FC<IssueFormProps> = (props) => {
export const IssueForm: FC<IssueFormProps> = observer((props) => {
const {
handleFormSubmit,
initialData,
@ -94,6 +93,8 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
user,
fieldsToShow,
handleFormDirty,
customAttributesList,
handleCustomAttributesChange,
} = props;
const [stateModal, setStateModal] = useState(false);
@ -111,6 +112,8 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
const { setToastAlert } = useToast();
const { customAttributes, customAttributeValues } = useMobxStore();
const {
register,
formState: { errors, isSubmitting, isDirty },
@ -143,6 +146,8 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
module: getValues("module"),
};
const entityId = watch("entity");
useEffect(() => {
if (isDirty) handleFormDirty(payload);
else handleFormDirty(null);
@ -152,19 +157,13 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
const handleCreateUpdateIssue = async (formData: Partial<IIssue>) => {
await handleFormSubmit(formData);
if (!workspaceSlug) return;
setGptAssistantModal(false);
reset({
...defaultValues,
project: projectId,
description: {
type: "doc",
content: [
{
type: "paragraph",
},
],
},
description_html: "<p></p>",
});
editorRef?.current?.clearEditor();
@ -250,6 +249,73 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
const maxDate = targetDate ? new Date(targetDate) : null;
maxDate?.setDate(maxDate.getDate());
// fetch entity/object details, including the list of attributes
useEffect(() => {
if (!entityId) return;
if (!customAttributes.objectAttributes[entityId]) {
if (!workspaceSlug) return;
customAttributes.fetchObjectDetails(workspaceSlug.toString(), entityId);
}
}, [customAttributes, entityId, workspaceSlug]);
// assign default values to attributes
useEffect(() => {
if (
!entityId ||
!customAttributes.objectAttributes[entityId] ||
Object.keys(customAttributesList).length > 0
)
return;
Object.values(customAttributes.objectAttributes[entityId]).forEach((attribute) => {
handleCustomAttributesChange(attribute.id, attribute.default_value);
});
}, [customAttributes, customAttributesList, entityId, handleCustomAttributesChange]);
// fetch issue attribute values
useEffect(() => {
if (!initialData || !initialData.id) return;
if (
!customAttributeValues.issueAttributeValues ||
!customAttributeValues.issueAttributeValues[initialData.id]
) {
if (!workspaceSlug) return;
customAttributeValues
.fetchIssueAttributeValues(workspaceSlug.toString(), projectId, initialData.id)
.then(() => {
const issueAttributeValues =
customAttributeValues.issueAttributeValues?.[initialData.id ?? ""];
if (!issueAttributeValues || issueAttributeValues.length === 0) return;
issueAttributeValues.forEach((attributeValue) => {
if (attributeValue.prop_value)
handleCustomAttributesChange(
attributeValue.id,
attributeValue.prop_value.map((val) => val.value)
);
});
});
} else {
const issueAttributeValues =
customAttributeValues.issueAttributeValues?.[initialData.id ?? ""];
if (!issueAttributeValues || issueAttributeValues.length === 0) return;
issueAttributeValues.forEach((attributeValue) => {
if (attributeValue.prop_value)
handleCustomAttributesChange(
attributeValue.id,
attributeValue.prop_value.map((val) => val.value)
);
});
}
}, [customAttributeValues, handleCustomAttributesChange, initialData, projectId, workspaceSlug]);
return (
<>
{projectId && (
@ -273,7 +339,8 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
</>
)}
<form onSubmit={handleSubmit(handleCreateUpdateIssue)}>
<div className="space-y-5">
<div className="space-y-5 p-5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-x-2">
{(fieldsToShow.includes("all") || fieldsToShow.includes("project")) && (
<Controller
@ -294,6 +361,18 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
{status ? "Update" : "Create"} Issue
</h3>
</div>
<div className="flex-shrink-0">
{(fieldsToShow.includes("all") || fieldsToShow.includes("entity")) && (
<Controller
control={control}
name="entity"
render={({ field: { value, onChange } }) => (
<ObjectsSelect onChange={onChange} projectId={projectId} value={value} />
)}
/>
)}
</div>
</div>
{watch("parent") &&
(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) &&
selectedParentIssue && (
@ -417,7 +496,46 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
/>
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{entityId !== null && (
<>
{customAttributes.fetchObjectDetailsLoader ? (
<Loader className="space-y-3.5">
<Loader.Item height="35px" />
<Loader.Item height="35px" />
<Loader.Item height="35px" />
</Loader>
) : (
<div className="space-y-5">
<CustomAttributesDescriptionFields
objectId={entityId ?? ""}
issueId={watch("id") ?? ""}
onChange={handleCustomAttributesChange}
projectId={projectId}
values={customAttributesList}
/>
<CustomAttributesCheckboxes
objectId={entityId ?? ""}
issueId={watch("id") ?? ""}
onChange={handleCustomAttributesChange}
projectId={projectId}
values={customAttributesList}
/>
<CustomAttributesFileUploads
objectId={entityId ?? ""}
issueId={watch("id") ?? ""}
onChange={handleCustomAttributesChange}
projectId={projectId}
values={customAttributesList}
/>
</div>
)}
</>
)}
</div>
</div>
</div>
<div className="space-y-4 px-5 py-4 border-t border-custom-border-200">
<div className="flex items-center gap-2 flex-wrap">
{(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && (
<Controller
control={control}
@ -432,6 +550,9 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
)}
/>
)}
{/* default object properties */}
{entityId === null ? (
<>
{(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && (
<Controller
control={control}
@ -555,17 +676,24 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
)}
</CustomMenu>
)}
</>
) : (
<CustomAttributesSelectFields
objectId={entityId ?? ""}
issueId={watch("id") ?? ""}
onChange={handleCustomAttributesChange}
projectId={projectId}
values={customAttributesList}
/>
)}
</div>
</div>
</div>
</div>
<div className="-mx-5 mt-5 flex items-center justify-between gap-2 border-t border-custom-border-200 px-5 pt-5">
<div className="flex items-center justify-between gap-2">
<div
className="flex cursor-pointer items-center gap-1"
onClick={() => setCreateMore((prevData) => !prevData)}
>
<span className="text-xs">Create more</span>
<ToggleSwitch value={createMore} onChange={() => {}} size="md" />
<ToggleSwitch value={createMore} onChange={() => {}} size="sm" />
</div>
<div className="flex items-center gap-2">
<SecondaryButton
@ -586,7 +714,8 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
</PrimaryButton>
</div>
</div>
</div>
</form>
</>
);
};
});

View File

@ -1,13 +1,14 @@
export * from "./attachment";
export * from "./comment";
export * from "./gantt-chart";
export * from "./my-issues";
export * from "./peek-overview";
export * from "./sidebar-select";
export * from "./view-select";
export * from "./activity";
export * from "./delete-issue-modal";
export * from "./description-form";
export * from "./form";
export * from "./gantt-chart";
export * from "./main-content";
export * from "./modal";
export * from "./parent-issues-list-modal";

View File

@ -4,6 +4,9 @@ import { useRouter } from "next/router";
import { mutate } from "swr";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
@ -43,16 +46,11 @@ import {
// constants
import { INBOX_ISSUE_SOURCE } from "constants/inbox";
export interface IssuesModalProps {
data?: IIssue | null;
handleClose: () => void;
isOpen: boolean;
isUpdatingSingleIssue?: boolean;
prePopulateData?: Partial<IIssue>;
fieldsToShow?: (
export type TIssueFormAttributes =
| "project"
| "name"
| "description"
| "entity"
| "state"
| "priority"
| "assignee"
@ -61,20 +59,28 @@ export interface IssuesModalProps {
| "dueDate"
| "estimate"
| "parent"
| "all"
)[];
| "all";
export interface IssuesModalProps {
data?: IIssue | null;
fieldsToShow?: TIssueFormAttributes[];
handleClose: () => void;
isOpen: boolean;
isUpdatingSingleIssue?: boolean;
onSubmit?: (data: Partial<IIssue>) => Promise<void>;
prePopulateData?: Partial<IIssue>;
}
export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer(
({
data,
fieldsToShow = ["all"],
handleClose,
isOpen,
isUpdatingSingleIssue = false,
prePopulateData: prePopulateDataProps,
fieldsToShow = ["all"],
onSubmit,
}) => {
prePopulateData: prePopulateDataProps,
}) => {
// states
const [createMore, setCreateMore] = useState(false);
const [formDirtyState, setFormDirtyState] = useState<any>(null);
@ -82,10 +88,15 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
const [activeProject, setActiveProject] = useState<string | null>(null);
const [prePopulateData, setPreloadedData] = useState<Partial<IIssue>>({});
const [customAttributesList, setCustomAttributesList] = useState<{ [key: string]: string[] }>(
{}
);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId, globalViewId, inboxId } =
router.query;
const { customAttributeValues } = useMobxStore();
const { displayFilters, params } = useIssuesView();
const { params: calendarParams } = useCalendarIssuesView();
const { ...viewGanttParams } = params;
@ -140,6 +151,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
*/
const onClose = () => {
setCustomAttributesList({});
if (!showConfirmDiscard) handleClose();
if (formDirtyState === null) return setActiveProject(null);
const data = JSON.stringify(formDirtyState);
@ -163,9 +176,10 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
*/
const onDiscardClose = () => {
if (formDirtyState !== null) {
setShowConfirmDiscard(true);
} else {
setCustomAttributesList({});
if (formDirtyState !== null) setShowConfirmDiscard(true);
else {
handleClose();
setActiveProject(null);
}
@ -342,11 +356,14 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
const createIssue = async (payload: Partial<IIssue>) => {
if (!workspaceSlug || !activeProject) return;
let issueToReturn: Partial<IIssue> = {};
if (inboxId) await addIssueToInbox(payload);
else
await issuesService
.createIssues(workspaceSlug as string, activeProject ?? "", payload, user)
.then(async (res) => {
issueToReturn = res;
mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params));
if (payload.cycle && payload.cycle !== "") await addIssueToCycle(res.id, payload.cycle);
if (payload.module && payload.module !== "")
@ -371,14 +388,6 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
mutate(USER_ISSUE(workspaceSlug as string));
if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent));
if (globalViewId)
mutate(WORKSPACE_VIEW_ISSUES(globalViewId.toString(), globalViewParams));
if (currentWorkspaceIssuePath)
mutate(
WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), currentWorkspaceIssuePath?.params)
);
})
.catch(() => {
setToastAlert({
@ -389,6 +398,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
});
if (!createMore) onFormSubmitClose();
return issueToReturn;
};
const createDraftIssue = async () => {
@ -419,6 +430,14 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
mutate(USER_ISSUE(workspaceSlug as string));
if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent));
if (globalViewId)
mutate(WORKSPACE_VIEW_ISSUES(globalViewId.toString(), globalViewParams));
if (currentWorkspaceIssuePath)
mutate(
WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), currentWorkspaceIssuePath?.params)
);
})
.catch(() => {
setToastAlert({
@ -432,12 +451,16 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
const updateIssue = async (payload: Partial<IIssue>) => {
if (!user) return;
let issueToReturn: Partial<IIssue> = {};
await issuesService
.patchIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload, user)
.then((res) => {
if (isUpdatingSingleIssue) {
issueToReturn = res;
if (isUpdatingSingleIssue)
mutate<IIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false);
} else {
else {
if (displayFilters.layout === "calendar") mutate(calendarFetchKey);
if (displayFilters.layout === "spreadsheet") mutate(spreadsheetFetchKey);
if (payload.parent) mutate(SUB_ISSUES(payload.parent.toString()));
@ -462,25 +485,75 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
message: "Issue could not be updated. Please try again.",
});
});
return issueToReturn;
};
const handleFormSubmit = async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !activeProject) return;
const payload: Partial<IIssue> = {
// set the fixed issue properties for the payload
let payload: Partial<IIssue> = {
description_html: formData.description_html ?? "<p></p>",
entity: formData.entity,
name: formData.name,
state: formData.state,
};
// if entity is null, set the default object properties for the payload
if (formData.entity === null)
payload = {
...payload,
...formData,
assignees_list: formData.assignees ?? [],
labels_list: formData.labels ?? [],
description: formData.description ?? "",
description_html: formData.description_html ?? "<p></p>",
};
if (!data) await createIssue(payload);
else await updateIssue(payload);
let issueResponse: Partial<IIssue> | undefined = {};
if (!data) issueResponse = await createIssue(payload);
else issueResponse = await updateIssue(payload);
// create custom attribute values, if any
if (
payload.entity !== null &&
issueResponse &&
issueResponse.id &&
Object.keys(customAttributesList).length > 0
)
await customAttributeValues.createAttributeValue(
workspaceSlug.toString(),
activeProject,
issueResponse.id,
{
issue_properties: customAttributesList,
}
);
if (onSubmit) await onSubmit(payload);
};
const handleCustomAttributesChange = (
attributeId: string,
val: string | string[] | undefined
) => {
if (!val) {
setCustomAttributesList((prev) => {
const newCustomAttributesList = { ...prev };
delete newCustomAttributesList[attributeId];
return newCustomAttributesList;
});
return;
}
setCustomAttributesList((prev) => ({
...prev,
[attributeId]: Array.isArray(val) ? val : [val],
}));
};
if (!projects || projects.length === 0) return null;
return (
@ -497,7 +570,6 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
clearLocalStorageValue();
}}
/>
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
@ -512,8 +584,6 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
@ -523,7 +593,9 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
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 rounded-lg border border-custom-border-200 bg-custom-background-100 p-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
<Dialog.Panel className="fixed inset-0 h-full w-full z-20 overflow-y-auto">
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
<div className="relative rounded-lg border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
<IssueForm
handleFormSubmit={handleFormSubmit}
initialData={data ?? prePopulateData}
@ -535,14 +607,17 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
status={data ? true : false}
user={user}
fieldsToShow={fieldsToShow}
customAttributesList={customAttributesList}
handleCustomAttributesChange={handleCustomAttributesChange}
handleFormDirty={handleFormDirty}
/>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
</>
);
};
}
);

View File

@ -1,25 +1,22 @@
// mobx
import { observer } from "mobx-react-lite";
// headless ui
import { Disclosure } from "@headlessui/react";
import { StateGroupIcon } from "components/icons";
// hooks
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
// components
import {
SidebarAssigneeSelect,
SidebarEstimateSelect,
SidebarPrioritySelect,
SidebarStateSelect,
TPeekOverviewModes,
} from "components/issues";
import { PeekOverviewCustomAttributesList } from "components/custom-attributes";
// ui
import { CustomDatePicker, Icon } from "components/ui";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { IIssue, TIssuePriorities } from "types";
import { IIssue } from "types";
type Props = {
handleDeleteIssue: () => void;
@ -96,6 +93,8 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({
/>
</div>
</div>
{issue.entity === null && (
<>
<div className="flex items-center gap-2 text-sm">
<div className="flex-shrink-0 w-1/4 flex items-center gap-2 font-medium">
<Icon iconName="group" className="!text-base flex-shrink-0" />
@ -162,6 +161,11 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({
/>
</div>
</div>
</>
)}
{issue.entity !== null && (
<PeekOverviewCustomAttributesList issue={issue} projectId={issue.project} />
)}
{/* <div className="flex items-center gap-2 text-sm">
<div className="flex-shrink-0 w-1/4 flex items-center gap-2 font-medium">
<Icon iconName="change_history" className="!text-base flex-shrink-0" />

View File

@ -49,7 +49,7 @@ export const IssueAssigneeSelect: React.FC<Props> = ({ projectId, value = [], on
<AssigneesList userIds={value} length={3} showLength={true} />
</div>
) : (
<div className="flex items-center justify-center gap-2 px-1.5 py-1 rounded shadow-sm border border-custom-border-300 hover:bg-custom-background-80">
<div className="flex items-center justify-center gap-2 px-1.5 py-1 rounded shadow-sm border border-custom-border-200 hover:bg-custom-background-80">
<Icon iconName="person" className="!text-base !leading-4" />
<span className="text-custom-text-200">Assignee</span>
</div>

View File

@ -19,7 +19,7 @@ export const IssueDateSelect: React.FC<Props> = ({ label, maxDate, minDate, onCh
<Popover className="relative flex items-center justify-center rounded-lg">
{({ close }) => (
<>
<Popover.Button className="flex cursor-pointer items-center rounded-md border border-custom-border-200 text-xs shadow-sm duration-200">
<Popover.Button className="flex cursor-pointer items-center rounded border border-custom-border-200 text-xs shadow-sm duration-200">
<span className="flex items-center justify-center gap-2 px-2 py-1 text-xs text-custom-text-200 hover:bg-custom-background-80">
{value ? (
<>

View File

@ -69,7 +69,7 @@ export const IssueLabelSelect: React.FC<Props> = ({ setIsOpen, value, onChange,
/>
</span>
) : (
<span className="flex items-center justify-center gap-2 px-2 py-1 text-xs rounded shadow-sm border border-custom-border-300 hover:bg-custom-background-80">
<span className="flex items-center justify-center gap-2 px-2 py-1 text-xs rounded shadow-sm border border-custom-border-200 hover:bg-custom-background-80">
<TagIcon className="h-3.5 w-3.5 text-custom-text-200" />
<span className=" text-custom-text-200">Label</span>
</span>

View File

@ -33,6 +33,7 @@ import {
SidebarDuplicateSelect,
SidebarRelatesSelect,
} from "components/issues";
import { SidebarCustomAttributesList } from "components/custom-attributes";
// ui
import { CustomDatePicker, Icon } from "components/ui";
// icons
@ -48,13 +49,13 @@ import {
UserIcon,
RectangleGroupIcon,
} from "@heroicons/react/24/outline";
import { ContrastIcon } from "components/icons";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
import type { ICycle, IIssue, IIssueLink, linkDetails, IModule } from "types";
// fetch-keys
import { ISSUE_DETAILS } from "constants/fetch-keys";
import { ContrastIcon } from "components/icons";
type Props = {
control: any;
@ -62,6 +63,7 @@ type Props = {
issueDetail: IIssue | undefined;
watch: UseFormWatch<IIssue>;
fieldsToShow?: (
| "entity"
| "state"
| "assignee"
| "priority"
@ -303,7 +305,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
data={issueDetail ?? null}
user={user}
/>
<div className="h-full w-full flex flex-col divide-y-2 divide-custom-border-200 overflow-hidden">
<div className="h-full w-full flex flex-col divide-y divide-custom-border-300 overflow-hidden">
<div className="flex items-center justify-between px-5 pb-3">
<h4 className="text-sm font-medium">
{issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id}
@ -347,7 +349,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
</div>
<div className="h-full w-full px-5 overflow-y-auto">
<div className={`divide-y-2 divide-custom-border-200 ${uneditable ? "opacity-60" : ""}`}>
<div className={`divide-y divide-custom-border-300 ${uneditable ? "opacity-60" : ""}`}>
{showFirstSection && (
<div className="py-1">
{(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && (
@ -371,7 +373,8 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
</div>
</div>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && (
{(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) &&
watchIssue("entity") === null && (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
<UserGroupIcon className="h-4 w-4 flex-shrink-0" />
@ -392,7 +395,8 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
</div>
</div>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && (
{(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) &&
watchIssue("entity") === null && (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
<ChartBarIcon className="h-4 w-4 flex-shrink-0" />
@ -414,6 +418,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
</div>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) &&
watchIssue("entity") === null &&
isEstimateActive && (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
@ -439,7 +444,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
)}
</div>
)}
{showSecondSection && (
{showSecondSection && watchIssue("entity") === null && (
<div className="py-1">
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
<div className="flex flex-wrap items-center py-2">
@ -597,7 +602,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
)}
</div>
)}
{showThirdSection && (
{showThirdSection && watchIssue("entity") === null && (
<div className="py-1">
{(fieldsToShow.includes("all") || fieldsToShow.includes("cycle")) && (
<div className="flex flex-wrap items-center py-2">
@ -631,8 +636,17 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
)}
</div>
)}
{watchIssue("entity") && (
<div className="py-1">
<SidebarCustomAttributesList
issue={issueDetail}
projectId={projectId?.toString() ?? ""}
/>
</div>
{(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && (
)}
</div>
{(fieldsToShow.includes("all") || fieldsToShow.includes("label")) &&
watchIssue("entity") === null && (
<SidebarLabelSelect
issueDetails={issueDetail}
issueControl={control}
@ -642,7 +656,8 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
uneditable={uneditable ?? false}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (
{(fieldsToShow.includes("all") || fieldsToShow.includes("link")) &&
watchIssue("entity") === null && (
<div className={`min-h-[116px] py-1 text-xs ${uneditable ? "opacity-60" : ""}`}>
<div className="flex items-center justify-between gap-2">
<h4>Links</h4>

View File

@ -42,6 +42,10 @@ export const SettingsSidebar = () => {
label: "Automations",
href: `/${workspaceSlug}/projects/${projectId}/settings/automations`,
},
{
label: "Custom Objects",
href: `/${workspaceSlug}/projects/${projectId}/settings/custom-objects`,
},
];
const workspaceLinks: Array<{

View File

@ -78,7 +78,7 @@ export const CustomSearchSelect = ({
) : (
<Combobox.Button
type="button"
className={`flex items-center justify-between gap-1 w-full rounded-md shadow-sm border border-custom-border-300 duration-300 focus:outline-none ${
className={`flex items-center justify-between gap-1 w-full rounded shadow-custom-shadow-2xs border border-custom-border-200 duration-300 focus:outline-none ${
input ? "px-3 py-2 text-sm" : "px-2.5 py-1 text-xs"
} ${
disabled

View File

@ -45,7 +45,7 @@ const CustomSelect = ({
) : (
<Listbox.Button
type="button"
className={`flex items-center justify-between gap-1 w-full rounded-md border border-custom-border-300 shadow-sm duration-300 focus:outline-none ${
className={`flex items-center justify-between gap-1 w-full rounded border border-custom-border-200 shadow-custom-shadow-2xs duration-300 focus:outline-none ${
input ? "px-3 py-2 text-sm" : "px-2.5 py-1 text-xs"
} ${
disabled

View File

@ -26,6 +26,8 @@ import { DeleteAttachmentModal } from "components/issues";
// types
import type { IIssueAttachment } from "types";
// constants
import { MAX_FILE_SIZE } from "constants/workspace";
type Props = {
allowed: boolean;
@ -102,7 +104,7 @@ export const IssueAttachments: React.FC<Props> = (props) => {
const { getRootProps, getInputProps } = useDropzone({
onDrop,
maxSize: 5 * 1024 * 1024,
maxSize: MAX_FILE_SIZE,
disabled: !allowed || isLoading,
});

View File

@ -0,0 +1,173 @@
// icons
import {
AtSign,
CaseSensitive,
CheckCircle,
Clock4,
Disc,
FileMinus,
Forward,
Hash,
Link2,
LucideIcon,
} from "lucide-react";
// helpers
import { getRandomColor } from "helpers/color.helper";
// types
import { ICustomAttribute, TCustomAttributeTypes, TCustomAttributeUnits } from "types";
export const CUSTOM_ATTRIBUTES_LIST: {
[key in Partial<TCustomAttributeTypes>]: {
defaultFormValues: Partial<ICustomAttribute>;
icon: LucideIcon;
initialPayload?: Partial<ICustomAttribute>;
label: string;
};
} = {
checkbox: {
defaultFormValues: {
default_value: "true",
display_name: "",
extra_settings: {
representation: "check",
},
is_required: false,
},
icon: CheckCircle,
initialPayload: {
default_value: "true",
extra_settings: {
representation: "check",
},
},
label: "Checkbox",
},
datetime: {
defaultFormValues: {
default_value: "",
display_name: "",
extra_settings: {
date_format: "DD-MM-YYYY",
hide_date: false,
hide_time: false,
time_format: "12",
},
is_required: false,
},
icon: Clock4,
initialPayload: {
extra_settings: {
date_format: "DD-MM-YYYY",
hide_date: false,
hide_time: false,
time_format: "12",
},
},
label: "Date Time",
},
email: {
defaultFormValues: { default_value: "", display_name: "", is_required: false },
icon: AtSign,
label: "Email",
},
file: {
defaultFormValues: {
display_name: "",
extra_settings: {
file_formats: [],
},
is_multi: false,
is_required: false,
},
icon: FileMinus,
label: "File",
initialPayload: {
extra_settings: {
file_formats: [".jpg", ".jpeg"],
},
},
},
multi_select: {
defaultFormValues: { default_value: "", display_name: "", is_multi: true, is_required: false },
icon: Disc,
initialPayload: { is_multi: true },
label: "Multi Select",
},
number: {
defaultFormValues: {
color: getRandomColor(),
default_value: "",
display_name: "",
extra_settings: {
divided_by: 100,
representation: "numerical",
show_number: true,
},
is_required: false,
},
icon: Hash,
label: "Number",
initialPayload: {
color: getRandomColor(),
extra_settings: {
representation: "numerical",
},
},
},
relation: {
defaultFormValues: { display_name: "", is_multi: false, is_required: false, unit: "cycle" },
icon: Forward,
label: "Relation",
initialPayload: {
unit: "cycle",
},
},
select: {
defaultFormValues: { default_value: "", display_name: "", is_required: false },
icon: Disc,
label: "Select",
},
text: {
defaultFormValues: { default_value: "", display_name: "", is_required: false },
icon: CaseSensitive,
label: "Text",
},
url: {
defaultFormValues: { default_value: "", display_name: "", is_required: false },
icon: Link2,
label: "URL",
},
};
export const CUSTOM_ATTRIBUTE_UNITS: {
label: string;
value: TCustomAttributeUnits;
}[] = [
{
label: "Cycle",
value: "cycle",
},
{
label: "Issue",
value: "issue",
},
{
label: "Module",
value: "module",
},
{
label: "User",
value: "user",
},
];
export const DATE_FORMATS = [
{ label: "Day/Month/Year", value: "DD-MM-YYYY" },
{ label: "Month/Day/Year", value: "MM-DD-YYYY" },
{ label: "Year/Month/Day", value: "YYYY-MM-DD" },
];
export const TIME_FORMATS = [
{ label: "12 Hours", value: "12" },
{ label: "24 Hours", value: "24" },
];

View File

@ -386,3 +386,8 @@ export const COMMENT_REACTION_LIST = (
commendId: string
) =>
`COMMENT_REACTION_LIST_${workspaceSlug.toUpperCase()}_${projectId.toUpperCase()}_${commendId.toUpperCase()}`;
export const CUSTOM_OBJECTS_LIST = (projectId: string) =>
`CUSTOM_OBJECTS_LIST_${projectId.toUpperCase()}`;
export const CUSTOM_ATTRIBUTE_DETAILS = (attributeId: string) =>
`CUSTOM_ATTRIBUTE_DETAILS_${attributeId.toUpperCase()}`;

View File

@ -67,3 +67,5 @@ export const EXPORTERS_LIST = [
logo: JSONLogo,
},
];
export const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB

View File

@ -17,3 +17,32 @@ export const rgbToHex = (rgb: TRgb): string => {
return `#${hexR}${hexG}${hexB}`;
};
/**
* @returns {string} random hex color code
* @description function to generate a random vibrant hex color code
*/
export const getRandomColor = (): string => {
// Generate random RGB values
const r = Math.floor(Math.random() * 256);
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);
// Calculate the luminance
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
// Define thresholds for luminance (adjust as needed)
const minLuminance = 0.3;
const maxLuminance = 0.7;
// Check if the luminance is within the desired range
if (luminance >= minLuminance && luminance <= maxLuminance) {
// Convert RGB to hex format
const hexColor = `#${((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)}`;
return hexColor;
} else {
// Recurse to find a suitable color
return getRandomColor();
}
};

View File

@ -32,9 +32,7 @@ export interface SidebarProps {
}
const Sidebar: React.FC<SidebarProps> = observer(({ toggleSidebar, setToggleSidebar }) => {
const store: any = useMobxStore();
// theme
const { collapsed: sidebarCollapse } = useTheme();
const store = useMobxStore();
return (
<div

View File

@ -25,6 +25,7 @@
"@nivo/line": "0.80.0",
"@nivo/pie": "0.80.0",
"@nivo/scatterplot": "0.80.0",
"@popperjs/core": "^2.11.8",
"@sentry/nextjs": "^7.36.0",
"@tiptap/extension-code-block-lowlight": "^2.0.4",
"@tiptap/extension-color": "^2.0.4",
@ -72,6 +73,7 @@
"react-hook-form": "^7.38.0",
"react-markdown": "^8.0.7",
"react-moveable": "^0.54.1",
"react-popper": "^2.3.0",
"sharp": "^0.32.1",
"sonner": "^0.6.2",
"swr": "^2.1.3",

View File

@ -180,7 +180,7 @@ const ArchivedIssueDetailsPage: NextPage = () => {
</button>
</div>
)}
<div className="space-y-5 divide-y-2 divide-custom-border-200 opacity-60 pointer-events-none">
<div className="space-y-5 divide-y divide-custom-border-200 opacity-60 pointer-events-none">
<IssueMainContent
issueDetails={issueDetails}
submitChanges={submitChanges}

View File

@ -145,10 +145,10 @@ const IssueDetailsPage: NextPage = () => {
/>
) : issueDetails && projectId ? (
<div className="flex h-full overflow-hidden">
<div className="w-2/3 h-full overflow-y-auto space-y-5 divide-y-2 divide-custom-border-300 p-5">
<div className="w-2/3 h-full overflow-y-auto space-y-5 divide-y divide-custom-border-200 p-5">
<IssueMainContent issueDetails={issueDetails} submitChanges={submitChanges} />
</div>
<div className="w-1/3 h-full space-y-5 border-l border-custom-border-300 py-5 overflow-hidden">
<div className="w-1/3 h-full space-y-5 border-l border-custom-border-200 py-5 overflow-hidden">
<IssueDetailsSidebar
control={control}
issueDetail={issueDetails}

View File

@ -0,0 +1,66 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
// layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// hooks
import useProjectDetails from "hooks/use-project-details";
// components
import { SettingsSidebar } from "components/project";
import { ObjectModal, ObjectsList } from "components/custom-attributes";
// ui
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
import { PrimaryButton } from "components/ui";
// helpers
import { truncateText } from "helpers/string.helper";
// types
import type { NextPage } from "next";
const CustomObjectSettings: NextPage = () => {
const [isCreateObjectModalOpen, setIsCreateObjectModalOpen] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { projectDetails } = useProjectDetails();
return (
<ProjectAuthorizationWrapper
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${truncateText(projectDetails?.name ?? "Project", 32)}`}
link={`/${workspaceSlug}/projects/${projectId}/issues`}
linkTruncate
/>
<BreadcrumbItem title="Control Settings" unshrinkTitle />
</Breadcrumbs>
}
>
<ObjectModal
isOpen={isCreateObjectModalOpen}
onClose={() => setIsCreateObjectModalOpen(false)}
/>
<div className="flex flex-row gap-2">
<div className="w-80 py-8">
<SettingsSidebar />
</div>
<section className="pr-9 py-8 w-full">
<div className="flex items-center justify-between gap-2 py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">Custom Objects</h3>
<PrimaryButton onClick={() => setIsCreateObjectModalOpen(true)}>
Add Object
</PrimaryButton>
</div>
<div>
<div className="mt-4">
<ObjectsList projectId={projectId?.toString() ?? ""} />
</div>
</div>
</section>
</div>
</ProjectAuthorizationWrapper>
);
};
export default CustomObjectSettings;

View File

@ -0,0 +1,20 @@
<svg width="108" height="80" viewBox="0 0 108 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_5492_135824)">
<path d="M0.144318 2.30909C0.144318 1.11352 1.11352 0.144318 2.30909 0.144318H105.691C106.886 0.144318 107.856 1.11352 107.856 2.30909V79.8557H0.144318V2.30909Z" fill="white" stroke="#E5E5E5" stroke-width="0.288636"/>
<path d="M0.144318 2.30909C0.144318 1.11352 1.11352 0.144318 2.30909 0.144318H106.268C107.145 0.144318 107.856 0.855066 107.856 1.73182V5.85568H0.144318V2.30909Z" fill="#F5F5F5" stroke="#E5E5E5" stroke-width="0.288636"/>
<rect x="2.70312" y="2.125" width="1.73182" height="1.73182" rx="0.865909" fill="#EF4444"/>
<rect x="5.00781" y="2.125" width="1.73182" height="1.73182" rx="0.865909" fill="#FCD34D"/>
<rect x="7.32031" y="2.125" width="1.73182" height="1.73182" rx="0.865909" fill="#4ADE80"/>
<mask id="mask0_5492_135824" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="31" y="18" width="47" height="44">
<rect x="31.0547" y="18" width="46.3656" height="44" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_5492_135824)">
<path d="M51.6412 44.8737L47.3894 40.6219C47.136 40.3685 46.8174 40.2389 46.4338 40.233C46.0502 40.2271 45.7258 40.3568 45.4606 40.6219C45.1955 40.8871 45.0629 41.2085 45.0629 41.5863C45.0629 41.964 45.1955 42.2855 45.4606 42.5506L50.4832 47.5732C50.814 47.904 51.2 48.0694 51.6412 48.0694C52.0823 48.0694 52.4683 47.904 52.7991 47.5732L62.9815 37.3908C63.2349 37.1374 63.3645 36.8189 63.3704 36.4352C63.3763 36.0516 63.2466 35.7272 62.9815 35.4621C62.7163 35.1969 62.3949 35.0644 62.0171 35.0644C61.6394 35.0644 61.3179 35.1969 61.0528 35.4621L51.6412 44.8737ZM54.2417 58.3328C51.8369 58.3328 49.5765 57.8764 47.4606 56.9638C45.3445 56.0511 43.5039 54.8125 41.9387 53.248C40.3734 51.6834 39.1342 49.8436 38.2212 47.7285C37.3081 45.6135 36.8516 43.3535 36.8516 40.9488C36.8516 38.544 37.3079 36.2836 38.2206 34.1676C39.1332 32.0516 40.3718 30.2109 41.9364 28.6457C43.501 27.0804 45.3408 25.8413 47.4558 24.9282C49.5709 24.0151 51.8308 23.5586 54.2356 23.5586C56.6404 23.5586 58.9008 24.0149 61.0168 24.9276C63.1328 25.8403 64.9734 27.0789 66.5386 28.6434C68.1039 30.208 69.3431 32.0478 70.2561 34.1629C71.1692 36.2779 71.6258 38.5378 71.6258 40.9426C71.6258 43.3474 71.1694 45.6078 70.2567 47.7238C69.3441 49.8398 68.1055 51.6804 66.5409 53.2457C64.9764 54.8109 63.1365 56.0501 61.0215 56.9632C58.9064 57.8763 56.6465 58.3328 54.2417 58.3328ZM54.2387 55.5875C58.3262 55.5875 61.7883 54.1691 64.6252 51.3322C67.462 48.4954 68.8805 45.0332 68.8805 40.9457C68.8805 36.8582 67.462 33.396 64.6252 30.5592C61.7883 27.7223 58.3262 26.3039 54.2387 26.3039C50.1512 26.3039 46.689 27.7223 43.8521 30.5592C41.0153 33.396 39.5969 36.8582 39.5969 40.9457C39.5969 45.0332 41.0153 48.4954 43.8521 51.3322C46.689 54.1691 50.1512 55.5875 54.2387 55.5875Z" fill="#A3A3A3"/>
</g>
</g>
<defs>
<clipPath id="clip0_5492_135824">
<rect width="108" height="80" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -0,0 +1,16 @@
<svg width="108" height="80" viewBox="0 0 108 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_5492_135841)">
<path d="M0.144318 2.30909C0.144318 1.11352 1.11352 0.144318 2.30909 0.144318H105.691C106.886 0.144318 107.856 1.11352 107.856 2.30909V79.8557H0.144318V2.30909Z" fill="white" stroke="#E5E5E5" stroke-width="0.288636"/>
<path d="M0.144318 2.30909C0.144318 1.11352 1.11352 0.144318 2.30909 0.144318H106.268C107.145 0.144318 107.856 0.855066 107.856 1.73182V5.85568H0.144318V2.30909Z" fill="#F5F5F5" stroke="#E5E5E5" stroke-width="0.288636"/>
<rect x="2.70312" y="2.125" width="1.73182" height="1.73182" rx="0.865909" fill="#EF4444"/>
<rect x="5.00781" y="2.125" width="1.73182" height="1.73182" rx="0.865909" fill="#FCD34D"/>
<rect x="7.32031" y="2.125" width="1.73182" height="1.73182" rx="0.865909" fill="#4ADE80"/>
<rect x="30" y="25" width="48" height="31" rx="15.5" fill="#A3A3A3"/>
<ellipse cx="45.384" cy="40.5005" rx="12.9231" ry="13.02" fill="white"/>
</g>
<defs>
<clipPath id="clip0_5492_135841">
<rect width="108" height="80" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,16 @@
<svg width="108" height="80" viewBox="0 0 108 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_5160_64883)">
<path d="M0.144318 2.30909C0.144318 1.11352 1.11352 0.144318 2.30909 0.144318H105.691C106.886 0.144318 107.856 1.11352 107.856 2.30909V79.8557H0.144318V2.30909Z" fill="white" stroke="#E5E5E5" stroke-width="0.288636"/>
<path d="M0.144318 2.30909C0.144318 1.11352 1.11352 0.144318 2.30909 0.144318H106.268C107.145 0.144318 107.856 0.855066 107.856 1.73182V5.85568H0.144318V2.30909Z" fill="#F5F5F5" stroke="#E5E5E5" stroke-width="0.288636"/>
<rect x="2.70312" y="2.125" width="1.73182" height="1.73182" rx="0.865909" fill="#EF4444"/>
<rect x="5.00781" y="2.125" width="1.73182" height="1.73182" rx="0.865909" fill="#FCD34D"/>
<rect x="7.32031" y="2.125" width="1.73182" height="1.73182" rx="0.865909" fill="#4ADE80"/>
<path d="M26.5547 40H83.2214" stroke="#D4D4D4" stroke-width="5.66667" stroke-linecap="round"/>
<path d="M26.5547 40H51.1102" stroke="#525252" stroke-width="5.66667" stroke-linecap="round"/>
</g>
<defs>
<clipPath id="clip0_5160_64883">
<rect width="108" height="80" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,15 @@
<svg width="108" height="80" viewBox="0 0 108 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_5160_64788)">
<path d="M0.144318 2.30909C0.144318 1.11352 1.11352 0.144318 2.30909 0.144318H105.691C106.886 0.144318 107.856 1.11352 107.856 2.30909V79.8557H0.144318V2.30909Z" fill="white" stroke="#E5E5E5" stroke-width="0.288636"/>
<path d="M0.144318 2.30909C0.144318 1.11352 1.11352 0.144318 2.30909 0.144318H106.268C107.145 0.144318 107.856 0.855066 107.856 1.73182V5.85568H0.144318V2.30909Z" fill="#F5F5F5" stroke="#E5E5E5" stroke-width="0.288636"/>
<rect x="2.70312" y="2.125" width="1.73182" height="1.73182" rx="0.865909" fill="#EF4444"/>
<rect x="5.00781" y="2.125" width="1.73182" height="1.73182" rx="0.865909" fill="#FCD34D"/>
<rect x="7.32031" y="2.125" width="1.73182" height="1.73182" rx="0.865909" fill="#4ADE80"/>
<path d="M48.8118 30.5455V48H45.6499V33.6222H45.5476L41.4652 36.2301V33.3324L45.8033 30.5455H48.8118ZM53.4836 48V45.7159L59.5433 39.7756C60.1228 39.1903 60.6058 38.6705 60.9922 38.2159C61.3785 37.7614 61.6683 37.321 61.8615 36.8949C62.0547 36.4687 62.1512 36.0142 62.1512 35.5312C62.1512 34.9801 62.0262 34.5085 61.7762 34.1165C61.5262 33.7188 61.1825 33.4119 60.745 33.196C60.3075 32.9801 59.8103 32.8722 59.2535 32.8722C58.6797 32.8722 58.1768 32.9915 57.745 33.2301C57.3132 33.4631 56.978 33.7955 56.7393 34.2273C56.5064 34.6591 56.3899 35.1733 56.3899 35.7699H53.3814C53.3814 34.6619 53.6342 33.6989 54.1399 32.8807C54.6456 32.0625 55.3416 31.429 56.228 30.9801C57.12 30.5312 58.1427 30.3068 59.2961 30.3068C60.4666 30.3068 61.495 30.5256 62.3814 30.9631C63.2677 31.4006 63.9552 32 64.4439 32.7614C64.9382 33.5227 65.1853 34.392 65.1853 35.3693C65.1853 36.0227 65.0603 36.6648 64.8103 37.2955C64.5603 37.9261 64.12 38.625 63.4893 39.392C62.8643 40.1591 61.9865 41.0881 60.8558 42.179L57.8473 45.2386V45.358H65.4495V48H53.4836Z" fill="#737373"/>
</g>
<defs>
<clipPath id="clip0_5160_64788">
<rect width="108" height="80" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,24 @@
<svg width="108" height="80" viewBox="0 0 108 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_5160_64904)">
<path d="M0.144318 2.30909C0.144318 1.11352 1.11352 0.144318 2.30909 0.144318H105.691C106.886 0.144318 107.856 1.11352 107.856 2.30909V79.8557H0.144318V2.30909Z" fill="white" stroke="#E5E5E5" stroke-width="0.288636"/>
<path d="M0.144318 2.30909C0.144318 1.11352 1.11352 0.144318 2.30909 0.144318H106.268C107.145 0.144318 107.856 0.855066 107.856 1.73182V5.85568H0.144318V2.30909Z" fill="#F5F5F5" stroke="#E5E5E5" stroke-width="0.288636"/>
<rect x="2.70312" y="2.125" width="1.73182" height="1.73182" rx="0.865909" fill="#EF4444"/>
<rect x="5.00781" y="2.125" width="1.73182" height="1.73182" rx="0.865909" fill="#FCD34D"/>
<rect x="7.32031" y="2.125" width="1.73182" height="1.73182" rx="0.865909" fill="#4ADE80"/>
<g clip-path="url(#clip1_5160_64904)">
<circle cx="54.9411" cy="40.027" r="15.1351" stroke="#E5E5E5" stroke-width="3.78378"/>
<mask id="path-7-inside-1_5160_64904" fill="white">
<path d="M43.9218 53.0058C46.0076 54.7775 48.4897 56.0203 51.1578 56.629C53.8259 57.2377 56.6015 57.1943 59.2492 56.5024C61.897 55.8106 64.3391 54.4908 66.3684 52.6547C68.3978 50.8187 69.9547 48.5205 70.9072 45.955C71.8598 43.3894 72.1799 40.6321 71.8404 37.9165C71.501 35.201 70.512 32.6073 68.9573 30.3551C67.4025 28.103 65.3278 26.2588 62.909 24.9788C60.4901 23.6987 57.7983 23.0206 55.0617 23.0018L55.0362 26.713C57.1764 26.7277 59.2814 27.258 61.1731 28.259C63.0647 29.2601 64.6872 30.7023 65.9031 32.4636C67.1189 34.2248 67.8924 36.2532 68.1578 38.3769C68.4233 40.5005 68.1729 42.6569 67.428 44.6632C66.6831 46.6696 65.4655 48.4668 63.8785 49.9027C62.2915 51.3385 60.3817 52.3707 58.3111 52.9117C56.2404 53.4527 54.0698 53.4867 51.9832 53.0107C49.8967 52.5347 47.9556 51.5627 46.3244 50.1772L43.9218 53.0058Z"/>
</mask>
<path d="M43.9218 53.0058C46.0076 54.7775 48.4897 56.0203 51.1578 56.629C53.8259 57.2377 56.6015 57.1943 59.2492 56.5024C61.897 55.8106 64.3391 54.4908 66.3684 52.6547C68.3978 50.8187 69.9547 48.5205 70.9072 45.955C71.8598 43.3894 72.1799 40.6321 71.8404 37.9165C71.501 35.201 70.512 32.6073 68.9573 30.3551C67.4025 28.103 65.3278 26.2588 62.909 24.9788C60.4901 23.6987 57.7983 23.0206 55.0617 23.0018L55.0362 26.713C57.1764 26.7277 59.2814 27.258 61.1731 28.259C63.0647 29.2601 64.6872 30.7023 65.9031 32.4636C67.1189 34.2248 67.8924 36.2532 68.1578 38.3769C68.4233 40.5005 68.1729 42.6569 67.428 44.6632C66.6831 46.6696 65.4655 48.4668 63.8785 49.9027C62.2915 51.3385 60.3817 52.3707 58.3111 52.9117C56.2404 53.4527 54.0698 53.4867 51.9832 53.0107C49.8967 52.5347 47.9556 51.5627 46.3244 50.1772L43.9218 53.0058Z" fill="#D9D9D9" stroke="#737373" stroke-width="3.78378" mask="url(#path-7-inside-1_5160_64904)"/>
</g>
</g>
<defs>
<clipPath id="clip0_5160_64904">
<rect width="108" height="80" fill="white"/>
</clipPath>
<clipPath id="clip1_5160_64904">
<rect width="70" height="34.0541" fill="white" transform="translate(19 23)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -0,0 +1,9 @@
<svg width="90" height="90" viewBox="0 0 90 90" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M44.5229 89.0458C69.1123 89.0458 89.0459 69.1122 89.0459 44.5229C89.0459 19.9336 69.1123 0 44.5229 0C19.9336 0 0 19.9336 0 44.5229C0 69.1122 19.9336 89.0458 44.5229 89.0458Z" fill="#F2F2F2"/>
<mask id="mask0_6341_74161" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="23" y="22" width="44" height="44">
<rect x="23" y="22" width="44" height="44" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_6341_74161)">
<path d="M35.8593 52.7056H45.026C45.4162 52.7056 45.7429 52.574 46.0061 52.3108C46.2693 52.0476 46.401 51.7209 46.401 51.3307C46.401 50.9405 46.2693 50.6138 46.0061 50.3506C45.7429 50.0874 45.4162 49.9557 45.026 49.9557H35.8593C35.4692 49.9557 35.1425 50.0874 34.8792 50.3506C34.616 50.6138 34.4844 50.9405 34.4844 51.3307C34.4844 51.7209 34.616 52.0476 34.8792 52.3108C35.1425 52.574 35.4692 52.7056 35.8593 52.7056ZM54.1927 35.2891C53.8025 35.2891 53.4758 35.4207 53.2126 35.6839C52.9493 35.9472 52.8177 36.2739 52.8177 36.664V51.3307C52.8177 51.7209 52.9493 52.0476 53.2126 52.3108C53.4758 52.574 53.8025 52.7056 54.1927 52.7056C54.5828 52.7056 54.9095 52.574 55.1728 52.3108C55.436 52.0476 55.5676 51.7209 55.5676 51.3307V36.664C55.5676 36.2739 55.436 35.9472 55.1728 35.6839C54.9095 35.4207 54.5828 35.2891 54.1927 35.2891ZM35.8593 45.3723H45.026C45.4162 45.3723 45.7429 45.2407 46.0061 44.9775C46.2693 44.7142 46.401 44.3875 46.401 43.9974C46.401 43.6072 46.2693 43.2805 46.0061 43.0173C45.7429 42.754 45.4162 42.6224 45.026 42.6224H35.8593C35.4692 42.6224 35.1425 42.754 34.8792 43.0173C34.616 43.2805 34.4844 43.6072 34.4844 43.9974C34.4844 44.3875 34.616 44.7142 34.8792 44.9775C35.1425 45.2407 35.4692 45.3723 35.8593 45.3723ZM35.8593 38.039H45.026C45.4162 38.039 45.7429 37.9074 46.0061 37.6441C46.2693 37.3809 46.401 37.0542 46.401 36.664C46.401 36.2739 46.2693 35.9472 46.0061 35.6839C45.7429 35.4207 45.4162 35.2891 45.026 35.2891H35.8593C35.4692 35.2891 35.1425 35.4207 34.8792 35.6839C34.616 35.9472 34.4844 36.2739 34.4844 36.664C34.4844 37.0542 34.616 37.3809 34.8792 37.6441C35.1425 37.9074 35.4692 38.039 35.8593 38.039ZM30.9235 59.5806C29.9974 59.5806 29.2135 59.2598 28.5719 58.6181C27.9302 57.9765 27.6094 57.1926 27.6094 56.2665V31.7282C27.6094 30.8021 27.9302 30.0182 28.5719 29.3766C29.2135 28.7349 29.9974 28.4141 30.9235 28.4141H59.1285C60.0546 28.4141 60.8385 28.7349 61.4801 29.3766C62.1218 30.0182 62.4426 30.8021 62.4426 31.7282V56.2665C62.4426 57.1926 62.1218 57.9765 61.4801 58.6181C60.8385 59.2598 60.0546 59.5806 59.1285 59.5806H30.9235ZM30.9235 56.8307H59.1285C59.2695 56.8307 59.3988 56.7719 59.5164 56.6544C59.6339 56.5369 59.6927 56.4076 59.6927 56.2665V31.7282C59.6927 31.5871 59.6339 31.4578 59.5164 31.3403C59.3988 31.2228 59.2695 31.164 59.1285 31.164H30.9235C30.7824 31.164 30.6532 31.2228 30.5356 31.3403C30.4181 31.4578 30.3593 31.5871 30.3593 31.7282V56.2665C30.3593 56.4076 30.4181 56.5369 30.5356 56.6544C30.6532 56.7719 30.7824 56.8307 30.9235 56.8307Z" fill="#A3A3A3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1,113 @@
// services
import APIService from "services/api.service";
// types
import { ICustomAttribute, ICustomAttributeValue, ICustomAttributeValueFormData } from "types";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
class CustomAttributesService extends APIService {
constructor() {
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
}
async getObjectsList(
workspaceSlug: string,
params: { project: string }
): Promise<ICustomAttribute[]> {
return this.get(`/api/workspaces/${workspaceSlug}/entity-properties/`, {
params,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getAttributeDetails(workspaceSlug: string, propertyId: string): Promise<ICustomAttribute> {
return this.get(`/api/workspaces/${workspaceSlug}/properties/${propertyId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async createAttribute(
workspaceSlug: string,
data: Partial<ICustomAttribute>
): Promise<ICustomAttribute> {
return this.post(`/api/workspaces/${workspaceSlug}/properties/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async patchAttribute(
workspaceSlug: string,
propertyId: string,
data: Partial<ICustomAttribute>
): Promise<ICustomAttribute> {
return this.patch(`/api/workspaces/${workspaceSlug}/properties/${propertyId}/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async deleteAttribute(workspaceSlug: string, propertyId: string): Promise<any> {
return this.delete(`/api/workspaces/${workspaceSlug}/properties/${propertyId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getAttributeValues(
workspaceSlug: string,
projectId: string,
issueId: string
): Promise<{ children: ICustomAttributeValue[] }> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/property-values/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async createAttributeValues(
workspaceSlug: string,
projectId: string,
issueId: string,
data: ICustomAttributeValueFormData
): Promise<{ children: ICustomAttributeValue[] }> {
return this.post(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/property-values/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async deleteAttributeValue(
workspaceSlug: string,
projectId: string,
issueId: string,
propertyId: string
): Promise<any> {
return this.delete(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/property-values/${propertyId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
const customAttributesService = new CustomAttributesService();
export default customAttributesService;

View File

@ -22,7 +22,7 @@ class ProjectIssuesServices extends APIService {
projectId: string,
data: any,
user: ICurrentUserResponse | undefined
): Promise<any> {
): Promise<IIssue> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/`, data)
.then((response) => {
trackEventServices.trackIssueEvent(response.data, "ISSUE_CREATE", user);
@ -88,7 +88,9 @@ class ProjectIssuesServices extends APIService {
}
async getIssueProperties(workspaceSlug: string, projectId: string): Promise<any> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-properties/`)
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-display-properties/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
@ -191,7 +193,7 @@ class ProjectIssuesServices extends APIService {
async createIssueProperties(workspaceSlug: string, projectId: string, data: any): Promise<any> {
return this.post(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-properties/`,
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-display-properties/`,
data
)
.then((response) => response?.data)
@ -207,7 +209,7 @@ class ProjectIssuesServices extends APIService {
data: any
): Promise<any> {
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-properties/` +
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-display-properties/` +
`${issuePropertyId}/`,
data
)
@ -393,7 +395,7 @@ class ProjectIssuesServices extends APIService {
issueId: string,
data: Partial<IIssue>,
user: ICurrentUserResponse | undefined
): Promise<any> {
): Promise<IIssue> {
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/`,
data

View File

@ -0,0 +1,141 @@
// mobx
import { action, observable, runInAction, makeAutoObservable } from "mobx";
// services
import customAttributesService from "services/custom-attributes.service";
// types
import type { ICustomAttributeValue, ICustomAttributeValueFormData } from "types";
class CustomAttributeValuesStore {
issueAttributeValues: {
[key: string]: ICustomAttributeValue[];
} | null = null;
// loaders
fetchIssueAttributeValuesLoader = false;
// errors
error: any | null = null;
rootStore: any | null = null;
constructor(_rootStore: any | null = null) {
makeAutoObservable(this, {
issueAttributeValues: observable.ref,
fetchIssueAttributeValues: action,
createAttributeValue: action,
deleteAttributeValue: action,
});
this.rootStore = _rootStore;
}
fetchIssueAttributeValues = async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
runInAction(() => {
this.fetchIssueAttributeValuesLoader = true;
});
const response = await customAttributesService.getAttributeValues(
workspaceSlug,
projectId,
issueId
);
runInAction(() => {
this.issueAttributeValues = {
...this.issueAttributeValues,
[issueId]: response.children,
};
this.fetchIssueAttributeValuesLoader = false;
});
} catch (error) {
runInAction(() => {
this.fetchIssueAttributeValuesLoader = false;
this.error = error;
});
}
};
createAttributeValue = async (
workspaceSlug: string,
projectId: string,
issueId: string,
data: ICustomAttributeValueFormData
) => {
const newChildren = [...(this.issueAttributeValues?.[issueId] ?? [])];
const attributesToUpdate = [...Object.keys(data.issue_properties)];
newChildren.map((child) => {
if (attributesToUpdate.includes(child.id) && child)
child.prop_value = data.issue_properties[child.id].map((value) => ({
type: 0,
value,
}));
return child;
});
try {
runInAction(() => {
this.issueAttributeValues = {
...this.issueAttributeValues,
[issueId]: [...newChildren],
};
});
const date = new Date();
const unixEpochTimeInSeconds = Math.floor(date.getTime() / 1000);
const response = await customAttributesService.createAttributeValues(
workspaceSlug,
projectId,
issueId,
{ ...data, a_epoch: unixEpochTimeInSeconds }
);
runInAction(() => {
this.issueAttributeValues = {
...this.issueAttributeValues,
[issueId]: response.children,
};
});
} catch (error) {
runInAction(() => {
this.error = error;
});
this.fetchIssueAttributeValues(workspaceSlug, projectId, issueId);
}
};
deleteAttributeValue = async (
workspaceSlug: string,
projectId: string,
issueId: string,
propertyId: string
) => {
const newChildren = [...(this.issueAttributeValues?.[issueId] ?? [])];
newChildren.filter((c) => c.id !== propertyId);
try {
runInAction(() => {
this.issueAttributeValues = {
...this.issueAttributeValues,
[issueId]: newChildren,
};
});
await customAttributesService.deleteAttributeValue(
workspaceSlug,
projectId,
issueId,
propertyId
);
} catch (error) {
runInAction(() => {
this.error = error;
});
this.fetchIssueAttributeValues(workspaceSlug, projectId, issueId);
}
};
}
export default CustomAttributeValuesStore;

View File

@ -0,0 +1,345 @@
// mobx
import { action, observable, runInAction, makeAutoObservable } from "mobx";
// services
import customAttributesService from "services/custom-attributes.service";
// types
import type { ICustomAttribute } from "types";
class CustomAttributesStore {
objects: ICustomAttribute[] | null = null;
objectAttributes: {
[objectId: string]: { [objectAttributeId: string]: ICustomAttribute };
} = {};
// loaders
fetchObjectsLoader = false;
fetchObjectDetailsLoader = false;
createObjectAttributeLoader = false;
createAttributeOptionLoader = false;
// errors
attributesFetchError: any | null = null;
error: any | null = null;
rootStore: any | null = null;
constructor(_rootStore: any | null = null) {
makeAutoObservable(this, {
objects: observable.ref,
objectAttributes: observable.ref,
fetchObjects: action,
fetchObjectDetails: action,
createObject: action,
updateObject: action,
deleteObject: action,
createObjectAttribute: action,
updateObjectAttribute: action,
deleteObjectAttribute: action,
createAttributeOption: action,
updateAttributeOption: action,
deleteAttributeOption: action,
});
this.rootStore = _rootStore;
}
fetchObjects = async (workspaceSlug: string, projectId: string) => {
try {
runInAction(() => {
this.fetchObjectsLoader = true;
});
const response = await customAttributesService.getObjectsList(workspaceSlug, {
project: projectId,
});
if (response) {
runInAction(() => {
this.objects = response;
this.fetchObjectsLoader = false;
});
}
} catch (error) {
runInAction(() => {
this.fetchObjectsLoader = false;
this.attributesFetchError = error;
});
}
};
fetchObjectDetails = async (workspaceSlug: string, objectId: string) => {
try {
runInAction(() => {
this.fetchObjectDetailsLoader = true;
});
const response = await customAttributesService.getAttributeDetails(workspaceSlug, objectId);
const objectChildren: { [key: string]: ICustomAttribute } = response.children.reduce(
(acc, child) => ({
...acc,
[child.id]: child,
}),
{}
);
runInAction(() => {
this.objectAttributes = {
...this.objectAttributes,
[objectId]: objectChildren,
};
this.fetchObjectDetailsLoader = false;
});
return response;
} catch (error) {
runInAction(() => {
this.fetchObjectDetailsLoader = false;
this.error = error;
});
}
};
createObject = async (workspaceSlug: string, data: Partial<ICustomAttribute>) => {
try {
const response = await customAttributesService.createAttribute(workspaceSlug, data);
runInAction(() => {
this.objects = [...(this.objects ?? []), response];
});
return response;
} catch (error) {
runInAction(() => {
this.error = error;
});
}
};
updateObject = async (
workspaceSlug: string,
objectId: string,
data: Partial<ICustomAttribute>
) => {
try {
const response = await customAttributesService.patchAttribute(workspaceSlug, objectId, data);
const newObjects = [...(this.objects ?? [])].map((object) =>
object.id === objectId ? { ...object, ...response } : object
);
runInAction(() => {
this.objects = newObjects;
});
return response;
} catch (error) {
runInAction(() => {
this.error = error;
});
}
};
deleteObject = async (workspaceSlug: string, objectId: string) => {
try {
await customAttributesService.deleteAttribute(workspaceSlug, objectId);
const newObjects = this.objects?.filter((object) => object.id !== objectId);
runInAction(() => {
this.objects = [...(newObjects ?? [])];
});
} catch (error) {
runInAction(() => {
this.error = error;
});
}
};
createObjectAttribute = async (
workspaceSlug: string,
data: Partial<ICustomAttribute> & { parent: string }
) => {
try {
runInAction(() => {
this.createObjectAttributeLoader = true;
});
const response = await customAttributesService.createAttribute(workspaceSlug, data);
runInAction(() => {
this.objectAttributes = {
...this.objectAttributes,
[data.parent]: {
...this.objectAttributes[data.parent],
[response.id]: response,
},
};
this.createObjectAttributeLoader = false;
});
return response;
} catch (error) {
runInAction(() => {
this.error = error;
this.createObjectAttributeLoader = false;
});
}
};
updateObjectAttribute = async (
workspaceSlug: string,
parentId: string,
propertyId: string,
data: Partial<ICustomAttribute>
) => {
try {
await customAttributesService.patchAttribute(workspaceSlug, propertyId, data);
const newObjects = this.objectAttributes[parentId];
newObjects[propertyId] = {
...newObjects[propertyId],
...data,
};
runInAction(() => {
this.objectAttributes = {
...this.objectAttributes,
[parentId]: newObjects,
};
});
} catch (error) {
runInAction(() => {
this.error = error;
});
}
};
deleteObjectAttribute = async (workspaceSlug: string, parentId: string, propertyId: string) => {
try {
await customAttributesService.deleteAttribute(workspaceSlug, propertyId);
const newObjects = this.objectAttributes[parentId];
delete newObjects[propertyId];
runInAction(() => {
this.objectAttributes = {
...this.objectAttributes,
[parentId]: newObjects,
};
});
} catch (error) {
runInAction(() => {
this.error = error;
});
}
};
createAttributeOption = async (
workspaceSlug: string,
objectId: string,
data: Partial<ICustomAttribute> & { parent: string }
) => {
try {
runInAction(() => {
this.createAttributeOptionLoader = true;
});
const response = await customAttributesService.createAttribute(workspaceSlug, data);
runInAction(() => {
this.objectAttributes = {
...this.objectAttributes,
[objectId]: {
...this.objectAttributes[objectId],
[data.parent]: {
...this.objectAttributes[objectId][data.parent],
children: [...this.objectAttributes[objectId][data.parent].children, response],
},
},
};
this.createAttributeOptionLoader = false;
});
return response;
} catch (error) {
runInAction(() => {
this.error = error;
this.createAttributeOptionLoader = false;
});
}
};
updateAttributeOption = async (
workspaceSlug: string,
objectId: string,
parentId: string,
propertyId: string,
data: Partial<ICustomAttribute>
) => {
try {
const response = await customAttributesService.patchAttribute(
workspaceSlug,
propertyId,
data
);
const newOptions = this.objectAttributes[objectId][parentId].children.map((option) => ({
...option,
...(option.id === propertyId ? response : {}),
}));
runInAction(() => {
this.objectAttributes = {
...this.objectAttributes,
[objectId]: {
...this.objectAttributes[objectId],
[parentId]: {
...this.objectAttributes[objectId][parentId],
children: newOptions,
},
},
};
});
return response;
} catch (error) {
runInAction(() => {
this.error = error;
});
}
};
deleteAttributeOption = async (
workspaceSlug: string,
objectId: string,
parentId: string,
propertyId: string
) => {
const newOptions = this.objectAttributes[objectId][parentId].children.filter(
(option) => option.id !== propertyId
);
try {
runInAction(() => {
this.objectAttributes = {
...this.objectAttributes,
[objectId]: {
...this.objectAttributes[objectId],
[parentId]: {
...this.objectAttributes[objectId][parentId],
children: newOptions,
},
},
};
});
await customAttributesService.deleteAttribute(workspaceSlug, propertyId);
} catch (error) {
runInAction(() => {
this.error = error;
});
this.fetchObjectDetails(workspaceSlug, objectId);
}
};
}
export default CustomAttributesStore;

View File

@ -6,6 +6,8 @@ import ThemeStore from "./theme";
import ProjectStore, { IProjectStore } from "./project";
import ProjectPublishStore, { IProjectPublishStore } from "./project-publish";
import IssuesStore from "./issues";
import CustomAttributesStore from "./custom-attributes";
import CustomAttributeValuesStore from "./custom-attribute-values";
import DraftIssuesStore from "./draft-issue";
enableStaticRendering(typeof window === "undefined");
@ -16,6 +18,8 @@ export class RootStore {
project: IProjectStore;
projectPublish: IProjectPublishStore;
issues: IssuesStore;
customAttributes: CustomAttributesStore;
customAttributeValues: CustomAttributeValuesStore;
draftIssuesStore: DraftIssuesStore;
constructor() {
@ -24,6 +28,8 @@ export class RootStore {
this.project = new ProjectStore(this);
this.projectPublish = new ProjectPublishStore(this);
this.issues = new IssuesStore(this);
this.customAttributes = new CustomAttributesStore(this);
this.customAttributeValues = new CustomAttributeValuesStore(this);
this.draftIssuesStore = new DraftIssuesStore(this);
}
}

View File

@ -359,3 +359,27 @@ body {
.disable-scroll {
overflow: hidden !important;
}
/* Chrome, Safari, Edge, Opera */
input.hide-arrows::-webkit-outer-spin-button,
input.hide-arrows::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input.hide-arrows[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
input.hide-color[type="color"]::-webkit-color-swatch {
display: none;
border-radius: 2px;
border: none;
}
input.hide-color[type="color"]::-moz-color-swatch {
width: 10px !important;
border-radius: 2px;
border: none;
}

View File

@ -29,7 +29,8 @@
.react-datepicker {
font-family: "Inter" !important;
background-color: rgba(var(--color-background-100)) !important;
border: 1px solid rgba(var(--color-background-80)) !important;
border: 1px solid rgba(var(--color-border-200)) !important;
box-shadow: var(--color-shadow-xs);
}
.react-datepicker__month-container {

87
web/types/custom-attributes.d.ts vendored Normal file
View File

@ -0,0 +1,87 @@
export type TCustomAttributeTypes =
| "checkbox"
| "datetime"
| "email"
| "entity"
| "file"
| "multi_select"
| "number"
| "option"
| "relation"
| "select"
| "text"
| "url";
export type TCustomAttributeUnits = "cycle" | "issue" | "module" | "user" | null;
// export type TCustomAttributeExtraSettings =
// | {
// type: "checkbox";
// extra_settings: {
// representation: "check" | "toggle_switch";
// };
// }
// | {
// type: "datetime";
// extra_settings: {
// date_format: "DD-MM-YYYY" | "MM-DD-YYYY" | "YYYY-MM-DD";
// hide_date: boolean;
// hide_time: boolean;
// time_format: "12" | "24";
// };
// }
// | {
// type: "number";
// extra_settings: {
// divided_by: number;
// representation: "numerical" | "bar" | "ring";
// show_number: boolean;
// };
// };
export interface ICustomAttribute {
children: ICustomAttribute[];
color: string;
default_value: string;
description: string;
display_name: string;
extra_settings: { [key: string]: any };
icon: string | null;
id: string;
is_default: boolean;
is_multi: boolean;
is_required: boolean;
parent: string | null;
project: string | null;
sort_order: number;
type: TCustomAttributeTypes;
unit: TCustomAttributeUnits;
workspace: string;
}
export interface ICustomAttributeValue {
children: ICustomAttributeValue[];
id: string;
name: string;
prop_value:
| {
type: 0 | 1;
value: string;
}[]
| null;
prop_extra:
| {
id: string;
name: string;
}[]
| null;
type: TCustomAttributeTypes;
unit: TCustomAttributeUnits;
}
export interface ICustomAttributeValueFormData {
issue_properties: {
[key: string]: string[];
};
a_epoch?: number;
}

30
web/types/index.d.ts vendored
View File

@ -1,23 +1,23 @@
export * from "./users";
export * from "./workspace";
export * from "./cycles";
export * from "./projects";
export * from "./state";
export * from "./invitation";
export * from "./issues";
export * from "./modules";
export * from "./views";
export * from "./integration";
export * from "./pages";
export * from "./ai";
export * from "./estimate";
export * from "./importer";
export * from "./inbox";
export * from "./ai";
export * from "./analytics";
export * from "./calendar";
export * from "./custom-attributes";
export * from "./cycles";
export * from "./estimate";
export * from "./inbox";
export * from "./integration";
export * from "./issues";
export * from "./modules";
export * from "./notifications";
export * from "./waitlist";
export * from "./pages";
export * from "./projects";
export * from "./reaction";
export * from "./state";
export * from "./users";
export * from "./views";
export * from "./waitlist";
export * from "./workspace";
export * from "./view-props";
export type NestedKeyOf<ObjectType extends object> = {

21
web/types/issues.d.ts vendored
View File

@ -2,19 +2,13 @@ import { KeyedMutator } from "swr";
import type {
IState,
IUser,
IProject,
ICycle,
IModule,
IUserLite,
IProjectLite,
IWorkspaceLite,
IStateLite,
TStateGroups,
Properties,
IIssueFilterOptions,
TIssueGroupByOptions,
TIssueViewOptions,
TIssueOrderByOptions,
IIssueDisplayFilterOptions,
} from "types";
@ -89,6 +83,21 @@ export interface IIssue {
assignees_list: string[];
attachment_count: number;
attachments: any[];
entity: string | null;
issue_relations: {
id: string;
issue: string;
issue_detail: BlockeIssueDetail;
relation_type: IssueRelationType;
related_issue: string;
}[];
related_issues: {
id: string;
issue: string;
related_issue_detail: BlockeIssueDetail;
relation_type: IssueRelationType;
related_issue: string;
}[];
issue_relations: IssueRelation[];
related_issues: IssueRelation[];
bridge_id?: string | null;

View File

@ -6628,20 +6628,13 @@ prosemirror-menu@^1.2.1:
prosemirror-history "^1.0.0"
prosemirror-state "^1.0.0"
prosemirror-model@^1.0.0, prosemirror-model@^1.16.0, prosemirror-model@^1.18.1, prosemirror-model@^1.8.1:
prosemirror-model@1.18.1, prosemirror-model@^1.0.0, prosemirror-model@^1.16.0, prosemirror-model@^1.18.1, prosemirror-model@^1.19.0, prosemirror-model@^1.8.1:
version "1.18.1"
resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.18.1.tgz#1d5d6b6de7b983ee67a479dc607165fdef3935bd"
integrity sha512-IxSVBKAEMjD7s3n8cgtwMlxAXZrC7Mlag7zYsAKDndAqnDScvSmp/UdnRTV/B33lTCVU3CCm7dyAn/rVVD0mcw==
dependencies:
orderedmap "^2.0.0"
prosemirror-model@^1.19.0:
version "1.19.3"
resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.19.3.tgz#f0d55285487fefd962d0ac695f716f4ec6705006"
integrity sha512-tgSnwN7BS7/UM0sSARcW+IQryx2vODKX4MI7xpqY2X+iaepJdKBPc7I4aACIsDV/LTaTjt12Z56MhDr9LsyuZQ==
dependencies:
orderedmap "^2.0.0"
prosemirror-schema-basic@^1.2.0:
version "1.2.2"
resolved "https://registry.yarnpkg.com/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.2.tgz#6695f5175e4628aab179bf62e5568628b9cfe6c7"