diff --git a/apps/plane/components/command-palette/index.tsx b/apps/plane/components/command-palette/index.tsx index cd3870277..16053564f 100644 --- a/apps/plane/components/command-palette/index.tsx +++ b/apps/plane/components/command-palette/index.tsx @@ -1,6 +1,10 @@ import React, { useState, useCallback, useEffect } from "react"; // next import { useRouter } from "next/router"; +// swr +import { mutate } from "swr"; +// react hook form +import { SubmitHandler, useForm } from "react-hook-form"; // headless ui import { Combobox, Dialog, Transition } from "@headlessui/react"; // hooks @@ -9,7 +13,11 @@ import useTheme from "lib/hooks/useTheme"; import useToast from "lib/hooks/useToast"; // icons import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; -import { DocumentPlusIcon, FolderPlusIcon, FolderIcon } from "@heroicons/react/24/outline"; +import { + FolderIcon, + RectangleStackIcon, + ClipboardDocumentListIcon, +} from "@heroicons/react/24/outline"; // commons import { classNames, copyTextToClipboard } from "constants/common"; // components @@ -18,13 +26,22 @@ import CreateProjectModal from "components/project/CreateProjectModal"; import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal"; import CreateUpdateCycleModal from "components/project/cycles/CreateUpdateCyclesModal"; // types -import { IIssue } from "types"; +import { IIssue, IProject, IssueResponse } from "types"; +import { Button } from "ui"; +import issuesServices from "lib/services/issues.services"; +// fetch keys +import { PROJECTS_LIST, PROJECT_ISSUES_LIST } from "constants/fetch-keys"; + type ItemType = { name: string; url?: string; onClick?: () => void; }; +type FormInput = { + issue_ids: string[]; +}; + const CommandPalette: React.FC = () => { const router = useRouter(); @@ -36,7 +53,7 @@ const CommandPalette: React.FC = () => { const [isShortcutsModalOpen, setIsShortcutsModalOpen] = useState(false); const [isCreateCycleModalOpen, setIsCreateCycleModalOpen] = useState(false); - const { issues, activeProject } = useUser(); + const { activeWorkspace, activeProject, issues, cycles } = useUser(); const { toggleCollapsed } = useTheme(); @@ -48,10 +65,18 @@ const CommandPalette: React.FC = () => { : issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? []; + const { + register, + formState: { errors, isSubmitting }, + handleSubmit, + reset, + setError, + } = useForm(); + const quickActions = [ { name: "Add new issue...", - icon: DocumentPlusIcon, + icon: RectangleStackIcon, shortcut: "I", onClick: () => { setIsIssueModalOpen(true); @@ -59,7 +84,7 @@ const CommandPalette: React.FC = () => { }, { name: "Add new project...", - icon: FolderPlusIcon, + icon: ClipboardDocumentListIcon, shortcut: "P", onClick: () => { setIsProjectModalOpen(true); @@ -70,6 +95,7 @@ const CommandPalette: React.FC = () => { const handleCommandPaletteClose = () => { setIsPaletteOpen(false); setQuery(""); + reset(); }; const handleKeyDown = useCallback( @@ -116,6 +142,44 @@ const CommandPalette: React.FC = () => { [toggleCollapsed, setToastAlert, router] ); + const handleDelete: SubmitHandler = (data) => { + if (activeWorkspace && activeProject && data.issue_ids) { + issuesServices + .bulkDeleteIssues(activeWorkspace.slug, activeProject.id, data) + .then((res) => { + mutate( + PROJECT_ISSUES_LIST(activeWorkspace.slug, activeProject.id), + (prevData) => { + return { + ...(prevData as IssueResponse), + count: (prevData?.results ?? []).filter( + (p) => !data.issue_ids.some((id) => p.id === id) + ).length, + results: (prevData?.results ?? []).filter( + (p) => !data.issue_ids.some((id) => p.id === id) + ), + }; + }, + false + ); + }) + .catch((e) => { + console.log(e); + }); + } + }; + + const handleAddToCycle: SubmitHandler = (data) => { + if (activeWorkspace && activeProject && data.issue_ids) { + issuesServices + .bulkAddIssuesToCycle(activeWorkspace.slug, activeProject.id, "", data) + .then((res) => {}) + .catch((e) => { + console.log(e); + }); + } + }; + useEffect(() => { document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); @@ -137,7 +201,6 @@ const CommandPalette: React.FC = () => { setIsOpen={setIsIssueModalOpen} projectId={activeProject?.id} /> - { leaveTo="opacity-0 scale-95" > - { - const { url, onClick } = item; - if (url) router.push(url); - else if (onClick) onClick(); - handleCommandPaletteClose(); - }} - > -
-
- - + { + // const { url, onClick } = item; + // if (url) router.push(url); + // else if (onClick) onClick(); + // handleCommandPaletteClose(); + // }} > - {filteredIssues.length > 0 && ( - <> +
+
+ + + {filteredIssues.length > 0 && ( + <> +
  • + {query === "" && ( +

    + Issues +

    + )} +
      + {filteredIssues.map((issue) => ( + + classNames( + "flex cursor-pointer select-none items-center rounded-md px-3 py-2", + active ? "bg-gray-900 bg-opacity-5 text-gray-900" : "" + ) + } + > + {({ active }) => ( + <> + {/* + ))} +
    +
  • + + )} + {query === "" && (
  • - {query === "" && ( -

    - Issues -

    - )} +

    Quick actions

      - {filteredIssues.map((issue) => ( + {quickActions.map((action) => ( classNames( - "flex cursor-pointer select-none items-center rounded-md px-3 py-2", + "flex cursor-default select-none items-center rounded-md px-3 py-2", active ? "bg-gray-900 bg-opacity-5 text-gray-900" : "" ) } > {({ active }) => ( <> - ))}
  • - - )} - {query === "" && ( -
  • -

    Quick actions

    -
      - {quickActions.map((action) => ( - - classNames( - "flex cursor-default select-none items-center rounded-md px-3 py-2", - active ? "bg-gray-900 bg-opacity-5 text-gray-900" : "" - ) - } - > - {({ active }) => ( - <> - - ))} -
    -
  • - )} -
    + )} +
    - {query !== "" && filteredIssues.length === 0 && ( -
    -
    diff --git a/apps/plane/components/lexical/config.ts b/apps/plane/components/lexical/config.ts new file mode 100644 index 000000000..41f9f1a67 --- /dev/null +++ b/apps/plane/components/lexical/config.ts @@ -0,0 +1,31 @@ +import { HeadingNode, QuoteNode } from "@lexical/rich-text"; +import { TableCellNode, TableNode, TableRowNode } from "@lexical/table"; +import { ListItemNode, ListNode } from "@lexical/list"; +import { CodeHighlightNode, CodeNode } from "@lexical/code"; +import { AutoLinkNode, LinkNode } from "@lexical/link"; +// theme +import { defaultTheme } from "./theme"; + +export const initialConfig = { + namespace: "LexicalEditor", + // The editor theme + theme: defaultTheme, + // Handling of errors during update + onError(error: any) { + console.error(error); + }, + // Any custom nodes go here + nodes: [ + HeadingNode, + ListNode, + ListItemNode, + QuoteNode, + CodeNode, + CodeHighlightNode, + TableNode, + TableCellNode, + TableRowNode, + AutoLinkNode, + LinkNode, + ], +}; diff --git a/apps/plane/components/lexical/editor.tsx b/apps/plane/components/lexical/editor.tsx new file mode 100644 index 000000000..3685de22e --- /dev/null +++ b/apps/plane/components/lexical/editor.tsx @@ -0,0 +1,75 @@ +import { FC } from "react"; +import { EditorState, LexicalEditor, $getRoot, $getSelection } from "lexical"; +import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"; +import { ListPlugin } from "@lexical/react/LexicalListPlugin"; +import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; +import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; +import { TRANSFORMERS, CHECK_LIST } from "@lexical/markdown"; +import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; +import { $generateHtmlFromNodes } from "@lexical/html"; +import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin"; + +// custom plugins +import { CodeHighlightPlugin } from "./plugins/code-highlight"; +import { LexicalToolbar } from "./toolbar"; +// config +import { initialConfig } from "./config"; +// helpers +import { getValidatedValue } from "./helpers/editor"; +import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; + +export interface RichTextEditorProps { + onChange: (state: string) => void; + id: string; + value: string; +} + +const RichTextEditor: FC = (props) => { + // props + const { onChange, value, id } = props; + + function handleChange(state: EditorState, editor: LexicalEditor) { + state.read(() => { + onChange(JSON.stringify(state.toJSON())); + }); + } + + return ( + +
    + +
    + + } + ErrorBoundary={LexicalErrorBoundary} + placeholder={ +
    + Enter some text... +
    + } + /> + + + + + + + +
    +
    +
    + ); +}; + +export default RichTextEditor; diff --git a/apps/plane/components/lexical/helpers/editor.ts b/apps/plane/components/lexical/helpers/editor.ts new file mode 100644 index 000000000..2f929a99b --- /dev/null +++ b/apps/plane/components/lexical/helpers/editor.ts @@ -0,0 +1,33 @@ +export const positionEditorElement = (editor: any, rect: any) => { + if (window) { + if (rect === null) { + editor.style.opacity = "0"; + editor.style.top = "-1000px"; + editor.style.left = "-1000px"; + } else { + editor.style.opacity = "1"; + editor.style.top = `${ + rect.top + rect.height + window.pageYOffset + 10 + }px`; + editor.style.left = `${ + rect.left + window.pageXOffset - editor.offsetWidth / 2 + rect.width / 2 + }px`; + } + } +}; + +export const getValidatedValue = (value: string) => { + const defaultValue = + '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'; + + if (value) { + try { + const json = JSON.parse(value); + return JSON.stringify(json); + } catch (error) { + return defaultValue; + } + } + + return defaultValue; +}; diff --git a/apps/plane/components/lexical/helpers/node.ts b/apps/plane/components/lexical/helpers/node.ts new file mode 100644 index 000000000..bedf95dd9 --- /dev/null +++ b/apps/plane/components/lexical/helpers/node.ts @@ -0,0 +1,17 @@ +import { $isAtNodeEnd } from "@lexical/selection"; + +export const getSelectedNode = (selection: any) => { + const anchor = selection.anchor; + const focus = selection.focus; + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + if (anchorNode === focusNode) { + return anchorNode; + } + const isBackward = selection.isBackward(); + if (isBackward) { + return $isAtNodeEnd(focus) ? anchorNode : focusNode; + } else { + return $isAtNodeEnd(anchor) ? focusNode : anchorNode; + } +}; diff --git a/apps/plane/components/lexical/plugins/code-highlight.tsx b/apps/plane/components/lexical/plugins/code-highlight.tsx new file mode 100644 index 000000000..b75fb9af4 --- /dev/null +++ b/apps/plane/components/lexical/plugins/code-highlight.tsx @@ -0,0 +1,11 @@ +import { useEffect } from "react"; +import { registerCodeHighlighting } from "@lexical/code"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; + +export const CodeHighlightPlugin = () => { + const [editor] = useLexicalComposerContext(); + useEffect(() => { + return registerCodeHighlighting(editor); + }, [editor]); + return null; +}; diff --git a/apps/plane/components/lexical/plugins/read-only.tsx b/apps/plane/components/lexical/plugins/read-only.tsx new file mode 100644 index 000000000..5c9b85db7 --- /dev/null +++ b/apps/plane/components/lexical/plugins/read-only.tsx @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; + +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { getValidatedValue } from "../helpers/editor"; + +const ReadOnlyPlugin = ({ value }: { value: string }) => { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (editor && value) { + const initialEditorState = editor?.parseEditorState( + getValidatedValue(value) || "" + ); + editor.setEditorState(initialEditorState); + } + }, [editor, value]); + + return <>; +}; + +export default ReadOnlyPlugin; diff --git a/apps/plane/components/lexical/theme.ts b/apps/plane/components/lexical/theme.ts new file mode 100644 index 000000000..4ffe94ddd --- /dev/null +++ b/apps/plane/components/lexical/theme.ts @@ -0,0 +1,67 @@ +export const defaultTheme = { + ltr: "ltr", + rtl: "rtl", + placeholder: "editor-placeholder", + paragraph: "mb-1", + quote: "editor-quote", + heading: { + h1: "text-3xl font-bold", + h2: "text-2xl font-bold", + h3: "text-xl font-bold", + h4: "text-lg font-bold", + h5: "text-base font-bold", + }, + list: { + nested: { + listitem: "list-item", + }, + ol: "list-decimal pl-4", + ul: "list-disc pl-4", + listitem: "list-item", + }, + image: "editor-image", + link: "editor-link", + text: { + bold: "font-bold", + italic: "italic", + overflowed: "editor-text-overflowed", + hashtag: "editor-text-hashtag", + underline: "underline", + strikethrough: "line-through", + underlineStrikethrough: "editor-text-underlineStrikethrough", + code: "editor-text-code", + }, + code: "editor-code", + codeHighlight: { + atrule: "editor-tokenAttr", + attr: "editor-tokenAttr", + boolean: "editor-tokenProperty", + builtin: "editor-tokenSelector", + cdata: "editor-tokenComment", + char: "editor-tokenSelector", + class: "editor-tokenFunction", + "class-name": "editor-tokenFunction", + comment: "editor-tokenComment", + constant: "editor-tokenProperty", + deleted: "editor-tokenProperty", + doctype: "editor-tokenComment", + entity: "editor-tokenOperator", + function: "editor-tokenFunction", + important: "editor-tokenVariable", + inserted: "editor-tokenSelector", + keyword: "editor-tokenAttr", + namespace: "editor-tokenVariable", + number: "editor-tokenProperty", + operator: "editor-tokenOperator", + prolog: "editor-tokenComment", + property: "editor-tokenProperty", + punctuation: "editor-tokenPunctuation", + regex: "editor-tokenVariable", + selector: "editor-tokenSelector", + string: "editor-tokenSelector", + symbol: "editor-tokenProperty", + tag: "editor-tokenProperty", + url: "editor-tokenOperator", + variable: "editor-tokenVariable", + }, +}; diff --git a/apps/plane/components/lexical/toolbar/block-type-select.tsx b/apps/plane/components/lexical/toolbar/block-type-select.tsx new file mode 100644 index 000000000..235046b79 --- /dev/null +++ b/apps/plane/components/lexical/toolbar/block-type-select.tsx @@ -0,0 +1,333 @@ +import { FC, Fragment, useState, useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { + CAN_REDO_COMMAND, + CAN_UNDO_COMMAND, + REDO_COMMAND, + UNDO_COMMAND, + SELECTION_CHANGE_COMMAND, + FORMAT_TEXT_COMMAND, + FORMAT_ELEMENT_COMMAND, + $getSelection, + $isRangeSelection, + $createParagraphNode, + $getNodeByKey, +} from "lexical"; +import { + INSERT_ORDERED_LIST_COMMAND, + INSERT_UNORDERED_LIST_COMMAND, + INSERT_CHECK_LIST_COMMAND, + REMOVE_LIST_COMMAND, + $isListNode, + ListNode, +} from "@lexical/list"; +import { + $isParentElementRTL, + $isAtNodeEnd, + $wrapNodes, +} from "@lexical/selection"; +import { + $createHeadingNode, + $createQuoteNode, + $isHeadingNode, +} from "@lexical/rich-text"; +import { + $createCodeNode, + $isCodeNode, + getDefaultCodeLanguage, + getCodeLanguages, +} from "@lexical/code"; + +const BLOCK_DATA = [ + { type: "paragraph", name: "Normal" }, + { type: "h1", name: "Large Heading" }, + { type: "h2", name: "Small Heading" }, + { type: "h3", name: "Heading" }, + { type: "h4", name: "Heading" }, + { type: "h5", name: "Heading" }, + { type: "Quote", name: "quote" }, + { type: "ol", name: "Numbered List" }, + { type: "ul", name: "Bulleted List" }, +]; + +const supportedBlockTypes = new Set([ + "paragraph", + "quote", + "code", + "h1", + "h2", + "ul", + "ol", +]); + +const blockTypeToBlockName: any = { + code: "Code Block", + h1: "Large Heading", + h2: "Small Heading", + h3: "Heading", + h4: "Heading", + h5: "Heading", + ol: "Numbered List", + paragraph: "Normal", + quote: "Quote", + ul: "Bulleted List", +}; + +export interface BlockTypeSelectProps { + editor: any; + toolbarRef: any; + blockType: string; +} + +export const BlockTypeSelect: FC = (props) => { + const { editor, toolbarRef, blockType } = props; + // refs + const dropDownRef = useRef(null); + // states + const [showBlockOptionsDropDown, setShowBlockOptionsDropDown] = + useState(false); + + useEffect(() => { + const toolbar = toolbarRef.current; + const dropDown = dropDownRef.current; + + if (toolbar !== null && dropDown !== null) { + const { top, left } = toolbar.getBoundingClientRect(); + dropDown.style.top = `${top + 40}px`; + dropDown.style.left = `${left}px`; + } + }, [dropDownRef, toolbarRef]); + + useEffect(() => { + const dropDown = dropDownRef.current; + const toolbar = toolbarRef.current; + + if (dropDown !== null && toolbar !== null) { + const handle = (event: any) => { + const target = event.target; + + if (!dropDown.contains(target) && !toolbar.contains(target)) { + setShowBlockOptionsDropDown(false); + } + }; + document.addEventListener("click", handle); + + return () => { + document.removeEventListener("click", handle); + }; + } + }, [dropDownRef, setShowBlockOptionsDropDown, toolbarRef]); + + const formatParagraph = () => { + if (blockType !== "paragraph") { + editor.update(() => { + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + $wrapNodes(selection, () => $createParagraphNode()); + } + }); + } + setShowBlockOptionsDropDown(false); + }; + + const formatLargeHeading = () => { + console.log("blockType ", blockType); + if (blockType !== "h1") { + editor.update(() => { + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + $wrapNodes(selection, () => $createHeadingNode("h1")); + } + }); + } + setShowBlockOptionsDropDown(false); + }; + + const formatSmallHeading = () => { + if (blockType !== "h2") { + editor.update(() => { + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + $wrapNodes(selection, () => $createHeadingNode("h2")); + } + }); + } + setShowBlockOptionsDropDown(false); + }; + + const formatBulletList = () => { + if (blockType !== "ul") { + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND); + } else { + editor.dispatchCommand(REMOVE_LIST_COMMAND); + } + setShowBlockOptionsDropDown(false); + }; + + const formatNumberedList = () => { + if (blockType !== "ol") { + editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND); + } else { + editor.dispatchCommand(REMOVE_LIST_COMMAND); + } + setShowBlockOptionsDropDown(false); + }; + + const formatQuote = () => { + if (blockType !== "quote") { + editor.update(() => { + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + $wrapNodes(selection, () => $createQuoteNode()); + } + }); + } + setShowBlockOptionsDropDown(false); + }; + + const formatCode = () => { + if (blockType !== "code") { + editor.update(() => { + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + $wrapNodes(selection, () => $createCodeNode()); + } + }); + } + setShowBlockOptionsDropDown(false); + }; + + return ( +
    + + {showBlockOptionsDropDown && ( +
      +
    • + + Normal + {blockType === "paragraph" && } +
    • +
    • + + Large Heading + {blockType === "h1" && } +
    • +
    • + + Small Heading + {blockType === "h2" && } +
    • +
    • + + Bullet List + {blockType === "ul" && } +
    • +
    • + + Numbered List + {blockType === "ol" && } +
    • + +
    • + + Quote + {blockType === "quote" && } +
    • + {/* */} +
    + )} +
    + ); +}; + +// export const BlockTypeSelect: FC = () => { +// const [selected, setSelected] = useState(BLOCK_DATA[0]); + +// return ( +//
    +// +//
    +// +// {selected.name} +// +// +// +// +// +// +// +// +// {BLOCK_DATA.map((blockType, index) => ( +// +// `relative cursor-default select-none py-2 px-2 ${ +// active ? 'bg-amber-100 text-amber-900' : 'text-gray-900' +// }` +// } +// value={blockType} +// > +// {({ selected }) => ( +// <> +// +// {blockType.name} +// +// +// )} +// +// ))} +// +// +//
    +//
    +//
    +// ); +// }; diff --git a/apps/plane/components/lexical/toolbar/floating-link-editor.tsx b/apps/plane/components/lexical/toolbar/floating-link-editor.tsx new file mode 100644 index 000000000..6eda2cda5 --- /dev/null +++ b/apps/plane/components/lexical/toolbar/floating-link-editor.tsx @@ -0,0 +1,156 @@ +import { useRef, useState, useCallback, useEffect } from 'react'; +import { SELECTION_CHANGE_COMMAND, $getSelection, $isRangeSelection } from 'lexical'; +import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'; +import { mergeRegister } from '@lexical/utils'; + +// helper functions +import { positionEditorElement } from '../helpers/editor'; +import { getSelectedNode } from '../helpers/node'; + +const LowPriority = 1; + +export interface FloatingLinkEditorProps { + editor: any; +} + +export const FloatingLinkEditor = ({ editor }: FloatingLinkEditorProps) => { + // refs + const editorRef = useRef(null); + const inputRef = useRef(null); + const mouseDownRef = useRef(false); + // states + const [linkUrl, setLinkUrl] = useState(''); + const [isEditMode, setEditMode] = useState(false); + const [lastSelection, setLastSelection] = useState(null); + + const updateLinkEditor = useCallback(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const node = getSelectedNode(selection); + const parent = node.getParent(); + if ($isLinkNode(parent)) { + setLinkUrl(parent.getURL()); + } else if ($isLinkNode(node)) { + setLinkUrl(node.getURL()); + } else { + setLinkUrl(''); + } + } + const editorElem = editorRef.current; + const nativeSelection = window?.getSelection(); + const activeElement = document.activeElement; + + if (editorElem === null) { + return; + } + + const rootElement = editor.getRootElement(); + if ( + selection !== null && + !nativeSelection?.isCollapsed && + rootElement !== null && + rootElement.contains(nativeSelection?.anchorNode) + ) { + const domRange = nativeSelection?.getRangeAt(0); + let rect; + if (nativeSelection?.anchorNode === rootElement) { + let inner = rootElement; + while (inner.firstElementChild != null) { + inner = inner.firstElementChild; + } + rect = inner.getBoundingClientRect(); + } else { + rect = domRange?.getBoundingClientRect(); + } + + if (!mouseDownRef.current) { + positionEditorElement(editorElem, rect); + } + setLastSelection(selection); + } else if (!activeElement || activeElement.className !== 'link-input') { + positionEditorElement(editorElem, null); + setLastSelection(null); + setEditMode(false); + setLinkUrl(''); + } + + return true; + }, [editor]); + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(({ editorState }: any) => { + editorState.read(() => { + updateLinkEditor(); + }); + }), + + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + updateLinkEditor(); + return true; + }, + LowPriority + ) + ); + }, [editor, updateLinkEditor]); + + useEffect(() => { + editor.getEditorState().read(() => { + updateLinkEditor(); + }); + }, [editor, updateLinkEditor]); + + useEffect(() => { + if (isEditMode && inputRef?.current) { + inputRef.current.focus(); + } + }, [isEditMode]); + + return ( +
    + {isEditMode ? ( + { + setLinkUrl(event.target.value); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + if (lastSelection !== null) { + if (linkUrl !== '') { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, linkUrl); + } + setEditMode(false); + } + } else if (event.key === 'Escape') { + event.preventDefault(); + setEditMode(false); + } + }} + /> + ) : ( + <> +
    + + {linkUrl} + +
    event.preventDefault()} + onClick={() => { + setEditMode(true); + }} + /> +
    + + )} +
    + ); +}; diff --git a/apps/plane/components/lexical/toolbar/index.tsx b/apps/plane/components/lexical/toolbar/index.tsx new file mode 100644 index 000000000..c0d5e098a --- /dev/null +++ b/apps/plane/components/lexical/toolbar/index.tsx @@ -0,0 +1,430 @@ +import { useEffect, useState, useRef, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { + CAN_REDO_COMMAND, + CAN_UNDO_COMMAND, + REDO_COMMAND, + UNDO_COMMAND, + SELECTION_CHANGE_COMMAND, + FORMAT_TEXT_COMMAND, + FORMAT_ELEMENT_COMMAND, + $getSelection, + $isRangeSelection, + $createParagraphNode, + $getNodeByKey, + RangeSelection, + NodeSelection, + GridSelection, +} from "lexical"; +import { + INSERT_ORDERED_LIST_COMMAND, + INSERT_UNORDERED_LIST_COMMAND, + REMOVE_LIST_COMMAND, + $isListNode, + ListNode, +} from "@lexical/list"; +import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { + $isParentElementRTL, + $wrapNodes, + $isAtNodeEnd, +} from "@lexical/selection"; +import { $getNearestNodeOfType, mergeRegister } from "@lexical/utils"; +import { + $createHeadingNode, + $createQuoteNode, + $isHeadingNode, +} from "@lexical/rich-text"; +// custom elements +import { FloatingLinkEditor } from "./floating-link-editor"; +import { BlockTypeSelect } from "./block-type-select"; + +const LowPriority = 1; + +function getSelectedNode(selection: any) { + const anchor = selection.anchor; + const focus = selection.focus; + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + if (anchorNode === focusNode) { + return anchorNode; + } + const isBackward = selection.isBackward(); + if (isBackward) { + return $isAtNodeEnd(focus) ? anchorNode : focusNode; + } else { + return $isAtNodeEnd(anchor) ? focusNode : anchorNode; + } +} + +export const LexicalToolbar = () => { + // editor + const [editor] = useLexicalComposerContext(); + // ref + const toolbarRef = useRef(null); + // states + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + const [blockType, setBlockType] = useState("paragraph"); + const [selectedElementKey, setSelectedElementKey] = useState( + null + ); + const [isRTL, setIsRTL] = useState(false); + const [isLink, setIsLink] = useState(false); + const [isBold, setIsBold] = useState(false); + const [isItalic, setIsItalic] = useState(false); + const [isUnderline, setIsUnderline] = useState(false); + const [isStrikethrough, setIsStrikethrough] = useState(false); + const [isCode, setIsCode] = useState(false); + + const updateToolbar = useCallback(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const anchorNode = selection.anchor.getNode(); + const element = + anchorNode.getKey() === "root" + ? anchorNode + : anchorNode.getTopLevelElementOrThrow(); + const elementKey = element.getKey(); + const elementDOM = editor.getElementByKey(elementKey); + if (elementDOM !== null) { + setSelectedElementKey(elementKey); + if ($isListNode(element)) { + const parentList = $getNearestNodeOfType(anchorNode, ListNode); + const type = parentList ? parentList.getTag() : element.getTag(); + setBlockType(type); + } else { + const type = $isHeadingNode(element) + ? element.getTag() + : element.getType(); + setBlockType(type); + } + } + // Update text format + setIsBold(selection.hasFormat("bold")); + setIsItalic(selection.hasFormat("italic")); + setIsUnderline(selection.hasFormat("underline")); + setIsStrikethrough(selection.hasFormat("strikethrough")); + setIsRTL($isParentElementRTL(selection)); + + // Update links + const node = getSelectedNode(selection); + const parent = node.getParent(); + if ($isLinkNode(parent) || $isLinkNode(node)) { + setIsLink(true); + } else { + setIsLink(false); + } + } + }, [editor]); + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + updateToolbar(); + }); + }), + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + (_payload, newEditor) => { + updateToolbar(); + return false; + }, + LowPriority + ), + editor.registerCommand( + CAN_UNDO_COMMAND, + (payload) => { + setCanUndo(payload); + return false; + }, + LowPriority + ), + editor.registerCommand( + CAN_REDO_COMMAND, + (payload) => { + setCanRedo(payload); + return false; + }, + LowPriority + ) + ); + }, [editor, updateToolbar]); + + const insertLink = useCallback( + (e: any) => { + e.preventDefault(); + if (!isLink) { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, "https://"); + } else { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); + } + }, + [editor, isLink] + ); + + return ( +
    + + + + + + + + + + {isLink && + createPortal(, document.body)} + + + + {" "} +
    + ); +}; diff --git a/apps/plane/components/lexical/viewer.tsx b/apps/plane/components/lexical/viewer.tsx new file mode 100644 index 000000000..83be573c0 --- /dev/null +++ b/apps/plane/components/lexical/viewer.tsx @@ -0,0 +1,60 @@ +import { FC } from "react"; +import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"; +import { ListPlugin } from "@lexical/react/LexicalListPlugin"; +import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; +import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; +import { TRANSFORMERS } from "@lexical/markdown"; +// custom plugins +import { CodeHighlightPlugin } from "./plugins/code-highlight"; +import ReadOnlyPlugin from "./plugins/read-only"; +// config +import { initialConfig } from "./config"; +// helpers +import { getValidatedValue } from "./helpers/editor"; +import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; + +export interface RichTextViewerProps { + id: string; + value: string; +} + +const RichTextViewer: FC = (props) => { + // props + const { value, id } = props; + + return ( + +
    + + } + ErrorBoundary={LexicalErrorBoundary} + placeholder={ +
    + Enter some text... +
    + } + /> + + + + + + +
    +
    + ); +}; + +export default RichTextViewer; diff --git a/apps/plane/components/project/cycles/CycleView.tsx b/apps/plane/components/project/cycles/CycleView.tsx index 7cc28ed54..dd1ee9041 100644 --- a/apps/plane/components/project/cycles/CycleView.tsx +++ b/apps/plane/components/project/cycles/CycleView.tsx @@ -175,7 +175,7 @@ const SprintView: React.FC = ({
    )) ) : ( -

    This sprint has no issues.

    +

    This cycle has no issues.

    ) ) : (
    diff --git a/apps/plane/components/project/issues/BoardView/SingleBoard.tsx b/apps/plane/components/project/issues/BoardView/SingleBoard.tsx index 63065de1d..928261f64 100644 --- a/apps/plane/components/project/issues/BoardView/SingleBoard.tsx +++ b/apps/plane/components/project/issues/BoardView/SingleBoard.tsx @@ -18,6 +18,7 @@ import { PlusIcon, } from "@heroicons/react/24/outline"; import Image from "next/image"; +import { divide } from "lodash"; type Props = { selectedGroup: NestedKeyOf | null; @@ -54,9 +55,6 @@ const SingleBoard: React.FC = ({ // Collapse/Expand const [show, setState] = useState(true); - // Edit state name - const [showInput, setInput] = useState(false); - if (selectedGroup === "priority") groupTitle === "high" ? (bgColor = "#dc2626") @@ -79,57 +77,52 @@ const SingleBoard: React.FC = ({
    - {showInput ? null : ( -
    - -
    + +
    + { - // setInput(true); + /> +

    - -

    - {groupTitle === null || groupTitle === "null" - ? "None" - : createdBy - ? createdBy - : addSpaceIfCamelCase(groupTitle)} -

    - - {groupedByIssues[groupTitle].length} - -

    + {groupTitle === null || groupTitle === "null" + ? "None" + : createdBy + ? createdBy + : addSpaceIfCamelCase(groupTitle)} + + + {groupedByIssues[groupTitle].length} +
    - )} +
    +
    + -
    - - -
    -
    */} + +
    +
    */} )} diff --git a/apps/plane/components/project/issues/BoardView/index.tsx b/apps/plane/components/project/issues/BoardView/index.tsx index 2dfd5575c..557ec406f 100644 --- a/apps/plane/components/project/issues/BoardView/index.tsx +++ b/apps/plane/components/project/issues/BoardView/index.tsx @@ -21,6 +21,8 @@ import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssue import { Spinner } from "ui"; // types import type { IState, IIssue, Properties, NestedKeyOf, ProjectMember } from "types"; +import ConfirmIssueDeletion from "../ConfirmIssueDeletion"; +import { TrashIcon } from "@heroicons/react/24/outline"; type Props = { properties: Properties; @@ -35,6 +37,8 @@ const BoardView: React.FC = ({ properties, selectedGroup, groupedByIssues const [isOpen, setIsOpen] = useState(false); const [isIssueOpen, setIsIssueOpen] = useState(false); + const [isIssueDeletionOpen, setIsIssueDeletionOpen] = useState(false); + const [issueDeletionData, setIssueDeletionData] = useState(); const [preloadedData, setPreloadedData] = useState< (Partial & { actionType: "createIssue" | "edit" | "delete" }) | undefined @@ -58,72 +62,96 @@ const BoardView: React.FC = ({ properties, selectedGroup, groupedByIssues if (!result.destination) return; const { source, destination, type } = result; - if (type === "state") { - const newStates = Array.from(states ?? []); - const [reorderedState] = newStates.splice(source.index, 1); - newStates.splice(destination.index, 0, reorderedState); - const prevSequenceNumber = newStates[destination.index - 1]?.sequence; - const nextSequenceNumber = newStates[destination.index + 1]?.sequence; + if (destination.droppableId === "trashBox") { + const removedItem = groupedByIssues[source.droppableId][source.index]; - const sequenceNumber = - prevSequenceNumber && nextSequenceNumber - ? (prevSequenceNumber + nextSequenceNumber) / 2 - : nextSequenceNumber - ? nextSequenceNumber - 15000 / 2 - : prevSequenceNumber - ? prevSequenceNumber + 15000 / 2 - : 15000; + setIssueDeletionData(removedItem); + setIsIssueDeletionOpen(true); - newStates[destination.index].sequence = sequenceNumber; - - mutateState(newStates, false); - if (!activeWorkspace) return; - stateServices - .patchState(activeWorkspace.slug, projectId as string, newStates[destination.index].id, { - sequence: sequenceNumber, - }) - .then((response) => { - console.log(response); - }) - .catch((err) => { - console.error(err); - }); + console.log(removedItem); } else { - if (source.droppableId !== destination.droppableId) { - const sourceGroup = source.droppableId; // source group id - const destinationGroup = destination.droppableId; // destination group id - if (!sourceGroup || !destinationGroup) return; + if (type === "state") { + const newStates = Array.from(states ?? []); + const [reorderedState] = newStates.splice(source.index, 1); + newStates.splice(destination.index, 0, reorderedState); + const prevSequenceNumber = newStates[destination.index - 1]?.sequence; + const nextSequenceNumber = newStates[destination.index + 1]?.sequence; - // removed/dragged item - const removedItem = groupedByIssues[source.droppableId][source.index]; + const sequenceNumber = + prevSequenceNumber && nextSequenceNumber + ? (prevSequenceNumber + nextSequenceNumber) / 2 + : nextSequenceNumber + ? nextSequenceNumber - 15000 / 2 + : prevSequenceNumber + ? prevSequenceNumber + 15000 / 2 + : 15000; - if (selectedGroup === "priority") { - // update the removed item for mutation - removedItem.priority = destinationGroup; + newStates[destination.index].sequence = sequenceNumber; - // patch request - issuesServices.patchIssue(activeWorkspace!.slug, projectId as string, removedItem.id, { - priority: destinationGroup, + mutateState(newStates, false); + if (!activeWorkspace) return; + stateServices + .patchState( + activeWorkspace.slug, + projectId as string, + newStates[destination.index].id, + { + sequence: sequenceNumber, + } + ) + .then((response) => { + console.log(response); + }) + .catch((err) => { + console.error(err); }); - } else if (selectedGroup === "state_detail.name") { - const destinationState = states?.find((s) => s.name === destinationGroup); - const destinationStateId = destinationState?.id; + } else { + if (source.droppableId !== destination.droppableId) { + const sourceGroup = source.droppableId; // source group id + const destinationGroup = destination.droppableId; // destination group id + if (!sourceGroup || !destinationGroup) return; - // update the removed item for mutation - if (!destinationStateId || !destinationState) return; - removedItem.state = destinationStateId; - removedItem.state_detail = destinationState; + // removed/dragged item + const removedItem = groupedByIssues[source.droppableId][source.index]; - // patch request - issuesServices.patchIssue(activeWorkspace!.slug, projectId as string, removedItem.id, { - state: destinationStateId, - }); + if (selectedGroup === "priority") { + // update the removed item for mutation + removedItem.priority = destinationGroup; + + // patch request + issuesServices.patchIssue( + activeWorkspace!.slug, + projectId as string, + removedItem.id, + { + priority: destinationGroup, + } + ); + } else if (selectedGroup === "state_detail.name") { + const destinationState = states?.find((s) => s.name === destinationGroup); + const destinationStateId = destinationState?.id; + + // update the removed item for mutation + if (!destinationStateId || !destinationState) return; + removedItem.state = destinationStateId; + removedItem.state_detail = destinationState; + + // patch request + issuesServices.patchIssue( + activeWorkspace!.slug, + projectId as string, + removedItem.id, + { + state: destinationStateId, + } + ); + } + + // remove item from the source group + groupedByIssues[source.droppableId].splice(source.index, 1); + // add item to the destination group + groupedByIssues[destination.droppableId].splice(destination.index, 0, removedItem); } - - // remove item from the source group - groupedByIssues[source.droppableId].splice(source.index, 1); - // add item to the destination group - groupedByIssues[destination.droppableId].splice(destination.index, 0, removedItem); } } }, @@ -155,6 +183,11 @@ const BoardView: React.FC = ({ properties, selectedGroup, groupedByIssues setIsOpen={setIsOpen} data={preloadedData as Partial} /> */} + setIsIssueDeletionOpen(false)} + data={issueDeletionData} + /> = ({ properties, selectedGroup, groupedByIssues projectId={projectId as string} /> {groupedByIssues ? ( - groupedByIssues ? ( -
    - -
    - - {(provided) => ( -
    -
    - {Object.keys(groupedByIssues).map((singleGroup, index) => ( - m.member.id === singleGroup)?.member - .first_name - : undefined - } - groupedByIssues={groupedByIssues} - index={index} - setIsIssueOpen={setIsIssueOpen} - properties={properties} - setPreloadedData={setPreloadedData} - stateId={ - selectedGroup === "state_detail.name" - ? states?.find((s) => s.name === singleGroup)?.id - : undefined - } - bgColor={ - selectedGroup === "state_detail.name" - ? states?.find((s) => s.name === singleGroup)?.color - : undefined - } - /> - ))} -
    - {provided.placeholder} +
    + + {/* + {(provided, snapshot) => ( + + )} + */} +
    + + {(provided) => ( +
    +
    + {Object.keys(groupedByIssues).map((singleGroup, index) => ( + m.member.id === singleGroup)?.member.first_name + : undefined + } + groupedByIssues={groupedByIssues} + index={index} + setIsIssueOpen={setIsIssueOpen} + properties={properties} + setPreloadedData={setPreloadedData} + stateId={ + selectedGroup === "state_detail.name" + ? states?.find((s) => s.name === singleGroup)?.id + : undefined + } + bgColor={ + selectedGroup === "state_detail.name" + ? states?.find((s) => s.name === singleGroup)?.color + : undefined + } + /> + ))}
    - )} - -
    - -
    - ) : null + {provided.placeholder} +
    + )} + +
    + +
    ) : ( -
    +
    )} diff --git a/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectCycles.tsx b/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectCycles.tsx index f66b0022d..7987bff8d 100644 --- a/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectCycles.tsx +++ b/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectCycles.tsx @@ -20,7 +20,7 @@ type Props = { }; const SelectSprint: React.FC = ({ control, setIsOpen }) => { - const { sprints } = useUser(); + const { cycles } = useUser(); return ( <> @@ -35,7 +35,7 @@ const SelectSprint: React.FC = ({ control, setIsOpen }) => { - {sprints?.find((i) => i.id.toString() === value?.toString())?.name ?? "Cycle"} + {cycles?.find((i) => i.id.toString() === value?.toString())?.name ?? "Cycle"} @@ -48,10 +48,10 @@ const SelectSprint: React.FC = ({ control, setIsOpen }) => { >
    - {sprints?.map((sprint) => ( + {cycles?.map((cycle) => ( `relative cursor-pointer select-none p-2 rounded-md ${ active ? "bg-theme text-white" : "text-gray-900" @@ -61,7 +61,7 @@ const SelectSprint: React.FC = ({ control, setIsOpen }) => { {({ active, selected }) => ( <> - {sprint.name} + {cycle.name} )} diff --git a/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectParentIssues.tsx b/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectParentIssues.tsx index 7fead660b..83db0b895 100644 --- a/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectParentIssues.tsx +++ b/apps/plane/components/project/issues/CreateUpdateIssueModal/SelectParentIssues.tsx @@ -52,6 +52,7 @@ const SelectParent: React.FC = ({ control }) => { }; })} value={value} + width="xs" buttonClassName="max-h-30 overflow-y-scroll" optionsClassName="max-h-30 overflow-y-scroll" onChange={onChange} diff --git a/apps/plane/components/project/issues/ListView/index.tsx b/apps/plane/components/project/issues/ListView/index.tsx index 9c12ac1c3..ff6fc68d9 100644 --- a/apps/plane/components/project/issues/ListView/index.tsx +++ b/apps/plane/components/project/issues/ListView/index.tsx @@ -77,11 +77,11 @@ const ListView: React.FC = ({ const handleHover = (issueId: string) => { document.addEventListener("keydown", (e) => { - if (e.code === "Space") { - e.preventDefault(); - setPreviewModalIssueId(issueId); - setIssuePreviewModal(true); - } + // if (e.code === "Space") { + // e.preventDefault(); + // setPreviewModalIssueId(issueId); + // setIssuePreviewModal(true); + // } }); }; diff --git a/apps/plane/components/project/issues/issue-detail/IssueDetailSidebar.tsx b/apps/plane/components/project/issues/issue-detail/IssueDetailSidebar.tsx index 985146cdc..0522506df 100644 --- a/apps/plane/components/project/issues/issue-detail/IssueDetailSidebar.tsx +++ b/apps/plane/components/project/issues/issue-detail/IssueDetailSidebar.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; // swr import useSWR from "swr"; // headless ui @@ -21,7 +21,8 @@ import { // commons import { classNames, copyTextToClipboard } from "constants/common"; // ui -import { Input, Button } from "ui"; +import { Input, Button, Spinner } from "ui"; +import { Popover } from "@headlessui/react"; // icons import { UserIcon, @@ -32,10 +33,12 @@ import { ChartBarIcon, ClipboardDocumentIcon, LinkIcon, + ArrowPathIcon, } from "@heroicons/react/24/outline"; // types import type { Control } from "react-hook-form"; import type { IIssue, IIssueLabels, IssueResponse, IState, WorkspaceMember } from "types"; +import { TwitterPicker } from "react-color"; type Props = { control: Control; @@ -47,10 +50,11 @@ const PRIORITIES = ["high", "medium", "low"]; const defaultValues: Partial = { name: "", + colour: "#ff0000", }; const IssueDetailSidebar: React.FC = ({ control, submitChanges, issueDetail }) => { - const { activeWorkspace, activeProject } = useUser(); + const { activeWorkspace, activeProject, cycles } = useUser(); const { data: states } = useSWR( activeWorkspace && activeProject ? STATE_LIST(activeProject.id) : null, @@ -85,6 +89,8 @@ const IssueDetailSidebar: React.FC = ({ control, submitChanges, issueDeta handleSubmit, formState: { isSubmitting }, reset, + watch, + control: controlLabel, } = useForm({ defaultValues, }); @@ -100,250 +106,374 @@ const IssueDetailSidebar: React.FC = ({ control, submitChanges, issueDeta }); }; - const sidebarOptions = [ - { - label: "Priority", - name: "priority", - canSelectMultipleOptions: false, - icon: ChartBarIcon, - options: PRIORITIES.map((property) => ({ - label: property, - value: property, - })), - }, - { - label: "Status", - name: "state", - canSelectMultipleOptions: false, - icon: Squares2X2Icon, - options: states?.map((state) => ({ - label: state.name, - value: state.id, - })), - }, - { - label: "Assignees", - name: "assignees_list", - canSelectMultipleOptions: true, - icon: UserGroupIcon, - options: people?.map((person) => ({ - label: person.member.first_name, - value: person.member.id, - })), - }, - { - label: "Blocker", - name: "blockers_list", - canSelectMultipleOptions: true, - icon: UserIcon, - options: projectIssues?.results?.map((issue) => ({ - label: issue.name, - value: issue.id, - })), - }, - { - label: "Blocked", - name: "blocked_list", - canSelectMultipleOptions: true, - icon: UserIcon, - options: projectIssues?.results?.map((issue) => ({ - label: issue.name, - value: issue.id, - })), - }, + const sidebarSections = [ + [ + { + label: "Status", + name: "state", + canSelectMultipleOptions: false, + icon: Squares2X2Icon, + options: states?.map((state) => ({ + label: state.name, + value: state.id, + })), + }, + { + label: "Assignees", + name: "assignees_list", + canSelectMultipleOptions: true, + icon: UserGroupIcon, + options: people?.map((person) => ({ + label: person.member.first_name, + value: person.member.id, + })), + }, + { + label: "Priority", + name: "priority", + canSelectMultipleOptions: false, + icon: ChartBarIcon, + options: PRIORITIES.map((property) => ({ + label: property, + value: property, + })), + }, + ], + [ + { + label: "Blocker", + name: "blockers_list", + canSelectMultipleOptions: true, + icon: UserIcon, + options: projectIssues?.results?.map((issue) => ({ + label: issue.name, + value: issue.id, + })), + }, + { + label: "Blocked", + name: "blocked_list", + canSelectMultipleOptions: true, + icon: UserIcon, + options: projectIssues?.results?.map((issue) => ({ + label: issue.name, + value: issue.id, + })), + }, + { + label: "Due Date", + name: "target_date", + canSelectMultipleOptions: true, + icon: UserIcon, + }, + ], + [ + { + label: "Cycle", + name: "cycle", + canSelectMultipleOptions: false, + icon: ArrowPathIcon, + options: cycles?.map((cycle) => ({ + label: cycle.name, + value: cycle.id, + })), + }, + ], ]; + const handleCycleChange = (cycleId: string) => { + if (activeWorkspace && activeProject && issueDetail) + issuesServices.addIssueToSprint(activeWorkspace.slug, activeProject.id, cycleId, { + issue: issueDetail.id, + }); + }; + return ( -
    -
    -
    -

    Quick Actions

    -
    - - -
    - {sidebarOptions.map((item) => ( -
    -
    - -

    {item.label}

    -
    -
    - ( - submitChanges({ [item.name]: value })} - className="flex-shrink-0" - > - {({ open }) => ( -
    - - - {value - ? Array.isArray(value) - ? value - .map( - (i: any) => - item.options?.find((option) => option.value === i)?.label - ) - .join(", ") || item.label - : item.options?.find((option) => option.value === value)?.label - : "None"} - - - - - - -
    - {item.options?.map((option) => ( - - `${ - active || selected ? "text-white bg-theme" : "text-gray-900" - } ${ - item.label === "Priority" && "capitalize" - } cursor-pointer select-none relative p-2 rounded-md truncate` - } - value={option.value} - > - {option.label} - - ))} -
    -
    -
    -
    +
    +
    +

    + {activeProject?.identifier}-{issueDetail?.sequence_id} +

    +
    + + +
    +
    +
    + {sidebarSections.map((section, index) => ( +
    + {section.map((item) => ( +
    +
    + +

    {item.label}

    +
    +
    + {item.name === "target_date" ? ( + ( + { + submitChanges({ target_date: e.target.value }); + onChange(e.target.value); + }} + className="hover:bg-gray-100 border rounded-md shadow-sm px-2 py-1 cursor-pointer focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-xs duration-300" + /> )} - - )} - /> -
    -
    - ))} -
    -
    - - -
    -
    -
    -
    - -

    Label

    -
    -
    - ( - submitChanges({ labels_list: value })} - className="flex-shrink-0" - > - {({ open }) => ( - <> - Label -
    - - - {value && value.length > 0 - ? value - .map( - (i: string) => - issueLabels?.find((option) => option.id === i)?.name - ) - .join(", ") - : "None"} - - - + /> + ) : ( + ( + { + if (item.name === "cycle") handleCycleChange(value); + else submitChanges({ [item.name]: value }); + }} + className="flex-shrink-0" + > + {({ open }) => ( +
    + + + {value + ? Array.isArray(value) + ? value + .map( + (i: any) => + item.options?.find((option) => option.value === i) + ?.label + ) + .join(", ") || item.label + : item.options?.find((option) => option.value === value) + ?.label + : "None"} + + + - - -
    - {issueLabels?.map((label: any) => ( - - `${ - active || selected ? "text-white bg-theme" : "text-gray-900" - } cursor-pointer select-none relative p-2 rounded-md truncate` - } - value={label.id} - > - {label.name} - - ))} -
    -
    -
    -
    - + + +
    + {item.options ? ( + item.options.length > 0 ? ( + item.options.map((option) => ( + + `${ + active || selected + ? "text-white bg-theme" + : "text-gray-900" + } ${ + item.label === "Priority" && "capitalize" + } cursor-pointer select-none relative p-2 rounded-md truncate` + } + value={option.value} + > + {option.label} + + )) + ) : ( +
    No {item.label}s found
    + ) + ) : ( + + )} +
    +
    +
    +
    + )} +
    + )} + /> + )} +
    +
    + ))} +
    + ))} +
    +
    +
    Add new label
    +
    +
    + + {({ open }) => ( + <> + + {watch("colour") && watch("colour") !== "" && ( + )} - - )} - /> -
    + + + + + + ( + onChange(value.hex)} /> + )} + /> + + + + )} + +
    + + + +
    +
    + +

    Label

    +
    +
    + ( + submitChanges({ labels_list: value })} + className="flex-shrink-0" + > + {({ open }) => ( + <> + Label +
    + + + {value && value.length > 0 + ? value + .map( + (i: string) => + issueLabels?.find((option) => option.id === i)?.name + ) + .join(", ") + : "None"} + + + + + + +
    + {issueLabels ? ( + issueLabels.length > 0 ? ( + issueLabels.map((label: IIssueLabels) => ( + + `${ + active || selected + ? "text-white bg-theme" + : "text-gray-900" + } flex items-center gap-2 cursor-pointer select-none relative p-2 rounded-md truncate` + } + value={label.id} + > + + {label.name} + + )) + ) : ( +
    No labels found
    + ) + ) : ( + + )} +
    +
    +
    +
    + + )} +
    + )} + />
    diff --git a/apps/plane/components/project/issues/issue-detail/activity/index.tsx b/apps/plane/components/project/issues/issue-detail/activity/index.tsx index c48ba6ea8..ab80eb9c3 100644 --- a/apps/plane/components/project/issues/issue-detail/activity/index.tsx +++ b/apps/plane/components/project/issues/issue-detail/activity/index.tsx @@ -1,6 +1,7 @@ // next import Image from "next/image"; import { + CalendarDaysIcon, ChartBarIcon, ChatBubbleBottomCenterTextIcon, Squares2X2Icon, @@ -19,6 +20,7 @@ const activityIcons = { priority: , name: , description: , + target_date: , }; const IssueActivitySection: React.FC = ({ issueActivities, states }) => { @@ -45,7 +47,7 @@ const IssueActivitySection: React.FC = ({ issueActivities, states }) => {
    ) : ( -
    +
    {activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? ( = ({ issueActivities, states }) => {
    )} -
    +

    - {activity.actor_detail.first_name} {activity.actor_detail.last_name}{" "} + + {activity.actor_detail.first_name} {activity.actor_detail.last_name} + {" "} {activity.verb}{" "} {activity.verb !== "created" ? ( {activity.field ?? "commented"} ) : ( " this issue" )} + {timeAgo(activity.created_at)}

    -

    {timeAgo(activity.created_at)}

    {activity.verb !== "created" && ( -
    +
    - From:{" "} - - {activity.field === "state" - ? activity.old_value - ? addSpaceIfCamelCase( - states?.find((s) => s.id === activity.old_value)?.name ?? "" - ) - : "None" - : activity.old_value} - + From: + {activity.field === "state" + ? activity.old_value + ? addSpaceIfCamelCase( + states?.find((s) => s.id === activity.old_value)?.name ?? "" + ) + : "None" + : activity.old_value}
    - To:{" "} - - {activity.field === "state" - ? activity.new_value - ? addSpaceIfCamelCase( - states?.find((s) => s.id === activity.new_value)?.name ?? "" - ) - : "None" - : activity.new_value} - + To: + {activity.field === "state" + ? activity.new_value + ? addSpaceIfCamelCase( + states?.find((s) => s.id === activity.new_value)?.name ?? "" + ) + : "None" + : activity.new_value}
    )} diff --git a/apps/plane/components/project/memberInvitations.tsx b/apps/plane/components/project/memberInvitations.tsx index bfeaf73a6..dc7f3af69 100644 --- a/apps/plane/components/project/memberInvitations.tsx +++ b/apps/plane/components/project/memberInvitations.tsx @@ -39,7 +39,7 @@ const ProjectMemberInvitations = ({ return ( <>
    diff --git a/apps/plane/components/workspace/SingleInvitation.tsx b/apps/plane/components/workspace/SingleInvitation.tsx new file mode 100644 index 000000000..27614e984 --- /dev/null +++ b/apps/plane/components/workspace/SingleInvitation.tsx @@ -0,0 +1,77 @@ +// next +import Image from "next/image"; +// react +import { useState } from "react"; +// types +import { IWorkspaceInvitation } from "types"; + +type Props = { + invitation: IWorkspaceInvitation; + invitationsRespond: string[]; + handleInvitation: any; +}; + +const SingleInvitation: React.FC = ({ + invitation, + invitationsRespond, + handleInvitation, +}) => { + const [isChecked, setIsChecked] = useState(invitationsRespond.includes(invitation.id)); + + return ( + <> +
  • + +
  • + + ); +}; + +export default SingleInvitation; diff --git a/apps/plane/constants/api-routes.ts b/apps/plane/constants/api-routes.ts index ea22163f7..136e13d3b 100644 --- a/apps/plane/constants/api-routes.ts +++ b/apps/plane/constants/api-routes.ts @@ -103,6 +103,13 @@ export const ISSUE_LABELS = (workspaceSlug: string, projectId: string) => export const FILTER_STATE_ISSUES = (workspaceSlug: string, projectId: string, state: string) => `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/?state=${state}`; +export const BULK_DELETE_ISSUES = (workspaceSlug: string, projectId: string) => + `/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-delete-issues/`; +export const BULK_ADD_ISSUES_TO_CYCLE = ( + workspaceSlug: string, + projectId: string, + cycleId: string +) => `/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/bulk-assign-issues/`; // states export const STATES_ENDPOINT = (workspaceSlug: string, projectId: string) => diff --git a/apps/plane/contexts/user.context.tsx b/apps/plane/contexts/user.context.tsx index 55037af14..6b52b0939 100644 --- a/apps/plane/contexts/user.context.tsx +++ b/apps/plane/contexts/user.context.tsx @@ -16,7 +16,6 @@ import { CURRENT_USER, PROJECTS_LIST, USER_WORKSPACES, - USER_WORKSPACE_INVITATIONS, PROJECT_ISSUES_LIST, STATE_LIST, CYCLE_LIST, @@ -24,7 +23,8 @@ import { // types import type { KeyedMutator } from "swr"; -import type { IUser, IWorkspace, IProject, IIssue, IssueResponse, ICycle, IState } from "types"; +import type { IUser, IWorkspace, IProject, IssueResponse, ICycle, IState } from "types"; + interface IUserContextProps { user?: IUser; isUserLoading: boolean; @@ -38,8 +38,8 @@ interface IUserContextProps { activeProject?: IProject; issues?: IssueResponse; mutateIssues: KeyedMutator; - sprints?: ICycle[]; - mutateSprints: KeyedMutator; + cycles?: ICycle[]; + mutateCycles: KeyedMutator; states?: IState[]; mutateStates: KeyedMutator; } @@ -92,7 +92,7 @@ export const UserProvider = ({ children }: { children: ReactElement }) => { : null ); - const { data: sprints, mutate: mutateSprints } = useSWR( + const { data: cycles, mutate: mutateCycles } = useSWR( activeWorkspace && activeProject ? CYCLE_LIST(activeProject.id) : null, activeWorkspace && activeProject ? () => sprintsServices.getCycles(activeWorkspace.slug, activeProject.id) @@ -141,8 +141,8 @@ export const UserProvider = ({ children }: { children: ReactElement }) => { activeProject, issues, mutateIssues, - sprints, - mutateSprints, + cycles, + mutateCycles, states, mutateStates, setActiveProject, diff --git a/apps/plane/layouts/Navbar/Sidebar.tsx b/apps/plane/layouts/Navbar/Sidebar.tsx index 03863f4cf..48c6f7842 100644 --- a/apps/plane/layouts/Navbar/Sidebar.tsx +++ b/apps/plane/layouts/Navbar/Sidebar.tsx @@ -28,11 +28,13 @@ import { XMarkIcon, ArrowLongLeftIcon, QuestionMarkCircleIcon, + EllipsisHorizontalIcon, + ClipboardDocumentIcon, } from "@heroicons/react/24/outline"; // constants -import { classNames } from "constants/common"; +import { classNames, copyTextToClipboard } from "constants/common"; // ui -import { Spinner, Tooltip } from "ui"; +import { CustomListbox, Spinner, Tooltip } from "ui"; // types import type { IUser } from "types"; @@ -423,23 +425,66 @@ const Sidebar: React.FC = () => { {({ open }) => ( <> - - - {project?.name.charAt(0)} - - {!sidebarCollapse && ( - - {project?.name} - +
    + + + {project?.name.charAt(0)} + {!sidebarCollapse && ( + + {project?.name} + + + + + )} + + {!sidebarCollapse && ( + + + + + + + +
    + + {(active) => ( + + )} + +
    +
    +
    +
    )} - +
    { )}
    - + +
    -
    +

    Workspace Invitations

    +

    + Select invites that you want to accept. +

    +
      + {invitations.map((invitation) => ( + + ))} +
    +
    diff --git a/apps/plane/pages/me/my-issues.tsx b/apps/plane/pages/me/my-issues.tsx index 91f79ae81..eaf206a26 100644 --- a/apps/plane/pages/me/my-issues.tsx +++ b/apps/plane/pages/me/my-issues.tsx @@ -145,7 +145,9 @@ const MyIssues: NextPage = () => { {myIssue.name} - {myIssue.description} + + {myIssue.description} + {myIssue.project_detail?.name}
    diff --git a/apps/plane/pages/projects/[projectId]/cycles.tsx b/apps/plane/pages/projects/[projectId]/cycles.tsx index be21dfc41..eec2bbc91 100644 --- a/apps/plane/pages/projects/[projectId]/cycles.tsx +++ b/apps/plane/pages/projects/[projectId]/cycles.tsx @@ -14,21 +14,18 @@ import { CYCLE_ISSUES, CYCLE_LIST } from "constants/fetch-keys"; // layouts import AdminLayout from "layouts/AdminLayout"; // components -import SprintView from "components/project/cycles/CycleView"; +import CycleView from "components/project/cycles/CycleView"; import ConfirmIssueDeletion from "components/project/issues/ConfirmIssueDeletion"; import ConfirmSprintDeletion from "components/project/cycles/ConfirmCycleDeletion"; import CreateUpdateIssuesModal from "components/project/issues/CreateUpdateIssueModal"; import CreateUpdateSprintsModal from "components/project/cycles/CreateUpdateCyclesModal"; // ui -import { Spinner } from "ui"; +import { BreadcrumbItem, Breadcrumbs, HeaderButton, Spinner, EmptySpace, EmptySpaceItem } from "ui"; // icons import { PlusIcon } from "@heroicons/react/20/solid"; +import { ArrowPathIcon } from "@heroicons/react/24/outline"; // types import { IIssue, ICycle, SelectSprintType, SelectIssue } from "types"; -import { EmptySpace, EmptySpaceItem } from "ui/EmptySpace"; -import { ArrowPathIcon } from "@heroicons/react/24/outline"; -import HeaderButton from "ui/HeaderButton"; -import { BreadcrumbItem, Breadcrumbs } from "ui/Breadcrumbs"; const ProjectSprints: NextPage = () => { const [isOpen, setIsOpen] = useState(false); @@ -44,7 +41,7 @@ const ProjectSprints: NextPage = () => { const { projectId } = router.query; - const { data: sprints } = useSWR( + const { data: cycles } = useSWR( projectId && activeWorkspace ? CYCLE_LIST(projectId as string) : null, activeWorkspace && projectId ? () => sprintService.getCycles(activeWorkspace.slug, projectId as string) @@ -52,14 +49,14 @@ const ProjectSprints: NextPage = () => { ); const openIssueModal = ( - sprintId: string, + cycleId: string, issue?: IIssue, actionType: "create" | "edit" | "delete" = "create" ) => { - const sprint = sprints?.find((sprint) => sprint.id === sprintId); - if (sprint) { + const cycle = cycles?.find((cycle) => cycle.id === cycleId); + if (cycle) { setSelectedSprint({ - ...sprint, + ...cycle, actionType: "create-issue", }); if (issue) setSelectedIssues({ ...issue, actionType }); @@ -67,16 +64,16 @@ const ProjectSprints: NextPage = () => { } }; - const addIssueToSprint = (sprintId: string, issueId: string) => { + const addIssueToSprint = (cycleId: string, issueId: string) => { if (!activeWorkspace || !projectId) return; issuesServices - .addIssueToSprint(activeWorkspace.slug, projectId as string, sprintId, { + .addIssueToSprint(activeWorkspace.slug, projectId as string, cycleId, { issue: issueId, }) .then((response) => { console.log(response); - mutate(CYCLE_ISSUES(sprintId)); + mutate(CYCLE_ISSUES(cycleId)); }) .catch((error) => { console.log(error); @@ -134,8 +131,8 @@ const ProjectSprints: NextPage = () => { setIsOpen={setIsOpen} projectId={projectId as string} /> - {sprints ? ( - sprints.length > 0 ? ( + {cycles ? ( + cycles.length > 0 ? (
    @@ -146,15 +143,15 @@ const ProjectSprints: NextPage = () => { setIsOpen(true)} />
    - {sprints.map((sprint) => ( - ( + ))}
    diff --git a/apps/plane/pages/projects/[projectId]/issues/[issueId].tsx b/apps/plane/pages/projects/[projectId]/issues/[issueId].tsx index 0c3471983..740529415 100644 --- a/apps/plane/pages/projects/[projectId]/issues/[issueId].tsx +++ b/apps/plane/pages/projects/[projectId]/issues/[issueId].tsx @@ -53,7 +53,19 @@ const IssueDetail: NextPage = () => { handleSubmit, reset, control, - } = useForm({}); + } = useForm({ + defaultValues: { + name: "", + description: "", + state: "", + assignees_list: [], + priority: "low", + blockers_list: [], + blocked_list: [], + target_date: new Date().toString(), + cycle: "", + }, + }); const { data: issueActivities } = useSWR( activeWorkspace && projectId && issueId ? PROJECT_ISSUES_ACTIVITY : null, @@ -150,24 +162,23 @@ const IssueDetail: NextPage = () => { />
    - - - -
    -

    {`${activeProject?.name}/${activeProject?.identifier}-${issueDetail?.sequence_id}`}

    + + + +
    { if (!prevIssue) return; router.push(`/projects/${prevIssue.project}/issues/${prevIssue.id}`); @@ -177,6 +188,7 @@ const IssueDetail: NextPage = () => { Icon={ChevronRightIcon} disabled={!nextIssue} label="Next" + className={`${!nextIssue ? "cursor-not-allowed opacity-70" : ""}`} onClick={() => { if (!nextIssue) return; router.push(`/projects/${nextIssue.project}/issues/${nextIssue?.id}`); @@ -188,7 +200,7 @@ const IssueDetail: NextPage = () => { {issueDetail && activeProject ? (
    -
    +