diff --git a/packages/types/src/issues/base.d.ts b/packages/types/src/issues/base.d.ts index 1ad8530cd97..8292c111649 100644 --- a/packages/types/src/issues/base.d.ts +++ b/packages/types/src/issues/base.d.ts @@ -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[]; \ No newline at end of file +export type TUnGroupedIssues = string[]; diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index 079ef12cc35..1584a3d16ce 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -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 = { diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts index 335e9f98f60..c53d301c48f 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -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 = { diff --git a/packages/ui/src/collapsible/collapsible.tsx b/packages/ui/src/collapsible/collapsible.tsx index a069be3ed57..2ced4cf0ec4 100644 --- a/packages/ui/src/collapsible/collapsible.tsx +++ b/packages/ui/src/collapsible/collapsible.tsx @@ -4,6 +4,7 @@ import { Disclosure, Transition } from "@headlessui/react"; export type TCollapsibleProps = { title: string | React.ReactNode; children: React.ReactNode; + buttonRef?: React.RefObject; className?: string; buttonClassName?: string; isOpen?: boolean; @@ -12,7 +13,7 @@ export type TCollapsibleProps = { }; export const Collapsible: FC = (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(isOpen || defaultOpen ? true : false); @@ -33,7 +34,7 @@ export const Collapsible: FC = (props) => { return ( - + {title} { 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) => { )} - {isOpen && ( - -
-
- - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
+ {isOpen && + createPortal( +
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - 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 }) => ( - <> - {option.content} - {selected && } - - )} - - )) +
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + /> +
+
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + 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 }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

No matches found

+ ) ) : ( -

No matches found

- ) - ) : ( -

Loading...

- )} +

Loading...

