forked from github/plane
Compare commits
52 Commits
preview
...
feat/custo
Author | SHA1 | Date | |
---|---|---|---|
|
dd934e63a2 | ||
|
22d659cd4c | ||
|
639a30abb5 | ||
|
2d3c1f93c1 | ||
|
4d158a6d8f | ||
|
4f33c4bea3 | ||
|
3ae292a2da | ||
|
c4143a1a64 | ||
|
998dc1bbae | ||
|
d57e99ed30 | ||
|
1428e6b555 | ||
|
95ae0f065a | ||
|
3c3f0f7581 | ||
|
cd4d56d071 | ||
|
4a5ca4d51b | ||
|
8ff1e8dd87 | ||
|
4a13b30874 | ||
|
c714b5e50c | ||
|
4c016c85f1 | ||
|
220c572461 | ||
|
4842fc8e58 | ||
|
d04eac30b0 | ||
|
e94fb40602 | ||
|
5dc9e00c3d | ||
|
9ef30b4fbf | ||
|
dc908d21bb | ||
|
3b682c2124 | ||
|
121d4cb4eb | ||
|
47866fb511 | ||
|
6b2a5a97ac | ||
|
7cf263ecd4 | ||
|
14be78564a | ||
|
a014564d11 | ||
|
2169ba35a9 | ||
|
fb87bfc140 | ||
|
d36a8b1325 | ||
|
501a704108 | ||
|
ef77ca6524 | ||
|
57f4941ee2 | ||
|
e713db48b3 | ||
|
057ddf1310 | ||
|
ed25e09557 | ||
|
fff07a2353 | ||
|
529a286954 | ||
|
cf384d3a4d | ||
|
02d18e9edd | ||
|
43659631cf | ||
|
9b6efa2ed3 | ||
|
2e2cace5de | ||
|
1d57f686e2 | ||
|
04bf575011 | ||
|
e5b466a3c4 |
@ -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 () => {
|
||||
|
@ -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 () => {
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
91
web/components/custom-attributes/attribute-display/email.tsx
Normal file
91
web/components/custom-attributes/attribute-display/email.tsx
Normal 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>
|
||||
);
|
||||
};
|
153
web/components/custom-attributes/attribute-display/file.tsx
Normal file
153
web/components/custom-attributes/attribute-display/file.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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";
|
138
web/components/custom-attributes/attribute-display/number.tsx
Normal file
138
web/components/custom-attributes/attribute-display/number.tsx
Normal 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>
|
||||
);
|
||||
};
|
150
web/components/custom-attributes/attribute-display/relation.tsx
Normal file
150
web/components/custom-attributes/attribute-display/relation.tsx
Normal 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>
|
||||
);
|
||||
};
|
166
web/components/custom-attributes/attribute-display/select.tsx
Normal file
166
web/components/custom-attributes/attribute-display/select.tsx
Normal 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>
|
||||
);
|
||||
};
|
92
web/components/custom-attributes/attribute-display/text.tsx
Normal file
92
web/components/custom-attributes/attribute-display/text.tsx
Normal 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>
|
||||
);
|
||||
};
|
95
web/components/custom-attributes/attribute-display/url.tsx
Normal file
95
web/components/custom-attributes/attribute-display/url.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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;
|
||||
}
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
10
web/components/custom-attributes/attribute-forms/index.ts
Normal file
10
web/components/custom-attributes/attribute-forms/index.ts
Normal 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";
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
export * from "./option-form";
|
||||
export * from "./select-attribute-form";
|
||||
export * from "./select-option";
|
@ -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>
|
||||
);
|
||||
});
|
@ -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>
|
||||
);
|
||||
});
|
@ -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>
|
||||
);
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
export * from "./issue-modal";
|
||||
export * from "./peek-overview-custom-attributes-list";
|
||||
export * from "./sidebar-custom-attributes-list";
|
@ -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>
|
||||
);
|
||||
});
|
@ -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>
|
||||
);
|
||||
});
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
@ -0,0 +1,4 @@
|
||||
export * from "./checkboxes";
|
||||
export * from "./description-fields";
|
||||
export * from "./file-uploads";
|
||||
export * from "./select-fields";
|
@ -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>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
@ -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>
|
||||
);
|
||||
});
|
111
web/components/custom-attributes/delete-object-modal.tsx
Normal file
111
web/components/custom-attributes/delete-object-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
47
web/components/custom-attributes/dropdowns/color-picker.tsx
Normal file
47
web/components/custom-attributes/dropdowns/color-picker.tsx
Normal 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>
|
||||
);
|
@ -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>
|
||||
);
|
||||
};
|
3
web/components/custom-attributes/dropdowns/index.ts
Normal file
3
web/components/custom-attributes/dropdowns/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./color-picker";
|
||||
export * from "./file-formats-dropdown";
|
||||
export * from "./types-dropdown";
|
@ -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>
|
||||
);
|
||||
};
|
10
web/components/custom-attributes/index.ts
Normal file
10
web/components/custom-attributes/index.ts
Normal 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";
|
18
web/components/custom-attributes/input.tsx
Normal file
18
web/components/custom-attributes/input.tsx
Normal 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";
|
278
web/components/custom-attributes/object-modal.tsx
Normal file
278
web/components/custom-attributes/object-modal.tsx
Normal 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>
|
||||
);
|
||||
});
|
59
web/components/custom-attributes/objects-list.tsx
Normal file
59
web/components/custom-attributes/objects-list.tsx
Normal 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>
|
||||
);
|
||||
});
|
85
web/components/custom-attributes/objects-select.tsx
Normal file
85
web/components/custom-attributes/objects-select.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
});
|
61
web/components/custom-attributes/single-object.tsx
Normal file
61
web/components/custom-attributes/single-object.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
2
web/components/emoji-icon-picker/types.d.ts
vendored
2
web/components/emoji-icon-picker/types.d.ts
vendored
@ -11,4 +11,6 @@ export type Props = {
|
||||
) => void;
|
||||
onIconColorChange?: (data: any) => void;
|
||||
disabled?: boolean;
|
||||
showEmojiPicker?: boolean;
|
||||
showIconPicker?: boolean;
|
||||
};
|
||||
|
@ -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 (
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -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";
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
@ -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" />
|
||||
|
@ -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>
|
||||
|
@ -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 ? (
|
||||
<>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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<{
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
});
|
||||
|
||||
|
173
web/constants/custom-attributes.ts
Normal file
173
web/constants/custom-attributes.ts
Normal 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" },
|
||||
];
|
@ -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()}`;
|
||||
|
@ -67,3 +67,5 @@ export const EXPORTERS_LIST = [
|
||||
logo: JSONLogo,
|
||||
},
|
||||
];
|
||||
|
||||
export const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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;
|
20
web/public/custom-attributes/checkbox/check.svg
Normal file
20
web/public/custom-attributes/checkbox/check.svg
Normal 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 |
16
web/public/custom-attributes/checkbox/toggle-switch.svg
Normal file
16
web/public/custom-attributes/checkbox/toggle-switch.svg
Normal 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 |
16
web/public/custom-attributes/number/bar.svg
Normal file
16
web/public/custom-attributes/number/bar.svg
Normal 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 |
15
web/public/custom-attributes/number/numerical.svg
Normal file
15
web/public/custom-attributes/number/numerical.svg
Normal 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 |
24
web/public/custom-attributes/number/ring.svg
Normal file
24
web/public/custom-attributes/number/ring.svg
Normal 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 |
9
web/public/empty-state/custom-objects.svg
Normal file
9
web/public/empty-state/custom-objects.svg
Normal 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 |
113
web/services/custom-attributes.service.ts
Normal file
113
web/services/custom-attributes.service.ts
Normal 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;
|
@ -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
|
||||
|
141
web/store/custom-attribute-values.ts
Normal file
141
web/store/custom-attribute-values.ts
Normal 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;
|
345
web/store/custom-attributes.ts
Normal file
345
web/store/custom-attributes.ts
Normal 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;
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
87
web/types/custom-attributes.d.ts
vendored
Normal 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
30
web/types/index.d.ts
vendored
@ -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
21
web/types/issues.d.ts
vendored
@ -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;
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user