forked from github/plane
[WEB-575] chore: safely re-enable SWR (#3805)
* safley enable swr and make sure to minimalize re renders * resolve build errors * fix dropdowns updation by adding observer
This commit is contained in:
parent
bd142989b4
commit
6c70d3854a
@ -49,8 +49,10 @@ export const BulkDeleteIssuesModal: React.FC<Props> = observer((props) => {
|
||||
const [query, setQuery] = useState("");
|
||||
// fetching project issues.
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
|
||||
workspaceSlug && projectId ? () => issueService.getIssues(workspaceSlug as string, projectId as string) : null
|
||||
workspaceSlug && projectId && isOpen ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
|
||||
workspaceSlug && projectId && isOpen
|
||||
? () => issueService.getIssues(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
@ -1,277 +0,0 @@
|
||||
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Check, ChevronDown, Search } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useCycle } from "hooks/store";
|
||||
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
// components
|
||||
import { DropdownButton } from "./buttons";
|
||||
// icons
|
||||
import { ContrastIcon, CycleGroupIcon } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { TDropdownProps } from "./types";
|
||||
import { TCycleGroups } from "@plane/types";
|
||||
// constants
|
||||
import { BUTTON_VARIANTS_WITH_TEXT } from "./constants";
|
||||
|
||||
type Props = TDropdownProps & {
|
||||
button?: ReactNode;
|
||||
dropdownArrow?: boolean;
|
||||
dropdownArrowClassName?: string;
|
||||
onChange: (val: string | null) => void;
|
||||
onClose?: () => void;
|
||||
projectId: string;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
type DropdownOptions =
|
||||
| {
|
||||
value: string | null;
|
||||
query: string;
|
||||
content: JSX.Element;
|
||||
}[]
|
||||
| undefined;
|
||||
|
||||
export const CycleDropdown: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
button,
|
||||
buttonClassName,
|
||||
buttonContainerClassName,
|
||||
buttonVariant,
|
||||
className = "",
|
||||
disabled = false,
|
||||
dropdownArrow = false,
|
||||
dropdownArrowClassName = "",
|
||||
hideIcon = false,
|
||||
onChange,
|
||||
onClose,
|
||||
placeholder = "Cycle",
|
||||
placement,
|
||||
projectId,
|
||||
showTooltip = false,
|
||||
tabIndex,
|
||||
value,
|
||||
} = props;
|
||||
// states
|
||||
const [query, setQuery] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// popper-js init
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "bottom-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
// store hooks
|
||||
const {
|
||||
router: { workspaceSlug },
|
||||
} = useApplication();
|
||||
const { getProjectCycleIds, fetchAllCycles, getCycleById } = useCycle();
|
||||
|
||||
const cycleIds = (getProjectCycleIds(projectId) ?? [])?.filter((cycleId) => {
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
return cycleDetails?.status ? (cycleDetails?.status.toLowerCase() != "completed" ? true : false) : true;
|
||||
});
|
||||
|
||||
const options: DropdownOptions = cycleIds?.map((cycleId) => {
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
const cycleStatus = cycleDetails?.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
|
||||
|
||||
return {
|
||||
value: cycleId,
|
||||
query: `${cycleDetails?.name}`,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="flex-grow truncate">{cycleDetails?.name}</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
options?.unshift({
|
||||
value: null,
|
||||
query: "No cycle",
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<ContrastIcon className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="flex-grow truncate">No cycle</span>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
const filteredOptions =
|
||||
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const selectedCycle = value ? getCycleById(value) : null;
|
||||
|
||||
const onOpen = () => {
|
||||
if (workspaceSlug && !cycleIds) fetchAllCycles(workspaceSlug, projectId);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!isOpen) onOpen();
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
};
|
||||
|
||||
const dropdownOnChange = (val: string | null) => {
|
||||
onChange(val);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
|
||||
|
||||
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (query !== "" && e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
useOutsideClickDetector(dropdownRef, handleClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
tabIndex={tabIndex}
|
||||
className={cn("h-full", className)}
|
||||
value={value}
|
||||
onChange={dropdownOnChange}
|
||||
disabled={disabled}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Combobox.Button as={Fragment}>
|
||||
{button ? (
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"clickable block h-full w-full outline-none hover:bg-custom-background-80",
|
||||
buttonContainerClassName
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{button}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"clickable block h-full max-w-full outline-none hover:bg-custom-background-80",
|
||||
{
|
||||
"cursor-not-allowed text-custom-text-200": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
},
|
||||
buttonContainerClassName
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
<DropdownButton
|
||||
className={buttonClassName}
|
||||
isActive={isOpen}
|
||||
tooltipHeading="Cycle"
|
||||
tooltipContent={selectedCycle?.name ?? placeholder}
|
||||
showTooltip={showTooltip}
|
||||
variant={buttonVariant}
|
||||
>
|
||||
{!hideIcon && <ContrastIcon className="h-3 w-3 flex-shrink-0" />}
|
||||
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
||||
<span className="flex-grow truncate max-w-40">{selectedCycle?.name ?? placeholder}</span>
|
||||
)}
|
||||
{dropdownArrow && (
|
||||
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
|
||||
)}
|
||||
</DropdownButton>
|
||||
</button>
|
||||
)}
|
||||
</Combobox.Button>
|
||||
{isOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<div
|
||||
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search"
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
onKeyDown={searchInputKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
||||
active ? "bg-custom-background-80" : ""
|
||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className="flex-grow truncate">{option.content}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="text-custom-text-400 italic py-1 px-1.5">No matches found</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
});
|
162
web/components/dropdowns/cycle/cycle-options.tsx
Normal file
162
web/components/dropdowns/cycle/cycle-options.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { observer } from "mobx-react";
|
||||
//components
|
||||
import { ContrastIcon, CycleGroupIcon } from "@plane/ui";
|
||||
//store
|
||||
import { useApplication, useCycle } from "hooks/store";
|
||||
//hooks
|
||||
import { usePopper } from "react-popper";
|
||||
//icon
|
||||
import { Check, Search } from "lucide-react";
|
||||
//types
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { TCycleGroups } from "@plane/types";
|
||||
|
||||
type DropdownOptions =
|
||||
| {
|
||||
value: string | null;
|
||||
query: string;
|
||||
content: JSX.Element;
|
||||
}[]
|
||||
| undefined;
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
referenceElement: HTMLButtonElement | null;
|
||||
placement: Placement | undefined;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export const CycleOptions = observer((props: any) => {
|
||||
const { projectId, isOpen, referenceElement, placement } = props;
|
||||
|
||||
//state hooks
|
||||
const [query, setQuery] = useState("");
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// store hooks
|
||||
const {
|
||||
router: { workspaceSlug },
|
||||
} = useApplication();
|
||||
const { getProjectCycleIds, fetchAllCycles, getCycleById } = useCycle();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
onOpen();
|
||||
inputRef.current && inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// popper-js init
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "bottom-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const cycleIds = (getProjectCycleIds(projectId) ?? [])?.filter((cycleId) => {
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
return cycleDetails?.status ? (cycleDetails?.status.toLowerCase() != "completed" ? true : false) : true;
|
||||
});
|
||||
|
||||
const onOpen = () => {
|
||||
if (workspaceSlug && !cycleIds) fetchAllCycles(workspaceSlug, projectId);
|
||||
};
|
||||
|
||||
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (query !== "" && e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
const options: DropdownOptions = cycleIds?.map((cycleId) => {
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
const cycleStatus = cycleDetails?.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
|
||||
|
||||
return {
|
||||
value: cycleId,
|
||||
query: `${cycleDetails?.name}`,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="flex-grow truncate">{cycleDetails?.name}</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
options?.unshift({
|
||||
value: null,
|
||||
query: "No cycle",
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<ContrastIcon className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="flex-grow truncate">No cycle</span>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
const filteredOptions =
|
||||
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
return (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<div
|
||||
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search"
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
onKeyDown={searchInputKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
||||
active ? "bg-custom-background-80" : ""
|
||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className="flex-grow truncate">{option.content}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="text-custom-text-400 italic py-1 px-1.5">No matches found</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
);
|
||||
});
|
149
web/components/dropdowns/cycle/index.tsx
Normal file
149
web/components/dropdowns/cycle/index.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import { Fragment, ReactNode, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
// hooks
|
||||
import { useCycle } from "hooks/store";
|
||||
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
// components
|
||||
import { DropdownButton } from "../buttons";
|
||||
// icons
|
||||
import { ContrastIcon } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { TDropdownProps } from "../types";
|
||||
// constants
|
||||
import { BUTTON_VARIANTS_WITH_TEXT } from "../constants";
|
||||
import { CycleOptions } from "./cycle-options";
|
||||
|
||||
type Props = TDropdownProps & {
|
||||
button?: ReactNode;
|
||||
dropdownArrow?: boolean;
|
||||
dropdownArrowClassName?: string;
|
||||
onChange: (val: string | null) => void;
|
||||
onClose?: () => void;
|
||||
projectId: string;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
export const CycleDropdown: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
button,
|
||||
buttonClassName,
|
||||
buttonContainerClassName,
|
||||
buttonVariant,
|
||||
className = "",
|
||||
disabled = false,
|
||||
dropdownArrow = false,
|
||||
dropdownArrowClassName = "",
|
||||
hideIcon = false,
|
||||
onChange,
|
||||
onClose,
|
||||
placeholder = "Cycle",
|
||||
placement,
|
||||
projectId,
|
||||
showTooltip = false,
|
||||
tabIndex,
|
||||
value,
|
||||
} = props;
|
||||
// states
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { getCycleNameById } = useCycle();
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
|
||||
const selectedName = value ? getCycleNameById(value) : null;
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
};
|
||||
|
||||
const dropdownOnChange = (val: string | null) => {
|
||||
onChange(val);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
|
||||
|
||||
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
useOutsideClickDetector(dropdownRef, handleClose);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
tabIndex={tabIndex}
|
||||
className={cn("h-full", className)}
|
||||
value={value}
|
||||
onChange={dropdownOnChange}
|
||||
disabled={disabled}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Combobox.Button as={Fragment}>
|
||||
{button ? (
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"clickable block h-full w-full outline-none hover:bg-custom-background-80",
|
||||
buttonContainerClassName
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{button}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"clickable block h-full max-w-full outline-none hover:bg-custom-background-80",
|
||||
{
|
||||
"cursor-not-allowed text-custom-text-200": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
},
|
||||
buttonContainerClassName
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
<DropdownButton
|
||||
className={buttonClassName}
|
||||
isActive={isOpen}
|
||||
tooltipHeading="Cycle"
|
||||
tooltipContent={selectedName ?? placeholder}
|
||||
showTooltip={showTooltip}
|
||||
variant={buttonVariant}
|
||||
>
|
||||
{!hideIcon && <ContrastIcon className="h-3 w-3 flex-shrink-0" />}
|
||||
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
||||
<span className="flex-grow truncate max-w-40">{selectedName ?? placeholder}</span>
|
||||
)}
|
||||
{dropdownArrow && (
|
||||
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
|
||||
)}
|
||||
</DropdownButton>
|
||||
</button>
|
||||
)}
|
||||
</Combobox.Button>
|
||||
{isOpen && (
|
||||
<CycleOptions isOpen={isOpen} projectId={projectId} placement={placement} referenceElement={referenceElement} />
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
});
|
@ -1,2 +0,0 @@
|
||||
export * from "./project-member";
|
||||
export * from "./workspace-member";
|
156
web/components/dropdowns/member/index.tsx
Normal file
156
web/components/dropdowns/member/index.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
import { Fragment, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
// hooks
|
||||
import { useMember } from "hooks/store";
|
||||
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
// components
|
||||
import { ButtonAvatars } from "./avatar";
|
||||
import { DropdownButton } from "../buttons";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { MemberDropdownProps } from "./types";
|
||||
// constants
|
||||
import { BUTTON_VARIANTS_WITH_TEXT } from "../constants";
|
||||
import { MemberOptions } from "./member-options";
|
||||
|
||||
type Props = {
|
||||
projectId?: string;
|
||||
onClose?: () => void;
|
||||
} & MemberDropdownProps;
|
||||
|
||||
export const MemberDropdown: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
button,
|
||||
buttonClassName,
|
||||
buttonContainerClassName,
|
||||
buttonVariant,
|
||||
className = "",
|
||||
disabled = false,
|
||||
dropdownArrow = false,
|
||||
dropdownArrowClassName = "",
|
||||
hideIcon = false,
|
||||
multiple,
|
||||
onChange,
|
||||
onClose,
|
||||
placeholder = "Members",
|
||||
placement,
|
||||
projectId,
|
||||
showTooltip = false,
|
||||
tabIndex,
|
||||
value,
|
||||
} = props;
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
|
||||
const { getUserDetails } = useMember();
|
||||
|
||||
const comboboxProps: any = {
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
};
|
||||
if (multiple) comboboxProps.multiple = true;
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
};
|
||||
|
||||
const dropdownOnChange = (val: string & string[]) => {
|
||||
onChange(val);
|
||||
if (!multiple) handleClose();
|
||||
};
|
||||
|
||||
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
|
||||
|
||||
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
useOutsideClickDetector(dropdownRef, handleClose);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
tabIndex={tabIndex}
|
||||
className={cn("h-full", className)}
|
||||
onChange={dropdownOnChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...comboboxProps}
|
||||
>
|
||||
<Combobox.Button as={Fragment}>
|
||||
{button ? (
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{button}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"clickable block h-full max-w-full outline-none",
|
||||
{
|
||||
"cursor-not-allowed text-custom-text-200": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
},
|
||||
buttonContainerClassName
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
<DropdownButton
|
||||
className={buttonClassName}
|
||||
isActive={isOpen}
|
||||
tooltipHeading={placeholder}
|
||||
tooltipContent={`${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`}
|
||||
showTooltip={showTooltip}
|
||||
variant={buttonVariant}
|
||||
>
|
||||
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} />}
|
||||
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
||||
<span className="flex-grow truncate text-xs leading-5">
|
||||
{Array.isArray(value) && value.length > 0
|
||||
? value.length === 1
|
||||
? getUserDetails(value[0])?.display_name
|
||||
: ""
|
||||
: placeholder}
|
||||
</span>
|
||||
)}
|
||||
{dropdownArrow && (
|
||||
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
|
||||
)}
|
||||
</DropdownButton>
|
||||
</button>
|
||||
)}
|
||||
</Combobox.Button>
|
||||
{isOpen && (
|
||||
<MemberOptions
|
||||
isOpen={isOpen}
|
||||
projectId={projectId}
|
||||
placement={placement}
|
||||
referenceElement={referenceElement}
|
||||
/>
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
});
|
142
web/components/dropdowns/member/member-options.tsx
Normal file
142
web/components/dropdowns/member/member-options.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { observer } from "mobx-react";
|
||||
//components
|
||||
import { Avatar } from "@plane/ui";
|
||||
//store
|
||||
import { useApplication, useMember, useUser } from "hooks/store";
|
||||
//hooks
|
||||
import { usePopper } from "react-popper";
|
||||
//icon
|
||||
import { Check, Search } from "lucide-react";
|
||||
//types
|
||||
import { Placement } from "@popperjs/core";
|
||||
|
||||
interface Props {
|
||||
projectId?: string;
|
||||
referenceElement: HTMLButtonElement | null;
|
||||
placement: Placement | undefined;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export const MemberOptions = observer((props: Props) => {
|
||||
const { projectId, referenceElement, placement, isOpen } = props;
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// store hooks
|
||||
const {
|
||||
router: { workspaceSlug },
|
||||
} = useApplication();
|
||||
const {
|
||||
getUserDetails,
|
||||
project: { getProjectMemberIds, fetchProjectMembers },
|
||||
workspace: { workspaceMemberIds },
|
||||
} = useMember();
|
||||
const { currentUser } = useUser();
|
||||
|
||||
// popper-js init
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "bottom-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
onOpen();
|
||||
inputRef.current && inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const memberIds = projectId ? getProjectMemberIds(projectId) : workspaceMemberIds;
|
||||
const onOpen = () => {
|
||||
if (!memberIds && workspaceSlug && projectId) fetchProjectMembers(workspaceSlug, projectId);
|
||||
};
|
||||
|
||||
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (query !== "" && e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
const options = memberIds?.map((userId) => {
|
||||
const userDetails = getUserDetails(userId);
|
||||
|
||||
return {
|
||||
value: userId,
|
||||
query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={userDetails?.display_name} src={userDetails?.avatar} />
|
||||
<span className="flex-grow truncate">{currentUser?.id === userId ? "You" : userDetails?.display_name}</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const filteredOptions =
|
||||
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
return (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<div
|
||||
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search"
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
onKeyDown={searchInputKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
||||
active ? "bg-custom-background-80" : ""
|
||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className="flex-grow truncate">{option.content}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
);
|
||||
});
|
@ -1,261 +0,0 @@
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Check, ChevronDown, Search } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useMember, useUser } from "hooks/store";
|
||||
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
// components
|
||||
import { ButtonAvatars } from "./avatar";
|
||||
import { DropdownButton } from "../buttons";
|
||||
// icons
|
||||
import { Avatar } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { MemberDropdownProps } from "./types";
|
||||
// constants
|
||||
import { BUTTON_VARIANTS_WITH_TEXT } from "../constants";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
onClose?: () => void;
|
||||
} & MemberDropdownProps;
|
||||
|
||||
export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
button,
|
||||
buttonClassName,
|
||||
buttonContainerClassName,
|
||||
buttonVariant,
|
||||
className = "",
|
||||
disabled = false,
|
||||
dropdownArrow = false,
|
||||
dropdownArrowClassName = "",
|
||||
hideIcon = false,
|
||||
multiple,
|
||||
onChange,
|
||||
onClose,
|
||||
placeholder = "Members",
|
||||
placement,
|
||||
projectId,
|
||||
showTooltip = false,
|
||||
tabIndex,
|
||||
value,
|
||||
} = props;
|
||||
// states
|
||||
const [query, setQuery] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// popper-js init
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "bottom-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
// store hooks
|
||||
const {
|
||||
router: { workspaceSlug },
|
||||
} = useApplication();
|
||||
const { currentUser } = useUser();
|
||||
const {
|
||||
getUserDetails,
|
||||
project: { getProjectMemberIds, fetchProjectMembers },
|
||||
} = useMember();
|
||||
const projectMemberIds = getProjectMemberIds(projectId);
|
||||
|
||||
const options = projectMemberIds?.map((userId) => {
|
||||
const userDetails = getUserDetails(userId);
|
||||
|
||||
return {
|
||||
value: userId,
|
||||
query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={userDetails?.display_name} src={userDetails?.avatar} />
|
||||
<span className="flex-grow truncate">{currentUser?.id === userId ? "You" : userDetails?.display_name}</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const filteredOptions =
|
||||
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const comboboxProps: any = {
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
};
|
||||
if (multiple) comboboxProps.multiple = true;
|
||||
|
||||
const onOpen = () => {
|
||||
if (!projectMemberIds && workspaceSlug) fetchProjectMembers(workspaceSlug, projectId);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!isOpen) onOpen();
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
};
|
||||
|
||||
const dropdownOnChange = (val: string & string[]) => {
|
||||
onChange(val);
|
||||
if (!multiple) handleClose();
|
||||
};
|
||||
|
||||
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
|
||||
|
||||
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (query !== "" && e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
useOutsideClickDetector(dropdownRef, handleClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
tabIndex={tabIndex}
|
||||
className={cn("h-full", className)}
|
||||
onChange={dropdownOnChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...comboboxProps}
|
||||
>
|
||||
<Combobox.Button as={Fragment}>
|
||||
{button ? (
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{button}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"clickable block h-full max-w-full outline-none",
|
||||
{
|
||||
"cursor-not-allowed text-custom-text-200": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
},
|
||||
buttonContainerClassName
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
<DropdownButton
|
||||
className={buttonClassName}
|
||||
isActive={isOpen}
|
||||
tooltipHeading={placeholder}
|
||||
tooltipContent={`${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`}
|
||||
showTooltip={showTooltip}
|
||||
variant={buttonVariant}
|
||||
>
|
||||
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} />}
|
||||
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
||||
<span className="flex-grow truncate text-xs leading-5">
|
||||
{Array.isArray(value) && value.length > 0
|
||||
? value.length === 1
|
||||
? getUserDetails(value[0])?.display_name
|
||||
: ""
|
||||
: placeholder}
|
||||
</span>
|
||||
)}
|
||||
{dropdownArrow && (
|
||||
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
|
||||
)}
|
||||
</DropdownButton>
|
||||
</button>
|
||||
)}
|
||||
</Combobox.Button>
|
||||
{isOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<div
|
||||
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search"
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
onKeyDown={searchInputKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
||||
active ? "bg-custom-background-80" : ""
|
||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className="flex-grow truncate">{option.content}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
});
|
@ -1,238 +0,0 @@
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Check, ChevronDown, Search } from "lucide-react";
|
||||
// hooks
|
||||
import { useMember, useUser } from "hooks/store";
|
||||
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
// components
|
||||
import { ButtonAvatars } from "./avatar";
|
||||
import { DropdownButton } from "../buttons";
|
||||
// icons
|
||||
import { Avatar } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { MemberDropdownProps } from "./types";
|
||||
// constants
|
||||
import { BUTTON_VARIANTS_WITH_TEXT } from "../constants";
|
||||
|
||||
export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((props) => {
|
||||
const {
|
||||
button,
|
||||
buttonClassName,
|
||||
buttonContainerClassName,
|
||||
buttonVariant,
|
||||
className = "",
|
||||
disabled = false,
|
||||
dropdownArrow = false,
|
||||
dropdownArrowClassName = "",
|
||||
hideIcon = false,
|
||||
multiple,
|
||||
onChange,
|
||||
onClose,
|
||||
placeholder = "Members",
|
||||
placement,
|
||||
showTooltip = false,
|
||||
tabIndex,
|
||||
value,
|
||||
} = props;
|
||||
// states
|
||||
const [query, setQuery] = useState("");
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// popper-js init
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "bottom-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
// store hooks
|
||||
const { currentUser } = useUser();
|
||||
const {
|
||||
getUserDetails,
|
||||
workspace: { workspaceMemberIds },
|
||||
} = useMember();
|
||||
|
||||
const options = workspaceMemberIds?.map((userId) => {
|
||||
const userDetails = getUserDetails(userId);
|
||||
|
||||
return {
|
||||
value: userId,
|
||||
query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={userDetails?.display_name} src={userDetails?.avatar} />
|
||||
<span className="flex-grow truncate">{currentUser?.id === userId ? "You" : userDetails?.display_name}</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const filteredOptions =
|
||||
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const comboboxProps: any = {
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
};
|
||||
if (multiple) comboboxProps.multiple = true;
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
};
|
||||
|
||||
const dropdownOnChange = (val: string & string[]) => {
|
||||
onChange(val);
|
||||
if (!multiple) handleClose();
|
||||
};
|
||||
|
||||
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
|
||||
|
||||
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
useOutsideClickDetector(dropdownRef, handleClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
tabIndex={tabIndex}
|
||||
className={cn("h-full", className)}
|
||||
{...comboboxProps}
|
||||
handleKeyDown={handleKeyDown}
|
||||
onChange={dropdownOnChange}
|
||||
>
|
||||
<Combobox.Button as={Fragment}>
|
||||
{button ? (
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{button}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"clickable block h-full max-w-full outline-none",
|
||||
{
|
||||
"cursor-not-allowed text-custom-text-200": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
},
|
||||
buttonContainerClassName
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
<DropdownButton
|
||||
className={buttonClassName}
|
||||
isActive={isOpen}
|
||||
tooltipHeading={placeholder}
|
||||
tooltipContent={`${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`}
|
||||
showTooltip={showTooltip}
|
||||
variant={buttonVariant}
|
||||
>
|
||||
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} />}
|
||||
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
||||
<span className="flex-grow truncate text-xs leading-5">
|
||||
{Array.isArray(value) && value.length > 0
|
||||
? value.length === 1
|
||||
? getUserDetails(value[0])?.display_name
|
||||
: ""
|
||||
: placeholder}
|
||||
</span>
|
||||
)}
|
||||
{dropdownArrow && (
|
||||
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
|
||||
)}
|
||||
</DropdownButton>
|
||||
</button>
|
||||
)}
|
||||
</Combobox.Button>
|
||||
{isOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<div
|
||||
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search"
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
|
||||
active ? "bg-custom-background-80" : ""
|
||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className="flex-grow truncate">{option.content}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
});
|
@ -1,22 +1,22 @@
|
||||
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Check, ChevronDown, Search, X } from "lucide-react";
|
||||
import { ChevronDown, X } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useModule } from "hooks/store";
|
||||
import { useModule } from "hooks/store";
|
||||
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
// components
|
||||
import { DropdownButton } from "./buttons";
|
||||
import { DropdownButton } from "../buttons";
|
||||
// icons
|
||||
import { DiceIcon, Tooltip } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { TDropdownProps } from "./types";
|
||||
import { TDropdownProps } from "../types";
|
||||
// constants
|
||||
import { BUTTON_VARIANTS_WITHOUT_TEXT } from "./constants";
|
||||
import { BUTTON_VARIANTS_WITHOUT_TEXT } from "../constants";
|
||||
import { ModuleOptions } from "./module-options";
|
||||
|
||||
type Props = TDropdownProps & {
|
||||
button?: ReactNode;
|
||||
@ -38,14 +38,6 @@ type Props = TDropdownProps & {
|
||||
}
|
||||
);
|
||||
|
||||
type DropdownOptions =
|
||||
| {
|
||||
value: string | null;
|
||||
query: string;
|
||||
content: JSX.Element;
|
||||
}[]
|
||||
| undefined;
|
||||
|
||||
type ButtonContentProps = {
|
||||
disabled: boolean;
|
||||
dropdownArrow: boolean;
|
||||
@ -166,64 +158,14 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
value,
|
||||
} = props;
|
||||
// states
|
||||
const [query, setQuery] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
// popper-js refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// popper-js init
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "bottom-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
// store hooks
|
||||
const {
|
||||
router: { workspaceSlug },
|
||||
} = useApplication();
|
||||
const { getProjectModuleIds, fetchModules, getModuleById } = useModule();
|
||||
const moduleIds = getProjectModuleIds(projectId);
|
||||
|
||||
const options: DropdownOptions = moduleIds?.map((moduleId) => {
|
||||
const moduleDetails = getModuleById(moduleId);
|
||||
return {
|
||||
value: moduleId,
|
||||
query: `${moduleDetails?.name}`,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<DiceIcon className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="flex-grow truncate">{moduleDetails?.name}</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
if (!multiple)
|
||||
options?.unshift({
|
||||
value: null,
|
||||
query: "No module",
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<DiceIcon className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="flex-grow truncate">No module</span>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
const filteredOptions =
|
||||
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const onOpen = () => {
|
||||
if (!moduleIds && workspaceSlug) fetchModules(workspaceSlug, projectId);
|
||||
};
|
||||
const { getModuleNameById } = useModule();
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
@ -232,7 +174,6 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!isOpen) onOpen();
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
};
|
||||
|
||||
@ -249,13 +190,6 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (query !== "" && e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
useOutsideClickDetector(dropdownRef, handleClose);
|
||||
|
||||
const comboboxProps: any = {
|
||||
@ -314,7 +248,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
tooltipContent={
|
||||
Array.isArray(value)
|
||||
? `${value
|
||||
.map((moduleId) => getModuleById(moduleId)?.name)
|
||||
.map((moduleId) => getModuleNameById(moduleId))
|
||||
.toString()
|
||||
.replaceAll(",", ", ")}`
|
||||
: ""
|
||||
@ -339,61 +273,13 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
)}
|
||||
</Combobox.Button>
|
||||
{isOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<div
|
||||
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search"
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
onKeyDown={searchInputKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
cn(
|
||||
"w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none",
|
||||
{
|
||||
"bg-custom-background-80": active,
|
||||
"text-custom-text-100": selected,
|
||||
"text-custom-text-200": !selected,
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className="flex-grow truncate">{option.content}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
<ModuleOptions
|
||||
isOpen={isOpen}
|
||||
projectId={projectId}
|
||||
placement={placement}
|
||||
referenceElement={referenceElement}
|
||||
multiple={multiple}
|
||||
/>
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
163
web/components/dropdowns/module/module-options.tsx
Normal file
163
web/components/dropdowns/module/module-options.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { observer } from "mobx-react";
|
||||
//components
|
||||
import { DiceIcon } from "@plane/ui";
|
||||
//store
|
||||
import { useApplication, useModule } from "hooks/store";
|
||||
//hooks
|
||||
import { usePopper } from "react-popper";
|
||||
import { cn } from "helpers/common.helper";
|
||||
//icon
|
||||
import { Check, Search } from "lucide-react";
|
||||
//types
|
||||
import { Placement } from "@popperjs/core";
|
||||
|
||||
type DropdownOptions =
|
||||
| {
|
||||
value: string | null;
|
||||
query: string;
|
||||
content: JSX.Element;
|
||||
}[]
|
||||
| undefined;
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
referenceElement: HTMLButtonElement | null;
|
||||
placement: Placement | undefined;
|
||||
isOpen: boolean;
|
||||
multiple: boolean;
|
||||
}
|
||||
|
||||
export const ModuleOptions = observer((props: Props) => {
|
||||
const { projectId, isOpen, referenceElement, placement, multiple } = props;
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// store hooks
|
||||
const {
|
||||
router: { workspaceSlug },
|
||||
} = useApplication();
|
||||
const { getProjectModuleIds, fetchModules, getModuleById } = useModule();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
onOpen();
|
||||
inputRef.current && inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// popper-js init
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "bottom-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const moduleIds = getProjectModuleIds(projectId);
|
||||
|
||||
const onOpen = () => {
|
||||
if (workspaceSlug && !moduleIds) fetchModules(workspaceSlug, projectId);
|
||||
};
|
||||
|
||||
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (query !== "" && e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
const options: DropdownOptions = moduleIds?.map((moduleId) => {
|
||||
const moduleDetails = getModuleById(moduleId);
|
||||
return {
|
||||
value: moduleId,
|
||||
query: `${moduleDetails?.name}`,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<DiceIcon className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="flex-grow truncate">{moduleDetails?.name}</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
if (!multiple)
|
||||
options?.unshift({
|
||||
value: null,
|
||||
query: "No module",
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<DiceIcon className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="flex-grow truncate">No module</span>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
const filteredOptions =
|
||||
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
return (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<div
|
||||
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search"
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
onKeyDown={searchInputKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
cn(
|
||||
"w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none",
|
||||
{
|
||||
"bg-custom-background-80": active,
|
||||
"text-custom-text-100": selected,
|
||||
"text-custom-text-200": !selected,
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className="flex-grow truncate">{option.content}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
);
|
||||
});
|
@ -21,10 +21,10 @@ import {
|
||||
CycleDropdown,
|
||||
DateDropdown,
|
||||
EstimateDropdown,
|
||||
MemberDropdown,
|
||||
ModuleDropdown,
|
||||
PriorityDropdown,
|
||||
ProjectDropdown,
|
||||
ProjectMemberDropdown,
|
||||
StateDropdown,
|
||||
} from "components/dropdowns";
|
||||
// ui
|
||||
@ -474,7 +474,7 @@ export const DraftIssueForm: FC<IssueFormProps> = observer((props) => {
|
||||
name="assignee_ids"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<ProjectMemberDropdown
|
||||
<MemberDropdown
|
||||
projectId={projectId}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
|
@ -5,7 +5,7 @@ import { CalendarCheck2, Signal, Tag } from "lucide-react";
|
||||
import { useIssueDetail, useProject, useProjectState } from "hooks/store";
|
||||
// components
|
||||
import { IssueLabel, TIssueOperations } from "components/issues";
|
||||
import { DateDropdown, PriorityDropdown, ProjectMemberDropdown, StateDropdown } from "components/dropdowns";
|
||||
import { DateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "components/dropdowns";
|
||||
// icons
|
||||
import { DoubleCircleIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui";
|
||||
// helper
|
||||
@ -80,7 +80,7 @@ export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
<UserGroupIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Assignees</span>
|
||||
</div>
|
||||
<ProjectMemberDropdown
|
||||
<MemberDropdown
|
||||
value={issue?.assignee_ids ?? undefined}
|
||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
|
||||
disabled={!is_editable}
|
||||
|
@ -27,13 +27,7 @@ import {
|
||||
IssueLabel,
|
||||
} from "components/issues";
|
||||
import { IssueSubscription } from "./subscription";
|
||||
import {
|
||||
DateDropdown,
|
||||
EstimateDropdown,
|
||||
PriorityDropdown,
|
||||
ProjectMemberDropdown,
|
||||
StateDropdown,
|
||||
} from "components/dropdowns";
|
||||
import { DateDropdown, EstimateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "components/dropdowns";
|
||||
// icons
|
||||
import { ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, UserGroupIcon } from "@plane/ui";
|
||||
// helpers
|
||||
@ -161,7 +155,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
<UserGroupIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Assignees</span>
|
||||
</div>
|
||||
<ProjectMemberDropdown
|
||||
<MemberDropdown
|
||||
value={issue?.assignee_ids ?? undefined}
|
||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
|
||||
disabled={!is_editable}
|
||||
|
@ -26,7 +26,7 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
router: { workspaceSlug, projectId },
|
||||
} = useApplication();
|
||||
const { getProjectById } = useProject();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
const { getProjectStates } = useProjectState();
|
||||
const { peekIssue, setPeekIssue } = useIssueDetail();
|
||||
// states
|
||||
@ -108,7 +108,7 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
|
||||
}}
|
||||
/>
|
||||
<div className="flex-shrink-0 text-xs text-custom-text-300">
|
||||
{getProjectById(issue?.project_id)?.identifier}-{issue.sequence_id}
|
||||
{getProjectIdentifierById(issue?.project_id)}-{issue.sequence_id}
|
||||
</div>
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<div className="truncate text-xs">{issue.name}</div>
|
||||
|
@ -66,7 +66,7 @@ export const IssueGanttSidebarBlock: React.FC<Props> = observer((props) => {
|
||||
const { issueId } = props;
|
||||
// store hooks
|
||||
const { getStateById } = useProjectState();
|
||||
const { getProjectById } = useProject();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
const {
|
||||
router: { workspaceSlug },
|
||||
} = useApplication();
|
||||
@ -76,7 +76,7 @@ export const IssueGanttSidebarBlock: React.FC<Props> = observer((props) => {
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId);
|
||||
const projectDetails = issueDetails && getProjectById(issueDetails?.project_id);
|
||||
const projectIdentifier = issueDetails && getProjectIdentifierById(issueDetails?.project_id);
|
||||
const stateDetails = issueDetails && getStateById(issueDetails?.state_id);
|
||||
|
||||
const handleIssuePeekOverview = () =>
|
||||
@ -95,7 +95,7 @@ export const IssueGanttSidebarBlock: React.FC<Props> = observer((props) => {
|
||||
<div className="relative flex h-full w-full cursor-pointer items-center gap-2">
|
||||
{stateDetails && <StateGroupIcon stateGroup={stateDetails?.group} color={stateDetails?.color} />}
|
||||
<div className="flex-shrink-0 text-xs text-custom-text-300">
|
||||
{projectDetails?.identifier} {issueDetails?.sequence_id}
|
||||
{projectIdentifier} {issueDetails?.sequence_id}
|
||||
</div>
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issueDetails?.name}>
|
||||
<span className="flex-grow truncate text-sm font-medium">{issueDetails?.name}</span>
|
||||
|
@ -42,9 +42,9 @@ interface IssueDetailsBlockProps {
|
||||
const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((props: IssueDetailsBlockProps) => {
|
||||
const { issue, handleIssues, quickActions, isReadOnly, displayProperties } = props;
|
||||
// hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
const {
|
||||
router: { workspaceSlug, projectId },
|
||||
router: { workspaceSlug },
|
||||
} = useApplication();
|
||||
const { setPeekIssue } = useIssueDetail();
|
||||
|
||||
@ -64,7 +64,7 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties || {}} displayPropertyKey="key">
|
||||
<div className="relative">
|
||||
<div className="line-clamp-1 text-xs text-custom-text-300">
|
||||
{getProjectById(issue.project_id)?.identifier}-{issue.sequence_id}
|
||||
{getProjectIdentifierById(issue.project_id)}-{issue.sequence_id}
|
||||
</div>
|
||||
<div className="absolute -top-1 right-0 hidden group-hover/kanban-block:block">{quickActions(issue)}</div>
|
||||
</div>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
@ -46,7 +46,15 @@ export const CycleKanBanLayout: React.FC = observer(() => {
|
||||
const isCompletedCycle =
|
||||
cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false;
|
||||
|
||||
const canEditIssueProperties = () => !isCompletedCycle;
|
||||
const canEditIssueProperties = useCallback(() => !isCompletedCycle, [isCompletedCycle]);
|
||||
|
||||
const addIssuesToView = useCallback(
|
||||
(issueIds: string[]) => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) throw new Error();
|
||||
return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds);
|
||||
},
|
||||
[issues?.addIssueToCycle, workspaceSlug, projectId, cycleId]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseKanBanRoot
|
||||
@ -57,10 +65,7 @@ export const CycleKanBanLayout: React.FC = observer(() => {
|
||||
QuickActions={CycleIssueQuickActions}
|
||||
viewId={cycleId?.toString() ?? ""}
|
||||
storeType={EIssuesStoreType.CYCLE}
|
||||
addIssuesToView={(issueIds: string[]) => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) throw new Error();
|
||||
return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds);
|
||||
}}
|
||||
addIssuesToView={addIssuesToView}
|
||||
canEditPropertiesBasedOnProject={canEditIssueProperties}
|
||||
isCompletedCycle={isCompletedCycle}
|
||||
/>
|
||||
|
@ -24,9 +24,9 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
||||
const { issuesMap, issueId, handleIssues, quickActions, displayProperties, canEditProperties } = props;
|
||||
// hooks
|
||||
const {
|
||||
router: { workspaceSlug, projectId },
|
||||
router: { workspaceSlug },
|
||||
} = useApplication();
|
||||
const { getProjectById } = useProject();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
const { peekIssue, setPeekIssue } = useIssueDetail();
|
||||
|
||||
const updateIssue = async (issueToUpdate: TIssue) => {
|
||||
@ -45,7 +45,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
||||
if (!issue) return null;
|
||||
|
||||
const canEditIssueProperties = canEditProperties(issue.project_id);
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
const projectIdentifier = getProjectIdentifierById(issue.project_id);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -56,7 +56,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
||||
>
|
||||
{displayProperties && displayProperties?.key && (
|
||||
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
|
||||
{projectDetails?.identifier}-{issue.sequence_id}
|
||||
{projectIdentifier}-{issue.sequence_id}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
@ -44,7 +44,15 @@ export const CycleListLayout: React.FC = observer(() => {
|
||||
const isCompletedCycle =
|
||||
cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false;
|
||||
|
||||
const canEditIssueProperties = () => !isCompletedCycle;
|
||||
const canEditIssueProperties = useCallback(() => !isCompletedCycle, [isCompletedCycle]);
|
||||
|
||||
const addIssuesToView = useCallback(
|
||||
(issueIds: string[]) => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) throw new Error();
|
||||
return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds);
|
||||
},
|
||||
[issues?.addIssueToCycle, workspaceSlug, projectId, cycleId]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseListRoot
|
||||
@ -54,10 +62,7 @@ export const CycleListLayout: React.FC = observer(() => {
|
||||
issueActions={issueActions}
|
||||
viewId={cycleId?.toString()}
|
||||
storeType={EIssuesStoreType.CYCLE}
|
||||
addIssuesToView={(issueIds: string[]) => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) throw new Error();
|
||||
return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds);
|
||||
}}
|
||||
addIssuesToView={addIssuesToView}
|
||||
canEditPropertiesBasedOnProject={canEditIssueProperties}
|
||||
isCompletedCycle={isCompletedCycle}
|
||||
/>
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
DateDropdown,
|
||||
EstimateDropdown,
|
||||
PriorityDropdown,
|
||||
ProjectMemberDropdown,
|
||||
MemberDropdown,
|
||||
ModuleDropdown,
|
||||
CycleDropdown,
|
||||
StateDropdown,
|
||||
@ -313,7 +313,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
{/* assignee */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
|
||||
<div className="h-5">
|
||||
<ProjectMemberDropdown
|
||||
<MemberDropdown
|
||||
projectId={issue?.project_id}
|
||||
value={issue?.assignee_ids}
|
||||
onChange={handleAssignee}
|
||||
|
@ -88,11 +88,15 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||
}
|
||||
};
|
||||
|
||||
useSWR(workspaceSlug ? `WORKSPACE_GLOBAL_VIEWS${workspaceSlug}` : null, async () => {
|
||||
if (workspaceSlug) {
|
||||
await fetchAllGlobalViews(workspaceSlug.toString());
|
||||
}
|
||||
});
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_GLOBAL_VIEWS_${workspaceSlug}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug) {
|
||||
await fetchAllGlobalViews(workspaceSlug.toString());
|
||||
}
|
||||
},
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
useSWR(
|
||||
workspaceSlug && globalViewId ? `WORKSPACE_GLOBAL_VIEW_ISSUES_${workspaceSlug}_${globalViewId}` : null,
|
||||
@ -103,7 +107,8 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||
await fetchIssues(workspaceSlug.toString(), globalViewId.toString(), issueIds ? "mutation" : "init-loader");
|
||||
routerFilterParams();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const canEditProperties = useCallback(
|
||||
|
@ -33,7 +33,8 @@ export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
|
||||
issues?.groupedIssueIds ? "mutation" : "init-loader"
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
if (issues?.loader === "init-loader" || !issues?.groupedIssueIds) {
|
||||
|
@ -47,7 +47,8 @@ export const CycleLayoutRoot: React.FC = observer(() => {
|
||||
cycleId.toString()
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
|
||||
|
@ -33,7 +33,8 @@ export const DraftIssueLayoutRoot: React.FC = observer(() => {
|
||||
issues?.groupedIssueIds ? "mutation" : "init-loader"
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout || undefined;
|
||||
|
@ -43,7 +43,8 @@ export const ModuleLayoutRoot: React.FC = observer(() => {
|
||||
moduleId.toString()
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const userFilters = issuesFilter?.issueFilters?.filters;
|
||||
|
@ -29,16 +29,20 @@ export const ProjectLayoutRoot: FC = observer(() => {
|
||||
// hooks
|
||||
const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT);
|
||||
|
||||
useSWR(workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null, async () => {
|
||||
if (workspaceSlug && projectId) {
|
||||
await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString());
|
||||
await issues?.fetchIssues(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issues?.groupedIssueIds ? "mutation" : "init-loader"
|
||||
);
|
||||
}
|
||||
});
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId) {
|
||||
await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString());
|
||||
await issues?.fetchIssues(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issues?.groupedIssueIds ? "mutation" : "init-loader"
|
||||
);
|
||||
}
|
||||
},
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
|
||||
|
||||
|
@ -41,7 +41,8 @@ export const ProjectViewLayoutRoot: React.FC = observer(() => {
|
||||
viewId.toString()
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const issueActions = useMemo(
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { ProjectMemberDropdown } from "components/dropdowns";
|
||||
import { MemberDropdown } from "components/dropdowns";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
|
||||
@ -17,7 +17,7 @@ export const SpreadsheetAssigneeColumn: React.FC<Props> = observer((props: Props
|
||||
|
||||
return (
|
||||
<div className="h-11 border-b-[0.5px] border-custom-border-200">
|
||||
<ProjectMemberDropdown
|
||||
<MemberDropdown
|
||||
value={issue?.assignee_ids ?? []}
|
||||
onChange={(data) => {
|
||||
onChange(
|
||||
|
@ -142,7 +142,7 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
//hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
const { peekIssue, setPeekIssue } = useIssueDetail();
|
||||
// states
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
@ -212,7 +212,7 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
|
||||
isMenuActive ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
{getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id}
|
||||
{getProjectIdentifierById(issueDetail.project_id)}-{issueDetail.sequence_id}
|
||||
</span>
|
||||
|
||||
{canEditProperties(issueDetail.project_id) && (
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
// mobx store
|
||||
@ -39,7 +39,7 @@ export const CycleSpreadsheetLayout: React.FC = observer(() => {
|
||||
const isCompletedCycle =
|
||||
cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false;
|
||||
|
||||
const canEditIssueProperties = () => !isCompletedCycle;
|
||||
const canEditIssueProperties = useCallback(() => !isCompletedCycle, [isCompletedCycle]);
|
||||
|
||||
return (
|
||||
<BaseSpreadsheetRoot
|
||||
|
@ -23,7 +23,7 @@ import {
|
||||
ModuleDropdown,
|
||||
PriorityDropdown,
|
||||
ProjectDropdown,
|
||||
ProjectMemberDropdown,
|
||||
MemberDropdown,
|
||||
StateDropdown,
|
||||
} from "components/dropdowns";
|
||||
// ui
|
||||
@ -510,7 +510,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
name="assignee_ids"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<ProjectMemberDropdown
|
||||
<MemberDropdown
|
||||
projectId={projectId}
|
||||
value={value}
|
||||
onChange={(assigneeIds) => {
|
||||
|
@ -54,7 +54,6 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
||||
const {
|
||||
router: { workspaceSlug, projectId, cycleId, moduleId, viewId: projectViewId },
|
||||
} = useApplication();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { workspaceProjectIds } = useProject();
|
||||
const { fetchCycleDetails } = useCycle();
|
||||
const { fetchModuleDetails } = useModule();
|
||||
|
@ -14,13 +14,7 @@ import {
|
||||
TIssueOperations,
|
||||
IssueRelationSelect,
|
||||
} from "components/issues";
|
||||
import {
|
||||
DateDropdown,
|
||||
EstimateDropdown,
|
||||
PriorityDropdown,
|
||||
ProjectMemberDropdown,
|
||||
StateDropdown,
|
||||
} from "components/dropdowns";
|
||||
import { DateDropdown, EstimateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "components/dropdowns";
|
||||
// components
|
||||
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||
// helpers
|
||||
@ -87,7 +81,7 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
<UserGroupIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Assignees</span>
|
||||
</div>
|
||||
<ProjectMemberDropdown
|
||||
<MemberDropdown
|
||||
value={issue?.assignee_ids ?? undefined}
|
||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
|
||||
disabled={disabled}
|
||||
|
@ -2,7 +2,7 @@ import React from "react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "hooks/store";
|
||||
// components
|
||||
import { PriorityDropdown, ProjectMemberDropdown, StateDropdown } from "components/dropdowns";
|
||||
import { PriorityDropdown, MemberDropdown, StateDropdown } from "components/dropdowns";
|
||||
// types
|
||||
import { TSubIssueOperations } from "./root";
|
||||
|
||||
@ -62,7 +62,7 @@ export const IssueProperty: React.FC<IIssueProperty> = (props) => {
|
||||
</div>
|
||||
|
||||
<div className="h-5 flex-shrink-0">
|
||||
<ProjectMemberDropdown
|
||||
<MemberDropdown
|
||||
value={issue.assignee_ids}
|
||||
projectId={issue.project_id}
|
||||
onChange={(val) =>
|
||||
|
@ -1,7 +1,6 @@
|
||||
export * from "./create-label-modal";
|
||||
export * from "./create-update-label-inline";
|
||||
export * from "./delete-label-modal";
|
||||
export * from "./labels-list-modal";
|
||||
export * from "./project-setting-label-group";
|
||||
export * from "./project-setting-label-item";
|
||||
export * from "./project-setting-label-list";
|
||||
|
@ -1,157 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
import { Search } from "lucide-react";
|
||||
// hooks
|
||||
import { useLabel } from "hooks/store";
|
||||
// icons
|
||||
import { LayerStackIcon } from "@plane/ui";
|
||||
// types
|
||||
import { IIssueLabel } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
parent: IIssueLabel | undefined;
|
||||
};
|
||||
|
||||
export const LabelsListModal: React.FC<Props> = observer((props) => {
|
||||
const { isOpen, handleClose, parent } = props;
|
||||
// states
|
||||
const [query, setQuery] = useState("");
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// store hooks
|
||||
const { projectLabels, fetchProjectLabels, updateLabel } = useLabel();
|
||||
|
||||
// api call to fetch project details
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? "PROJECT_LABELS" : null,
|
||||
workspaceSlug && projectId ? () => fetchProjectLabels(workspaceSlug.toString(), projectId.toString()) : null
|
||||
);
|
||||
|
||||
// derived values
|
||||
const filteredLabels: IIssueLabel[] =
|
||||
query === ""
|
||||
? projectLabels ?? []
|
||||
: projectLabels?.filter((l) => l.name.toLowerCase().includes(query.toLowerCase())) ?? [];
|
||||
|
||||
const handleModalClose = () => {
|
||||
handleClose();
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
const addChildLabel = async (label: IIssueLabel) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
await updateLabel(workspaceSlug.toString(), projectId.toString(), label.id, {
|
||||
parent: parent?.id!,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleModalClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto p-4 sm:p-6 md:p-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform rounded-lg bg-custom-background-100 shadow-custom-shadow-md transition-all">
|
||||
<Combobox>
|
||||
<div className="relative m-1">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-4 top-3.5 h-5 w-5 text-custom-text-100 text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Combobox.Input
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-custom-text-100 outline-none focus:ring-0 sm:text-sm"
|
||||
placeholder="Search..."
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Combobox.Options static className="max-h-80 scroll-py-2 overflow-y-auto">
|
||||
{filteredLabels.length > 0 && (
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mb-2 mt-4 px-3 text-xs font-semibold text-custom-text-100">Labels</h2>
|
||||
)}
|
||||
<ul className="text-sm text-gray-700">
|
||||
{filteredLabels.map((label) => {
|
||||
const children = projectLabels?.filter((l) => l.parent === label.id);
|
||||
|
||||
if (
|
||||
(label.parent === "" || label.parent === null) && // issue does not have any other parent
|
||||
label.id !== parent?.id && // issue is not itself
|
||||
children?.length === 0 // issue doesn't have any other children
|
||||
)
|
||||
return (
|
||||
<Combobox.Option
|
||||
key={label.id}
|
||||
value={{
|
||||
name: label.name,
|
||||
}}
|
||||
className={({ active }) =>
|
||||
`flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-custom-text-200 ${
|
||||
active ? "bg-custom-background-80 text-custom-text-100" : ""
|
||||
}`
|
||||
}
|
||||
onClick={() => {
|
||||
addChildLabel(label);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color !== "" ? label.color : "#000000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</Combobox.Option>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
|
||||
{query !== "" && filteredLabels.length === 0 && (
|
||||
<div className="px-6 py-14 text-center sm:px-14">
|
||||
<LayerStackIcon
|
||||
className="mx-auto h-6 w-6 text-custom-text-100 text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="mt-4 text-sm text-custom-text-100">
|
||||
We couldn{"'"}t find any label with that term. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Combobox>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
});
|
@ -2,7 +2,7 @@ import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// components
|
||||
import { ModuleStatusSelect } from "components/modules";
|
||||
import { DateRangeDropdown, ProjectDropdown, ProjectMemberDropdown } from "components/dropdowns";
|
||||
import { DateRangeDropdown, ProjectDropdown, MemberDropdown } from "components/dropdowns";
|
||||
// ui
|
||||
import { Button, Input, TextArea } from "@plane/ui";
|
||||
// helpers
|
||||
@ -175,7 +175,7 @@ export const ModuleForm: React.FC<Props> = (props) => {
|
||||
name="lead_id"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<ProjectMemberDropdown
|
||||
<MemberDropdown
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
projectId={projectId}
|
||||
@ -192,7 +192,7 @@ export const ModuleForm: React.FC<Props> = (props) => {
|
||||
name="member_ids"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<ProjectMemberDropdown
|
||||
<MemberDropdown
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
projectId={projectId}
|
||||
|
@ -21,7 +21,7 @@ import useToast from "hooks/use-toast";
|
||||
import { LinkModal, LinksList, SidebarProgressStats } from "components/core";
|
||||
import { DeleteModuleModal } from "components/modules";
|
||||
import ProgressChart from "components/core/sidebar/progress-chart";
|
||||
import { DateRangeDropdown, ProjectMemberDropdown } from "components/dropdowns";
|
||||
import { DateRangeDropdown, MemberDropdown } from "components/dropdowns";
|
||||
// ui
|
||||
import { CustomMenu, Loader, LayersIcon, CustomSelect, ModuleStatusIcon, UserGroupIcon } from "@plane/ui";
|
||||
// helpers
|
||||
@ -385,7 +385,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
name="lead_id"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="w-3/5 h-7">
|
||||
<ProjectMemberDropdown
|
||||
<MemberDropdown
|
||||
value={value ?? null}
|
||||
onChange={(val) => {
|
||||
submitChanges({ lead_id: val });
|
||||
@ -409,7 +409,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
name="member_ids"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="w-3/5 h-7">
|
||||
<ProjectMemberDropdown
|
||||
<MemberDropdown
|
||||
value={value ?? []}
|
||||
onChange={(val: string[]) => {
|
||||
submitChanges({ member_ids: val });
|
||||
|
@ -51,7 +51,8 @@ export const ProfileIssuesPage = observer((props: IProfileIssuesPage) => {
|
||||
await fetchFilters(workspaceSlug, userId);
|
||||
await fetchIssues(workspaceSlug, undefined, groupedIssueIds ? "mutation" : "init-loader", userId, type);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light";
|
||||
|
@ -11,7 +11,7 @@ import { Button, CustomSelect, Input, TextArea } from "@plane/ui";
|
||||
// components
|
||||
import { ImagePickerPopover } from "components/core";
|
||||
import EmojiIconPicker from "components/emoji-icon-picker";
|
||||
import { WorkspaceMemberDropdown } from "components/dropdowns";
|
||||
import { MemberDropdown } from "components/dropdowns";
|
||||
// helpers
|
||||
import { getRandomEmoji, renderEmoji } from "helpers/emoji.helper";
|
||||
// constants
|
||||
@ -383,7 +383,7 @@ export const CreateProjectModal: FC<Props> = observer((props) => {
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7 flex-shrink-0" tabIndex={5}>
|
||||
<WorkspaceMemberDropdown
|
||||
<MemberDropdown
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="Lead"
|
||||
|
@ -1,7 +1,7 @@
|
||||
export const SWR_CONFIG = {
|
||||
refreshWhenHidden: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: true,
|
||||
revalidateOnFocus: true,
|
||||
revalidateOnMount: true,
|
||||
refreshInterval: 600000,
|
||||
errorRetryCount: 3,
|
||||
|
@ -15,30 +15,35 @@ export const useWorkspaceIssueProperties = (workspaceSlug: string | string[] | u
|
||||
// fetch workspace Modules
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_MODULES_${workspaceSlug}` : null,
|
||||
workspaceSlug ? () => fetchWorkspaceModules(workspaceSlug.toString()) : null
|
||||
workspaceSlug ? () => fetchWorkspaceModules(workspaceSlug.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
// fetch workspace Cycles
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_CYCLES_${workspaceSlug}` : null,
|
||||
workspaceSlug ? () => fetchWorkspaceCycles(workspaceSlug.toString()) : null
|
||||
workspaceSlug ? () => fetchWorkspaceCycles(workspaceSlug.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
// fetch workspace labels
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_LABELS_${workspaceSlug}` : null,
|
||||
workspaceSlug ? () => fetchWorkspaceLabels(workspaceSlug.toString()) : null
|
||||
workspaceSlug ? () => fetchWorkspaceLabels(workspaceSlug.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
// fetch workspace states
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_STATES_${workspaceSlug}` : null,
|
||||
workspaceSlug ? () => fetchWorkspaceStates(workspaceSlug.toString()) : null
|
||||
workspaceSlug ? () => fetchWorkspaceStates(workspaceSlug.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
// fetch workspace estimates
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_ESTIMATES_${workspaceSlug}` : null,
|
||||
workspaceSlug ? () => fetchWorkspaceEstimates(workspaceSlug.toString()) : null
|
||||
workspaceSlug ? () => fetchWorkspaceEstimates(workspaceSlug.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
};
|
||||
|
@ -33,7 +33,7 @@ export const AppLayout: FC<IAppLayout> = observer((props) => {
|
||||
// await issues?.fetchIssues(workspaceSlug, projectId, issues?.groupedIssueIds ? "mutation" : "init-loader");
|
||||
}
|
||||
},
|
||||
{ revalidateOnFocus: false, refreshInterval: 600000, revalidateOnMount: true }
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -66,37 +66,44 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
|
||||
// fetching project labels
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_LABELS_${workspaceSlug}_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchProjectLabels(workspaceSlug.toString(), projectId.toString()) : null
|
||||
workspaceSlug && projectId ? () => fetchProjectLabels(workspaceSlug.toString(), projectId.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
// fetching project members
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_MEMBERS_${workspaceSlug}_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchProjectMembers(workspaceSlug.toString(), projectId.toString()) : null
|
||||
workspaceSlug && projectId ? () => fetchProjectMembers(workspaceSlug.toString(), projectId.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
// fetching project states
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_STATES_${workspaceSlug}_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchProjectStates(workspaceSlug.toString(), projectId.toString()) : null
|
||||
workspaceSlug && projectId ? () => fetchProjectStates(workspaceSlug.toString(), projectId.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
// fetching project estimates
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_ESTIMATES_${workspaceSlug}_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchProjectEstimates(workspaceSlug.toString(), projectId.toString()) : null
|
||||
workspaceSlug && projectId ? () => fetchProjectEstimates(workspaceSlug.toString(), projectId.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
// fetching project cycles
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_ALL_CYCLES_${workspaceSlug}_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchAllCycles(workspaceSlug.toString(), projectId.toString()) : null
|
||||
workspaceSlug && projectId ? () => fetchAllCycles(workspaceSlug.toString(), projectId.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
// fetching project modules
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_MODULES_${workspaceSlug}_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchModules(workspaceSlug.toString(), projectId.toString()) : null
|
||||
workspaceSlug && projectId ? () => fetchModules(workspaceSlug.toString(), projectId.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
// fetching project views
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_VIEWS_${workspaceSlug}_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchViews(workspaceSlug.toString(), projectId.toString()) : null
|
||||
workspaceSlug && projectId ? () => fetchViews(workspaceSlug.toString(), projectId.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
// fetching project inboxes if inbox is enabled in project settings
|
||||
useSWR(
|
||||
|
@ -26,22 +26,26 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
|
||||
// fetching user workspace information
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_MEMBERS_ME_${workspaceSlug}` : null,
|
||||
workspaceSlug ? () => membership.fetchUserWorkspaceInfo(workspaceSlug.toString()) : null
|
||||
workspaceSlug ? () => membership.fetchUserWorkspaceInfo(workspaceSlug.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
// fetching workspace projects
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_PROJECTS_${workspaceSlug}` : null,
|
||||
workspaceSlug ? () => fetchProjects(workspaceSlug.toString()) : null
|
||||
workspaceSlug ? () => fetchProjects(workspaceSlug.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
// fetch workspace members
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_MEMBERS_${workspaceSlug}` : null,
|
||||
workspaceSlug ? () => fetchWorkspaceMembers(workspaceSlug.toString()) : null
|
||||
workspaceSlug ? () => fetchWorkspaceMembers(workspaceSlug.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
// fetch workspace user projects role
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_PROJECTS_ROLE_${workspaceSlug}` : null,
|
||||
workspaceSlug ? () => membership.fetchUserWorkspaceProjectsRole(workspaceSlug.toString()) : null
|
||||
workspaceSlug ? () => membership.fetchUserWorkspaceProjectsRole(workspaceSlug.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
// while data is being loaded
|
||||
|
@ -28,6 +28,7 @@ export interface ICycleStore {
|
||||
currentProjectActiveCycleId: string | null;
|
||||
// computed actions
|
||||
getCycleById: (cycleId: string) => ICycle | null;
|
||||
getCycleNameById: (cycleId: string) => string | undefined;
|
||||
getActiveCycleById: (cycleId: string) => ICycle | null;
|
||||
getProjectCycleIds: (projectId: string) => string[] | null;
|
||||
// actions
|
||||
@ -189,6 +190,13 @@ export class CycleStore implements ICycleStore {
|
||||
*/
|
||||
getCycleById = computedFn((cycleId: string): ICycle | null => this.cycleMap?.[cycleId] ?? null);
|
||||
|
||||
/**
|
||||
* @description returns cycle name by cycle id
|
||||
* @param cycleId
|
||||
* @returns
|
||||
*/
|
||||
getCycleNameById = computedFn((cycleId: string): string => this.cycleMap?.[cycleId]?.name);
|
||||
|
||||
/**
|
||||
* @description returns active cycle details by cycle id
|
||||
* @param cycleId
|
||||
|
@ -19,6 +19,7 @@ export interface IModuleStore {
|
||||
projectModuleIds: string[] | null;
|
||||
// computed actions
|
||||
getModuleById: (moduleId: string) => IModule | null;
|
||||
getModuleNameById: (moduleId: string) => string;
|
||||
getProjectModuleIds: (projectId: string) => string[] | null;
|
||||
// actions
|
||||
// fetch
|
||||
@ -114,6 +115,13 @@ export class ModulesStore implements IModuleStore {
|
||||
*/
|
||||
getModuleById = computedFn((moduleId: string) => this.moduleMap?.[moduleId] || null);
|
||||
|
||||
/**
|
||||
* @description get module by id
|
||||
* @param moduleId
|
||||
* @returns IModule | null
|
||||
*/
|
||||
getModuleNameById = computedFn((moduleId: string) => this.moduleMap?.[moduleId]?.name);
|
||||
|
||||
/**
|
||||
* @description returns list of module ids of the project id passed as argument
|
||||
* @param projectId
|
||||
|
@ -24,6 +24,7 @@ export interface IProjectStore {
|
||||
// actions
|
||||
setSearchQuery: (query: string) => void;
|
||||
getProjectById: (projectId: string) => IProject | null;
|
||||
getProjectIdentifierById: (projectId: string) => string;
|
||||
// fetch actions
|
||||
fetchProjects: (workspaceSlug: string) => Promise<IProject[]>;
|
||||
fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise<any>;
|
||||
@ -210,6 +211,16 @@ export class ProjectStore implements IProjectStore {
|
||||
return projectInfo;
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns project identifier using project id
|
||||
* @param projectId
|
||||
* @returns string
|
||||
*/
|
||||
getProjectIdentifierById = computedFn((projectId: string) => {
|
||||
const projectInfo = this.projectMap?.[projectId];
|
||||
return projectInfo?.identifier;
|
||||
});
|
||||
|
||||
/**
|
||||
* Adds project to favorites and updates project favorite status in the store
|
||||
* @param workspaceSlug
|
||||
|
Loading…
Reference in New Issue
Block a user