+ )} +
+ {footerOption}
- {footerOption} -
-
- )} + , + document.body + )} ); }} diff --git a/packages/ui/src/dropdowns/helper.tsx b/packages/ui/src/dropdowns/helper.tsx index 0eda5ac5c9f..8566f183b23 100644 --- a/packages/ui/src/dropdowns/helper.tsx +++ b/packages/ui/src/dropdowns/helper.tsx @@ -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; diff --git a/packages/ui/src/emoji/icons.ts b/packages/ui/src/emoji/icons.ts index 3d650e244ef..ab9aa69b3f7 100644 --- a/packages/ui/src/emoji/icons.ts +++ b/packages/ui/src/emoji/icons.ts @@ -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 }, ]; diff --git a/packages/ui/src/emoji/logo.tsx b/packages/ui/src/emoji/logo.tsx index 528e1604753..a2a5dd6abc3 100644 --- a/packages/ui/src/emoji/logo.tsx +++ b/packages/ui/src/emoji/logo.tsx @@ -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) => { - 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) => { {lucideIcon && ( )} ) : ( diff --git a/packages/ui/src/form-fields/input.tsx b/packages/ui/src/form-fields/input.tsx index 10f9fd85c19..fa15901f454 100644 --- a/packages/ui/src/form-fields/input.tsx +++ b/packages/ui/src/form-fields/input.tsx @@ -4,7 +4,7 @@ import { cn } from "../../helpers"; export interface InputProps extends React.InputHTMLAttributes { mode?: "primary" | "transparent" | "true-transparent"; - inputSize?: "sm" | "md"; + inputSize?: "xs" | "sm" | "md"; hasError?: boolean; className?: string; } @@ -26,6 +26,7 @@ const Input = React.forwardRef((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", }, diff --git a/packages/ui/src/form-fields/textarea.tsx b/packages/ui/src/form-fields/textarea.tsx index e6927a9682a..48cc311e383 100644 --- a/packages/ui/src/form-fields/textarea.tsx +++ b/packages/ui/src/form-fields/textarea.tsx @@ -6,12 +6,22 @@ import { useAutoResizeTextArea } from "../hooks/use-auto-resize-textarea"; export interface TextAreaProps extends React.TextareaHTMLAttributes { mode?: "primary" | "transparent"; + textAreaSize?: "xs" | "sm" | "md"; hasError?: boolean; className?: string; } const TextArea = React.forwardRef((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(ref); // auto re-size @@ -24,11 +34,14 @@ const TextArea = React.forwardRef((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", }, diff --git a/packages/ui/src/hooks/use-outside-click-detector.tsx b/packages/ui/src/hooks/use-outside-click-detector.tsx index 5331d11c880..c1a47780377 100644 --- a/packages/ui/src/hooks/use-outside-click-detector.tsx +++ b/packages/ui/src/hooks/use-outside-click-detector.tsx @@ -1,8 +1,31 @@ import React, { useEffect } from "react"; +// TODO: move it to helpers package const useOutsideClickDetector = (ref: React.RefObject, 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(); } }; diff --git a/packages/ui/src/sortable/sortable.tsx b/packages/ui/src/sortable/sortable.tsx index b495d535ec4..93603d173f5 100644 --- a/packages/ui/src/sortable/sortable.tsx +++ b/packages/ui/src/sortable/sortable.tsx @@ -5,7 +5,7 @@ import { Draggable } from "./draggable"; type Props = { 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 = ( source: T, destination: T & Record, 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 = ( newData.splice(adjustedDestinationIndex, 0, movedItem); - return newData; + return { newData, movedItem }; }; export const Sortable = ({ data, render, onChange, keyExtractor, containerClassName, id }: Props) => { @@ -50,7 +53,13 @@ export const Sortable = ({ 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); }, }); diff --git a/packages/ui/src/toast/index.tsx b/packages/ui/src/toast/index.tsx index ce2d05ef784..94cf1fa28c0 100644 --- a/packages/ui/src/toast/index.tsx +++ b/packages/ui/src/toast/index.tsx @@ -69,7 +69,7 @@ export const setToast = (props: SetToastProps) => { borderColorClassName, }: ToastContentProps) => props.type === TOAST_TYPE.LOADING ? ( -
+
{ e.stopPropagation(); @@ -96,6 +96,7 @@ export const setToast = (props: SetToastProps) => {
) : (
{ e.stopPropagation(); e.preventDefault(); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx index be9e3781a3d..b4346394f07 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx @@ -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 diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx index 0b9a94f82e0..c90f155ebe8 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx @@ -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(); diff --git a/web/app/profile/page.tsx b/web/app/profile/page.tsx index ebad3bc5846..03a738a4823 100644 --- a/web/app/profile/page.tsx +++ b/web/app/profile/page.tsx @@ -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 diff --git a/web/ce/components/issue-types/index.ts b/web/ce/components/issue-types/index.ts new file mode 100644 index 00000000000..11413e4c194 --- /dev/null +++ b/web/ce/components/issue-types/index.ts @@ -0,0 +1 @@ +export * from "./values"; diff --git a/web/ce/components/issue-types/values/index.ts b/web/ce/components/issue-types/values/index.ts new file mode 100644 index 00000000000..635be6440d2 --- /dev/null +++ b/web/ce/components/issue-types/values/index.ts @@ -0,0 +1 @@ +export * from "./update"; diff --git a/web/ce/components/issue-types/values/update.tsx b/web/ce/components/issue-types/values/update.tsx new file mode 100644 index 00000000000..52ccb4bd808 --- /dev/null +++ b/web/ce/components/issue-types/values/update.tsx @@ -0,0 +1,8 @@ +type TIssueAdditionalPropertyValuesUpdateProps = { + issueId: string; + issueTypeId: string; + projectId: string; + workspaceSlug: string; +}; + +export const IssueAdditionalPropertyValuesUpdate: React.FC = () => <>; diff --git a/web/ce/components/issues/index.ts b/web/ce/components/issues/index.ts index 1c463b9b983..82d35e3aa2d 100644 --- a/web/ce/components/issues/index.ts +++ b/web/ce/components/issues/index.ts @@ -1,2 +1,4 @@ export * from "./bulk-operations"; export * from "./worklog"; +export * from "./issue-modal"; +export * from "./issue-details"; diff --git a/web/ce/components/issues/issue-details/index.ts b/web/ce/components/issues/issue-details/index.ts new file mode 100644 index 00000000000..36386e658f8 --- /dev/null +++ b/web/ce/components/issues/issue-details/index.ts @@ -0,0 +1 @@ +export * from "./issue-identifier"; diff --git a/web/ce/components/issues/issue-details/issue-identifier.tsx b/web/ce/components/issues/issue-details/issue-identifier.tsx new file mode 100644 index 00000000000..8b32a8d3218 --- /dev/null +++ b/web/ce/components/issues/issue-details/issue-identifier.tsx @@ -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 = 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 ( +
+ + {projectDetails?.identifier}-{issue?.sequence_id} + +
+ ); +}); diff --git a/web/core/components/issues/issue-modal/draft-issue-layout.tsx b/web/ce/components/issues/issue-modal/draft-issue-layout.tsx similarity index 98% rename from web/core/components/issues/issue-modal/draft-issue-layout.tsx rename to web/ce/components/issues/issue-modal/draft-issue-layout.tsx index c22d2a19ffa..d198756c378 100644 --- a/web/core/components/issues/issue-modal/draft-issue-layout.tsx +++ b/web/ce/components/issues/issue-modal/draft-issue-layout.tsx @@ -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"; diff --git a/web/core/components/issues/issue-modal/form.tsx b/web/ce/components/issues/issue-modal/form.tsx similarity index 100% rename from web/core/components/issues/issue-modal/form.tsx rename to web/ce/components/issues/issue-modal/form.tsx diff --git a/web/ce/components/issues/issue-modal/index.ts b/web/ce/components/issues/issue-modal/index.ts new file mode 100644 index 00000000000..ba2baa60a7f --- /dev/null +++ b/web/ce/components/issues/issue-modal/index.ts @@ -0,0 +1,3 @@ +export * from "./form"; +export * from "./draft-issue-layout"; +export * from "./modal"; diff --git a/web/ce/components/issues/issue-modal/modal.tsx b/web/ce/components/issues/issue-modal/modal.tsx new file mode 100644 index 00000000000..f671709916f --- /dev/null +++ b/web/ce/components/issues/issue-modal/modal.tsx @@ -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; + isOpen: boolean; + onClose: () => void; + onSubmit?: (res: TIssue) => Promise; + withDraftIssueWrapper?: boolean; + storeType?: EIssuesStoreType; + isDraft?: boolean; +} + +export const CreateUpdateIssueModal: React.FC = 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(null); + // states + const [changesMade, setChangesMade] = useState | null>(null); + const [createMore, setCreateMore] = useState(false); + const [activeProjectId, setActiveProjectId] = useState(null); + const [description, setDescription] = useState(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> + >("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 || "

"); + return; + } + const response = await fetchIssue( + workspaceSlug.toString(), + projectId.toString(), + issueId, + isDraft ? "DRAFT" : "DEFAULT" + ); + if (response) setDescription(response?.description_html || "

"); + }; + + 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, + is_draft_issue: boolean = false + ): Promise => { + 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 && ( + + ), + }); + captureIssueEvent({ + eventName: ISSUE_CREATED, + payload: { ...response, state: "SUCCESS" }, + path: pathname, + }); + !createMore && handleClose(); + if (createMore) issueTitleRef && issueTitleRef?.current?.focus(); + setDescription("

"); + 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): Promise => { + 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, 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 | null) => setChangesMade(formData); + + // don't open the modal if there are no projects + if (!workspaceProjectIds || workspaceProjectIds.length === 0 || !activeProjectId) return null; + + return ( + handleClose(true)} + position={EModalPosition.TOP} + width={EModalWidth.XXXXL} + > + {withDraftIssueWrapper ? ( + + ) : ( + handleClose(false)} + isCreateMoreToggleEnabled={createMore} + onCreateMoreToggleChange={handleCreateMoreToggleChange} + onSubmit={handleFormSubmit} + projectId={activeProjectId} + isDraft={isDraft} + /> + )} + + ); +}); diff --git a/web/ce/constants/project/settings/index.ts b/web/ce/constants/project/settings/index.ts index 0e849261ac1..a6a842e7be9 100644 --- a/web/ce/constants/project/settings/index.ts +++ b/web/ce/constants/project/settings/index.ts @@ -1 +1,2 @@ export * from "./features"; +export * from "./tabs"; diff --git a/web/ce/constants/project/settings/tabs.ts b/web/ce/constants/project/settings/tabs.ts new file mode 100644 index 00000000000..6da1430caef --- /dev/null +++ b/web/ce/constants/project/settings/tabs.ts @@ -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; +}[] = [ + PROJECT_SETTINGS["general"], + PROJECT_SETTINGS["members"], + PROJECT_SETTINGS["features"], + PROJECT_SETTINGS["states"], + PROJECT_SETTINGS["labels"], + PROJECT_SETTINGS["estimates"], + PROJECT_SETTINGS["automations"], + ]; diff --git a/web/core/components/analytics/custom-analytics/select/project.tsx b/web/core/components/analytics/custom-analytics/select/project.tsx index 4562cff5540..e9d226a7e63 100644 --- a/web/core/components/analytics/custom-analytics/select/project.tsx +++ b/web/core/components/analytics/custom-analytics/select/project.tsx @@ -46,7 +46,6 @@ export const SelectProject: React.FC = observer((props) => { : "All projects"}
} - optionsClassName="w-48" multiple /> ); diff --git a/web/core/components/dropdowns/date.tsx b/web/core/components/dropdowns/date.tsx index 327bb459807..1977d066205 100644 --- a/web/core/components/dropdowns/date.tsx +++ b/web/core/components/dropdowns/date.tsx @@ -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) => { @@ -48,6 +50,7 @@ export const DateDropdown: React.FC = (props) => { showTooltip = false, tabIndex, value, + formatToken, } = props; // states const [isOpen, setIsOpen] = useState(false); @@ -126,13 +129,15 @@ export const DateDropdown: React.FC = (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) && ( - {value ? renderFormattedDate(value) : placeholder} + + {value ? renderFormattedDate(value, formatToken) : placeholder} + )} {isClearable && !disabled && isDateSelected && ( = (props) => { - {isOpen && ( - -
- { - dropdownOnChange(date ?? null); - }} - showOutsideDays - initialFocus - disabled={disabledDays} - mode="single" - /> -
-
- )} + {isOpen && + createPortal( + +
+ { + dropdownOnChange(date ?? null); + }} + showOutsideDays + initialFocus + disabled={disabledDays} + mode="single" + /> +
+
, + document.body + )} ); }; diff --git a/web/core/components/dropdowns/member/index.tsx b/web/core/components/dropdowns/member/index.tsx index f68f5a6fa97..64dc566353f 100644 --- a/web/core/components/dropdowns/member/index.tsx +++ b/web/core/components/dropdowns/member/index.tsx @@ -42,6 +42,7 @@ export const MemberDropdown: React.FC = observer((props) => { placement, projectId, showTooltip = false, + showUserDetails = false, tabIndex, value, icon, @@ -75,6 +76,26 @@ export const MemberDropdown: React.FC = 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 ( = observer((props) => { onClick={handleOnClick} > = observer((props) => { > {!hideIcon && } {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( - - {Array.isArray(value) && value.length > 0 - ? value.length === 1 - ? getUserDetails(value[0])?.display_name - : "" - : placeholder} + + {getDisplayName(value, showUserDetails, placeholder)} )} {dropdownArrow && ( diff --git a/web/core/components/dropdowns/member/member-options.tsx b/web/core/components/dropdowns/member/member-options.tsx index 360b85f11d7..8e7003f24a2 100644 --- a/web/core/components/dropdowns/member/member-options.tsx +++ b/web/core/components/dropdowns/member/member-options.tsx @@ -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 ( - + return createPortal( +
@@ -134,6 +137,7 @@ export const MemberOptions = observer((props: Props) => { )}
-
+
, + document.body ); }); diff --git a/web/core/components/dropdowns/member/types.d.ts b/web/core/components/dropdowns/member/types.d.ts index 758389650ae..9bdc5192c31 100644 --- a/web/core/components/dropdowns/member/types.d.ts +++ b/web/core/components/dropdowns/member/types.d.ts @@ -7,6 +7,7 @@ export type MemberDropdownProps = TDropdownProps & { placeholder?: string; tooltipContent?: string; onClose?: () => void; + showUserDetails?: boolean; } & ( | { multiple: false; diff --git a/web/core/components/estimates/radio-select.tsx b/web/core/components/estimates/radio-select.tsx index f8569ea881e..0515b2c8a4b 100644 --- a/web/core/components/estimates/radio-select.tsx +++ b/web/core/components/estimates/radio-select.tsx @@ -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 (
-
{inputLabel}
+ {inputLabel &&
{inputLabel}
}
{options.map(({ value, label, disabled }, index) => (
!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} /> -
diff --git a/web/core/components/integration/github/import-data.tsx b/web/core/components/integration/github/import-data.tsx index bae8207a303..ca2dac95599 100644 --- a/web/core/components/integration/github/import-data.tsx +++ b/web/core/components/integration/github/import-data.tsx @@ -84,7 +84,7 @@ export const GithubImportData: FC = observer((props) => { } onChange={onChange} options={options} - optionsClassName="w-full" + optionsClassName="w-48" /> )} /> diff --git a/web/core/components/integration/github/select-repository.tsx b/web/core/components/integration/github/select-repository.tsx index d04d33b36a5..122e534dfe5 100644 --- a/web/core/components/integration/github/select-repository.tsx +++ b/web/core/components/integration/github/select-repository.tsx @@ -81,7 +81,7 @@ export const SelectRepository: React.FC = (props) => { )} } - optionsClassName="w-full" + optionsClassName="w-48" /> ); }; diff --git a/web/core/components/integration/github/single-user-select.tsx b/web/core/components/integration/github/single-user-select.tsx index 0f50a5a128e..a936db6302e 100644 --- a/web/core/components/integration/github/single-user-select.tsx +++ b/web/core/components/integration/github/single-user-select.tsx @@ -124,7 +124,7 @@ export const SingleUserSelect: React.FC = ({ collaborator, index, users, newUsers[index].email = val; setUsers(newUsers); }} - optionsClassName="w-full" + optionsClassName="w-48" /> )}
diff --git a/web/core/components/integration/jira/import-users.tsx b/web/core/components/integration/jira/import-users.tsx index 0f8f82165dd..3b7a7cd737a 100644 --- a/web/core/components/integration/jira/import-users.tsx +++ b/web/core/components/integration/jira/import-users.tsx @@ -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" /> )} /> diff --git a/web/core/components/issues/issue-detail/main-content.tsx b/web/core/components/issues/issue-detail/main-content.tsx index 0236e5f1899..f36d8e9849d 100644 --- a/web/core/components/issues/issue-detail/main-content.tsx +++ b/web/core/components/issues/issue-detail/main-content.tsx @@ -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 = 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 = 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 ( <>
@@ -70,14 +67,8 @@ export const IssueMainContent: React.FC = observer((props) => { )}
- {currentIssueState && ( - - )} - + +
= observer((props) => { issueId={issueId} disabled={!isEditable} /> + + {issue.type_id && ( + + )}
diff --git a/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx b/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx index cc1f9d1fb35..2c12ed18be1 100644 --- a/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx +++ b/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx @@ -67,6 +67,7 @@ export const AllIssueQuickActions: React.FC = observer((props { ...issue, name: `${issue.name} (copy)`, + sourceIssueId: issue.id, }, ["id"] ); diff --git a/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx b/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx index e53a4c2a44a..6e5187b64b0 100644 --- a/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx +++ b/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx @@ -77,6 +77,7 @@ export const CycleIssueQuickActions: React.FC = observer((pro { ...issue, name: `${issue.name} (copy)`, + sourceIssueId: issue.id, }, ["id"] ); diff --git a/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx b/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx index 2170a249998..1bfa57d06a7 100644 --- a/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx +++ b/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx @@ -77,6 +77,7 @@ export const ModuleIssueQuickActions: React.FC = observer((pr { ...issue, name: `${issue.name} (copy)`, + sourceIssueId: issue.id, }, ["id"] ); diff --git a/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx b/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx index 943b72c3455..c47d76af77b 100644 --- a/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx +++ b/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx @@ -77,6 +77,7 @@ export const ProjectIssueQuickActions: React.FC = observer((p ...issue, name: `${issue.name} (copy)`, is_draft: isDraftIssue ? false : issue.is_draft, + sourceIssueId: issue.id, }, ["id"] ); diff --git a/web/core/components/issues/issue-modal/index.ts b/web/core/components/issues/issue-modal/index.ts index feac885d4c9..031608e25ff 100644 --- a/web/core/components/issues/issue-modal/index.ts +++ b/web/core/components/issues/issue-modal/index.ts @@ -1,3 +1 @@ -export * from "./draft-issue-layout"; -export * from "./form"; export * from "./modal"; diff --git a/web/core/components/issues/issue-modal/modal.tsx b/web/core/components/issues/issue-modal/modal.tsx index 1520ec767b5..7a984104eb2 100644 --- a/web/core/components/issues/issue-modal/modal.tsx +++ b/web/core/components/issues/issue-modal/modal.tsx @@ -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; - isOpen: boolean; - onClose: () => void; - onSubmit?: (res: TIssue) => Promise; - withDraftIssueWrapper?: boolean; - storeType?: EIssuesStoreType; - isDraft?: boolean; -} - -export const CreateUpdateIssueModal: React.FC = 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(null); - // states - const [changesMade, setChangesMade] = useState | null>(null); - const [createMore, setCreateMore] = useState(false); - const [activeProjectId, setActiveProjectId] = useState(null); - const [description, setDescription] = useState(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> - >("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 || "

"); - return; - } - const response = await fetchIssue( - workspaceSlug.toString(), - projectId.toString(), - issueId, - isDraft ? "DRAFT" : "DEFAULT" - ); - if (response) setDescription(response?.description_html || "

"); - }; - - 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, - is_draft_issue: boolean = false - ): Promise => { - 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 && ( - - ), - }); - captureIssueEvent({ - eventName: ISSUE_CREATED, - payload: { ...response, state: "SUCCESS" }, - path: pathname, - }); - !createMore && handleClose(); - if (createMore) issueTitleRef && issueTitleRef?.current?.focus(); - setDescription("

"); - 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): Promise => { - 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, 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 | null) => setChangesMade(formData); - - // don't open the modal if there are no projects - if (!workspaceProjectIds || workspaceProjectIds.length === 0 || !activeProjectId) return null; - - return ( - handleClose(true)} - position={EModalPosition.TOP} - width={EModalWidth.XXXXL} - > - {withDraftIssueWrapper ? ( - - ) : ( - handleClose(false)} - isCreateMoreToggleEnabled={createMore} - onCreateMoreToggleChange={handleCreateMoreToggleChange} - onSubmit={handleFormSubmit} - projectId={activeProjectId} - isDraft={isDraft} - /> - )} - - ); -}); +export * from "@/plane-web/components/issues/issue-modal/modal"; diff --git a/web/core/components/issues/issue-update-status.tsx b/web/core/components/issues/issue-update-status.tsx index 018e5754aae..6eb064b529d 100644 --- a/web/core/components/issues/issue-update-status.tsx +++ b/web/core/components/issues/issue-update-status.tsx @@ -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 = observer((props) => { - const { isSubmitting, issueDetail } = props; - // hooks - const { getProjectById } = useProject(); + const { isSubmitting } = props; return ( <> - {issueDetail && ( -

- {getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id} -

- )}
= 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 = 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 = observer( return (
- - {projectDetails?.identifier}-{issue?.sequence_id} - + = observer((pro issueId={issueId} disabled={disabled} /> + + {issue.type_id && ( + + )}
); diff --git a/web/core/components/project/card.tsx b/web/core/components/project/card.tsx index 36c1f72686f..678958af856 100644 --- a/web/core/components/project/card.tsx +++ b/web/core/components/project/card.tsx @@ -110,7 +110,7 @@ export const ProjectCard: React.FC = 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), diff --git a/web/core/components/project/send-project-invitation-modal.tsx b/web/core/components/project/send-project-invitation-modal.tsx index c0281e840dc..e0c05061548 100644 --- a/web/core/components/project/send-project-invitation-modal.tsx +++ b/web/core/components/project/send-project-invitation-modal.tsx @@ -239,7 +239,7 @@ export const SendProjectInvitationModal: React.FC = observer((props) => { onChange(val); }} options={options} - optionsClassName="w-full" + optionsClassName="w-48" /> ); }} diff --git a/web/core/constants/project.ts b/web/core/constants/project.ts index 0737e77d0d3..4a7899fd946 100644 --- a/web/core/constants/project.ts +++ b/web/core/constants/project.ts @@ -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; -}[] = [ - { - 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; diff --git a/web/core/hooks/use-outside-click-detector.tsx b/web/core/hooks/use-outside-click-detector.tsx index 5331d11c880..c1a47780377 100644 --- a/web/core/hooks/use-outside-click-detector.tsx +++ b/web/core/hooks/use-outside-click-detector.tsx @@ -1,8 +1,31 @@ import React, { useEffect } from "react"; +// TODO: move it to helpers package const useOutsideClickDetector = (ref: React.RefObject, 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(); } }; diff --git a/web/core/hooks/use-peek-overview-outside-click.tsx b/web/core/hooks/use-peek-overview-outside-click.tsx index 22b9badff4c..d8c198ca2e9 100644 --- a/web/core/hooks/use-peek-overview-outside-click.tsx +++ b/web/core/hooks/use-peek-overview-outside-click.tsx @@ -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; diff --git a/web/core/store/issue/issue-details/issue.store.ts b/web/core/store/issue/issue-details/issue.store.ts index c464c25893f..500d8e034b8 100644 --- a/web/core/store/issue/issue-details/issue.store.ts +++ b/web/core/store/issue/issue-details/issue.store.ts @@ -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, diff --git a/web/ee/components/issue-types/index.ts b/web/ee/components/issue-types/index.ts new file mode 100644 index 00000000000..11413e4c194 --- /dev/null +++ b/web/ee/components/issue-types/index.ts @@ -0,0 +1 @@ +export * from "./values"; diff --git a/web/ee/components/issue-types/values/index.ts b/web/ee/components/issue-types/values/index.ts new file mode 100644 index 00000000000..635be6440d2 --- /dev/null +++ b/web/ee/components/issue-types/values/index.ts @@ -0,0 +1 @@ +export * from "./update"; diff --git a/web/ee/components/issue-types/values/update.tsx b/web/ee/components/issue-types/values/update.tsx new file mode 100644 index 00000000000..0077ac339cc --- /dev/null +++ b/web/ee/components/issue-types/values/update.tsx @@ -0,0 +1 @@ +export * from "ce/components/issue-types/values/update"; diff --git a/web/ee/components/issues/index.ts b/web/ee/components/issues/index.ts index 1c463b9b983..82d35e3aa2d 100644 --- a/web/ee/components/issues/index.ts +++ b/web/ee/components/issues/index.ts @@ -1,2 +1,4 @@ export * from "./bulk-operations"; export * from "./worklog"; +export * from "./issue-modal"; +export * from "./issue-details"; diff --git a/web/ee/components/issues/issue-details/index.ts b/web/ee/components/issues/issue-details/index.ts new file mode 100644 index 00000000000..36386e658f8 --- /dev/null +++ b/web/ee/components/issues/issue-details/index.ts @@ -0,0 +1 @@ +export * from "./issue-identifier"; diff --git a/web/ee/components/issues/issue-details/issue-identifier.tsx b/web/ee/components/issues/issue-details/issue-identifier.tsx new file mode 100644 index 00000000000..739e414828e --- /dev/null +++ b/web/ee/components/issues/issue-details/issue-identifier.tsx @@ -0,0 +1 @@ +export * from "ce/components/issues/issue-details/issue-identifier"; diff --git a/web/ee/components/issues/issue-modal/index.ts b/web/ee/components/issues/issue-modal/index.ts new file mode 100644 index 00000000000..031608e25ff --- /dev/null +++ b/web/ee/components/issues/issue-modal/index.ts @@ -0,0 +1 @@ +export * from "./modal"; diff --git a/web/ee/components/issues/issue-modal/modal.tsx b/web/ee/components/issues/issue-modal/modal.tsx new file mode 100644 index 00000000000..609809f8628 --- /dev/null +++ b/web/ee/components/issues/issue-modal/modal.tsx @@ -0,0 +1 @@ +export * from "ce/components/issues/issue-modal/modal"; diff --git a/web/ee/constants/project/settings/tabs.ts b/web/ee/constants/project/settings/tabs.ts new file mode 100644 index 00000000000..0f004a83259 --- /dev/null +++ b/web/ee/constants/project/settings/tabs.ts @@ -0,0 +1 @@ +export * from "ce/constants/project/settings/tabs"; diff --git a/web/helpers/date-time.helper.ts b/web/helpers/date-time.helper.ts index 4b79cfde50d..cc8fd8f82d8 100644 --- a/web/helpers/date-time.helper.ts +++ b/web/helpers/date-time.helper.ts @@ -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; }; diff --git a/web/helpers/emoji.helper.tsx b/web/helpers/emoji.helper.tsx index 72f22bed5c4..0ce98d634c8 100644 --- a/web/helpers/emoji.helper.tsx +++ b/web/helpers/emoji.helper.tsx @@ -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 ""; diff --git a/web/styles/globals.css b/web/styles/globals.css index bfcd8b6c03c..76f429a2861 100644 --- a/web/styles/globals.css +++ b/web/styles/globals.css @@ -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;