chore: components restructuring and UI improvements. (#5285)
* chore: components restructuring and minor UI improvements. * chore: minor UI improvements fro icons and member dropdown. * chore: update issue identifier. * chore: rename `Issue Extra Property` to `Issue Additional Property` * chore: fix popovers placement issue on components with overflow. * chore: add `scrollbar-xs` * chore: add `xs` size for input and textarea components. * chore: update `sortable` to return back `movedItem` in the onChange callback. * chore: minor UI adjustments for radio-select. * chore: update outside click delay to 1ms.
This commit is contained in:
parent
07574b4222
commit
333a989b1a
67 changed files with 824 additions and 569 deletions
9
packages/types/src/issues/base.d.ts
vendored
9
packages/types/src/issues/base.d.ts
vendored
|
|
@ -10,7 +10,12 @@ export * from "./issue_relation";
|
|||
export * from "./issue_sub_issues";
|
||||
export * from "./activity/base";
|
||||
|
||||
export type TLoader = "init-loader" | "mutation" | "pagination" | undefined;
|
||||
export type TLoader =
|
||||
| "init-loader"
|
||||
| "mutation"
|
||||
| "pagination"
|
||||
| "loaded"
|
||||
| undefined;
|
||||
|
||||
export type TGroupedIssues = {
|
||||
[group_id: string]: string[];
|
||||
|
|
@ -36,4 +41,4 @@ export type TGroupedIssueCount = {
|
|||
[group_id: string]: number;
|
||||
};
|
||||
|
||||
export type TUnGroupedIssues = string[];
|
||||
export type TUnGroupedIssues = string[];
|
||||
|
|
|
|||
3
packages/types/src/issues/issue.d.ts
vendored
3
packages/types/src/issues/issue.d.ts
vendored
|
|
@ -25,6 +25,7 @@ export type TBaseIssue = {
|
|||
parent_id: string | null;
|
||||
cycle_id: string | null;
|
||||
module_ids: string[] | null;
|
||||
type_id: string | null;
|
||||
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
|
@ -48,6 +49,8 @@ export type TIssue = TBaseIssue & {
|
|||
issue_link?: TIssueLink[];
|
||||
// tempId is used for optimistic updates. It is not a part of the API response.
|
||||
tempId?: string;
|
||||
// sourceIssueId is used to store the original issue id when creating a copy of an issue. Used in cloning property values. It is not a part of the API response.
|
||||
sourceIssueId?: string;
|
||||
};
|
||||
|
||||
export type TIssueMap = {
|
||||
|
|
|
|||
2
packages/types/src/project/projects.d.ts
vendored
2
packages/types/src/project/projects.d.ts
vendored
|
|
@ -34,6 +34,7 @@ export interface IProject {
|
|||
identifier: string;
|
||||
anchor: string | null;
|
||||
is_favorite: boolean;
|
||||
is_issue_type_enabled: boolean;
|
||||
is_member: boolean;
|
||||
is_time_tracking_enabled: boolean;
|
||||
logo_props: TLogoProps;
|
||||
|
|
@ -58,6 +59,7 @@ export interface IProjectLite {
|
|||
id: string;
|
||||
name: string;
|
||||
identifier: string;
|
||||
logo_props: TLogoProps;
|
||||
}
|
||||
|
||||
type ProjectPreferences = {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Disclosure, Transition } from "@headlessui/react";
|
|||
export type TCollapsibleProps = {
|
||||
title: string | React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
buttonRef?: React.RefObject<HTMLButtonElement>;
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
isOpen?: boolean;
|
||||
|
|
@ -12,7 +13,7 @@ export type TCollapsibleProps = {
|
|||
};
|
||||
|
||||
export const Collapsible: FC<TCollapsibleProps> = (props) => {
|
||||
const { title, children, className, buttonClassName, isOpen, onToggle, defaultOpen } = props;
|
||||
const { title, children, buttonRef, className, buttonClassName, isOpen, onToggle, defaultOpen } = props;
|
||||
// state
|
||||
const [localIsOpen, setLocalIsOpen] = useState<boolean>(isOpen || defaultOpen ? true : false);
|
||||
|
||||
|
|
@ -33,7 +34,7 @@ export const Collapsible: FC<TCollapsibleProps> = (props) => {
|
|||
|
||||
return (
|
||||
<Disclosure as="div" className={className}>
|
||||
<Disclosure.Button className={buttonClassName} onClick={handleOnClick}>
|
||||
<Disclosure.Button ref={buttonRef} className={buttonClassName} onClick={handleOnClick}>
|
||||
{title}
|
||||
</Disclosure.Button>
|
||||
<Transition
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useRef, useState } from "react";
|
|||
import { usePopper } from "react-popper";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
import { Check, ChevronDown, Search } from "lucide-react";
|
||||
import { createPortal } from "react-dom";
|
||||
// hooks
|
||||
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
|
||||
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
|
||||
|
|
@ -15,6 +16,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
|
|||
customButtonClassName = "",
|
||||
buttonClassName = "",
|
||||
className = "",
|
||||
chevronClassName = "",
|
||||
customButton,
|
||||
placement,
|
||||
disabled = false,
|
||||
|
|
@ -59,10 +61,12 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
|
|||
setIsOpen(true);
|
||||
if (referenceElement) referenceElement.focus();
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
setIsOpen(false);
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||
useOutsideClickDetector(dropdownRef, closeDropdown);
|
||||
|
||||
|
|
@ -105,86 +109,93 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
|
|||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 ${
|
||||
input ? "px-3 py-2 text-sm" : "px-2 py-1 text-xs"
|
||||
} ${
|
||||
disabled
|
||||
? "cursor-not-allowed text-custom-text-200"
|
||||
: "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${buttonClassName}`}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300",
|
||||
{
|
||||
"px-3 py-2 text-sm": input,
|
||||
"px-2 py-1 text-xs": !input,
|
||||
"cursor-not-allowed text-custom-text-200": disabled,
|
||||
"cursor-pointer hover:bg-custom-background-80": !disabled,
|
||||
},
|
||||
buttonClassName
|
||||
)}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
{label}
|
||||
{!noChevron && !disabled && <ChevronDown className="h-3 w-3 flex-shrink-0" aria-hidden="true" />}
|
||||
{!noChevron && !disabled && (
|
||||
<ChevronDown className={cn("h-3 w-3 flex-shrink-0", chevronClassName)} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</Combobox.Button>
|
||||
)}
|
||||
{isOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<div
|
||||
className={cn(
|
||||
"my-1 overflow-y-scroll rounded-md 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 min-w-[12rem] whitespace-nowrap",
|
||||
optionsClassName
|
||||
)}
|
||||
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
|
||||
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>
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Combobox.Options data-prevent-outside-click static>
|
||||
<div
|
||||
className={cn("mt-2 space-y-1 overflow-y-scroll", {
|
||||
"max-h-60": maxHeight === "lg",
|
||||
"max-h-48": maxHeight === "md",
|
||||
"max-h-36": maxHeight === "rg",
|
||||
"max-h-28": maxHeight === "sm",
|
||||
})}
|
||||
>
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active }) =>
|
||||
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,
|
||||
}
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
if (!multiple) closeDropdown();
|
||||
}}
|
||||
>
|
||||
{({ 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>
|
||||
className={cn(
|
||||
"my-1 overflow-y-scroll rounded-md 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 min-w-48 whitespace-nowrap z-20",
|
||||
optionsClassName
|
||||
)}
|
||||
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
|
||||
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={cn("mt-2 space-y-1 overflow-y-scroll", {
|
||||
"max-h-60": maxHeight === "lg",
|
||||
"max-h-48": maxHeight === "md",
|
||||
"max-h-36": maxHeight === "rg",
|
||||
"max-h-28": maxHeight === "sm",
|
||||
})}
|
||||
>
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active }) =>
|
||||
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,
|
||||
}
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
if (!multiple) closeDropdown();
|
||||
}}
|
||||
>
|
||||
{({ 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>
|
||||
{footerOption}
|
||||
</div>
|
||||
{footerOption}
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
)}
|
||||
</Combobox.Options>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export interface IDropdownProps {
|
|||
label?: string | JSX.Element;
|
||||
maxHeight?: "sm" | "rg" | "md" | "lg";
|
||||
noChevron?: boolean;
|
||||
chevronClassName?: string;
|
||||
onOpen?: () => void;
|
||||
optionsClassName?: string;
|
||||
placement?: Placement;
|
||||
|
|
|
|||
|
|
@ -149,6 +149,9 @@ import {
|
|||
Minus,
|
||||
MinusCircle,
|
||||
MinusSquare,
|
||||
CircleChevronDown,
|
||||
UsersRound,
|
||||
ToggleLeft,
|
||||
} from "lucide-react";
|
||||
|
||||
export const MATERIAL_ICONS_LIST = [
|
||||
|
|
@ -791,6 +794,7 @@ export const LUCIDE_ICONS_LIST = [
|
|||
{ name: "Camera", element: Camera },
|
||||
{ name: "CameraOff", element: CameraOff },
|
||||
{ name: "Cast", element: Cast },
|
||||
{ name: "CircleChevronDown", element: CircleChevronDown },
|
||||
{ name: "Check", element: Check },
|
||||
{ name: "CheckCircle", element: CheckCircle },
|
||||
{ name: "CheckSquare", element: CheckSquare },
|
||||
|
|
@ -908,4 +912,6 @@ export const LUCIDE_ICONS_LIST = [
|
|||
{ name: "Minus", element: Minus },
|
||||
{ name: "MinusCircle", element: MinusCircle },
|
||||
{ name: "MinusSquare", element: MinusSquare },
|
||||
{ name: "ToggleLeft", element: ToggleLeft },
|
||||
{ name: "UsersRound", element: UsersRound },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import useFontFaceObserver from "use-font-face-observer";
|
|||
import { LUCIDE_ICONS_LIST } from "./icons";
|
||||
// helpers
|
||||
import { emojiCodeToUnicode } from "./helpers";
|
||||
import { cn } from "../../helpers";
|
||||
|
||||
type TLogoProps = {
|
||||
in_use: "emoji" | "icon";
|
||||
|
|
@ -22,10 +23,11 @@ type Props = {
|
|||
logo: TLogoProps;
|
||||
size?: number;
|
||||
type?: "lucide" | "material";
|
||||
customColor?: string;
|
||||
};
|
||||
|
||||
export const Logo: FC<Props> = (props) => {
|
||||
const { logo, size = 16, type = "material" } = props;
|
||||
const { logo, size = 16, customColor, type = "material" } = props;
|
||||
|
||||
// destructuring the logo object
|
||||
const { in_use, emoji, icon } = logo;
|
||||
|
|
@ -72,19 +74,20 @@ export const Logo: FC<Props> = (props) => {
|
|||
{lucideIcon && (
|
||||
<lucideIcon.element
|
||||
style={{
|
||||
color: color,
|
||||
color: !customColor ? color : undefined,
|
||||
height: size,
|
||||
width: size,
|
||||
}}
|
||||
className={cn(customColor)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
className="material-symbols-rounded"
|
||||
className={cn("material-symbols-rounded", customColor)}
|
||||
style={{
|
||||
fontSize: size,
|
||||
color: color,
|
||||
color: !customColor ? color : undefined,
|
||||
scale: "115%",
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { cn } from "../../helpers";
|
|||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
mode?: "primary" | "transparent" | "true-transparent";
|
||||
inputSize?: "sm" | "md";
|
||||
inputSize?: "xs" | "sm" | "md";
|
||||
hasError?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
|
|||
mode === "transparent",
|
||||
"rounded border-none bg-transparent ring-0": mode === "true-transparent",
|
||||
"border-red-500": hasError,
|
||||
"px-1.5 py-1": inputSize === "xs",
|
||||
"px-3 py-2": inputSize === "sm",
|
||||
"p-3": inputSize === "md",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,12 +6,22 @@ import { useAutoResizeTextArea } from "../hooks/use-auto-resize-textarea";
|
|||
|
||||
export interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
mode?: "primary" | "transparent";
|
||||
textAreaSize?: "xs" | "sm" | "md";
|
||||
hasError?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>((props, ref) => {
|
||||
const { id, name, value = "", mode = "primary", hasError = false, className = "", ...rest } = props;
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
value = "",
|
||||
mode = "primary",
|
||||
textAreaSize = "sm",
|
||||
hasError = false,
|
||||
className = "",
|
||||
...rest
|
||||
} = props;
|
||||
// refs
|
||||
const textAreaRef = useRef<any>(ref);
|
||||
// auto re-size
|
||||
|
|
@ -24,11 +34,14 @@ const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>((props, re
|
|||
ref={textAreaRef}
|
||||
value={value}
|
||||
className={cn(
|
||||
"no-scrollbar w-full bg-transparent px-3 py-2 placeholder-custom-text-400 outline-none",
|
||||
"no-scrollbar w-full bg-transparent placeholder-custom-text-400 outline-none",
|
||||
{
|
||||
"rounded-md border-[0.5px] border-custom-border-200": mode === "primary",
|
||||
"focus:ring-theme rounded border-none bg-transparent ring-0 transition-all focus:ring-1":
|
||||
mode === "transparent",
|
||||
"px-1.5 py-1": textAreaSize === "xs",
|
||||
"px-3 py-2": textAreaSize === "sm",
|
||||
"p-3": textAreaSize === "md",
|
||||
"border-red-500": hasError,
|
||||
"bg-red-100": hasError && mode === "primary",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,31 @@
|
|||
import React, { useEffect } from "react";
|
||||
|
||||
// TODO: move it to helpers package
|
||||
const useOutsideClickDetector = (ref: React.RefObject<HTMLElement>, callback: () => void) => {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
// get all the element with attribute name data-prevent-outside-click
|
||||
const preventOutsideClickElements = document.querySelectorAll("[data-prevent-outside-click]");
|
||||
// check if the click target is any of the elements with attribute name data-prevent-outside-click
|
||||
for (let i = 0; i < preventOutsideClickElements.length; i++) {
|
||||
if (preventOutsideClickElements[i].contains(event.target as Node)) {
|
||||
// if the click target is any of the elements with attribute name data-prevent-outside-click, return
|
||||
return;
|
||||
}
|
||||
}
|
||||
// get all the element with attribute name data-delay-outside-click
|
||||
const delayOutsideClickElements = document.querySelectorAll("[data-delay-outside-click]");
|
||||
// check if the click target is any of the elements with attribute name data-delay-outside-click
|
||||
for (let i = 0; i < delayOutsideClickElements.length; i++) {
|
||||
if (delayOutsideClickElements[i].contains(event.target as Node)) {
|
||||
// if the click target is any of the elements with attribute name data-delay-outside-click, delay the callback
|
||||
setTimeout(() => {
|
||||
callback();
|
||||
}, 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// else, call the callback immediately
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Draggable } from "./draggable";
|
|||
type Props<T> = {
|
||||
data: T[];
|
||||
render: (item: T, index: number) => React.ReactNode;
|
||||
onChange: (data: T[]) => void;
|
||||
onChange: (data: T[], movedItem?: T) => void;
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
containerClassName?: string;
|
||||
id?: string;
|
||||
|
|
@ -16,13 +16,16 @@ const moveItem = <T,>(
|
|||
source: T,
|
||||
destination: T & Record<symbol, string>,
|
||||
keyExtractor: (item: T, index: number) => string
|
||||
) => {
|
||||
): {
|
||||
newData: T[];
|
||||
movedItem: T | undefined;
|
||||
} => {
|
||||
const sourceIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(source, 0));
|
||||
if (sourceIndex === -1) return data;
|
||||
if (sourceIndex === -1) return { newData: data, movedItem: undefined };
|
||||
|
||||
const destinationIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(destination, 0));
|
||||
|
||||
if (destinationIndex === -1) return data;
|
||||
if (destinationIndex === -1) return { newData: data, movedItem: undefined };
|
||||
|
||||
const symbolKey = Reflect.ownKeys(destination).find((key) => key.toString() === "Symbol(closestEdge)");
|
||||
const position = symbolKey ? destination[symbolKey as symbol] : "bottom"; // Add 'as symbol' to cast symbolKey to symbol
|
||||
|
|
@ -41,7 +44,7 @@ const moveItem = <T,>(
|
|||
|
||||
newData.splice(adjustedDestinationIndex, 0, movedItem);
|
||||
|
||||
return newData;
|
||||
return { newData, movedItem };
|
||||
};
|
||||
|
||||
export const Sortable = <T,>({ data, render, onChange, keyExtractor, containerClassName, id }: Props<T>) => {
|
||||
|
|
@ -50,7 +53,13 @@ export const Sortable = <T,>({ data, render, onChange, keyExtractor, containerCl
|
|||
onDrop({ source, location }) {
|
||||
const destination = location?.current?.dropTargets[0];
|
||||
if (!destination) return;
|
||||
onChange(moveItem(data, source.data as T, destination.data as T & { closestEdge: string }, keyExtractor));
|
||||
const { newData, movedItem } = moveItem(
|
||||
data,
|
||||
source.data as T,
|
||||
destination.data as T & { closestEdge: string },
|
||||
keyExtractor
|
||||
);
|
||||
onChange(newData, movedItem);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export const setToast = (props: SetToastProps) => {
|
|||
borderColorClassName,
|
||||
}: ToastContentProps) =>
|
||||
props.type === TOAST_TYPE.LOADING ? (
|
||||
<div className="flex items-center h-[98px] w-[350px]">
|
||||
<div className="flex items-center h-[98px] w-[350px]" data-prevent-outside-click>
|
||||
<div
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -96,6 +96,7 @@ export const setToast = (props: SetToastProps) => {
|
|||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-prevent-outside-click
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ import { Breadcrumbs, CustomMenu } from "@plane/ui";
|
|||
// components
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
// constants
|
||||
import { EUserProjectRoles, PROJECT_SETTINGS_LINKS } from "@/constants/project";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// hooks
|
||||
import { useProject, useUser } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web constants
|
||||
import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project";
|
||||
|
||||
export const ProjectSettingHeader: FC = observer(() => {
|
||||
// router
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ import { Loader } from "@plane/ui";
|
|||
// components
|
||||
import { SidebarNavItem } from "@/components/sidebar";
|
||||
// constants
|
||||
import { EUserProjectRoles, PROJECT_SETTINGS_LINKS } from "@/constants/project";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
// plane web constants
|
||||
import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project";
|
||||
|
||||
export const ProjectSettingsSidebar = () => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
|
|
|
|||
|
|
@ -387,7 +387,6 @@ const ProfileSettingsPage = observer(() => {
|
|||
label={value ? TIME_ZONES.find((t) => t.value === value)?.label ?? value : "Select a timezone"}
|
||||
options={timeZoneOptions}
|
||||
onChange={onChange}
|
||||
optionsClassName="w-full"
|
||||
buttonClassName={errors.user_timezone ? "border-red-500" : "border-none"}
|
||||
className="rounded-md border-[0.5px] !border-custom-border-200"
|
||||
input
|
||||
|
|
|
|||
1
web/ce/components/issue-types/index.ts
Normal file
1
web/ce/components/issue-types/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./values";
|
||||
1
web/ce/components/issue-types/values/index.ts
Normal file
1
web/ce/components/issue-types/values/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./update";
|
||||
8
web/ce/components/issue-types/values/update.tsx
Normal file
8
web/ce/components/issue-types/values/update.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
type TIssueAdditionalPropertyValuesUpdateProps = {
|
||||
issueId: string;
|
||||
issueTypeId: string;
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const IssueAdditionalPropertyValuesUpdate: React.FC<TIssueAdditionalPropertyValuesUpdateProps> = () => <></>;
|
||||
|
|
@ -1,2 +1,4 @@
|
|||
export * from "./bulk-operations";
|
||||
export * from "./worklog";
|
||||
export * from "./issue-modal";
|
||||
export * from "./issue-details";
|
||||
|
|
|
|||
1
web/ce/components/issues/issue-details/index.ts
Normal file
1
web/ce/components/issues/issue-details/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./issue-identifier";
|
||||
28
web/ce/components/issues/issue-details/issue-identifier.tsx
Normal file
28
web/ce/components/issues/issue-details/issue-identifier.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useIssueDetail, useProject } from "@/hooks/store";
|
||||
|
||||
type TIssueIdentifierProps = {
|
||||
issueId: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const IssueIdentifier: React.FC<TIssueIdentifierProps> = observer((props) => {
|
||||
const { issueId, projectId } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
const projectDetails = getProjectById(projectId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-base font-medium text-custom-text-300">
|
||||
{projectDetails?.identifier}-{issue?.sequence_id}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -8,9 +8,9 @@ import type { TIssue } from "@plane/types";
|
|||
// hooks
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { ConfirmIssueDiscard } from "@/components/issues";
|
||||
import { IssueFormRoot } from "@/components/issues/issue-modal/form";
|
||||
import { isEmptyHtmlString } from "@/helpers/string.helper";
|
||||
import { useEventTracker } from "@/hooks/store";
|
||||
import { IssueFormRoot } from "@/plane-web/components/issues/issue-modal/form";
|
||||
// services
|
||||
import { IssueDraftService } from "@/services/issue";
|
||||
|
||||
3
web/ce/components/issues/issue-modal/index.ts
Normal file
3
web/ce/components/issues/issue-modal/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./form";
|
||||
export * from "./draft-issue-layout";
|
||||
export * from "./modal";
|
||||
323
web/ce/components/issues/issue-modal/modal.tsx
Normal file
323
web/ce/components/issues/issue-modal/modal.tsx
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
// types
|
||||
import type { TIssue } from "@plane/types";
|
||||
// ui
|
||||
import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { CreateIssueToastActionItems } from "@/components/issues";
|
||||
// constants
|
||||
import { ISSUE_CREATED, ISSUE_UPDATED } from "@/constants/event-tracker";
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
// hooks
|
||||
import { useEventTracker, useCycle, useIssues, useModule, useProject, useIssueDetail } from "@/hooks/store";
|
||||
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
// components
|
||||
import { DraftIssueLayout } from "./draft-issue-layout";
|
||||
import { IssueFormRoot } from "./form";
|
||||
|
||||
export interface IssuesModalProps {
|
||||
data?: Partial<TIssue>;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit?: (res: TIssue) => Promise<void>;
|
||||
withDraftIssueWrapper?: boolean;
|
||||
storeType?: EIssuesStoreType;
|
||||
isDraft?: boolean;
|
||||
}
|
||||
|
||||
export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((props) => {
|
||||
const {
|
||||
data,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
withDraftIssueWrapper = true,
|
||||
storeType: issueStoreFromProps,
|
||||
isDraft = false,
|
||||
} = props;
|
||||
const issueStoreType = useIssueStoreType();
|
||||
|
||||
const storeType = issueStoreFromProps ?? issueStoreType;
|
||||
// ref
|
||||
const issueTitleRef = useRef<HTMLInputElement>(null);
|
||||
// states
|
||||
const [changesMade, setChangesMade] = useState<Partial<TIssue> | null>(null);
|
||||
const [createMore, setCreateMore] = useState(false);
|
||||
const [activeProjectId, setActiveProjectId] = useState<string | null>(null);
|
||||
const [description, setDescription] = useState<string | undefined>(undefined);
|
||||
// store hooks
|
||||
const { captureIssueEvent } = useEventTracker();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = useParams();
|
||||
const { workspaceProjectIds } = useProject();
|
||||
const { fetchCycleDetails } = useCycle();
|
||||
const { fetchModuleDetails } = useModule();
|
||||
const { issues } = useIssues(storeType);
|
||||
const { issues: projectIssues } = useIssues(EIssuesStoreType.PROJECT);
|
||||
const { issues: draftIssues } = useIssues(EIssuesStoreType.DRAFT);
|
||||
const { fetchIssue } = useIssueDetail();
|
||||
// pathname
|
||||
const pathname = usePathname();
|
||||
// local storage
|
||||
const { storedValue: localStorageDraftIssues, setValue: setLocalStorageDraftIssue } = useLocalStorage<
|
||||
Record<string, Partial<TIssue>>
|
||||
>("draftedIssue", {});
|
||||
// current store details
|
||||
const { createIssue, updateIssue } = useIssuesActions(storeType);
|
||||
|
||||
const fetchIssueDetail = async (issueId: string | undefined) => {
|
||||
setDescription(undefined);
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
if (!projectId || issueId === undefined) {
|
||||
setDescription(data?.description_html || "<p></p>");
|
||||
return;
|
||||
}
|
||||
const response = await fetchIssue(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issueId,
|
||||
isDraft ? "DRAFT" : "DEFAULT"
|
||||
);
|
||||
if (response) setDescription(response?.description_html || "<p></p>");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// fetching issue details
|
||||
if (isOpen) fetchIssueDetail(data?.id);
|
||||
|
||||
// if modal is closed, reset active project to null
|
||||
// and return to avoid activeProjectId being set to some other project
|
||||
if (!isOpen) {
|
||||
setActiveProjectId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// if data is present, set active project to the project of the
|
||||
// issue. This has more priority than the project in the url.
|
||||
if (data && data.project_id) {
|
||||
setActiveProjectId(data.project_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// if data is not present, set active project to the project
|
||||
// in the url. This has the least priority.
|
||||
if (workspaceProjectIds && workspaceProjectIds.length > 0 && !activeProjectId)
|
||||
setActiveProjectId(projectId?.toString() ?? workspaceProjectIds?.[0]);
|
||||
|
||||
// clearing up the description state when we leave the component
|
||||
return () => setDescription(undefined);
|
||||
}, [data, projectId, isOpen, activeProjectId]);
|
||||
|
||||
const addIssueToCycle = async (issue: TIssue, cycleId: string) => {
|
||||
if (!workspaceSlug || !issue.project_id) return;
|
||||
|
||||
await issues.addIssueToCycle(workspaceSlug.toString(), issue.project_id, cycleId, [issue.id]);
|
||||
fetchCycleDetails(workspaceSlug.toString(), issue.project_id, cycleId);
|
||||
};
|
||||
|
||||
const addIssueToModule = async (issue: TIssue, moduleIds: string[]) => {
|
||||
if (!workspaceSlug || !activeProjectId) return;
|
||||
|
||||
await issues.changeModulesInIssue(workspaceSlug.toString(), activeProjectId, issue.id, moduleIds, []);
|
||||
moduleIds.forEach((moduleId) => fetchModuleDetails(workspaceSlug.toString(), activeProjectId, moduleId));
|
||||
};
|
||||
|
||||
const handleCreateMoreToggleChange = (value: boolean) => {
|
||||
setCreateMore(value);
|
||||
};
|
||||
|
||||
const handleClose = (saveDraftIssueInLocalStorage?: boolean) => {
|
||||
if (changesMade && saveDraftIssueInLocalStorage) {
|
||||
// updating the current edited issue data in the local storage
|
||||
let draftIssues = localStorageDraftIssues ? localStorageDraftIssues : {};
|
||||
if (workspaceSlug) {
|
||||
draftIssues = { ...draftIssues, [workspaceSlug.toString()]: changesMade };
|
||||
setLocalStorageDraftIssue(draftIssues);
|
||||
}
|
||||
}
|
||||
|
||||
setActiveProjectId(null);
|
||||
setChangesMade(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreateIssue = async (
|
||||
payload: Partial<TIssue>,
|
||||
is_draft_issue: boolean = false
|
||||
): Promise<TIssue | undefined> => {
|
||||
if (!workspaceSlug || !payload.project_id) return;
|
||||
|
||||
try {
|
||||
let response;
|
||||
|
||||
// if draft issue, use draft issue store to create issue
|
||||
if (is_draft_issue) {
|
||||
response = await draftIssues.createIssue(workspaceSlug.toString(), payload.project_id, payload);
|
||||
}
|
||||
// if cycle id in payload does not match the cycleId in url
|
||||
// or if the moduleIds in Payload does not match the moduleId in url
|
||||
// use the project issue store to create issues
|
||||
else if (
|
||||
(payload.cycle_id !== cycleId && storeType === EIssuesStoreType.CYCLE) ||
|
||||
(!payload.module_ids?.includes(moduleId?.toString()) && storeType === EIssuesStoreType.MODULE)
|
||||
) {
|
||||
response = await projectIssues.createIssue(workspaceSlug.toString(), payload.project_id, payload);
|
||||
} // else just use the existing store type's create method
|
||||
else if (createIssue) {
|
||||
response = await createIssue(payload.project_id, payload);
|
||||
}
|
||||
|
||||
if (!response) throw new Error();
|
||||
|
||||
// check if we should add issue to cycle/module
|
||||
if (
|
||||
payload.cycle_id &&
|
||||
payload.cycle_id !== "" &&
|
||||
(payload.cycle_id !== cycleId || storeType !== EIssuesStoreType.CYCLE)
|
||||
) {
|
||||
await addIssueToCycle(response, payload.cycle_id);
|
||||
}
|
||||
if (
|
||||
payload.module_ids &&
|
||||
payload.module_ids.length > 0 &&
|
||||
(!payload.module_ids.includes(moduleId?.toString()) || storeType !== EIssuesStoreType.MODULE)
|
||||
) {
|
||||
await addIssueToModule(response, payload.module_ids);
|
||||
}
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: `${is_draft_issue ? "Draft issue" : "Issue"} created successfully.`,
|
||||
actionItems: !is_draft_issue && response?.project_id && (
|
||||
<CreateIssueToastActionItems
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={response?.project_id}
|
||||
issueId={response.id}
|
||||
/>
|
||||
),
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_CREATED,
|
||||
payload: { ...response, state: "SUCCESS" },
|
||||
path: pathname,
|
||||
});
|
||||
!createMore && handleClose();
|
||||
if (createMore) issueTitleRef && issueTitleRef?.current?.focus();
|
||||
setDescription("<p></p>");
|
||||
setChangesMade(null);
|
||||
return response;
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: `${is_draft_issue ? "Draft issue" : "Issue"} could not be created. Please try again.`,
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_CREATED,
|
||||
payload: { ...payload, state: "FAILED" },
|
||||
path: pathname,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateIssue = async (payload: Partial<TIssue>): Promise<TIssue | undefined> => {
|
||||
if (!workspaceSlug || !payload.project_id || !data?.id) return;
|
||||
|
||||
try {
|
||||
isDraft
|
||||
? await draftIssues.updateIssue(workspaceSlug.toString(), payload.project_id, data.id, payload)
|
||||
: updateIssue && (await updateIssue(payload.project_id, data.id, payload));
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Issue updated successfully.",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...payload, issueId: data.id, state: "SUCCESS" },
|
||||
path: pathname,
|
||||
});
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Issue could not be updated. Please try again.",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...payload, state: "FAILED" },
|
||||
path: pathname,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (payload: Partial<TIssue>, is_draft_issue: boolean = false) => {
|
||||
if (!workspaceSlug || !payload.project_id || !storeType) return;
|
||||
// remove sourceIssueId from payload since it is not needed
|
||||
if (data?.sourceIssueId) delete data.sourceIssueId;
|
||||
|
||||
let response: TIssue | undefined = undefined;
|
||||
if (!data?.id) response = await handleCreateIssue(payload, is_draft_issue);
|
||||
else response = await handleUpdateIssue(payload);
|
||||
|
||||
if (response != undefined && onSubmit) await onSubmit(response);
|
||||
};
|
||||
|
||||
const handleFormChange = (formData: Partial<TIssue> | null) => setChangesMade(formData);
|
||||
|
||||
// don't open the modal if there are no projects
|
||||
if (!workspaceProjectIds || workspaceProjectIds.length === 0 || !activeProjectId) return null;
|
||||
|
||||
return (
|
||||
<ModalCore
|
||||
isOpen={isOpen}
|
||||
handleClose={() => handleClose(true)}
|
||||
position={EModalPosition.TOP}
|
||||
width={EModalWidth.XXXXL}
|
||||
>
|
||||
{withDraftIssueWrapper ? (
|
||||
<DraftIssueLayout
|
||||
changesMade={changesMade}
|
||||
data={{
|
||||
...data,
|
||||
description_html: description,
|
||||
cycle_id: data?.cycle_id ? data?.cycle_id : cycleId ? cycleId.toString() : null,
|
||||
module_ids: data?.module_ids ? data?.module_ids : moduleId ? [moduleId.toString()] : null,
|
||||
}}
|
||||
issueTitleRef={issueTitleRef}
|
||||
onChange={handleFormChange}
|
||||
onClose={handleClose}
|
||||
onSubmit={handleFormSubmit}
|
||||
projectId={activeProjectId}
|
||||
isCreateMoreToggleEnabled={createMore}
|
||||
onCreateMoreToggleChange={handleCreateMoreToggleChange}
|
||||
isDraft={isDraft}
|
||||
/>
|
||||
) : (
|
||||
<IssueFormRoot
|
||||
issueTitleRef={issueTitleRef}
|
||||
data={{
|
||||
...data,
|
||||
description_html: description,
|
||||
cycle_id: data?.cycle_id ? data?.cycle_id : cycleId ? cycleId.toString() : null,
|
||||
module_ids: data?.module_ids ? data?.module_ids : moduleId ? [moduleId.toString()] : null,
|
||||
}}
|
||||
onClose={() => handleClose(false)}
|
||||
isCreateMoreToggleEnabled={createMore}
|
||||
onCreateMoreToggleChange={handleCreateMoreToggleChange}
|
||||
onSubmit={handleFormSubmit}
|
||||
projectId={activeProjectId}
|
||||
isDraft={isDraft}
|
||||
/>
|
||||
)}
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
|
|
@ -1 +1,2 @@
|
|||
export * from "./features";
|
||||
export * from "./tabs";
|
||||
|
|
|
|||
82
web/ce/constants/project/settings/tabs.ts
Normal file
82
web/ce/constants/project/settings/tabs.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// icons
|
||||
import { SettingIcon } from "@/components/icons/attachment";
|
||||
// types
|
||||
import { Props } from "@/components/icons/types";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
|
||||
export const PROJECT_SETTINGS = {
|
||||
general: {
|
||||
key: "general",
|
||||
label: "General",
|
||||
href: `/settings`,
|
||||
access: EUserProjectRoles.MEMBER,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
members: {
|
||||
key: "members",
|
||||
label: "Members",
|
||||
href: `/settings/members`,
|
||||
access: EUserProjectRoles.MEMBER,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
features: {
|
||||
key: "features",
|
||||
label: "Features",
|
||||
href: `/settings/features`,
|
||||
access: EUserProjectRoles.ADMIN,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/features/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
states: {
|
||||
key: "states",
|
||||
label: "States",
|
||||
href: `/settings/states`,
|
||||
access: EUserProjectRoles.MEMBER,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/states/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
labels: {
|
||||
key: "labels",
|
||||
label: "Labels",
|
||||
href: `/settings/labels`,
|
||||
access: EUserProjectRoles.MEMBER,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/labels/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
estimates: {
|
||||
key: "estimates",
|
||||
label: "Estimates",
|
||||
href: `/settings/estimates`,
|
||||
access: EUserProjectRoles.ADMIN,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/estimates/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
automations: {
|
||||
key: "automations",
|
||||
label: "Automations",
|
||||
href: `/settings/automations`,
|
||||
access: EUserProjectRoles.ADMIN,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/automations/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
};
|
||||
|
||||
export const PROJECT_SETTINGS_LINKS: {
|
||||
key: string;
|
||||
label: string;
|
||||
href: string;
|
||||
access: EUserProjectRoles;
|
||||
highlight: (pathname: string, baseUrl: string) => boolean;
|
||||
Icon: React.FC<Props>;
|
||||
}[] = [
|
||||
PROJECT_SETTINGS["general"],
|
||||
PROJECT_SETTINGS["members"],
|
||||
PROJECT_SETTINGS["features"],
|
||||
PROJECT_SETTINGS["states"],
|
||||
PROJECT_SETTINGS["labels"],
|
||||
PROJECT_SETTINGS["estimates"],
|
||||
PROJECT_SETTINGS["automations"],
|
||||
];
|
||||
|
|
@ -46,7 +46,6 @@ export const SelectProject: React.FC<Props> = observer((props) => {
|
|||
: "All projects"}
|
||||
</div>
|
||||
}
|
||||
optionsClassName="w-48"
|
||||
multiple
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useRef, useState } from "react";
|
||||
import { DayPicker, Matcher } from "react-day-picker";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePopper } from "react-popper";
|
||||
import { CalendarDays, X } from "lucide-react";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
|
|
@ -25,6 +26,7 @@ type Props = TDropdownProps & {
|
|||
onClose?: () => void;
|
||||
value: Date | string | null;
|
||||
closeOnSelect?: boolean;
|
||||
formatToken?: string;
|
||||
};
|
||||
|
||||
export const DateDropdown: React.FC<Props> = (props) => {
|
||||
|
|
@ -48,6 +50,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
|
|||
showTooltip = false,
|
||||
tabIndex,
|
||||
value,
|
||||
formatToken,
|
||||
} = props;
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
|
@ -126,13 +129,15 @@ export const DateDropdown: React.FC<Props> = (props) => {
|
|||
className={buttonClassName}
|
||||
isActive={isOpen}
|
||||
tooltipHeading={placeholder}
|
||||
tooltipContent={value ? renderFormattedDate(value) : "None"}
|
||||
tooltipContent={value ? renderFormattedDate(value, formatToken) : "None"}
|
||||
showTooltip={showTooltip}
|
||||
variant={buttonVariant}
|
||||
>
|
||||
{!hideIcon && icon}
|
||||
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
||||
<span className="flex-grow truncate">{value ? renderFormattedDate(value) : placeholder}</span>
|
||||
<span className="flex-grow truncate">
|
||||
{value ? renderFormattedDate(value, formatToken) : placeholder}
|
||||
</span>
|
||||
)}
|
||||
{isClearable && !disabled && isDateSelected && (
|
||||
<X
|
||||
|
|
@ -147,28 +152,30 @@ export const DateDropdown: React.FC<Props> = (props) => {
|
|||
</DropdownButton>
|
||||
</button>
|
||||
</Combobox.Button>
|
||||
{isOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<div
|
||||
className="my-1 bg-custom-background-100 shadow-custom-shadow-rg rounded-md overflow-hidden p-3"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<DayPicker
|
||||
selected={getDate(value)}
|
||||
defaultMonth={getDate(value)}
|
||||
onSelect={(date) => {
|
||||
dropdownOnChange(date ?? null);
|
||||
}}
|
||||
showOutsideDays
|
||||
initialFocus
|
||||
disabled={disabledDays}
|
||||
mode="single"
|
||||
/>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
)}
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Combobox.Options data-prevent-outside-click static>
|
||||
<div
|
||||
className="my-1 bg-custom-background-100 shadow-custom-shadow-rg rounded-md overflow-hidden p-3 z-20"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<DayPicker
|
||||
selected={getDate(value)}
|
||||
defaultMonth={getDate(value)}
|
||||
onSelect={(date) => {
|
||||
dropdownOnChange(date ?? null);
|
||||
}}
|
||||
showOutsideDays
|
||||
initialFocus
|
||||
disabled={disabledDays}
|
||||
mode="single"
|
||||
/>
|
||||
</div>
|
||||
</Combobox.Options>,
|
||||
document.body
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
|
|||
placement,
|
||||
projectId,
|
||||
showTooltip = false,
|
||||
showUserDetails = false,
|
||||
tabIndex,
|
||||
value,
|
||||
icon,
|
||||
|
|
@ -75,6 +76,26 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
|
|||
if (!multiple) handleClose();
|
||||
};
|
||||
|
||||
const getDisplayName = (value: string | string[] | null, showUserDetails: boolean, placeholder: string = "") => {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) {
|
||||
if (value.length === 1) {
|
||||
return getUserDetails(value[0])?.display_name || placeholder;
|
||||
} else {
|
||||
return showUserDetails ? `${value.length} members` : "";
|
||||
}
|
||||
} else {
|
||||
return placeholder;
|
||||
}
|
||||
} else {
|
||||
if (showUserDetails && value) {
|
||||
return getUserDetails(value)?.display_name || placeholder;
|
||||
} else {
|
||||
return placeholder;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
|
|
@ -110,7 +131,7 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
|
|||
onClick={handleOnClick}
|
||||
>
|
||||
<DropdownButton
|
||||
className={buttonClassName}
|
||||
className={cn("text-xs", buttonClassName)}
|
||||
isActive={isOpen}
|
||||
tooltipHeading={placeholder}
|
||||
tooltipContent={tooltipContent ?? `${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`}
|
||||
|
|
@ -119,12 +140,8 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
|
|||
>
|
||||
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} icon={icon} />}
|
||||
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
||||
<span className="flex-grow truncate">
|
||||
{Array.isArray(value) && value.length > 0
|
||||
? value.length === 1
|
||||
? getUserDetails(value[0])?.display_name
|
||||
: ""
|
||||
: placeholder}
|
||||
<span className="flex-grow truncate leading-5">
|
||||
{getDisplayName(value, showUserDetails, placeholder)}
|
||||
</span>
|
||||
)}
|
||||
{dropdownArrow && (
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
|||
import { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
|
|
@ -84,12 +85,14 @@ export const MemberOptions = observer((props: Props) => {
|
|||
const filteredOptions =
|
||||
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
return (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
return createPortal(
|
||||
<Combobox.Options data-prevent-outside-click 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"
|
||||
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 z-20"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
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">
|
||||
|
|
@ -134,6 +137,7 @@ export const MemberOptions = observer((props: Props) => {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
</Combobox.Options>,
|
||||
document.body
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export type MemberDropdownProps = TDropdownProps & {
|
|||
placeholder?: string;
|
||||
tooltipContent?: string;
|
||||
onClose?: () => void;
|
||||
showUserDetails?: boolean;
|
||||
} & (
|
||||
| {
|
||||
multiple: false;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { cn } from "@/helpers/common.helper";
|
|||
|
||||
type RadioInputProps = {
|
||||
name?: string;
|
||||
label: string | React.ReactNode | undefined;
|
||||
label?: string | React.ReactNode;
|
||||
wrapperClassName?: string;
|
||||
fieldClassName?: string;
|
||||
buttonClassName?: string;
|
||||
|
|
@ -46,14 +46,14 @@ export const RadioInput = ({
|
|||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={cn(`mb-2`, inputLabelClassName)}>{inputLabel}</div>
|
||||
{inputLabel && <div className={cn(`mb-2`, inputLabelClassName)}>{inputLabel}</div>}
|
||||
<div className={cn(`${wrapperClass}`, inputWrapperClassName)}>
|
||||
{options.map(({ value, label, disabled }, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => !disabled && setSelected(value)}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
"flex items-center gap-2 text-base",
|
||||
disabled ? `bg-custom-background-200 border-custom-border-200 cursor-not-allowed` : ``,
|
||||
inputFieldClassName
|
||||
)}
|
||||
|
|
@ -62,7 +62,7 @@ export const RadioInput = ({
|
|||
id={`${name}_${index}`}
|
||||
name={name}
|
||||
className={cn(
|
||||
`group flex size-5 items-center justify-center rounded-full border border-custom-border-400 bg-custom-background-500 cursor-pointer`,
|
||||
`group flex flex-shrink-0 size-5 items-center justify-center rounded-full border border-custom-border-400 bg-custom-background-500 cursor-pointer`,
|
||||
selected === value ? `bg-custom-primary-200 border-custom-primary-100 ` : ``,
|
||||
disabled ? `bg-custom-background-200 border-custom-border-200 cursor-not-allowed` : ``,
|
||||
inputButtonClassName
|
||||
|
|
@ -72,7 +72,7 @@ export const RadioInput = ({
|
|||
disabled={disabled}
|
||||
checked={selected === value}
|
||||
/>
|
||||
<label htmlFor={`${name}_${index}`} className="text-base cursor-pointer">
|
||||
<label htmlFor={`${name}_${index}`} className="cursor-pointer w-full">
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export const GithubImportData: FC<Props> = observer((props) => {
|
|||
}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
optionsClassName="w-full"
|
||||
optionsClassName="w-48"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export const SelectRepository: React.FC<Props> = (props) => {
|
|||
)}
|
||||
</>
|
||||
}
|
||||
optionsClassName="w-full"
|
||||
optionsClassName="w-48"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ export const SingleUserSelect: React.FC<Props> = ({ collaborator, index, users,
|
|||
newUsers[index].email = val;
|
||||
setUsers(newUsers);
|
||||
}}
|
||||
optionsClassName="w-full"
|
||||
optionsClassName="w-48"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ export const JiraImportUsers: FC = () => {
|
|||
label={value !== "" ? value : "Select user from project"}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
optionsClassName="w-full"
|
||||
optionsClassName="w-48"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import { useEffect, useState } from "react";
|
|||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
// ui
|
||||
import { StateGroupIcon } from "@plane/ui";
|
||||
// components
|
||||
import {
|
||||
IssueActivity,
|
||||
|
|
@ -17,8 +15,10 @@ import {
|
|||
IssueDetailWidgets,
|
||||
} from "@/components/issues";
|
||||
// hooks
|
||||
import { useIssueDetail, useProjectState, useUser } from "@/hooks/store";
|
||||
import { useIssueDetail, useUser } from "@/hooks/store";
|
||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||
// plane web components
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues";
|
||||
// types
|
||||
import { TIssueOperations } from "./root";
|
||||
|
||||
|
|
@ -38,7 +38,6 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
|
|||
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
||||
// hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { projectStates } = useProjectState();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
|
|
@ -54,8 +53,6 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
|
|||
const issue = issueId ? getIssueById(issueId) : undefined;
|
||||
if (!issue || !issue.project_id) return <></>;
|
||||
|
||||
const currentIssueState = projectStates?.find((s) => s.id === issue.state_id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg space-y-4">
|
||||
|
|
@ -70,14 +67,8 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
|
|||
)}
|
||||
|
||||
<div className="mb-2.5 flex items-center">
|
||||
{currentIssueState && (
|
||||
<StateGroupIcon
|
||||
className="mr-3 h-4 w-4"
|
||||
stateGroup={currentIssueState.group}
|
||||
color={currentIssueState.color}
|
||||
/>
|
||||
)}
|
||||
<IssueUpdateStatus isSubmitting={isSubmitting} issueDetail={issue} />
|
||||
<IssueIdentifier issueId={issueId} projectId={issue.project_id} />
|
||||
<IssueUpdateStatus isSubmitting={isSubmitting} />
|
||||
</div>
|
||||
|
||||
<IssueTitleInput
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
|||
// hooks
|
||||
import { useProjectEstimates, useIssueDetail, useProject, useProjectState, useMember } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { IssueAdditionalPropertyValuesUpdate } from "@/plane-web/components/issue-types/values";
|
||||
import { IssueWorklogProperty } from "@/plane-web/components/issues";
|
||||
// components
|
||||
import type { TIssueOperations } from "./root";
|
||||
|
|
@ -288,6 +289,15 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||
issueId={issueId}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
|
||||
{issue.type_id && (
|
||||
<IssueAdditionalPropertyValuesUpdate
|
||||
issueId={issueId}
|
||||
issueTypeId={issue.type_id}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
|
|||
{
|
||||
...issue,
|
||||
name: `${issue.name} (copy)`,
|
||||
sourceIssueId: issue.id,
|
||||
},
|
||||
["id"]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
|||
{
|
||||
...issue,
|
||||
name: `${issue.name} (copy)`,
|
||||
sourceIssueId: issue.id,
|
||||
},
|
||||
["id"]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
|
|||
{
|
||||
...issue,
|
||||
name: `${issue.name} (copy)`,
|
||||
sourceIssueId: issue.id,
|
||||
},
|
||||
["id"]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
|
|||
...issue,
|
||||
name: `${issue.name} (copy)`,
|
||||
is_draft: isDraftIssue ? false : issue.is_draft,
|
||||
sourceIssueId: issue.id,
|
||||
},
|
||||
["id"]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1 @@
|
|||
export * from "./draft-issue-layout";
|
||||
export * from "./form";
|
||||
export * from "./modal";
|
||||
|
|
|
|||
|
|
@ -1,321 +1,3 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
// types
|
||||
import type { TIssue } from "@plane/types";
|
||||
// ui
|
||||
import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { CreateIssueToastActionItems } from "@/components/issues";
|
||||
// constants
|
||||
import { ISSUE_CREATED, ISSUE_UPDATED } from "@/constants/event-tracker";
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
// hooks
|
||||
import { useEventTracker, useCycle, useIssues, useModule, useProject, useIssueDetail } from "@/hooks/store";
|
||||
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
// components
|
||||
import { DraftIssueLayout } from "./draft-issue-layout";
|
||||
import { IssueFormRoot } from "./form";
|
||||
|
||||
export interface IssuesModalProps {
|
||||
data?: Partial<TIssue>;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit?: (res: TIssue) => Promise<void>;
|
||||
withDraftIssueWrapper?: boolean;
|
||||
storeType?: EIssuesStoreType;
|
||||
isDraft?: boolean;
|
||||
}
|
||||
|
||||
export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((props) => {
|
||||
const {
|
||||
data,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
withDraftIssueWrapper = true,
|
||||
storeType: issueStoreFromProps,
|
||||
isDraft = false,
|
||||
} = props;
|
||||
const issueStoreType = useIssueStoreType();
|
||||
|
||||
const storeType = issueStoreFromProps ?? issueStoreType;
|
||||
// ref
|
||||
const issueTitleRef = useRef<HTMLInputElement>(null);
|
||||
// states
|
||||
const [changesMade, setChangesMade] = useState<Partial<TIssue> | null>(null);
|
||||
const [createMore, setCreateMore] = useState(false);
|
||||
const [activeProjectId, setActiveProjectId] = useState<string | null>(null);
|
||||
const [description, setDescription] = useState<string | undefined>(undefined);
|
||||
// store hooks
|
||||
const { captureIssueEvent } = useEventTracker();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = useParams();
|
||||
const { workspaceProjectIds } = useProject();
|
||||
const { fetchCycleDetails } = useCycle();
|
||||
const { fetchModuleDetails } = useModule();
|
||||
const { issues } = useIssues(storeType);
|
||||
const { issues: projectIssues } = useIssues(EIssuesStoreType.PROJECT);
|
||||
const { issues: draftIssues } = useIssues(EIssuesStoreType.DRAFT);
|
||||
const { fetchIssue } = useIssueDetail();
|
||||
// pathname
|
||||
const pathname = usePathname();
|
||||
// local storage
|
||||
const { storedValue: localStorageDraftIssues, setValue: setLocalStorageDraftIssue } = useLocalStorage<
|
||||
Record<string, Partial<TIssue>>
|
||||
>("draftedIssue", {});
|
||||
// current store details
|
||||
const { createIssue, updateIssue } = useIssuesActions(storeType);
|
||||
|
||||
const fetchIssueDetail = async (issueId: string | undefined) => {
|
||||
setDescription(undefined);
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
if (!projectId || issueId === undefined) {
|
||||
setDescription(data?.description_html || "<p></p>");
|
||||
return;
|
||||
}
|
||||
const response = await fetchIssue(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issueId,
|
||||
isDraft ? "DRAFT" : "DEFAULT"
|
||||
);
|
||||
if (response) setDescription(response?.description_html || "<p></p>");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// fetching issue details
|
||||
if (isOpen) fetchIssueDetail(data?.id);
|
||||
|
||||
// if modal is closed, reset active project to null
|
||||
// and return to avoid activeProjectId being set to some other project
|
||||
if (!isOpen) {
|
||||
setActiveProjectId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// if data is present, set active project to the project of the
|
||||
// issue. This has more priority than the project in the url.
|
||||
if (data && data.project_id) {
|
||||
setActiveProjectId(data.project_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// if data is not present, set active project to the project
|
||||
// in the url. This has the least priority.
|
||||
if (workspaceProjectIds && workspaceProjectIds.length > 0 && !activeProjectId)
|
||||
setActiveProjectId(projectId?.toString() ?? workspaceProjectIds?.[0]);
|
||||
|
||||
// clearing up the description state when we leave the component
|
||||
return () => setDescription(undefined);
|
||||
}, [data, projectId, isOpen, activeProjectId]);
|
||||
|
||||
const addIssueToCycle = async (issue: TIssue, cycleId: string) => {
|
||||
if (!workspaceSlug || !issue.project_id) return;
|
||||
|
||||
await issues.addIssueToCycle(workspaceSlug.toString(), issue.project_id, cycleId, [issue.id]);
|
||||
fetchCycleDetails(workspaceSlug.toString(), issue.project_id, cycleId);
|
||||
};
|
||||
|
||||
const addIssueToModule = async (issue: TIssue, moduleIds: string[]) => {
|
||||
if (!workspaceSlug || !activeProjectId) return;
|
||||
|
||||
await issues.changeModulesInIssue(workspaceSlug.toString(), activeProjectId, issue.id, moduleIds, []);
|
||||
moduleIds.forEach((moduleId) => fetchModuleDetails(workspaceSlug.toString(), activeProjectId, moduleId));
|
||||
};
|
||||
|
||||
const handleCreateMoreToggleChange = (value: boolean) => {
|
||||
setCreateMore(value);
|
||||
};
|
||||
|
||||
const handleClose = (saveDraftIssueInLocalStorage?: boolean) => {
|
||||
if (changesMade && saveDraftIssueInLocalStorage) {
|
||||
// updating the current edited issue data in the local storage
|
||||
let draftIssues = localStorageDraftIssues ? localStorageDraftIssues : {};
|
||||
if (workspaceSlug) {
|
||||
draftIssues = { ...draftIssues, [workspaceSlug.toString()]: changesMade };
|
||||
setLocalStorageDraftIssue(draftIssues);
|
||||
}
|
||||
}
|
||||
|
||||
setActiveProjectId(null);
|
||||
setChangesMade(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreateIssue = async (
|
||||
payload: Partial<TIssue>,
|
||||
is_draft_issue: boolean = false
|
||||
): Promise<TIssue | undefined> => {
|
||||
if (!workspaceSlug || !payload.project_id) return;
|
||||
|
||||
try {
|
||||
let response;
|
||||
|
||||
// if draft issue, use draft issue store to create issue
|
||||
if (is_draft_issue) {
|
||||
response = await draftIssues.createIssue(workspaceSlug.toString(), payload.project_id, payload);
|
||||
}
|
||||
// if cycle id in payload does not match the cycleId in url
|
||||
// or if the moduleIds in Payload does not match the moduleId in url
|
||||
// use the project issue store to create issues
|
||||
else if (
|
||||
(payload.cycle_id !== cycleId && storeType === EIssuesStoreType.CYCLE) ||
|
||||
(!payload.module_ids?.includes(moduleId?.toString()) && storeType === EIssuesStoreType.MODULE)
|
||||
) {
|
||||
response = await projectIssues.createIssue(workspaceSlug.toString(), payload.project_id, payload);
|
||||
} // else just use the existing store type's create method
|
||||
else if (createIssue) {
|
||||
response = await createIssue(payload.project_id, payload);
|
||||
}
|
||||
|
||||
if (!response) throw new Error();
|
||||
|
||||
// check if we should add issue to cycle/module
|
||||
if (
|
||||
payload.cycle_id &&
|
||||
payload.cycle_id !== "" &&
|
||||
(payload.cycle_id !== cycleId || storeType !== EIssuesStoreType.CYCLE)
|
||||
) {
|
||||
await addIssueToCycle(response, payload.cycle_id);
|
||||
}
|
||||
if (
|
||||
payload.module_ids &&
|
||||
payload.module_ids.length > 0 &&
|
||||
(!payload.module_ids.includes(moduleId?.toString()) || storeType !== EIssuesStoreType.MODULE)
|
||||
) {
|
||||
await addIssueToModule(response, payload.module_ids);
|
||||
}
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: `${is_draft_issue ? "Draft issue" : "Issue"} created successfully.`,
|
||||
actionItems: !is_draft_issue && response?.project_id && (
|
||||
<CreateIssueToastActionItems
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={response?.project_id}
|
||||
issueId={response.id}
|
||||
/>
|
||||
),
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_CREATED,
|
||||
payload: { ...response, state: "SUCCESS" },
|
||||
path: pathname,
|
||||
});
|
||||
!createMore && handleClose();
|
||||
if (createMore) issueTitleRef && issueTitleRef?.current?.focus();
|
||||
setDescription("<p></p>");
|
||||
setChangesMade(null);
|
||||
return response;
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: `${is_draft_issue ? "Draft issue" : "Issue"} could not be created. Please try again.`,
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_CREATED,
|
||||
payload: { ...payload, state: "FAILED" },
|
||||
path: pathname,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateIssue = async (payload: Partial<TIssue>): Promise<TIssue | undefined> => {
|
||||
if (!workspaceSlug || !payload.project_id || !data?.id) return;
|
||||
|
||||
try {
|
||||
isDraft
|
||||
? await draftIssues.updateIssue(workspaceSlug.toString(), payload.project_id, data.id, payload)
|
||||
: updateIssue && (await updateIssue(payload.project_id, data.id, payload));
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Issue updated successfully.",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...payload, issueId: data.id, state: "SUCCESS" },
|
||||
path: pathname,
|
||||
});
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Issue could not be updated. Please try again.",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...payload, state: "FAILED" },
|
||||
path: pathname,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (payload: Partial<TIssue>, is_draft_issue: boolean = false) => {
|
||||
if (!workspaceSlug || !payload.project_id || !storeType) return;
|
||||
|
||||
let response: TIssue | undefined = undefined;
|
||||
if (!data?.id) response = await handleCreateIssue(payload, is_draft_issue);
|
||||
else response = await handleUpdateIssue(payload);
|
||||
|
||||
if (response != undefined && onSubmit) await onSubmit(response);
|
||||
};
|
||||
|
||||
const handleFormChange = (formData: Partial<TIssue> | null) => setChangesMade(formData);
|
||||
|
||||
// don't open the modal if there are no projects
|
||||
if (!workspaceProjectIds || workspaceProjectIds.length === 0 || !activeProjectId) return null;
|
||||
|
||||
return (
|
||||
<ModalCore
|
||||
isOpen={isOpen}
|
||||
handleClose={() => handleClose(true)}
|
||||
position={EModalPosition.TOP}
|
||||
width={EModalWidth.XXXXL}
|
||||
>
|
||||
{withDraftIssueWrapper ? (
|
||||
<DraftIssueLayout
|
||||
changesMade={changesMade}
|
||||
data={{
|
||||
...data,
|
||||
description_html: description,
|
||||
cycle_id: data?.cycle_id ? data?.cycle_id : cycleId ? cycleId.toString() : null,
|
||||
module_ids: data?.module_ids ? data?.module_ids : moduleId ? [moduleId.toString()] : null,
|
||||
}}
|
||||
issueTitleRef={issueTitleRef}
|
||||
onChange={handleFormChange}
|
||||
onClose={handleClose}
|
||||
onSubmit={handleFormSubmit}
|
||||
projectId={activeProjectId}
|
||||
isCreateMoreToggleEnabled={createMore}
|
||||
onCreateMoreToggleChange={handleCreateMoreToggleChange}
|
||||
isDraft={isDraft}
|
||||
/>
|
||||
) : (
|
||||
<IssueFormRoot
|
||||
issueTitleRef={issueTitleRef}
|
||||
data={{
|
||||
...data,
|
||||
description_html: description,
|
||||
cycle_id: data?.cycle_id ? data?.cycle_id : cycleId ? cycleId.toString() : null,
|
||||
module_ids: data?.module_ids ? data?.module_ids : moduleId ? [moduleId.toString()] : null,
|
||||
}}
|
||||
onClose={() => handleClose(false)}
|
||||
isCreateMoreToggleEnabled={createMore}
|
||||
onCreateMoreToggleChange={handleCreateMoreToggleChange}
|
||||
onSubmit={handleFormSubmit}
|
||||
projectId={activeProjectId}
|
||||
isDraft={isDraft}
|
||||
/>
|
||||
)}
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
export * from "@/plane-web/components/issues/issue-modal/modal";
|
||||
|
|
|
|||
|
|
@ -1,27 +1,16 @@
|
|||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { TIssue } from "@plane/types";
|
||||
// types
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
isSubmitting: "submitting" | "submitted" | "saved";
|
||||
issueDetail?: TIssue;
|
||||
};
|
||||
|
||||
export const IssueUpdateStatus: React.FC<Props> = observer((props) => {
|
||||
const { isSubmitting, issueDetail } = props;
|
||||
// hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { isSubmitting } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{issueDetail && (
|
||||
<h4 className="mr-4 text-lg font-medium text-custom-text-300">
|
||||
{getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id}
|
||||
</h4>
|
||||
)}
|
||||
<div
|
||||
className={`flex items-center gap-x-2 transition-all duration-300 ${
|
||||
isSubmitting === "saved" ? "fade-out" : "fade-in"
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import { FC, useEffect } from "react";
|
|||
import { observer } from "mobx-react";
|
||||
// store hooks
|
||||
import { TIssueOperations } from "@/components/issues";
|
||||
import { useIssueDetail, useProject, useUser } from "@/hooks/store";
|
||||
import { useIssueDetail, useUser } from "@/hooks/store";
|
||||
// hooks
|
||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||
// plane web components
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues";
|
||||
// components
|
||||
import { IssueDescriptionInput } from "../description-input";
|
||||
import { IssueReaction } from "../issue-detail/reactions";
|
||||
|
|
@ -24,7 +26,6 @@ interface IPeekOverviewIssueDetails {
|
|||
export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer((props) => {
|
||||
const { workspaceSlug, issueId, issueOperations, disabled, isArchived, isSubmitting, setIsSubmitting } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { data: currentUser } = useUser();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
|
|
@ -46,8 +47,6 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
|
|||
const issue = issueId ? getIssueById(issueId) : undefined;
|
||||
if (!issue || !issue.project_id) return <></>;
|
||||
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
|
||||
const issueDescription =
|
||||
issue.description_html !== undefined || issue.description_html !== null
|
||||
? issue.description_html != ""
|
||||
|
|
@ -57,9 +56,7 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
|
|||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<span className="text-base font-medium text-custom-text-400">
|
||||
{projectDetails?.identifier}-{issue?.sequence_id}
|
||||
</span>
|
||||
<IssueIdentifier issueId={issueId} projectId={issue.project_id} />
|
||||
<IssueTitleInput
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"
|
|||
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
||||
import { useIssueDetail, useMember, useProject, useProjectState } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { IssueAdditionalPropertyValuesUpdate } from "@/plane-web/components/issue-types/values";
|
||||
import { IssueWorklogProperty } from "@/plane-web/components/issues";
|
||||
|
||||
interface IPeekOverviewProperties {
|
||||
|
|
@ -288,6 +289,15 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
|||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{issue.type_id && (
|
||||
<IssueAdditionalPropertyValuesUpdate
|
||||
issueId={issueId}
|
||||
issueTypeId={issue.type_id}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
|
|||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "settings",
|
||||
action: () => router.push(`/${workspaceSlug}/projects/${project.id}/settings`),
|
||||
action: () => router.push(`/${workspaceSlug}/projects/${project.id}/settings`, {}, { showProgressBar: false }),
|
||||
title: "Settings",
|
||||
icon: Settings,
|
||||
shouldRender: !isArchived && (isOwner || isMember),
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
|
|||
onChange(val);
|
||||
}}
|
||||
options={options}
|
||||
optionsClassName="w-full"
|
||||
optionsClassName="w-48"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
// icons
|
||||
import { Globe2, Lock, LucideIcon } from "lucide-react";
|
||||
import { TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types";
|
||||
import { SettingIcon } from "@/components/icons/attachment";
|
||||
// types
|
||||
import { Props } from "@/components/icons/types";
|
||||
|
||||
export enum EUserProjectRoles {
|
||||
GUEST = 5,
|
||||
|
|
@ -67,72 +64,6 @@ export const PROJECT_UNSPLASH_COVERS = [
|
|||
"https://images.unsplash.com/photo-1675351066828-6fc770b90dd2?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80",
|
||||
];
|
||||
|
||||
export const PROJECT_SETTINGS_LINKS: {
|
||||
key: string;
|
||||
label: string;
|
||||
href: string;
|
||||
access: EUserProjectRoles;
|
||||
highlight: (pathname: string, baseUrl: string) => boolean;
|
||||
Icon: React.FC<Props>;
|
||||
}[] = [
|
||||
{
|
||||
key: "general",
|
||||
label: "General",
|
||||
href: `/settings`,
|
||||
access: EUserProjectRoles.MEMBER,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
{
|
||||
key: "members",
|
||||
label: "Members",
|
||||
href: `/settings/members`,
|
||||
access: EUserProjectRoles.MEMBER,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
{
|
||||
key: "features",
|
||||
label: "Features",
|
||||
href: `/settings/features`,
|
||||
access: EUserProjectRoles.ADMIN,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/features/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
{
|
||||
key: "states",
|
||||
label: "States",
|
||||
href: `/settings/states`,
|
||||
access: EUserProjectRoles.MEMBER,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/states/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
{
|
||||
key: "labels",
|
||||
label: "Labels",
|
||||
href: `/settings/labels`,
|
||||
access: EUserProjectRoles.MEMBER,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/labels/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
{
|
||||
key: "estimates",
|
||||
label: "Estimates",
|
||||
href: `/settings/estimates`,
|
||||
access: EUserProjectRoles.ADMIN,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/estimates/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
{
|
||||
key: "automations",
|
||||
label: "Automations",
|
||||
href: `/settings/automations`,
|
||||
access: EUserProjectRoles.ADMIN,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/automations/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export const PROJECT_ORDER_BY_OPTIONS: {
|
||||
key: TProjectOrderByOptions;
|
||||
label: string;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,31 @@
|
|||
import React, { useEffect } from "react";
|
||||
|
||||
// TODO: move it to helpers package
|
||||
const useOutsideClickDetector = (ref: React.RefObject<HTMLElement>, callback: () => void) => {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
// get all the element with attribute name data-prevent-outside-click
|
||||
const preventOutsideClickElements = document.querySelectorAll("[data-prevent-outside-click]");
|
||||
// check if the click target is any of the elements with attribute name data-prevent-outside-click
|
||||
for (let i = 0; i < preventOutsideClickElements.length; i++) {
|
||||
if (preventOutsideClickElements[i].contains(event.target as Node)) {
|
||||
// if the click target is any of the elements with attribute name data-prevent-outside-click, return
|
||||
return;
|
||||
}
|
||||
}
|
||||
// get all the element with attribute name data-delay-outside-click
|
||||
const delayOutsideClickElements = document.querySelectorAll("[data-delay-outside-click]");
|
||||
// check if the click target is any of the elements with attribute name data-delay-outside-click
|
||||
for (let i = 0; i < delayOutsideClickElements.length; i++) {
|
||||
if (delayOutsideClickElements[i].contains(event.target as Node)) {
|
||||
// if the click target is any of the elements with attribute name data-delay-outside-click, delay the callback
|
||||
setTimeout(() => {
|
||||
callback();
|
||||
}, 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// else, call the callback immediately
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,13 +7,37 @@ const usePeekOverviewOutsideClickDetector = (
|
|||
) => {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
// get all the element with attribute name data-prevent-outside-click
|
||||
const preventOutsideClickElements = document.querySelectorAll("[data-prevent-outside-click]");
|
||||
// check if the click target is any of the elements with attribute name data-prevent-outside-click
|
||||
for (let i = 0; i < preventOutsideClickElements.length; i++) {
|
||||
if (preventOutsideClickElements[i].contains(event.target as Node)) {
|
||||
// if the click target is any of the elements with attribute name data-prevent-outside-click, return
|
||||
return;
|
||||
}
|
||||
}
|
||||
// check if the click target is the current issue element or its children
|
||||
let targetElement = event.target as HTMLElement | null;
|
||||
while (targetElement) {
|
||||
if (targetElement.id === `issue-${issueId}`) {
|
||||
// if the click target is the current issue element, return
|
||||
return;
|
||||
}
|
||||
targetElement = targetElement.parentElement;
|
||||
}
|
||||
// get all the element with attribute name data-prevent-outside-click
|
||||
const delayOutsideClickElements = document.querySelectorAll("[data-delay-outside-click]");
|
||||
// check if the click target is any of the elements with attribute name data-delay-outside-click
|
||||
for (let i = 0; i < delayOutsideClickElements.length; i++) {
|
||||
if (delayOutsideClickElements[i].contains(event.target as Node)) {
|
||||
// if the click target is any of the elements with attribute name data-delay-outside-click, delay the callback
|
||||
setTimeout(() => {
|
||||
callback();
|
||||
}, 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// else, call the callback immediately
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
|
@ -26,4 +50,5 @@ const usePeekOverviewOutsideClickDetector = (
|
|||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default usePeekOverviewOutsideClickDetector;
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ export class IssueStore implements IIssueStore {
|
|||
parent_id: issue?.parent_id,
|
||||
cycle_id: issue?.cycle_id,
|
||||
module_ids: issue?.module_ids,
|
||||
type_id: issue?.type_id,
|
||||
created_at: issue?.created_at,
|
||||
updated_at: issue?.updated_at,
|
||||
start_date: issue?.start_date,
|
||||
|
|
|
|||
1
web/ee/components/issue-types/index.ts
Normal file
1
web/ee/components/issue-types/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./values";
|
||||
1
web/ee/components/issue-types/values/index.ts
Normal file
1
web/ee/components/issue-types/values/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./update";
|
||||
1
web/ee/components/issue-types/values/update.tsx
Normal file
1
web/ee/components/issue-types/values/update.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "ce/components/issue-types/values/update";
|
||||
|
|
@ -1,2 +1,4 @@
|
|||
export * from "./bulk-operations";
|
||||
export * from "./worklog";
|
||||
export * from "./issue-modal";
|
||||
export * from "./issue-details";
|
||||
|
|
|
|||
1
web/ee/components/issues/issue-details/index.ts
Normal file
1
web/ee/components/issues/issue-details/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./issue-identifier";
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "ce/components/issues/issue-details/issue-identifier";
|
||||
1
web/ee/components/issues/issue-modal/index.ts
Normal file
1
web/ee/components/issues/issue-modal/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./modal";
|
||||
1
web/ee/components/issues/issue-modal/modal.tsx
Normal file
1
web/ee/components/issues/issue-modal/modal.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "ce/components/issues/issue-modal/modal";
|
||||
1
web/ee/constants/project/settings/tabs.ts
Normal file
1
web/ee/constants/project/settings/tabs.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "ce/constants/project/settings/tabs";
|
||||
|
|
@ -3,20 +3,31 @@ import isNumber from "lodash/isNumber";
|
|||
|
||||
// Format Date Helpers
|
||||
/**
|
||||
* @returns {string | null} formatted date in the format of MMM dd, yyyy
|
||||
* @returns {string | null} formatted date in the desired format or platform default format (MMM dd, yyyy)
|
||||
* @description Returns date in the formatted format
|
||||
* @param {Date | string} date
|
||||
* @param {string} formatToken (optional) // default MMM dd, yyyy
|
||||
* @example renderFormattedDate("2024-01-01", "MM-DD-YYYY") // Jan 01, 2024
|
||||
* @example renderFormattedDate("2024-01-01") // Jan 01, 2024
|
||||
*/
|
||||
export const renderFormattedDate = (date: string | Date | undefined | null): string | null => {
|
||||
export const renderFormattedDate = (
|
||||
date: string | Date | undefined | null,
|
||||
formatToken: string = "MMM dd, yyyy"
|
||||
): string | null => {
|
||||
// Parse the date to check if it is valid
|
||||
const parsedDate = getDate(date);
|
||||
// return if undefined
|
||||
if (!parsedDate) return null;
|
||||
// Check if the parsed date is valid before formatting
|
||||
if (!isValid(parsedDate)) return null; // Return null for invalid dates
|
||||
// Format the date in format (MMM dd, yyyy)
|
||||
const formattedDate = format(parsedDate, "MMM dd, yyyy");
|
||||
let formattedDate;
|
||||
try {
|
||||
// Format the date in the format provided or default format (MMM dd, yyyy)
|
||||
formattedDate = format(parsedDate, formatToken);
|
||||
} catch (e) {
|
||||
// Format the date in format (MMM dd, yyyy) in case of any error
|
||||
formattedDate = format(parsedDate, "MMM dd, yyyy");
|
||||
}
|
||||
return formattedDate;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
// ui
|
||||
import { LUCIDE_ICONS_LIST } from "@plane/ui";
|
||||
|
||||
export const getRandomEmoji = () => {
|
||||
const emojis = [
|
||||
"8986",
|
||||
|
|
@ -18,6 +21,8 @@ export const getRandomEmoji = () => {
|
|||
return emojis[Math.floor(Math.random() * emojis.length)];
|
||||
};
|
||||
|
||||
export const getRandomIconName = () => LUCIDE_ICONS_LIST[Math.floor(Math.random() * LUCIDE_ICONS_LIST.length)].name;
|
||||
|
||||
export const renderEmoji = (
|
||||
emoji:
|
||||
| string
|
||||
|
|
@ -64,7 +69,6 @@ export const convertHexEmojiToDecimal = (emojiUnified: string): string => {
|
|||
.join("-");
|
||||
};
|
||||
|
||||
|
||||
export const emojiCodeToUnicode = (emoji: string) => {
|
||||
if (!emoji) return "";
|
||||
|
||||
|
|
|
|||
|
|
@ -636,6 +636,15 @@ div.web-view-spinner div.bar12 {
|
|||
margin-top: 44px;
|
||||
}
|
||||
|
||||
/* scrollbar xs size */
|
||||
.scrollbar-xs::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
}
|
||||
.scrollbar-xs::-webkit-scrollbar-thumb {
|
||||
border: 3px solid rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
/* scrollbar sm size */
|
||||
.scrollbar-sm::-webkit-scrollbar {
|
||||
height: 12px;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue