mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
feat: label grouping in dropdowns, default state in project settings (#266)
* feat: label grouping in dropdowns, default state in project settings * feat: label disclosure default open * refactor: label setting page * chore: tooltip component updated * chore: tooltip component updated * feat/state_sequence_change
This commit is contained in:
parent
7c06be19fc
commit
a403c0c346
@ -1,4 +1,3 @@
|
||||
// TODO: Refactor this component: into a different file, use this file to export the components
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
@ -14,7 +13,7 @@ import useTheme from "hooks/use-theme";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useUser from "hooks/use-user";
|
||||
// components
|
||||
import ShortcutsModal from "components/command-palette/shortcuts";
|
||||
import { ShortcutsModal } from "components/command-palette";
|
||||
import { BulkDeleteIssuesModal } from "components/core";
|
||||
import { CreateProjectModal } from "components/project";
|
||||
import { CreateUpdateIssueModal } from "components/issues";
|
||||
@ -36,7 +35,7 @@ import { IIssue } from "types";
|
||||
// fetch-keys
|
||||
import { USER_ISSUE } from "constants/fetch-keys";
|
||||
|
||||
const CommandPalette: React.FC = () => {
|
||||
export const CommandPalette: React.FC = () => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const [isPaletteOpen, setIsPaletteOpen] = useState(false);
|
||||
@ -369,5 +368,3 @@ const CommandPalette: React.FC = () => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommandPalette;
|
2
apps/app/components/command-palette/index.ts
Normal file
2
apps/app/components/command-palette/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./command-pallette";
|
||||
export * from "./shortcuts-modal";
|
@ -41,7 +41,7 @@ const shortcuts = [
|
||||
},
|
||||
];
|
||||
|
||||
const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filteredShortcuts = shortcuts.filter((shortcut) =>
|
||||
@ -150,5 +150,3 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortcutsModal;
|
@ -1,5 +1,6 @@
|
||||
export * from "./board-view";
|
||||
export * from "./list-view";
|
||||
export * from "./sidebar";
|
||||
export * from "./bulk-delete-issues-modal";
|
||||
export * from "./existing-issues-list-modal";
|
||||
export * from "./image-upload-modal";
|
||||
|
2
apps/app/components/core/sidebar/index.ts
Normal file
2
apps/app/components/core/sidebar/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./sidebar-progress-stats";
|
||||
export * from "./single-progress-stats";
|
@ -11,7 +11,7 @@ import { Tab } from "@headlessui/react";
|
||||
import issuesServices from "services/issues.service";
|
||||
import projectService from "services/project.service";
|
||||
// components
|
||||
import SingleProgressStats from "components/core/sidebar/single-progress-stats";
|
||||
import { SingleProgressStats } from "components/core";
|
||||
// ui
|
||||
import { Avatar } from "components/ui";
|
||||
// icons
|
||||
@ -36,7 +36,7 @@ const stateGroupColours: {
|
||||
completed: "#096e8d",
|
||||
};
|
||||
|
||||
const SidebarProgressStats: React.FC<Props> = ({ groupedIssues, issues }) => {
|
||||
export const SidebarProgressStats: React.FC<Props> = ({ groupedIssues, issues }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
const { data: issueLabels } = useSWR<IIssueLabels[]>(
|
||||
@ -180,5 +180,3 @@ const SidebarProgressStats: React.FC<Props> = ({ groupedIssues, issues }) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarProgressStats;
|
||||
|
@ -8,22 +8,22 @@ type TSingleProgressStatsProps = {
|
||||
total: number;
|
||||
};
|
||||
|
||||
const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({ title, completed, total }) => (
|
||||
<>
|
||||
<div className="flex items-center justify-between w-full py-3 text-xs border-b-[1px] border-gray-200">
|
||||
<div className="flex items-center justify-start w-1/2 gap-2">{title}</div>
|
||||
<div className="flex items-center justify-end w-1/2 gap-1 px-2">
|
||||
<div className="flex h-5 justify-center items-center gap-1 ">
|
||||
<span className="h-4 w-4 ">
|
||||
<CircularProgressbar value={completed} maxValue={total} strokeWidth={10} />
|
||||
</span>
|
||||
<span className="w-8 text-right">{Math.floor((completed / total) * 100)}%</span>
|
||||
</div>
|
||||
<span>of</span>
|
||||
<span>{total}</span>
|
||||
export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
|
||||
title,
|
||||
completed,
|
||||
total,
|
||||
}) => (
|
||||
<div className="flex items-center justify-between w-full py-3 text-xs border-b-[1px] border-gray-200">
|
||||
<div className="flex items-center justify-start w-1/2 gap-2">{title}</div>
|
||||
<div className="flex items-center justify-end w-1/2 gap-1 px-2">
|
||||
<div className="flex h-5 justify-center items-center gap-1 ">
|
||||
<span className="h-4 w-4 ">
|
||||
<CircularProgressbar value={completed} maxValue={total} strokeWidth={10} />
|
||||
</span>
|
||||
<span className="w-8 text-right">{Math.floor((completed / total) * 100)}%</span>
|
||||
</div>
|
||||
<span>of</span>
|
||||
<span>{total}</span>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SingleProgressStats;
|
||||
|
@ -1,12 +1,15 @@
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import cycleService from "services/cycles.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { CycleForm } from "components/cycles";
|
||||
// helpers
|
||||
import { renderDateFormat } from "helpers/date-time.helper";
|
||||
// types
|
||||
import type { ICycle } from "types";
|
||||
// fetch keys
|
||||
@ -20,8 +23,14 @@ export interface CycleModalProps {
|
||||
initialData?: ICycle;
|
||||
}
|
||||
|
||||
export const CycleModal: React.FC<CycleModalProps> = (props) => {
|
||||
const { isOpen, handleClose, initialData, projectId, workspaceSlug } = props;
|
||||
export const CycleModal: React.FC<CycleModalProps> = ({
|
||||
isOpen,
|
||||
handleClose,
|
||||
initialData,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
}) => {
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const createCycle = (payload: Partial<ICycle>) => {
|
||||
cycleService
|
||||
@ -31,12 +40,11 @@ export const CycleModal: React.FC<CycleModalProps> = (props) => {
|
||||
handleClose();
|
||||
})
|
||||
.catch((err) => {
|
||||
// TODO: Handle this ERROR.
|
||||
// Object.keys(err).map((key) => {
|
||||
// setError(key as keyof typeof defaultValues, {
|
||||
// message: err[key].join(", "),
|
||||
// });
|
||||
// });
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error",
|
||||
message: "Error in creating cycle. Please try again!",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@ -48,12 +56,11 @@ export const CycleModal: React.FC<CycleModalProps> = (props) => {
|
||||
handleClose();
|
||||
})
|
||||
.catch((err) => {
|
||||
// TODO: Handle this ERROR.
|
||||
// Object.keys(err).map((key) => {
|
||||
// setError(key as keyof typeof defaultValues, {
|
||||
// message: err[key].join(", "),
|
||||
// });
|
||||
// });
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error",
|
||||
message: "Error in updating cycle. Please try again!",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -9,7 +9,7 @@ import { useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Combobox, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
import { TagIcon } from "@heroicons/react/24/outline";
|
||||
import { RectangleGroupIcon, TagIcon } from "@heroicons/react/24/outline";
|
||||
// services
|
||||
import issuesServices from "services/issues.service";
|
||||
// types
|
||||
@ -58,8 +58,6 @@ export const IssueLabelSelect: React.FC<Props> = ({ value, onChange, projectId }
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
setFocus,
|
||||
reset,
|
||||
@ -69,16 +67,10 @@ export const IssueLabelSelect: React.FC<Props> = ({ value, onChange, projectId }
|
||||
isOpen && setFocus("name");
|
||||
}, [isOpen, setFocus]);
|
||||
|
||||
const options = issueLabels?.map((label) => ({
|
||||
value: label.id,
|
||||
display: label.name,
|
||||
color: label.color,
|
||||
}));
|
||||
|
||||
const filteredOptions =
|
||||
query === ""
|
||||
? options
|
||||
: options?.filter((option) => option.display.toLowerCase().includes(query.toLowerCase()));
|
||||
? issueLabels
|
||||
: issueLabels?.filter((l) => l.name.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -98,10 +90,9 @@ export const IssueLabelSelect: React.FC<Props> = ({ value, onChange, projectId }
|
||||
<TagIcon className="h-3 w-3 text-gray-500" />
|
||||
<span className={`flex items-center gap-2 ${!value ? "" : "text-gray-900"}`}>
|
||||
{Array.isArray(value)
|
||||
? value
|
||||
.map((v) => options?.find((option) => option.value === v)?.display)
|
||||
.join(", ") || "Labels"
|
||||
: options?.find((option) => option.value === value)?.display || "Labels"}
|
||||
? value.map((v) => issueLabels?.find((l) => l.id === v)?.name).join(", ") ||
|
||||
"Labels"
|
||||
: issueLabels?.find((l) => l.id === value)?.name || "Labels"}
|
||||
</span>
|
||||
</Combobox.Button>
|
||||
|
||||
@ -122,31 +113,62 @@ export const IssueLabelSelect: React.FC<Props> = ({ value, onChange, projectId }
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
/>
|
||||
<div className="py-1">
|
||||
{filteredOptions ? (
|
||||
{issueLabels && filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
className={({ active, selected }) =>
|
||||
`${active ? "bg-indigo-50" : ""} ${
|
||||
selected ? "bg-indigo-50 font-medium" : ""
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
|
||||
}
|
||||
value={option.value}
|
||||
>
|
||||
{issueLabels && (
|
||||
<>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: option.color,
|
||||
}}
|
||||
/>
|
||||
{option.display}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
filteredOptions.map((label) => {
|
||||
const children = issueLabels?.filter((l) => l.parent === label.id);
|
||||
|
||||
if (children.length === 0) {
|
||||
if (!label.parent)
|
||||
return (
|
||||
<Combobox.Option
|
||||
key={label.id}
|
||||
className={({ active, selected }) =>
|
||||
`${active ? "bg-indigo-50" : ""} ${
|
||||
selected ? "bg-indigo-50 font-medium" : ""
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
|
||||
}
|
||||
value={label.id}
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color ?? "green",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</Combobox.Option>
|
||||
);
|
||||
} else
|
||||
return (
|
||||
<div className="bg-gray-50 border-y border-gray-400">
|
||||
<div className="flex select-none font-medium items-center gap-2 truncate p-2 text-gray-900">
|
||||
<RectangleGroupIcon className="h-3 w-3" /> {label.name}
|
||||
</div>
|
||||
<div>
|
||||
{children.map((child) => (
|
||||
<Combobox.Option
|
||||
key={child.id}
|
||||
className={({ active, selected }) =>
|
||||
`${active ? "bg-indigo-50" : ""} ${
|
||||
selected ? "bg-indigo-50 font-medium" : ""
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
|
||||
}
|
||||
value={child.id}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: child?.color ?? "green",
|
||||
}}
|
||||
/>
|
||||
{child.name}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 px-2">No labels found</p>
|
||||
)
|
||||
|
@ -38,6 +38,7 @@ import {
|
||||
TrashIcon,
|
||||
PlusIcon,
|
||||
XMarkIcon,
|
||||
RectangleGroupIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
@ -298,30 +299,31 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
</div>
|
||||
<div className="basis-1/2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{watchIssue("labels_list")?.map((label) => {
|
||||
const singleLabel = issueLabels?.find((l) => l.id === label);
|
||||
{watchIssue("labels_list")?.map((labelId) => {
|
||||
const label = issueLabels?.find((l) => l.id === labelId);
|
||||
|
||||
if (!singleLabel) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
key={singleLabel.id}
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-2xl border px-1 py-0.5 text-xs hover:border-red-500 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
const updatedLabels = watchIssue("labels_list")?.filter((l) => l !== label);
|
||||
submitChanges({
|
||||
labels_list: updatedLabels,
|
||||
});
|
||||
}}
|
||||
>
|
||||
if (label)
|
||||
return (
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{ backgroundColor: singleLabel?.color ?? "green" }}
|
||||
/>
|
||||
{singleLabel.name}
|
||||
<XMarkIcon className="h-2 w-2 group-hover:text-red-500" />
|
||||
</span>
|
||||
);
|
||||
key={label.id}
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-2xl border px-1 py-0.5 text-xs hover:border-red-500 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
const updatedLabels = watchIssue("labels_list")?.filter(
|
||||
(l) => l !== labelId
|
||||
);
|
||||
submitChanges({
|
||||
labels_list: updatedLabels,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{ backgroundColor: label?.color ?? "green" }}
|
||||
/>
|
||||
{label.name}
|
||||
<XMarkIcon className="h-2 w-2 group-hover:text-red-500" />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<Controller
|
||||
control={control}
|
||||
@ -336,58 +338,95 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Listbox.Label className="sr-only">Label</Listbox.Label>
|
||||
<div className="relative">
|
||||
<Listbox.Button
|
||||
className={`flex ${
|
||||
isNotAllowed
|
||||
? "cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-gray-100"
|
||||
} items-center gap-2 rounded-2xl border px-2 py-0.5 text-xs`}
|
||||
>
|
||||
Select Label
|
||||
</Listbox.Button>
|
||||
<div className="relative">
|
||||
<Listbox.Button
|
||||
className={`flex ${
|
||||
isNotAllowed
|
||||
? "cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-gray-100"
|
||||
} items-center gap-2 rounded-2xl border px-2 py-0.5 text-xs`}
|
||||
>
|
||||
Select Label
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute right-0 z-10 mt-1 max-h-28 w-40 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="py-1">
|
||||
{issueLabels ? (
|
||||
issueLabels.length > 0 ? (
|
||||
issueLabels.map((label: IIssueLabels) => (
|
||||
<Listbox.Option
|
||||
key={label.id}
|
||||
className={({ active, selected }) =>
|
||||
`${
|
||||
active || selected ? "bg-indigo-50" : ""
|
||||
} relative flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
|
||||
}
|
||||
value={label.id}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{ backgroundColor: label.color ?? "green" }}
|
||||
/>
|
||||
{label.name}
|
||||
</Listbox.Option>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center">No labels found</div>
|
||||
)
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute right-0 z-10 mt-1 max-h-28 w-40 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="py-1">
|
||||
{issueLabels ? (
|
||||
issueLabels.length > 0 ? (
|
||||
issueLabels.map((label: IIssueLabels) => {
|
||||
const children = issueLabels?.filter(
|
||||
(l) => l.parent === label.id
|
||||
);
|
||||
|
||||
if (children.length === 0) {
|
||||
if (!label.parent)
|
||||
return (
|
||||
<Listbox.Option
|
||||
key={label.id}
|
||||
className={({ active, selected }) =>
|
||||
`${active || selected ? "bg-indigo-50" : ""} ${
|
||||
selected ? "font-medium" : ""
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
|
||||
}
|
||||
value={label.id}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color ?? "green",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</Listbox.Option>
|
||||
);
|
||||
} else
|
||||
return (
|
||||
<div className="bg-gray-50 border-y border-gray-400">
|
||||
<div className="flex select-none font-medium items-center gap-2 truncate p-2 text-gray-900">
|
||||
<RectangleGroupIcon className="h-3 w-3" />{" "}
|
||||
{label.name}
|
||||
</div>
|
||||
<div>
|
||||
{children.map((child) => (
|
||||
<Listbox.Option
|
||||
key={child.id}
|
||||
className={({ active, selected }) =>
|
||||
`${active || selected ? "bg-indigo-50" : ""} ${
|
||||
selected ? "font-medium" : ""
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900`
|
||||
}
|
||||
value={child.id}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: child?.color ?? "green",
|
||||
}}
|
||||
/>
|
||||
{child.name}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
<div className="text-center">No labels found</div>
|
||||
)
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
|
@ -69,10 +69,10 @@ export const ViewPrioritySelect: React.FC<Props> = ({
|
||||
{PRIORITIES?.map((priority) => (
|
||||
<Listbox.Option
|
||||
key={priority}
|
||||
className={({ active }) =>
|
||||
`flex cursor-pointer select-none items-center gap-x-2 px-3 py-2 capitalize ${
|
||||
active ? "bg-indigo-50" : "bg-white"
|
||||
}`
|
||||
className={({ active, selected }) =>
|
||||
`${active || selected ? "bg-indigo-50" : ""} ${
|
||||
selected ? "font-medium" : ""
|
||||
} flex cursor-pointer select-none items-center gap-x-2 px-3 py-2 capitalize`
|
||||
}
|
||||
value={priority}
|
||||
>
|
||||
|
189
apps/app/components/labels/create-update-label-inline.tsx
Normal file
189
apps/app/components/labels/create-update-label-inline.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, SubmitHandler, useForm } from "react-hook-form";
|
||||
// react-color
|
||||
import { TwitterPicker } from "react-color";
|
||||
// headless ui
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// ui
|
||||
import { Button, Input } from "components/ui";
|
||||
// types
|
||||
import { IIssueLabels } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
labelForm: boolean;
|
||||
setLabelForm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isUpdating: boolean;
|
||||
labelToUpdate: IIssueLabels | null;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<IIssueLabels> = {
|
||||
name: "",
|
||||
color: "#ff0000",
|
||||
};
|
||||
|
||||
export const CreateUpdateLabelInline: React.FC<Props> = ({
|
||||
labelForm,
|
||||
setLabelForm,
|
||||
isUpdating,
|
||||
labelToUpdate,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
register,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<IIssueLabels>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const handleLabelCreate: SubmitHandler<IIssueLabels> = async (formData) => {
|
||||
if (!workspaceSlug || !projectId || isSubmitting) return;
|
||||
|
||||
await issuesService
|
||||
.createIssueLabel(workspaceSlug as string, projectId as string, formData)
|
||||
.then((res) => {
|
||||
mutate<IIssueLabels[]>(
|
||||
PROJECT_ISSUE_LABELS(projectId as string),
|
||||
(prevData) => [res, ...(prevData ?? [])],
|
||||
false
|
||||
);
|
||||
reset(defaultValues);
|
||||
setLabelForm(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleLabelUpdate: SubmitHandler<IIssueLabels> = async (formData) => {
|
||||
if (!workspaceSlug || !projectId || isSubmitting) return;
|
||||
|
||||
await issuesService
|
||||
.patchIssueLabel(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
labelToUpdate?.id ?? "",
|
||||
formData
|
||||
)
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
reset(defaultValues);
|
||||
mutate<IIssueLabels[]>(
|
||||
PROJECT_ISSUE_LABELS(projectId as string),
|
||||
(prevData) =>
|
||||
prevData?.map((p) => (p.id === labelToUpdate?.id ? { ...p, ...formData } : p)),
|
||||
false
|
||||
);
|
||||
setLabelForm(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!labelForm && isUpdating) return;
|
||||
|
||||
reset();
|
||||
}, [labelForm, isUpdating, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!labelToUpdate) return;
|
||||
|
||||
setValue("color", labelToUpdate.color);
|
||||
setValue("name", labelToUpdate.name);
|
||||
}, [labelToUpdate, setValue]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-md border p-3 md:w-2/3 ${
|
||||
labelForm ? "" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<div className="h-8 w-8 flex-shrink-0">
|
||||
<Popover className="relative z-10 flex h-full w-full items-center justify-center rounded-xl bg-gray-200">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group inline-flex items-center text-base font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
|
||||
open ? "text-gray-900" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{watch("color") && watch("color") !== "" && (
|
||||
<span
|
||||
className="h-4 w-4 rounded"
|
||||
style={{
|
||||
backgroundColor: watch("color") ?? "green",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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 top-full left-0 z-20 mt-3 w-screen max-w-xs px-2 sm:px-0">
|
||||
<Controller
|
||||
name="color"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TwitterPicker color={value} onChange={(value) => onChange(value.hex)} />
|
||||
)}
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="flex w-full flex-col justify-center">
|
||||
<Input
|
||||
type="text"
|
||||
id="labelName"
|
||||
name="name"
|
||||
register={register}
|
||||
placeholder="Label title"
|
||||
validations={{
|
||||
required: "Label title is required",
|
||||
}}
|
||||
error={errors.name}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
theme="secondary"
|
||||
onClick={() => {
|
||||
reset();
|
||||
setLabelForm(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{isUpdating ? (
|
||||
<Button type="button" onClick={handleSubmit(handleLabelUpdate)} disabled={isSubmitting}>
|
||||
{isSubmitting ? "Updating" : "Update"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" onClick={handleSubmit(handleLabelCreate)} disabled={isSubmitting}>
|
||||
{isSubmitting ? "Adding" : "Add"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,2 +1,4 @@
|
||||
export * from "./create-update-label-inline";
|
||||
export * from "./labels-list-modal";
|
||||
export * from "./single-label-group";
|
||||
export * from "./single-label";
|
||||
|
136
apps/app/components/labels/single-label-group.tsx
Normal file
136
apps/app/components/labels/single-label-group.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// ui
|
||||
import { CustomMenu } from "components/ui";
|
||||
// icons
|
||||
import { ChevronDownIcon, RectangleGroupIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssueLabels } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
label: IIssueLabels;
|
||||
labelChildren: IIssueLabels[];
|
||||
addLabelToGroup: (parentLabel: IIssueLabels) => void;
|
||||
editLabel: (label: IIssueLabels) => void;
|
||||
handleLabelDelete: (labelId: string) => void;
|
||||
};
|
||||
|
||||
export const SingleLabelGroup: React.FC<Props> = ({
|
||||
label,
|
||||
labelChildren,
|
||||
addLabelToGroup,
|
||||
editLabel,
|
||||
handleLabelDelete,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const removeFromGroup = (label: IIssueLabels) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutate<IIssueLabels[]>(
|
||||
PROJECT_ISSUE_LABELS(projectId as string),
|
||||
(prevData) =>
|
||||
prevData?.map((l) => {
|
||||
if (l.id === label.id) return { ...l, parent: null };
|
||||
|
||||
return l;
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
issuesService
|
||||
.patchIssueLabel(workspaceSlug as string, projectId as string, label.id, {
|
||||
parent: null,
|
||||
})
|
||||
.then((res) => {
|
||||
mutate(PROJECT_ISSUE_LABELS(projectId as string));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Disclosure as="div" className="rounded-md border p-3 text-gray-900 md:w-2/3" defaultOpen>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 cursor-pointer">
|
||||
<Disclosure.Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
<ChevronDownIcon
|
||||
className={`h-4 w-4 text-gray-500 ${!open ? "-rotate-90 transform" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<RectangleGroupIcon className="h-4 w-4" />
|
||||
</span>
|
||||
<h6 className="text-sm">{label.name}</h6>
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}>
|
||||
Add more labels
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => editLabel(label)}>Edit</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => handleLabelDelete(label.id)}>
|
||||
Delete
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform opacity-0"
|
||||
enterTo="transform opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform opacity-100"
|
||||
leaveTo="transform opacity-0"
|
||||
>
|
||||
<Disclosure.Panel>
|
||||
<div className="mt-2 ml-4">
|
||||
{labelChildren.map((child) => (
|
||||
<div
|
||||
key={child.id}
|
||||
className="group pl-4 py-1 flex items-center justify-between rounded text-sm hover:bg-gray-100"
|
||||
>
|
||||
<h5 className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: child.color,
|
||||
}}
|
||||
/>
|
||||
{child.name}
|
||||
</h5>
|
||||
<div className="opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto">
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => removeFromGroup(child)}>
|
||||
Remove from group
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => editLabel(child)}>
|
||||
Edit
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => handleLabelDelete(child.id)}>
|
||||
Delete
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
@ -1,171 +1,43 @@
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// components
|
||||
import { LabelsListModal } from "components/labels";
|
||||
// ui
|
||||
import { CustomMenu } from "components/ui";
|
||||
// icons
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssueLabels } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
label: IIssueLabels;
|
||||
issueLabels: IIssueLabels[];
|
||||
addLabelToGroup: (parentLabel: IIssueLabels) => void;
|
||||
editLabel: (label: IIssueLabels) => void;
|
||||
handleLabelDelete: (labelId: string) => void;
|
||||
};
|
||||
|
||||
export const SingleLabel: React.FC<Props> = ({
|
||||
label,
|
||||
issueLabels,
|
||||
addLabelToGroup,
|
||||
editLabel,
|
||||
handleLabelDelete,
|
||||
}) => {
|
||||
const [labelsListModal, setLabelsListModal] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const children = issueLabels?.filter((l) => l.parent === label.id);
|
||||
|
||||
const removeFromGroup = (label: IIssueLabels) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutate<IIssueLabels[]>(
|
||||
PROJECT_ISSUE_LABELS(projectId as string),
|
||||
(prevData) =>
|
||||
prevData?.map((l) => {
|
||||
if (l.id === label.id) return { ...l, parent: null };
|
||||
|
||||
return l;
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
issuesService
|
||||
.patchIssueLabel(workspaceSlug as string, projectId as string, label.id, {
|
||||
parent: null,
|
||||
})
|
||||
.then((res) => {
|
||||
mutate(PROJECT_ISSUE_LABELS(projectId as string));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LabelsListModal
|
||||
isOpen={labelsListModal}
|
||||
handleClose={() => setLabelsListModal(false)}
|
||||
parent={label}
|
||||
/>
|
||||
{children && children.length === 0 ? (
|
||||
label.parent === "" || !label.parent ? (
|
||||
<div className="gap-2 space-y-3 divide-y rounded-md border p-3 md:w-2/3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-3 w-3 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color,
|
||||
}}
|
||||
/>
|
||||
<h6 className="text-sm">{label.name}</h6>
|
||||
</div>
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => setLabelsListModal(true)}>
|
||||
Convert to group
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => editLabel(label)}>Edit</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => handleLabelDelete(label.id)}>
|
||||
Delete
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<Disclosure as="div" className="relative z-20 rounded-md border p-3 text-gray-900 md:w-2/3">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 cursor-pointer">
|
||||
<Disclosure.Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
<ChevronDownIcon
|
||||
className={`h-4 w-4 text-gray-500 ${!open ? "-rotate-90 transform" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
<h6 className="text-sm">{label.name}</h6>
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => setLabelsListModal(true)}>
|
||||
Add more labels
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => editLabel(label)}>Edit</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => handleLabelDelete(label.id)}>
|
||||
Delete
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform opacity-0"
|
||||
enterTo="transform opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform opacity-100"
|
||||
leaveTo="transform opacity-0"
|
||||
>
|
||||
<Disclosure.Panel>
|
||||
<div className="mt-2 ml-4">
|
||||
{children.map((child) => (
|
||||
<div
|
||||
key={child.id}
|
||||
className="group pl-4 py-1 flex items-center justify-between rounded text-sm hover:bg-gray-100"
|
||||
>
|
||||
<h5 className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: child.color,
|
||||
}}
|
||||
/>
|
||||
{child.name}
|
||||
</h5>
|
||||
<div className="opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto">
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => removeFromGroup(child)}>
|
||||
Remove from group
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => editLabel(child)}>
|
||||
Edit
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => handleLabelDelete(child.id)}>
|
||||
Delete
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}) => (
|
||||
<div className="gap-2 space-y-3 divide-y rounded-md border p-3 md:w-2/3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-3 w-3 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color,
|
||||
}}
|
||||
/>
|
||||
<h6 className="text-sm">{label.name}</h6>
|
||||
</div>
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}>
|
||||
Convert to group
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => editLabel(label)}>Edit</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => handleLabelDelete(label.id)}>
|
||||
Delete
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -30,6 +30,8 @@ import {
|
||||
} from "components/modules";
|
||||
|
||||
import "react-circular-progressbar/dist/styles.css";
|
||||
// components
|
||||
import { SidebarProgressStats } from "components/core";
|
||||
// ui
|
||||
import { CustomDatePicker, Loader } from "components/ui";
|
||||
// helpers
|
||||
@ -40,7 +42,6 @@ import { groupBy } from "helpers/array.helper";
|
||||
import { IIssue, IModule, ModuleIssueResponse } from "types";
|
||||
// fetch-keys
|
||||
import { MODULE_DETAILS } from "constants/fetch-keys";
|
||||
import SidebarProgressStats from "components/core/sidebar/sidebar-progress-stats";
|
||||
|
||||
const defaultValues: Partial<IModule> = {
|
||||
lead: "",
|
||||
|
@ -20,17 +20,16 @@ import useToast from "hooks/use-toast";
|
||||
// services
|
||||
import cyclesService from "services/cycles.service";
|
||||
// components
|
||||
import SidebarProgressStats from "components/core/sidebar/sidebar-progress-stats";
|
||||
import { SidebarProgressStats } from "components/core";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
import { groupBy } from "helpers/array.helper";
|
||||
import { renderShortNumericDateFormat } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { CycleIssueResponse, ICycle, IIssue } from "types";
|
||||
// fetch-keys
|
||||
import { CYCLE_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
import { renderShortNumericDateFormat } from "helpers/date-time.helper";
|
||||
|
||||
type Props = {
|
||||
issues: IIssue[];
|
||||
cycle: ICycle | undefined;
|
||||
|
@ -1,5 +1,7 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
@ -15,15 +17,13 @@ import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Button, CustomSelect, Input } from "components/ui";
|
||||
// types
|
||||
import type { IState, StateResponse } from "types";
|
||||
import type { IState } from "types";
|
||||
// fetch-keys
|
||||
import { STATE_LIST } from "constants/fetch-keys";
|
||||
// constants
|
||||
import { GROUP_CHOICES } from "constants/project";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug?: string;
|
||||
projectId?: string;
|
||||
data: IState | null;
|
||||
onClose: () => void;
|
||||
selectedGroup: StateGroup | null;
|
||||
@ -37,13 +37,10 @@ const defaultValues: Partial<IState> = {
|
||||
group: "backlog",
|
||||
};
|
||||
|
||||
export const CreateUpdateStateInline: React.FC<Props> = ({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
data,
|
||||
onClose,
|
||||
selectedGroup,
|
||||
}) => {
|
||||
export const CreateUpdateStateInline: React.FC<Props> = ({ data, onClose, selectedGroup }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
@ -59,16 +56,18 @@ export const CreateUpdateStateInline: React.FC<Props> = ({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data === null) return;
|
||||
if (!data) return;
|
||||
|
||||
reset(data);
|
||||
}, [data, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data)
|
||||
reset({
|
||||
...defaultValues,
|
||||
group: selectedGroup ?? "backlog",
|
||||
});
|
||||
if (data) return;
|
||||
|
||||
reset({
|
||||
...defaultValues,
|
||||
group: selectedGroup ?? "backlog",
|
||||
});
|
||||
}, [selectedGroup, data, reset]);
|
||||
|
||||
const handleClose = () => {
|
||||
@ -78,14 +77,15 @@ export const CreateUpdateStateInline: React.FC<Props> = ({
|
||||
|
||||
const onSubmit = async (formData: IState) => {
|
||||
if (!workspaceSlug || !projectId || isSubmitting) return;
|
||||
|
||||
const payload: IState = {
|
||||
...formData,
|
||||
};
|
||||
if (!data) {
|
||||
await stateService
|
||||
.createState(workspaceSlug, projectId, { ...payload })
|
||||
.createState(workspaceSlug as string, projectId as string, { ...payload })
|
||||
.then((res) => {
|
||||
mutate(STATE_LIST(projectId));
|
||||
mutate(STATE_LIST(projectId as string));
|
||||
handleClose();
|
||||
|
||||
setToastAlert({
|
||||
@ -103,11 +103,11 @@ export const CreateUpdateStateInline: React.FC<Props> = ({
|
||||
});
|
||||
} else {
|
||||
await stateService
|
||||
.updateState(workspaceSlug, projectId, data.id, {
|
||||
.updateState(workspaceSlug as string, projectId as string, data.id, {
|
||||
...payload,
|
||||
})
|
||||
.then((res) => {
|
||||
mutate(STATE_LIST(projectId));
|
||||
mutate(STATE_LIST(projectId as string));
|
||||
handleClose();
|
||||
|
||||
setToastAlert({
|
||||
|
@ -1,3 +1,4 @@
|
||||
export * from "./create-update-state-inline";
|
||||
export * from "./create-update-state-modal";
|
||||
export * from "./delete-state-modal";
|
||||
export * from "./single-state";
|
||||
|
217
apps/app/components/states/single-state.tsx
Normal file
217
apps/app/components/states/single-state.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// services
|
||||
import stateService from "services/state.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
import { groupBy, orderArrayBy } from "helpers/array.helper";
|
||||
import { orderStateGroups } from "helpers/state.helper";
|
||||
// types
|
||||
import { IState } from "types";
|
||||
import { StateGroup } from "components/states";
|
||||
// fetch-keys
|
||||
import { STATE_LIST } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
index: number;
|
||||
currentGroup: string;
|
||||
state: IState;
|
||||
statesList: IState[];
|
||||
activeGroup: StateGroup;
|
||||
handleEditState: () => void;
|
||||
handleDeleteState: () => void;
|
||||
};
|
||||
|
||||
export const SingleState: React.FC<Props> = ({
|
||||
index,
|
||||
currentGroup,
|
||||
state,
|
||||
statesList,
|
||||
activeGroup,
|
||||
handleEditState,
|
||||
handleDeleteState,
|
||||
}) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const groupLength = statesList.filter((s) => s.group === currentGroup).length;
|
||||
|
||||
const handleMakeDefault = (stateId: string) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const currentDefaultState = statesList.find((s) => s.default);
|
||||
|
||||
if (currentDefaultState)
|
||||
stateService
|
||||
.patchState(workspaceSlug as string, projectId as string, currentDefaultState?.id ?? "", {
|
||||
default: false,
|
||||
})
|
||||
.then(() => {
|
||||
stateService
|
||||
.patchState(workspaceSlug as string, projectId as string, stateId, {
|
||||
default: true,
|
||||
})
|
||||
.then((res) => {
|
||||
mutate(STATE_LIST(projectId as string));
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Successful",
|
||||
message: `${res.name} state set to default successfuly.`,
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error",
|
||||
message: "Error in setting the state to default.",
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
});
|
||||
else
|
||||
stateService
|
||||
.patchState(workspaceSlug as string, projectId as string, stateId, {
|
||||
default: true,
|
||||
})
|
||||
.then((res) => {
|
||||
mutate(STATE_LIST(projectId as string));
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Successful",
|
||||
message: `${res.name} state set to default successfuly.`,
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error",
|
||||
message: "Error in setting the state to default.",
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleMove = (state: IState, index: number, direction: "up" | "down") => {
|
||||
let newSequence = 15000;
|
||||
|
||||
if (direction === "up") {
|
||||
if (index === 1) newSequence = statesList[0].sequence - 15000;
|
||||
else newSequence = (statesList[index - 2].sequence + statesList[index - 1].sequence) / 2;
|
||||
} else {
|
||||
if (index === groupLength - 2) newSequence = statesList[groupLength - 1].sequence + 15000;
|
||||
else newSequence = (statesList[index + 2].sequence + statesList[index + 1].sequence) / 2;
|
||||
}
|
||||
|
||||
let newStatesList = statesList.map((s) => {
|
||||
if (s.id === state.id)
|
||||
return {
|
||||
...s,
|
||||
sequence: newSequence,
|
||||
};
|
||||
|
||||
return s;
|
||||
});
|
||||
newStatesList = orderArrayBy(newStatesList, "sequence", "ascending");
|
||||
mutate(
|
||||
STATE_LIST(projectId as string),
|
||||
orderStateGroups(groupBy(newStatesList, "group")),
|
||||
false
|
||||
);
|
||||
|
||||
stateService
|
||||
.patchState(workspaceSlug as string, projectId as string, state.id, {
|
||||
sequence: newSequence,
|
||||
})
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
mutate(STATE_LIST(projectId as string));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex items-center justify-between gap-2 border-b bg-gray-50 p-3 ${
|
||||
activeGroup !== currentGroup ? "last:border-0" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-3 w-3 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: state.color,
|
||||
}}
|
||||
/>
|
||||
<h6 className="text-sm">{addSpaceIfCamelCase(state.name)}</h6>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{index !== 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="hidden group-hover:inline-block text-gray-400 hover:text-gray-900"
|
||||
onClick={() => handleMove(state, index, "up")}
|
||||
>
|
||||
<ArrowUpIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{!(index === groupLength - 1) && (
|
||||
<button
|
||||
type="button"
|
||||
className="hidden group-hover:inline-block text-gray-400 hover:text-gray-900"
|
||||
onClick={() => handleMove(state, index, "down")}
|
||||
>
|
||||
<ArrowDownIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{state.default ? (
|
||||
<span className="text-xs text-gray-400">Default</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="hidden group-hover:inline-block text-xs text-gray-400 hover:text-gray-900"
|
||||
onClick={() => handleMakeDefault(state.id)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Set as default
|
||||
</button>
|
||||
)}
|
||||
<Tooltip content="Cannot delete the default state." disabled={!state.default}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${state.default ? "cursor-not-allowed" : ""} grid place-items-center`}
|
||||
onClick={handleDeleteState}
|
||||
disabled={state.default}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4 text-red-400" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button type="button" className="grid place-items-center" onClick={handleEditState}>
|
||||
<PencilSquareIcon className="h-4 w-4 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
66
apps/app/components/ui/tooltip.tsx
Normal file
66
apps/app/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
export type Props = {
|
||||
direction?: "top" | "right" | "bottom" | "left";
|
||||
content: string | React.ReactNode;
|
||||
margin?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const Tooltip: React.FC<Props> = ({
|
||||
content,
|
||||
direction = "top",
|
||||
children,
|
||||
margin = "24px",
|
||||
className = "",
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [active, setActive] = useState(false);
|
||||
const [styleConfig, setStyleConfig] = useState(`top-[calc(-100%-${margin})]`);
|
||||
let timeout: any;
|
||||
|
||||
const showToolTip = () => {
|
||||
timeout = setTimeout(() => {
|
||||
setActive(true);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const hideToolTip = () => {
|
||||
clearInterval(timeout);
|
||||
setActive(false);
|
||||
};
|
||||
|
||||
const tooltipStyles = {
|
||||
top: "left-[50%] translate-x-[-50%] before:contents-[''] before:border-solid before:border-transparent before:h-0 before:w-0 before:absolute before:pointer-events-none before:border-[6px] before:left-[50%] before:ml-[calc(6px*-1)] before:top-full before:border-t-black",
|
||||
|
||||
right: "right-[-100%] top-[50%] translate-x-0 translate-y-[-50%]",
|
||||
|
||||
bottom:
|
||||
"left-[50%] translate-x-[-50%] before:contents-[''] before:border-solid before:border-transparent before:h-0 before:w-0 before:absolute before:pointer-events-none before:border-[6px] before:left-[50%] before:ml-[calc(6px*-1)] before:bottom-full before:border-b-black",
|
||||
|
||||
left: "left-[-100%] top-[50%] translate-x-0 translate-y-[-50%]",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const styleConfig = `${direction}-[calc(-100%-${margin})]`;
|
||||
setStyleConfig(styleConfig);
|
||||
}, [margin, direction]);
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" onMouseEnter={showToolTip} onMouseLeave={hideToolTip}>
|
||||
{children}
|
||||
{active && (
|
||||
<div
|
||||
className={`${className} ${
|
||||
disabled ? "hidden" : ""
|
||||
} absolute p-[6px] text-xs z-20 rounded leading-1 text-white bg-black text-center w-max max-w-[300px]
|
||||
${tooltipStyles[direction]} ${styleConfig}`}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,73 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
export type Props = {
|
||||
direction?: "top" | "right" | "bottom" | "left";
|
||||
content: string | React.ReactNode;
|
||||
margin?: string;
|
||||
children: React.ReactNode;
|
||||
customStyle?: string;
|
||||
};
|
||||
|
||||
const Tooltip: React.FC<Props> = ({
|
||||
content,
|
||||
direction = "top",
|
||||
children,
|
||||
margin = "24px",
|
||||
customStyle,
|
||||
}) => {
|
||||
const [active, setActive] = useState(false);
|
||||
const [styleConfig, setStyleConfig] = useState("top-[calc(-100%-24px)]");
|
||||
let timeout: any;
|
||||
|
||||
const showToolTip = () => {
|
||||
timeout = setTimeout(() => {
|
||||
setActive(true);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const hideToolTip = () => {
|
||||
clearInterval(timeout);
|
||||
setActive(false);
|
||||
};
|
||||
|
||||
const tooltipStyles = {
|
||||
top: `
|
||||
left-[50%] translate-x-[-50%] before:contents-[""] before:border-solid
|
||||
before:border-transparent before:h-0 before:w-0 before:absolute before:pointer-events-none
|
||||
before:border-[6px] before:left-[50%] before:ml-[calc(6px*-1)] before:top-full before:border-t-black`,
|
||||
|
||||
right: `
|
||||
right-[-100%] top-[50%]
|
||||
translate-x-0 translate-y-[-50%] `,
|
||||
|
||||
bottom: `
|
||||
left-[50%] translate-x-[-50%] before:contents-[""] before:border-solid
|
||||
before:border-transparent before:h-0 before:w-0 before:absolute before:pointer-events-none
|
||||
before:border-[6px] before:left-[50%] before:ml-[calc(6px*-1)] before:bottom-full before:border-b-black`,
|
||||
|
||||
left: `
|
||||
left-[-100%] top-[50%]
|
||||
translate-x-0 translate-y-[-50%] `,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const styleConfig = direction + "-[calc(-100%-" + margin + ")]";
|
||||
setStyleConfig(styleConfig);
|
||||
}, [margin, direction]);
|
||||
|
||||
return (
|
||||
<div className="inline-block relative" onMouseEnter={showToolTip} onMouseLeave={hideToolTip}>
|
||||
{children}
|
||||
{active && (
|
||||
<div
|
||||
className={`absolute p-[6px] text-xs z-20 rounded leading-1 text-white bg-black text-center w-max max-w-[300px]
|
||||
${tooltipStyles[direction]} ${customStyle ? customStyle : ""} ${styleConfig}`}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tooltip;
|
@ -17,7 +17,6 @@ const initialValues: Properties = {
|
||||
sub_issue_count: false,
|
||||
};
|
||||
|
||||
// TODO: CHECK THIS LOGIC
|
||||
const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => {
|
||||
const [properties, setProperties] = useState<Properties>(initialValues);
|
||||
|
||||
@ -34,6 +33,7 @@ const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!issueProperties || !workspaceSlug || !projectId || !user) return;
|
||||
|
||||
setProperties({ ...initialValues, ...issueProperties.properties });
|
||||
|
||||
if (Object.keys(issueProperties).length === 0)
|
||||
@ -53,6 +53,7 @@ const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => {
|
||||
if (!workspaceSlug || !user) return;
|
||||
|
||||
setProperties((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
if (issueProperties && projectId) {
|
||||
mutateIssueProperties(
|
||||
(prev) =>
|
||||
|
@ -13,7 +13,7 @@ import useUser from "hooks/use-user";
|
||||
import { Button, Spinner } from "components/ui";
|
||||
// components
|
||||
import { NotAuthorizedView } from "components/core";
|
||||
import CommandPalette from "components/command-palette";
|
||||
import { CommandPalette } from "components/command-palette";
|
||||
import { JoinProject } from "components/project";
|
||||
// local components
|
||||
import Container from "layouts/container";
|
||||
|
@ -4,24 +4,22 @@ import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, SubmitHandler, useForm } from "react-hook-form";
|
||||
// react-color
|
||||
import { TwitterPicker } from "react-color";
|
||||
// headless ui
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import projectService from "services/project.service";
|
||||
import workspaceService from "services/workspace.service";
|
||||
import issuesService from "services/issues.service";
|
||||
// lib
|
||||
import { requiredAdmin } from "lib/auth";
|
||||
// layouts
|
||||
import AppLayout from "layouts/app-layout";
|
||||
// components
|
||||
import { SingleLabel } from "components/labels";
|
||||
import {
|
||||
CreateUpdateLabelInline,
|
||||
LabelsListModal,
|
||||
SingleLabel,
|
||||
SingleLabelGroup,
|
||||
} from "components/labels";
|
||||
// ui
|
||||
import { Button, Input, Loader } from "components/ui";
|
||||
import { Button, Loader } from "components/ui";
|
||||
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
|
||||
// icons
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
@ -29,19 +27,21 @@ import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { IIssueLabels, UserAuth } from "types";
|
||||
import type { NextPageContext, NextPage } from "next";
|
||||
// fetch-keys
|
||||
import { PROJECT_DETAILS, PROJECT_ISSUE_LABELS, WORKSPACE_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
const defaultValues: Partial<IIssueLabels> = {
|
||||
name: "",
|
||||
color: "#ff0000",
|
||||
};
|
||||
import { PROJECT_DETAILS, PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
||||
|
||||
const LabelsSettings: NextPage<UserAuth> = (props) => {
|
||||
const { isMember, isOwner, isViewer, isGuest } = props;
|
||||
|
||||
// create/edit label form
|
||||
const [labelForm, setLabelForm] = useState(false);
|
||||
|
||||
// edit label
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [labelIdForUpdate, setLabelIdForUpdate] = useState<string | null>(null);
|
||||
const [labelToUpdate, setLabelToUpdate] = useState<IIssueLabels | null>(null);
|
||||
|
||||
// labels list modal
|
||||
const [labelsListModal, setLabelsListModal] = useState(false);
|
||||
const [parentLabel, setParentLabel] = useState<IIssueLabels | undefined>(undefined);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@ -60,57 +60,20 @@ const LabelsSettings: NextPage<UserAuth> = (props) => {
|
||||
: null
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
watch,
|
||||
} = useForm<IIssueLabels>({ defaultValues });
|
||||
|
||||
const newLabel = () => {
|
||||
reset();
|
||||
setIsUpdating(false);
|
||||
setLabelForm(true);
|
||||
};
|
||||
|
||||
const addLabelToGroup = (parentLabel: IIssueLabels) => {
|
||||
setLabelsListModal(true);
|
||||
setParentLabel(parentLabel);
|
||||
};
|
||||
|
||||
const editLabel = (label: IIssueLabels) => {
|
||||
setLabelForm(true);
|
||||
setValue("color", label.color);
|
||||
setValue("name", label.name);
|
||||
setIsUpdating(true);
|
||||
setLabelIdForUpdate(label.id);
|
||||
};
|
||||
|
||||
const handleLabelCreate: SubmitHandler<IIssueLabels> = async (formData) => {
|
||||
if (!workspaceSlug || !projectDetails || isSubmitting) return;
|
||||
|
||||
await issuesService
|
||||
.createIssueLabel(workspaceSlug as string, projectDetails.id, formData)
|
||||
.then((res) => {
|
||||
mutate((prevData) => [res, ...(prevData ?? [])], false);
|
||||
reset(defaultValues);
|
||||
setLabelForm(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleLabelUpdate: SubmitHandler<IIssueLabels> = async (formData) => {
|
||||
if (!workspaceSlug || !projectDetails || isSubmitting) return;
|
||||
|
||||
await issuesService
|
||||
.patchIssueLabel(workspaceSlug as string, projectDetails.id, labelIdForUpdate ?? "", formData)
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
reset(defaultValues);
|
||||
mutate(
|
||||
(prevData) =>
|
||||
prevData?.map((p) => (p.id === labelIdForUpdate ? { ...p, ...formData } : p)),
|
||||
false
|
||||
);
|
||||
setLabelForm(false);
|
||||
});
|
||||
setLabelToUpdate(label);
|
||||
};
|
||||
|
||||
const handleLabelDelete = (labelId: string) => {
|
||||
@ -128,146 +91,85 @@ const LabelsSettings: NextPage<UserAuth> = (props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
settingsLayout="project"
|
||||
memberType={{ isMember, isOwner, isViewer, isGuest }}
|
||||
breadcrumbs={
|
||||
<Breadcrumbs>
|
||||
<BreadcrumbItem
|
||||
title={`${projectDetails?.name ?? "Project"}`}
|
||||
link={`/${workspaceSlug}/projects/${projectDetails?.id}/issues`}
|
||||
/>
|
||||
<BreadcrumbItem title="Labels Settings" />
|
||||
</Breadcrumbs>
|
||||
}
|
||||
>
|
||||
<section className="space-y-8">
|
||||
<div>
|
||||
<h3 className="text-3xl font-bold leading-6 text-gray-900">Labels</h3>
|
||||
<p className="mt-4 text-sm text-gray-500">Manage the labels of this project.</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 md:w-2/3">
|
||||
<h4 className="text-md mb-1 leading-6 text-gray-900">Manage labels</h4>
|
||||
<Button theme="secondary" className="flex items-center gap-x-1" onClick={newLabel}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
New label
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-md border p-3 md:w-2/3 ${
|
||||
labelForm ? "" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<div className="h-8 w-8 flex-shrink-0">
|
||||
<Popover className="relative flex h-full w-full items-center justify-center rounded-xl bg-gray-200">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group inline-flex items-center text-base font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
|
||||
open ? "text-gray-900" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{watch("color") && watch("color") !== "" && (
|
||||
<span
|
||||
className="h-4 w-4 rounded"
|
||||
style={{
|
||||
backgroundColor: watch("color") ?? "green",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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 top-full left-0 z-20 mt-3 w-screen max-w-xs px-2 sm:px-0">
|
||||
<Controller
|
||||
name="color"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TwitterPicker
|
||||
color={value}
|
||||
onChange={(value) => onChange(value.hex)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="flex w-full flex-col justify-center">
|
||||
<Input
|
||||
type="text"
|
||||
id="labelName"
|
||||
name="name"
|
||||
register={register}
|
||||
placeholder="Label title"
|
||||
validations={{
|
||||
required: "Label title is required",
|
||||
}}
|
||||
error={errors.name}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
theme="secondary"
|
||||
onClick={() => {
|
||||
reset();
|
||||
setLabelForm(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{isUpdating ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit(handleLabelUpdate)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Updating" : "Update"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit(handleLabelCreate)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Adding" : "Add"}
|
||||
</Button>
|
||||
)}
|
||||
<>
|
||||
<LabelsListModal
|
||||
isOpen={labelsListModal}
|
||||
handleClose={() => setLabelsListModal(false)}
|
||||
parent={parentLabel}
|
||||
/>
|
||||
<AppLayout
|
||||
settingsLayout="project"
|
||||
memberType={{ isMember, isOwner, isViewer, isGuest }}
|
||||
breadcrumbs={
|
||||
<Breadcrumbs>
|
||||
<BreadcrumbItem
|
||||
title={`${projectDetails?.name ?? "Project"}`}
|
||||
link={`/${workspaceSlug}/projects/${projectDetails?.id}/issues`}
|
||||
/>
|
||||
<BreadcrumbItem title="Labels Settings" />
|
||||
</Breadcrumbs>
|
||||
}
|
||||
>
|
||||
<section className="space-y-8">
|
||||
<div>
|
||||
<h3 className="text-3xl font-bold leading-6 text-gray-900">Labels</h3>
|
||||
<p className="mt-4 text-sm text-gray-500">Manage the labels of this project.</p>
|
||||
</div>
|
||||
<>
|
||||
{issueLabels ? (
|
||||
issueLabels.map((label) => (
|
||||
<SingleLabel
|
||||
key={label.id}
|
||||
label={label}
|
||||
issueLabels={issueLabels}
|
||||
editLabel={editLabel}
|
||||
handleLabelDelete={handleLabelDelete}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Loader className="space-y-5 md:w-2/3">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</section>
|
||||
</AppLayout>
|
||||
<div className="flex items-center justify-between gap-2 md:w-2/3">
|
||||
<h4 className="text-md mb-1 leading-6 text-gray-900">Manage labels</h4>
|
||||
<Button theme="secondary" className="flex items-center gap-x-1" onClick={newLabel}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
New label
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
<CreateUpdateLabelInline
|
||||
labelForm={labelForm}
|
||||
setLabelForm={setLabelForm}
|
||||
isUpdating={isUpdating}
|
||||
labelToUpdate={labelToUpdate}
|
||||
/>
|
||||
<>
|
||||
{issueLabels ? (
|
||||
issueLabels.map((label) => {
|
||||
const children = issueLabels?.filter((l) => l.parent === label.id);
|
||||
|
||||
if (children && children.length === 0) {
|
||||
if (!label.parent)
|
||||
return (
|
||||
<SingleLabel
|
||||
key={label.id}
|
||||
label={label}
|
||||
addLabelToGroup={() => addLabelToGroup(label)}
|
||||
editLabel={editLabel}
|
||||
handleLabelDelete={handleLabelDelete}
|
||||
/>
|
||||
);
|
||||
} else
|
||||
return (
|
||||
<SingleLabelGroup
|
||||
key={label.id}
|
||||
label={label}
|
||||
labelChildren={children}
|
||||
addLabelToGroup={addLabelToGroup}
|
||||
editLabel={editLabel}
|
||||
handleLabelDelete={handleLabelDelete}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Loader className="space-y-5 md:w-2/3">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</section>
|
||||
</AppLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -4,22 +4,26 @@ import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// lib
|
||||
import { requiredAdmin } from "lib/auth";
|
||||
// services
|
||||
import stateService from "services/state.service";
|
||||
import projectService from "services/project.service";
|
||||
// lib
|
||||
import { requiredAdmin } from "lib/auth";
|
||||
// layouts
|
||||
import AppLayout from "layouts/app-layout";
|
||||
// components
|
||||
import { CreateUpdateStateInline, DeleteStateModal, StateGroup } from "components/states";
|
||||
import {
|
||||
CreateUpdateStateInline,
|
||||
DeleteStateModal,
|
||||
SingleState,
|
||||
StateGroup,
|
||||
} from "components/states";
|
||||
// ui
|
||||
import { Loader } from "components/ui";
|
||||
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
|
||||
// icons
|
||||
import { PencilSquareIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
import { getStatesList, orderStateGroups } from "helpers/state.helper";
|
||||
// types
|
||||
import { UserAuth } from "types";
|
||||
@ -34,9 +38,8 @@ const StatesSettings: NextPage<UserAuth> = (props) => {
|
||||
const [selectedState, setSelectedState] = useState<string | null>(null);
|
||||
const [selectDeleteState, setSelectDeleteState] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
query: { workspaceSlug, projectId },
|
||||
} = useRouter();
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: projectDetails } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
|
||||
@ -99,54 +102,33 @@ const StatesSettings: NextPage<UserAuth> = (props) => {
|
||||
<div className="space-y-1 rounded-xl border p-1 md:w-2/3">
|
||||
{key === activeGroup && (
|
||||
<CreateUpdateStateInline
|
||||
projectId={projectDetails.id}
|
||||
onClose={() => {
|
||||
setActiveGroup(null);
|
||||
setSelectedState(null);
|
||||
}}
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
data={null}
|
||||
selectedGroup={key as keyof StateGroup}
|
||||
/>
|
||||
)}
|
||||
{orderedStateGroups[key].map((state) =>
|
||||
{orderedStateGroups[key].map((state, index) =>
|
||||
state.id !== selectedState ? (
|
||||
<div
|
||||
<SingleState
|
||||
key={state.id}
|
||||
className={`flex items-center justify-between gap-2 border-b bg-gray-50 p-3 ${
|
||||
activeGroup !== key ? "last:border-0" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-3 w-3 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: state.color,
|
||||
}}
|
||||
/>
|
||||
<h6 className="text-sm">{addSpaceIfCamelCase(state.name)}</h6>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectDeleteState(state.id)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4 text-red-400" />
|
||||
</button>
|
||||
<button type="button" onClick={() => setSelectedState(state.id)}>
|
||||
<PencilSquareIcon className="h-4 w-4 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
index={index}
|
||||
currentGroup={key}
|
||||
state={state}
|
||||
statesList={statesList}
|
||||
activeGroup={activeGroup}
|
||||
handleEditState={() => setSelectedState(state.id)}
|
||||
handleDeleteState={() => setSelectDeleteState(state.id)}
|
||||
/>
|
||||
) : (
|
||||
<div className="border-b last:border-b-0" key={state.id}>
|
||||
<CreateUpdateStateInline
|
||||
projectId={projectDetails.id}
|
||||
onClose={() => {
|
||||
setActiveGroup(null);
|
||||
setSelectedState(null);
|
||||
}}
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
data={
|
||||
statesList?.find((state) => state.id === selectedState) ?? null
|
||||
}
|
||||
|
19
apps/app/types/state.d.ts
vendored
19
apps/app/types/state.d.ts
vendored
@ -1,17 +1,18 @@
|
||||
export interface IState {
|
||||
readonly id: string;
|
||||
readonly created_at: Date;
|
||||
readonly updated_at: Date;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
readonly slug: string;
|
||||
readonly created_at: Date;
|
||||
readonly created_by: string;
|
||||
readonly updated_by: string;
|
||||
project: string;
|
||||
workspace: string;
|
||||
sequence: number;
|
||||
default: boolean;
|
||||
description: string;
|
||||
group: "backlog" | "unstarted" | "started" | "completed" | "cancelled";
|
||||
name: string;
|
||||
project: string;
|
||||
sequence: number;
|
||||
readonly slug: string;
|
||||
readonly updated_at: Date;
|
||||
readonly updated_by: string;
|
||||
workspace: string;
|
||||
}
|
||||
|
||||
export interface StateResponse {
|
||||
|
Loading…
Reference in New Issue
Block a user