forked from github/plane
chore: single select options
This commit is contained in:
parent
02d18e9edd
commit
cf384d3a4d
@ -29,18 +29,53 @@ type Props = {
|
|||||||
data: Partial<ICustomAttribute>;
|
data: Partial<ICustomAttribute>;
|
||||||
handleDeleteAttribute: () => void;
|
handleDeleteAttribute: () => void;
|
||||||
handleUpdateAttribute: (data: Partial<ICustomAttribute>) => Promise<void>;
|
handleUpdateAttribute: (data: Partial<ICustomAttribute>) => Promise<void>;
|
||||||
|
objectId: string;
|
||||||
type: TCustomAttributeTypes;
|
type: TCustomAttributeTypes;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FormComponentProps = {
|
export type FormComponentProps = {
|
||||||
control: Control<Partial<ICustomAttribute>, any>;
|
control: Control<Partial<ICustomAttribute>, any>;
|
||||||
watch?: UseFormWatch<Partial<ICustomAttribute>>;
|
objectId: string;
|
||||||
|
watch: UseFormWatch<Partial<ICustomAttribute>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RenderForm: React.FC<{ type: TCustomAttributeTypes } & FormComponentProps> = ({
|
||||||
|
control,
|
||||||
|
objectId,
|
||||||
|
type,
|
||||||
|
watch,
|
||||||
|
}) => {
|
||||||
|
let FormToRender: any = <></>;
|
||||||
|
|
||||||
|
if (type === "checkbox")
|
||||||
|
FormToRender = <CheckboxAttributeForm control={control} objectId={objectId} watch={watch} />;
|
||||||
|
else if (type === "datetime")
|
||||||
|
FormToRender = <DateTimeAttributeForm control={control} objectId={objectId} watch={watch} />;
|
||||||
|
else if (type === "email")
|
||||||
|
FormToRender = <EmailAttributeForm control={control} objectId={objectId} watch={watch} />;
|
||||||
|
else if (type === "files")
|
||||||
|
FormToRender = <FileAttributeForm control={control} objectId={objectId} watch={watch} />;
|
||||||
|
else if (type === "multi_select")
|
||||||
|
FormToRender = <SelectAttributeForm control={control} objectId={objectId} watch={watch} />;
|
||||||
|
else if (type === "number")
|
||||||
|
FormToRender = <NumberAttributeForm control={control} objectId={objectId} watch={watch} />;
|
||||||
|
else if (type === "relation")
|
||||||
|
FormToRender = <RelationAttributeForm control={control} objectId={objectId} watch={watch} />;
|
||||||
|
else if (type === "select")
|
||||||
|
FormToRender = <SelectAttributeForm control={control} objectId={objectId} watch={watch} />;
|
||||||
|
else if (type === "text")
|
||||||
|
FormToRender = <TextAttributeForm control={control} objectId={objectId} watch={watch} />;
|
||||||
|
else if (type === "url")
|
||||||
|
FormToRender = <UrlAttributeForm control={control} objectId={objectId} watch={watch} />;
|
||||||
|
|
||||||
|
return FormToRender;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AttributeForm: React.FC<Props> = ({
|
export const AttributeForm: React.FC<Props> = ({
|
||||||
data,
|
data,
|
||||||
handleDeleteAttribute,
|
handleDeleteAttribute,
|
||||||
handleUpdateAttribute,
|
handleUpdateAttribute,
|
||||||
|
objectId,
|
||||||
type,
|
type,
|
||||||
}) => {
|
}) => {
|
||||||
const typeMetaData = CUSTOM_ATTRIBUTES_LIST[type];
|
const typeMetaData = CUSTOM_ATTRIBUTES_LIST[type];
|
||||||
@ -57,25 +92,6 @@ export const AttributeForm: React.FC<Props> = ({
|
|||||||
await handleUpdateAttribute(data);
|
await handleUpdateAttribute(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderForm = (type: TCustomAttributeTypes): JSX.Element => {
|
|
||||||
let FormToRender = <></>;
|
|
||||||
|
|
||||||
if (type === "checkbox") FormToRender = <CheckboxAttributeForm control={control} />;
|
|
||||||
else if (type === "datetime") FormToRender = <DateTimeAttributeForm control={control} />;
|
|
||||||
else if (type === "email") FormToRender = <EmailAttributeForm control={control} />;
|
|
||||||
else if (type === "files") FormToRender = <FileAttributeForm control={control} />;
|
|
||||||
else if (type === "multi_select")
|
|
||||||
FormToRender = <SelectAttributeForm control={control} multiple />;
|
|
||||||
else if (type === "number")
|
|
||||||
FormToRender = <NumberAttributeForm control={control} watch={watch} />;
|
|
||||||
else if (type === "relation") FormToRender = <RelationAttributeForm control={control} />;
|
|
||||||
else if (type === "select") FormToRender = <SelectAttributeForm control={control} />;
|
|
||||||
else if (type === "text") FormToRender = <TextAttributeForm control={control} />;
|
|
||||||
else if (type === "url") FormToRender = <UrlAttributeForm control={control} />;
|
|
||||||
|
|
||||||
return FormToRender;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
@ -95,7 +111,7 @@ export const AttributeForm: React.FC<Props> = ({
|
|||||||
<Disclosure.Button className="p-3 flex items-center justify-between gap-1 w-full">
|
<Disclosure.Button className="p-3 flex items-center justify-between gap-1 w-full">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<typeMetaData.icon size={14} strokeWidth={1.5} />
|
<typeMetaData.icon size={14} strokeWidth={1.5} />
|
||||||
<h6 className="text-sm">{typeMetaData.label}</h6>
|
<h6 className="text-sm">{data.display_name ?? typeMetaData.label}</h6>
|
||||||
</div>
|
</div>
|
||||||
<div className={`${open ? "-rotate-180" : ""} transition-all`}>
|
<div className={`${open ? "-rotate-180" : ""} transition-all`}>
|
||||||
<ChevronDown size={16} strokeWidth={1.5} rotate="180deg" />
|
<ChevronDown size={16} strokeWidth={1.5} rotate="180deg" />
|
||||||
@ -103,7 +119,9 @@ export const AttributeForm: React.FC<Props> = ({
|
|||||||
</Disclosure.Button>
|
</Disclosure.Button>
|
||||||
<Disclosure.Panel>
|
<Disclosure.Panel>
|
||||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-3 pl-9 pt-0">
|
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-3 pl-9 pt-0">
|
||||||
{renderForm(type)}
|
{data.type && (
|
||||||
|
<RenderForm type={data.type} control={control} objectId={objectId} watch={watch} />
|
||||||
|
)}
|
||||||
<div className="mt-8 flex items-center justify-between">
|
<div className="mt-8 flex items-center justify-between">
|
||||||
<div className="flex-shrink-0 flex items-center gap-2">
|
<div className="flex-shrink-0 flex items-center gap-2">
|
||||||
<Controller
|
<Controller
|
||||||
@ -123,7 +141,9 @@ export const AttributeForm: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
<PrimaryButton type="submit">{isSubmitting ? "Saving..." : "Save"}</PrimaryButton>
|
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||||
|
{isSubmitting ? "Saving..." : "Save"}
|
||||||
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
export * from "./select-attribute";
|
||||||
export * from "./attribute-form";
|
export * from "./attribute-form";
|
||||||
export * from "./checkbox-attribute-form";
|
export * from "./checkbox-attribute-form";
|
||||||
export * from "./date-time-attribute-form";
|
export * from "./date-time-attribute-form";
|
||||||
@ -5,6 +6,5 @@ export * from "./email-attribute-form";
|
|||||||
export * from "./file-attribute-form";
|
export * from "./file-attribute-form";
|
||||||
export * from "./number-attribute-form";
|
export * from "./number-attribute-form";
|
||||||
export * from "./relation-attribute-form";
|
export * from "./relation-attribute-form";
|
||||||
export * from "./select-attribute-form";
|
|
||||||
export * from "./text-attribute-form";
|
export * from "./text-attribute-form";
|
||||||
export * from "./url-attribute-form";
|
export * from "./url-attribute-form";
|
||||||
|
@ -1,105 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
// react-hook-form
|
|
||||||
import { Controller } from "react-hook-form";
|
|
||||||
// react-color
|
|
||||||
import { TwitterPicker } from "react-color";
|
|
||||||
// headless ui
|
|
||||||
import { Popover, Transition } from "@headlessui/react";
|
|
||||||
// components
|
|
||||||
import { FormComponentProps, Input } from "components/custom-attributes";
|
|
||||||
// icons
|
|
||||||
import { MoreHorizontal } from "lucide-react";
|
|
||||||
|
|
||||||
export const SelectOption: React.FC = () => (
|
|
||||||
<div className="group w-3/5 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> */}
|
|
||||||
<p className="bg-custom-primary-500/10 text-custom-text-300 text-xs p-1 rounded inline truncate">
|
|
||||||
🚀 Option 1
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex-shrink-0 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hidden group-hover:inline-block text-custom-text-300 text-xs"
|
|
||||||
>
|
|
||||||
Set as default
|
|
||||||
</button>
|
|
||||||
<button type="button">
|
|
||||||
<MoreHorizontal className="text-custom-text-400" size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const SelectAttributeForm: React.FC<FormComponentProps & { multiple?: boolean }> = ({
|
|
||||||
control,
|
|
||||||
multiple = false,
|
|
||||||
}) => (
|
|
||||||
<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">
|
|
||||||
{/* TODO: map over options */}
|
|
||||||
<SelectOption />
|
|
||||||
<SelectOption />
|
|
||||||
<SelectOption />
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 w-3/5">
|
|
||||||
<div className="bg-custom-background-100 rounded border border-custom-border-200 flex items-center gap-2 px-3 py-2">
|
|
||||||
<span className="flex-shrink-0 text-xs grid place-items-center">🚀</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="flex-grow border-none outline-none placeholder:text-custom-text-400 text-xs"
|
|
||||||
placeholder="Enter new option"
|
|
||||||
/>
|
|
||||||
<Popover className="relative">
|
|
||||||
{({ open, 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 bg-black" />
|
|
||||||
</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 right-0 z-10 mb-1 px-2 sm:px-0">
|
|
||||||
<Controller
|
|
||||||
name="color"
|
|
||||||
control={control}
|
|
||||||
render={({ field: { value, onChange } }) => (
|
|
||||||
<TwitterPicker
|
|
||||||
color={"#ff0000"}
|
|
||||||
onChange={(value) => {
|
|
||||||
onChange(value.hex);
|
|
||||||
close();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Popover.Panel>
|
|
||||||
</Transition>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./option-form";
|
||||||
|
export * from "./select-attribute-form";
|
||||||
|
export * from "./select-option";
|
@ -0,0 +1,107 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
// mobx
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
// headless ui
|
||||||
|
import { Popover, Transition } from "@headlessui/react";
|
||||||
|
// react-color
|
||||||
|
import { TwitterPicker } from "react-color";
|
||||||
|
// ui
|
||||||
|
import { PrimaryButton } from "components/ui";
|
||||||
|
import { ICustomAttribute } from "types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
objectId: string;
|
||||||
|
parentId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OptionForm: React.FC<Props> = observer(({ objectId, parentId }) => {
|
||||||
|
const [optionName, setOptionName] = useState("");
|
||||||
|
const [optionColor, setOptionColor] = useState("#000000");
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
|
const { customAttributes: customAttributesStore } = useMobxStore();
|
||||||
|
const { createAttributeOption, createAttributeOptionLoader } = customAttributesStore;
|
||||||
|
|
||||||
|
const handleCreateOption = async () => {
|
||||||
|
if (!workspaceSlug) return;
|
||||||
|
|
||||||
|
if (!optionName || optionName === "") return;
|
||||||
|
|
||||||
|
const payload: Partial<ICustomAttribute> = {
|
||||||
|
color: optionColor,
|
||||||
|
display_name: optionName,
|
||||||
|
type: "option",
|
||||||
|
};
|
||||||
|
|
||||||
|
await createAttributeOption(workspaceSlug.toString(), objectId, {
|
||||||
|
...payload,
|
||||||
|
parent: parentId,
|
||||||
|
}).then(() => {
|
||||||
|
setOptionName("");
|
||||||
|
setOptionColor("#000000");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
{/* <span className="flex-shrink-0 text-xs grid place-items-center">🚀</span> */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="flex-grow border-none outline-none placeholder:text-custom-text-400 text-xs"
|
||||||
|
value={optionName}
|
||||||
|
onChange={(e) => setOptionName(e.target.value)}
|
||||||
|
placeholder="Enter new option"
|
||||||
|
/>
|
||||||
|
<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: optionColor }}
|
||||||
|
/>
|
||||||
|
</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 right-0 z-10 mb-1 px-2 sm:px-0">
|
||||||
|
<TwitterPicker
|
||||||
|
color={optionColor}
|
||||||
|
onChange={(value) => {
|
||||||
|
setOptionColor(value.hex);
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Popover.Panel>
|
||||||
|
</Transition>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={handleCreateOption}
|
||||||
|
size="sm"
|
||||||
|
className="!py-1.5 !px-2"
|
||||||
|
loading={createAttributeOptionLoader}
|
||||||
|
>
|
||||||
|
{createAttributeOptionLoader ? "Adding..." : "Add"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
@ -0,0 +1,41 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// mobx
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
// react-hook-form
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
|
// components
|
||||||
|
import { FormComponentProps, Input, OptionForm, SelectOption } from "components/custom-attributes";
|
||||||
|
|
||||||
|
export const SelectAttributeForm: React.FC<FormComponentProps & { multiple?: boolean }> = observer(
|
||||||
|
({ control, multiple = false, objectId = "", watch }) => {
|
||||||
|
const { customAttributes: customAttributesStore } = useMobxStore();
|
||||||
|
const { entityAttributes } = customAttributesStore;
|
||||||
|
|
||||||
|
const options = entityAttributes?.[objectId]?.[watch("id") ?? ""]?.children;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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} objectId={objectId} option={option} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 w-3/5">
|
||||||
|
<OptionForm objectId={objectId} parentId={watch("id") ?? ""} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,68 @@
|
|||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
// mobx
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
// ui
|
||||||
|
import { Tooltip } from "components/ui";
|
||||||
|
// icons
|
||||||
|
import { MoreHorizontal } from "lucide-react";
|
||||||
|
// types
|
||||||
|
import { ICustomAttribute } from "types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
objectId: string;
|
||||||
|
option: ICustomAttribute;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectOption: React.FC<Props> = observer(({ objectId, option }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
|
const { customAttributes: customAttributesStore } = useMobxStore();
|
||||||
|
const { updateAttributeOption } = customAttributesStore;
|
||||||
|
|
||||||
|
const handleSetAsDefault = async () => {
|
||||||
|
if (!workspaceSlug || !option.parent) return;
|
||||||
|
|
||||||
|
await updateAttributeOption(workspaceSlug.toString(), objectId, option.parent, option.id, {
|
||||||
|
is_default: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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-300 text-xs p-1 rounded inline truncate"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${option.color}20`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
<button type="button">
|
||||||
|
<MoreHorizontal className="text-custom-text-400" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
1
web/components/custom-attributes/attributes/index.ts
Normal file
1
web/components/custom-attributes/attributes/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./text";
|
26
web/components/custom-attributes/attributes/text.tsx
Normal file
26
web/components/custom-attributes/attributes/text.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// types
|
||||||
|
import { ICustomAttribute } from "types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
attributeDetails: ICustomAttribute;
|
||||||
|
issueId: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
projectId: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomTextAttribute: React.FC<Props> = ({
|
||||||
|
attributeDetails,
|
||||||
|
issueId,
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
|
}) => (
|
||||||
|
<input
|
||||||
|
className="border border-custom-border-200 rounded outline-none p-1 text-xs"
|
||||||
|
defaultValue={attributeDetails.default_value ?? ""}
|
||||||
|
id={`attribute-${attributeDetails.display_name}-${attributeDetails.id}`}
|
||||||
|
name={`attribute-${attributeDetails.display_name}-${attributeDetails.id}`}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
);
|
@ -1,4 +1,5 @@
|
|||||||
export * from "./attribute-forms";
|
export * from "./attribute-forms";
|
||||||
|
export * from "./attributes";
|
||||||
export * from "./dropdowns";
|
export * from "./dropdowns";
|
||||||
export * from "./delete-object-modal";
|
export * from "./delete-object-modal";
|
||||||
export * from "./input";
|
export * from "./input";
|
||||||
|
@ -8,20 +8,7 @@ import { useMobxStore } from "lib/mobx/store-provider";
|
|||||||
// headless ui
|
// headless ui
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
// components
|
// components
|
||||||
import {
|
import { Input, TypesDropdown, AttributeForm } from "components/custom-attributes";
|
||||||
TextAttributeForm,
|
|
||||||
Input,
|
|
||||||
TypesDropdown,
|
|
||||||
NumberAttributeForm,
|
|
||||||
CheckboxAttributeForm,
|
|
||||||
RelationAttributeForm,
|
|
||||||
DateTimeAttributeForm,
|
|
||||||
UrlAttributeForm,
|
|
||||||
EmailAttributeForm,
|
|
||||||
FileAttributeForm,
|
|
||||||
SelectAttributeForm,
|
|
||||||
AttributeForm,
|
|
||||||
} from "components/custom-attributes";
|
|
||||||
// ui
|
// ui
|
||||||
import { Loader, PrimaryButton, SecondaryButton } from "components/ui";
|
import { Loader, PrimaryButton, SecondaryButton } from "components/ui";
|
||||||
// types
|
// types
|
||||||
@ -211,6 +198,7 @@ export const ObjectModal: React.FC<Props> = observer(
|
|||||||
handleUpdateAttribute={async (data) =>
|
handleUpdateAttribute={async (data) =>
|
||||||
await handleUpdateAttribute(attributeId, data)
|
await handleUpdateAttribute(attributeId, data)
|
||||||
}
|
}
|
||||||
|
objectId={object.id ?? ""}
|
||||||
type={attribute.type}
|
type={attribute.type}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
64
web/components/issues/custom-attributes-list.tsx
Normal file
64
web/components/issues/custom-attributes-list.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
// mobx
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Loader } from "components/ui";
|
||||||
|
import { CustomTextAttribute } from "components/custom-attributes";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
entityId: string;
|
||||||
|
issueId: string;
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomAttributesList: React.FC<Props> = observer(
|
||||||
|
({ entityId, issueId, projectId }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
|
const { customAttributes: customAttributesStore } = useMobxStore();
|
||||||
|
const { entityAttributes, fetchEntityDetails, fetchEntityDetailsLoader } =
|
||||||
|
customAttributesStore;
|
||||||
|
|
||||||
|
const attributes = entityAttributes[entityId] ?? {};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!entityAttributes[entityId]) {
|
||||||
|
if (!workspaceSlug) return;
|
||||||
|
|
||||||
|
fetchEntityDetails(workspaceSlug.toString(), entityId);
|
||||||
|
}
|
||||||
|
}, [entityAttributes, entityId, fetchEntityDetails, workspaceSlug]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{fetchEntityDetailsLoader ? (
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{Object.entries(attributes).map(([attributeId, attribute]) => (
|
||||||
|
<div key={attributeId}>
|
||||||
|
{attribute.type === "text" && (
|
||||||
|
<CustomTextAttribute
|
||||||
|
attributeDetails={attribute}
|
||||||
|
issueId={issueId}
|
||||||
|
onChange={() => {}}
|
||||||
|
projectId={projectId}
|
||||||
|
value={attribute.default_value ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
@ -10,7 +10,7 @@ import aiService from "services/ai.service";
|
|||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// components
|
// components
|
||||||
import { GptAssistantModal } from "components/core";
|
import { GptAssistantModal } from "components/core";
|
||||||
import { ParentIssuesListModal } from "components/issues";
|
import { CustomAttributesList, ParentIssuesListModal } from "components/issues";
|
||||||
import {
|
import {
|
||||||
IssueAssigneeSelect,
|
IssueAssigneeSelect,
|
||||||
IssueDateSelect,
|
IssueDateSelect,
|
||||||
@ -416,7 +416,7 @@ export const IssueForm: FC<IssueFormProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* default object properties */}
|
{/* default object properties */}
|
||||||
{watch("entity") === null && (
|
{watch("entity") === null ? (
|
||||||
<>
|
<>
|
||||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && (
|
{(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && (
|
||||||
<Controller
|
<Controller
|
||||||
@ -542,6 +542,12 @@ export const IssueForm: FC<IssueFormProps> = ({
|
|||||||
</CustomMenu>
|
</CustomMenu>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
<CustomAttributesList
|
||||||
|
entityId={watch("entity") ?? ""}
|
||||||
|
issueId=""
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
export * from "./attachment";
|
export * from "./attachment";
|
||||||
export * from "./comment";
|
export * from "./comment";
|
||||||
|
export * from "./gantt-chart";
|
||||||
export * from "./my-issues";
|
export * from "./my-issues";
|
||||||
|
export * from "./peek-overview";
|
||||||
export * from "./sidebar-select";
|
export * from "./sidebar-select";
|
||||||
export * from "./view-select";
|
export * from "./view-select";
|
||||||
export * from "./activity";
|
export * from "./activity";
|
||||||
|
export * from "./custom-attributes-list";
|
||||||
export * from "./delete-issue-modal";
|
export * from "./delete-issue-modal";
|
||||||
export * from "./description-form";
|
export * from "./description-form";
|
||||||
export * from "./form";
|
export * from "./form";
|
||||||
export * from "./gantt-chart";
|
|
||||||
export * from "./main-content";
|
export * from "./main-content";
|
||||||
export * from "./modal";
|
export * from "./modal";
|
||||||
export * from "./parent-issues-list-modal";
|
export * from "./parent-issues-list-modal";
|
||||||
@ -15,4 +17,3 @@ export * from "./sidebar";
|
|||||||
export * from "./sub-issues-list";
|
export * from "./sub-issues-list";
|
||||||
export * from "./label";
|
export * from "./label";
|
||||||
export * from "./issue-reaction";
|
export * from "./issue-reaction";
|
||||||
export * from "./peek-overview";
|
|
||||||
|
@ -8,7 +8,7 @@ import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
|
|||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
import useProjectDetails from "hooks/use-project-details";
|
import useProjectDetails from "hooks/use-project-details";
|
||||||
// components
|
// components
|
||||||
import { SettingsHeader } from "components/project";
|
import { SettingsSidebar } from "components/project";
|
||||||
import { ObjectModal, ObjectsList } from "components/custom-attributes";
|
import { ObjectModal, ObjectsList } from "components/custom-attributes";
|
||||||
// ui
|
// ui
|
||||||
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
|
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
|
||||||
@ -19,7 +19,7 @@ import { truncateText } from "helpers/string.helper";
|
|||||||
import type { NextPage } from "next";
|
import type { NextPage } from "next";
|
||||||
import { ICustomAttribute } from "types";
|
import { ICustomAttribute } from "types";
|
||||||
|
|
||||||
const ControlSettings: NextPage = () => {
|
const CustomObjectSettings: NextPage = () => {
|
||||||
const [isCreateObjectModalOpen, setIsCreateObjectModalOpen] = useState(false);
|
const [isCreateObjectModalOpen, setIsCreateObjectModalOpen] = useState(false);
|
||||||
const [objectToEdit, setObjectToEdit] = useState<ICustomAttribute | null>(null);
|
const [objectToEdit, setObjectToEdit] = useState<ICustomAttribute | null>(null);
|
||||||
|
|
||||||
@ -56,15 +56,18 @@ const ControlSettings: NextPage = () => {
|
|||||||
setObjectToEdit(null);
|
setObjectToEdit(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="p-8">
|
<div className="flex flex-row gap-2">
|
||||||
<SettingsHeader />
|
<div className="w-80 py-8">
|
||||||
<div>
|
<SettingsSidebar />
|
||||||
<div className="flex items-center justify-between gap-4">
|
</div>
|
||||||
<h2 className="text-xl font-medium">Custom Objects</h2>
|
<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)}>
|
<PrimaryButton onClick={() => setIsCreateObjectModalOpen(true)}>
|
||||||
Add Object
|
Add Object
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<div className="mt-4 border-y border-custom-border-100">
|
<div className="mt-4 border-y border-custom-border-100">
|
||||||
<ObjectsList
|
<ObjectsList
|
||||||
handleEditObject={handleEditObject}
|
handleEditObject={handleEditObject}
|
||||||
@ -72,9 +75,10 @@ const ControlSettings: NextPage = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</ProjectAuthorizationWrapper>
|
</ProjectAuthorizationWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ControlSettings;
|
export default CustomObjectSettings;
|
||||||
|
@ -14,6 +14,7 @@ class CustomAttributesStore {
|
|||||||
fetchEntitiesLoader = false;
|
fetchEntitiesLoader = false;
|
||||||
fetchEntityDetailsLoader = false;
|
fetchEntityDetailsLoader = false;
|
||||||
createEntityAttributeLoader = false;
|
createEntityAttributeLoader = false;
|
||||||
|
createAttributeOptionLoader = false;
|
||||||
// errors
|
// errors
|
||||||
attributesFetchError: any | null = null;
|
attributesFetchError: any | null = null;
|
||||||
error: any | null = null;
|
error: any | null = null;
|
||||||
@ -30,6 +31,9 @@ class CustomAttributesStore {
|
|||||||
createEntityAttribute: action,
|
createEntityAttribute: action,
|
||||||
updateEntityAttribute: action,
|
updateEntityAttribute: action,
|
||||||
deleteEntityAttribute: action,
|
deleteEntityAttribute: action,
|
||||||
|
createAttributeOption: action,
|
||||||
|
updateAttributeOption: action,
|
||||||
|
deleteAttributeOption: action,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.rootStore = _rootStore;
|
this.rootStore = _rootStore;
|
||||||
@ -196,6 +200,117 @@ class CustomAttributesStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
createAttributeOption = async (
|
||||||
|
workspaceSlug: string,
|
||||||
|
objectId: string,
|
||||||
|
data: Partial<ICustomAttribute> & { parent: string }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
this.createAttributeOptionLoader = true;
|
||||||
|
|
||||||
|
const response = await customAttributesService.createProperty(workspaceSlug, data);
|
||||||
|
|
||||||
|
runInAction(() => {
|
||||||
|
this.entityAttributes = {
|
||||||
|
...this.entityAttributes,
|
||||||
|
[objectId]: {
|
||||||
|
...this.entityAttributes[objectId],
|
||||||
|
[data.parent]: {
|
||||||
|
...this.entityAttributes[objectId][data.parent],
|
||||||
|
children: [...this.entityAttributes[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 {
|
||||||
|
this.createAttributeOptionLoader = true;
|
||||||
|
|
||||||
|
const response = await customAttributesService.patchProperty(workspaceSlug, propertyId, data);
|
||||||
|
|
||||||
|
const newOptions = this.entityAttributes[objectId][parentId].children.map((option) => ({
|
||||||
|
...option,
|
||||||
|
...(option.id === propertyId ? response : {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
runInAction(() => {
|
||||||
|
this.entityAttributes = {
|
||||||
|
...this.entityAttributes,
|
||||||
|
[objectId]: {
|
||||||
|
...this.entityAttributes[objectId],
|
||||||
|
[parentId]: {
|
||||||
|
...this.entityAttributes[objectId][parentId],
|
||||||
|
children: newOptions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.createAttributeOptionLoader = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
runInAction(() => {
|
||||||
|
this.error = error;
|
||||||
|
this.createAttributeOptionLoader = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteAttributeOption = async (
|
||||||
|
workspaceSlug: string,
|
||||||
|
objectId: string,
|
||||||
|
parentId: string,
|
||||||
|
propertyId: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
this.createAttributeOptionLoader = true;
|
||||||
|
|
||||||
|
const response = await customAttributesService.deleteProperty(workspaceSlug, propertyId);
|
||||||
|
|
||||||
|
const newOptions = this.entityAttributes[objectId][parentId].children.filter(
|
||||||
|
(option) => option.id !== propertyId
|
||||||
|
);
|
||||||
|
|
||||||
|
runInAction(() => {
|
||||||
|
this.entityAttributes = {
|
||||||
|
...this.entityAttributes,
|
||||||
|
[objectId]: {
|
||||||
|
...this.entityAttributes[objectId],
|
||||||
|
[parentId]: {
|
||||||
|
...this.entityAttributes[objectId][parentId],
|
||||||
|
children: newOptions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.createAttributeOptionLoader = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
runInAction(() => {
|
||||||
|
this.error = error;
|
||||||
|
this.createAttributeOptionLoader = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CustomAttributesStore;
|
export default CustomAttributesStore;
|
||||||
|
4
web/types/custom-attributes.d.ts
vendored
4
web/types/custom-attributes.d.ts
vendored
@ -17,7 +17,7 @@ export type TCustomAttributeUnits = "cycle" | "issue" | "module" | "user";
|
|||||||
export interface ICustomAttribute {
|
export interface ICustomAttribute {
|
||||||
children: ICustomAttribute[];
|
children: ICustomAttribute[];
|
||||||
color: string;
|
color: string;
|
||||||
default_value: string | null;
|
default_value: string;
|
||||||
description: string;
|
description: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
extra_settings: { [key: string]: any };
|
extra_settings: { [key: string]: any };
|
||||||
@ -26,7 +26,7 @@ export interface ICustomAttribute {
|
|||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
is_multi: boolean;
|
is_multi: boolean;
|
||||||
is_required: boolean;
|
is_required: boolean;
|
||||||
parent: string;
|
parent: string | null;
|
||||||
project: string | null;
|
project: string | null;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
type: TCustomAttributeTypes;
|
type: TCustomAttributeTypes;
|
||||||
|
Loading…
Reference in New Issue
Block a user