From 0a88db975a3b1549324a3525060b02ed1fd02b16 Mon Sep 17 00:00:00 2001 From: Manish Gupta <59428681+manishg3@users.noreply.github.com> Date: Fri, 17 Nov 2023 11:51:54 +0530 Subject: [PATCH 01/10] dev: Self Hosting with private repo fixes (#2787) * fixes to self hosting * self hosting fixes * removed .temp * wip * wip * self install private repo * folder change * fix --------- Co-authored-by: sriram veeraghanta --- .gitignore | 1 + deploy/selfhost/install.sh | 22 ++++++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index dcb8b8671..0b655bd0e 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,4 @@ pnpm-workspace.yaml tmp/ ## packages dist +.temp/ diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh index f9437a844..6f1226821 100755 --- a/deploy/selfhost/install.sh +++ b/deploy/selfhost/install.sh @@ -1,9 +1,8 @@ #!/bin/bash -BRANCH=${BRANCH:-master} +BRANCH=master SCRIPT_DIR=$PWD PLANE_INSTALL_DIR=$PWD/plane-app -mkdir -p $PLANE_INSTALL_DIR/archive function install(){ echo @@ -28,7 +27,20 @@ function download(){ mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env fi + if [ "$BRANCH" != "master" ]; + then + cp $PLANE_INSTALL_DIR/docker-compose.yaml $PLANE_INSTALL_DIR/temp.yaml + sed -e 's@plane-frontend:@plane-frontend-private:@g' \ + -e 's@plane-space:@plane-space-private:@g' \ + -e 's@plane-backend:@plane-backend-private:@g' \ + -e 's@plane-proxy:@plane-proxy-private:@g' \ + -e 's@${APP_RELEASE:-latest}@'"$BRANCH"'@g' \ + $PLANE_INSTALL_DIR/temp.yaml > $PLANE_INSTALL_DIR/docker-compose.yaml + + rm $PLANE_INSTALL_DIR/temp.yaml + fi + echo "" echo "Latest version is now available for you to use" echo "" @@ -108,4 +120,10 @@ function askForAction(){ fi } +if [ "$BRANCH" != "master" ]; +then + PLANE_INSTALL_DIR=$PWD/plane-app-$(echo $BRANCH | sed -r 's@(\/|" "|\.)@-@g') +fi +mkdir -p $PLANE_INSTALL_DIR/archive + askForAction From 9369ee50089756de2d6fb3a309871c435d61bd08 Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Fri, 17 Nov 2023 12:29:30 +0530 Subject: [PATCH 02/10] [feat]: Drag and Drop Handles for all Data Structures (#2745) * better variable names and comments * drag drop migrated * custom horizontal rule created * init transaction hijack * fixed code block with better contrast, keyboard tripple enter press disabled and syntax highlighting * fixed link selector closing on open behaviour * added better keymaps and syntax highlights * made drag and drop working for code blocks * fixed drag drop for code blocks * moved drag drop only to rich text editor * fixed drag and drop only for description * enabled drag handles for peek overview and main issues * got images to old state --- packages/editor/core/package.json | 8 +- packages/editor/core/src/index.ts | 1 + .../editor/core/src/lib/editor-commands.ts | 7 +- .../editor/core/src/styles/github-dark.css | 85 ++++++ .../editor/core/src/types/validate-image.ts | 1 + .../core/src/ui/extensions/code/index.tsx | 29 ++ .../src/ui/extensions/horizontal-rule.tsx | 116 ++++++++ .../editor/core/src/ui/extensions/index.tsx | 24 +- .../editor/core/src/ui/extensions/keymap.tsx | 54 ++++ .../editor/core/src/ui/hooks/useEditor.tsx | 4 + .../core/src/ui/menus/menu-items/index.tsx | 16 +- .../src/ui/menus/fixed-menu/index.tsx | 8 +- packages/editor/rich-text-editor/package.json | 3 - packages/editor/rich-text-editor/src/index.ts | 2 - .../src/styles/github-dark.css | 2 - .../src/ui/extensions/drag-drop.tsx | 252 ++++++++++++++++++ .../src/ui/extensions/index.tsx | 42 +-- .../editor/rich-text-editor/src/ui/index.tsx | 12 +- .../src/ui/menus/bubble-menu/index.tsx | 15 +- .../ui/menus/bubble-menu/node-selector.tsx | 2 +- web/components/issues/description-form.tsx | 1 + .../issue-peek-overview/issue-detail.tsx | 1 + web/services/file.service.ts | 10 + web/styles/editor.css | 98 +++++++ yarn.lock | 11 +- 25 files changed, 716 insertions(+), 88 deletions(-) create mode 100644 packages/editor/core/src/styles/github-dark.css create mode 100644 packages/editor/core/src/types/validate-image.ts create mode 100644 packages/editor/core/src/ui/extensions/code/index.tsx create mode 100644 packages/editor/core/src/ui/extensions/horizontal-rule.tsx create mode 100644 packages/editor/core/src/ui/extensions/keymap.tsx delete mode 100644 packages/editor/rich-text-editor/src/styles/github-dark.css create mode 100644 packages/editor/rich-text-editor/src/ui/extensions/drag-drop.tsx diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index ab6c77724..072dc28c6 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/core/package.json @@ -30,6 +30,9 @@ "dependencies": { "@blueprintjs/popover2": "^2.0.10", "@tiptap/core": "^2.1.7", + "@tiptap/extension-code-block-lowlight": "^2.1.12", + "highlight.js": "^11.8.0", + "lowlight": "^3.0.0", "@tiptap/extension-color": "^2.1.11", "@tiptap/extension-image": "^2.1.7", "@tiptap/extension-link": "^2.1.7", @@ -42,9 +45,8 @@ "@tiptap/extension-task-list": "^2.1.7", "@tiptap/extension-text-style": "^2.1.11", "@tiptap/extension-underline": "^2.1.7", - "@tiptap/prosemirror-tables": "^1.1.4", - "jsx-dom-cjs": "^8.0.3", "@tiptap/pm": "^2.1.7", + "@tiptap/prosemirror-tables": "^1.1.4", "@tiptap/react": "^2.1.7", "@tiptap/starter-kit": "^2.1.10", "@tiptap/suggestion": "^2.0.4", @@ -56,7 +58,9 @@ "eslint": "8.36.0", "eslint-config-next": "13.2.4", "eventsource-parser": "^0.1.0", + "jsx-dom-cjs": "^8.0.3", "lucide-react": "^0.244.0", + "prosemirror-async-query": "^0.0.4", "react-markdown": "^8.0.7", "react-moveable": "^0.54.2", "tailwind-merge": "^1.14.0", diff --git a/packages/editor/core/src/index.ts b/packages/editor/core/src/index.ts index 9c1c292b2..55f68ac74 100644 --- a/packages/editor/core/src/index.ts +++ b/packages/editor/core/src/index.ts @@ -1,6 +1,7 @@ // styles // import "./styles/tailwind.css"; // import "./styles/editor.css"; +import "./styles/github-dark.css"; export { isCellSelection } from "./ui/extensions/table/table/utilities/is-cell-selection"; diff --git a/packages/editor/core/src/lib/editor-commands.ts b/packages/editor/core/src/lib/editor-commands.ts index 8f9e36350..229341f08 100644 --- a/packages/editor/core/src/lib/editor-commands.ts +++ b/packages/editor/core/src/lib/editor-commands.ts @@ -50,10 +50,11 @@ export const toggleUnderline = (editor: Editor, range?: Range) => { else editor.chain().focus().toggleUnderline().run(); }; -export const toggleCode = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).toggleCode().run(); - else editor.chain().focus().toggleCode().run(); +export const toggleCodeBlock = (editor: Editor, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); + else editor.chain().focus().toggleCodeBlock().run(); }; + export const toggleOrderedList = (editor: Editor, range?: Range) => { if (range) editor.chain().focus().deleteRange(range).toggleOrderedList().run(); diff --git a/packages/editor/core/src/styles/github-dark.css b/packages/editor/core/src/styles/github-dark.css new file mode 100644 index 000000000..9374de403 --- /dev/null +++ b/packages/editor/core/src/styles/github-dark.css @@ -0,0 +1,85 @@ +pre code.hljs { + display: block; + overflow-x: auto; + padding: 1em; +} +code.hljs { + padding: 3px 5px; +} +.hljs { + color: #c9d1d9; + background: #0d1117; +} +.hljs-doctag, +.hljs-keyword, +.hljs-meta .hljs-keyword, +.hljs-template-tag, +.hljs-template-variable, +.hljs-type, +.hljs-variable.language_ { + color: #ff7b72; +} +.hljs-title, +.hljs-title.class_, +.hljs-title.class_.inherited__, +.hljs-title.function_ { + color: #d2a8ff; +} +.hljs-attr, +.hljs-attribute, +.hljs-literal, +.hljs-meta, +.hljs-number, +.hljs-operator, +.hljs-selector-attr, +.hljs-selector-class, +.hljs-selector-id, +.hljs-variable { + color: #79c0ff; +} +.hljs-meta .hljs-string, +.hljs-regexp, +.hljs-string { + color: #a5d6ff; +} +.hljs-built_in, +.hljs-symbol { + color: #ffa657; +} +.hljs-code, +.hljs-comment, +.hljs-formula { + color: #8b949e; +} +.hljs-name, +.hljs-quote, +.hljs-selector-pseudo, +.hljs-selector-tag { + color: #7ee787; +} +.hljs-subst { + color: #c9d1d9; +} +.hljs-section { + color: #1f6feb; + font-weight: 700; +} +.hljs-bullet { + color: #f2cc60; +} +.hljs-emphasis { + color: #c9d1d9; + font-style: italic; +} +.hljs-strong { + color: #c9d1d9; + font-weight: 700; +} +.hljs-addition { + color: #aff5b4; + background-color: #033a16; +} +.hljs-deletion { + color: #ffdcd7; + background-color: #67060c; +} diff --git a/packages/editor/core/src/types/validate-image.ts b/packages/editor/core/src/types/validate-image.ts new file mode 100644 index 000000000..d976d5cdb --- /dev/null +++ b/packages/editor/core/src/types/validate-image.ts @@ -0,0 +1 @@ +export type ValidateImage = (assetUrlWithWorkspaceId: string) => Promise; diff --git a/packages/editor/core/src/ui/extensions/code/index.tsx b/packages/editor/core/src/ui/extensions/code/index.tsx new file mode 100644 index 000000000..016cec2c3 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/code/index.tsx @@ -0,0 +1,29 @@ +import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; + +import { common, createLowlight } from "lowlight"; +import ts from "highlight.js/lib/languages/typescript"; + +const lowlight = createLowlight(common); +lowlight.register("ts", ts); + +export const CustomCodeBlock = CodeBlockLowlight.extend({ + addKeyboardShortcuts() { + return { + Tab: ({ editor }) => { + const { state } = editor; + const { selection, doc } = state; + const { $from, empty } = selection; + + if (!empty || $from.parent.type !== this.type) { + return false; + } + + return editor.commands.insertContent(" "); + }, + }; + }, +}).configure({ + lowlight, + defaultLanguage: "plaintext", + exitOnTripleEnter: false, +}); diff --git a/packages/editor/core/src/ui/extensions/horizontal-rule.tsx b/packages/editor/core/src/ui/extensions/horizontal-rule.tsx new file mode 100644 index 000000000..0e3b5fe94 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/horizontal-rule.tsx @@ -0,0 +1,116 @@ +import { TextSelection } from "prosemirror-state"; + +import { + InputRule, + mergeAttributes, + Node, + nodeInputRule, + wrappingInputRule, +} from "@tiptap/core"; + +/** + * Extension based on: + * - Tiptap HorizontalRule extension (https://tiptap.dev/api/nodes/horizontal-rule) + */ + +export interface HorizontalRuleOptions { + HTMLAttributes: Record; +} + +declare module "@tiptap/core" { + interface Commands { + horizontalRule: { + /** + * Add a horizontal rule + */ + setHorizontalRule: () => ReturnType; + }; + } +} + +export default Node.create({ + name: "horizontalRule", + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + group: "block", + + addAttributes() { + return { + color: { + default: "#dddddd", + }, + }; + }, + + parseHTML() { + return [ + { + tag: `div[data-type="${this.name}"]`, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + "data-type": this.name, + }), + ["div", {}], + ]; + }, + + addCommands() { + return { + setHorizontalRule: + () => + ({ chain }) => { + return ( + chain() + .insertContent({ type: this.name }) + // set cursor after horizontal rule + .command(({ tr, dispatch }) => { + if (dispatch) { + const { $to } = tr.selection; + const posAfter = $to.end(); + + if ($to.nodeAfter) { + tr.setSelection(TextSelection.create(tr.doc, $to.pos)); + } else { + // add node after horizontal rule if it’s the end of the document + const node = + $to.parent.type.contentMatch.defaultType?.create(); + + if (node) { + tr.insert(posAfter, node); + tr.setSelection(TextSelection.create(tr.doc, posAfter)); + } + } + + tr.scrollIntoView(); + } + + return true; + }) + .run() + ); + }, + }; + }, + + addInputRules() { + return [ + new InputRule({ + find: /^(?:---|—-|___\s|\*\*\*\s)$/, + handler: ({ state, range, match }) => { + state.tr.replaceRangeWith(range.from, range.to, this.type.create()); + }, + }), + ]; + }, +}); diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index 3f191a912..8106bbd8f 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -6,12 +6,13 @@ import { Color } from "@tiptap/extension-color"; import TaskItem from "@tiptap/extension-task-item"; import TaskList from "@tiptap/extension-task-list"; import { Markdown } from "tiptap-markdown"; -import Gapcursor from "@tiptap/extension-gapcursor"; import TableHeader from "./table/table-header/table-header"; import Table from "./table/table"; import TableCell from "./table/table-cell/table-cell"; import TableRow from "./table/table-row/table-row"; +import DragDrop from "./drag-drop"; +import HorizontalRule from "./horizontal-rule"; import ImageExtension from "./image"; @@ -19,6 +20,10 @@ import { DeleteImage } from "../../types/delete-image"; import { isValidHttpUrl } from "../../lib/utils"; import { IMentionSuggestion } from "../../types/mention-suggestion"; import { Mentions } from "../mentions"; +import { ValidateImage } from "../../types/validate-image"; + +import { CustomKeymap } from "./keymap"; +import { CustomCodeBlock } from "./code"; export const CoreEditorExtensions = ( mentionConfig: { @@ -26,6 +31,7 @@ export const CoreEditorExtensions = ( mentionHighlights: string[]; }, deleteFile: DeleteImage, + validateFile?: ValidateImage, cancelUploadImage?: () => any, ) => [ StarterKit.configure({ @@ -49,22 +55,15 @@ export const CoreEditorExtensions = ( class: "border-l-4 border-custom-border-300", }, }, - code: { - HTMLAttributes: { - class: - "rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000", - spellcheck: "false", - }, - }, + code: false, codeBlock: false, horizontalRule: false, dropcursor: { color: "rgba(var(--color-text-100))", width: 2, }, - gapcursor: false, }), - Gapcursor, + CustomKeymap, TiptapLink.configure({ protocols: ["http", "https"], validate: (url) => isValidHttpUrl(url), @@ -73,7 +72,7 @@ export const CoreEditorExtensions = ( "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", }, }), - ImageExtension(deleteFile, cancelUploadImage).configure({ + ImageExtension(deleteFile, validateFile, cancelUploadImage).configure({ HTMLAttributes: { class: "rounded-lg border border-custom-border-300", }, @@ -86,6 +85,7 @@ export const CoreEditorExtensions = ( class: "not-prose pl-2", }, }), + CustomCodeBlock, TaskItem.configure({ HTMLAttributes: { class: "flex items-start my-4", @@ -95,7 +95,9 @@ export const CoreEditorExtensions = ( Markdown.configure({ html: true, transformCopiedText: true, + transformPastedText: true, }), + HorizontalRule, Table, TableHeader, TableCell, diff --git a/packages/editor/core/src/ui/extensions/keymap.tsx b/packages/editor/core/src/ui/extensions/keymap.tsx new file mode 100644 index 000000000..0caa194cd --- /dev/null +++ b/packages/editor/core/src/ui/extensions/keymap.tsx @@ -0,0 +1,54 @@ +import { Extension } from "@tiptap/core"; + +declare module "@tiptap/core" { + // eslint-disable-next-line no-unused-vars + interface Commands { + customkeymap: { + /** + * Select text between node boundaries + */ + selectTextWithinNodeBoundaries: () => ReturnType; + }; + } +} + +export const CustomKeymap = Extension.create({ + name: "CustomKeymap", + + addCommands() { + return { + selectTextWithinNodeBoundaries: + () => + ({ editor, commands }) => { + const { state } = editor; + const { tr } = state; + const startNodePos = tr.selection.$from.start(); + const endNodePos = tr.selection.$to.end(); + return commands.setTextSelection({ + from: startNodePos, + to: endNodePos, + }); + }, + }; + }, + + addKeyboardShortcuts() { + return { + "Mod-a": ({ editor }) => { + const { state } = editor; + const { tr } = state; + const startSelectionPos = tr.selection.from; + const endSelectionPos = tr.selection.to; + const startNodePos = tr.selection.$from.start(); + const endNodePos = tr.selection.$to.end(); + const isCurrentTextSelectionNotExtendedToNodeBoundaries = + startSelectionPos > startNodePos || endSelectionPos < endNodePos; + if (isCurrentTextSelectionNotExtendedToNodeBoundaries) { + editor.chain().selectTextWithinNodeBoundaries().run(); + return true; + } + return false; + }, + }; + }, +}); diff --git a/packages/editor/core/src/ui/hooks/useEditor.tsx b/packages/editor/core/src/ui/hooks/useEditor.tsx index 258da8652..56cf83d32 100644 --- a/packages/editor/core/src/ui/hooks/useEditor.tsx +++ b/packages/editor/core/src/ui/hooks/useEditor.tsx @@ -6,6 +6,7 @@ import { useEffect, } from "react"; import { DeleteImage } from "../../types/delete-image"; +import { ValidateImage } from "../../types/validate-image"; import { CoreEditorProps } from "../props"; import { CoreEditorExtensions } from "../extensions"; import { EditorProps } from "@tiptap/pm/view"; @@ -16,6 +17,7 @@ import { IMentionSuggestion } from "../../types/mention-suggestion"; interface CustomEditorProps { uploadFile: UploadImage; + validateFile?: ValidateImage; setIsSubmitting?: ( isSubmitting: "submitting" | "submitted" | "saved", ) => void; @@ -35,6 +37,7 @@ interface CustomEditorProps { export const useEditor = ({ uploadFile, deleteFile, + validateFile, cancelUploadImage, editorProps = {}, value, @@ -59,6 +62,7 @@ export const useEditor = ({ mentionHighlights: mentionHighlights ?? [], }, deleteFile, + validateFile, cancelUploadImage, ), ...extensions, diff --git a/packages/editor/core/src/ui/menus/menu-items/index.tsx b/packages/editor/core/src/ui/menus/menu-items/index.tsx index 8a2651d1e..a9a724720 100644 --- a/packages/editor/core/src/ui/menus/menu-items/index.tsx +++ b/packages/editor/core/src/ui/menus/menu-items/index.tsx @@ -22,7 +22,7 @@ import { toggleBlockquote, toggleBold, toggleBulletList, - toggleCode, + toggleCodeBlock, toggleHeadingOne, toggleHeadingThree, toggleHeadingTwo, @@ -89,13 +89,6 @@ export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({ icon: StrikethroughIcon, }); -export const CodeItem = (editor: Editor): EditorMenuItem => ({ - name: "code", - isActive: () => editor?.isActive("code"), - command: () => toggleCode(editor), - icon: CodeIcon, -}); - export const BulletListItem = (editor: Editor): EditorMenuItem => ({ name: "bullet-list", isActive: () => editor?.isActive("bulletList"), @@ -110,6 +103,13 @@ export const TodoListItem = (editor: Editor): EditorMenuItem => ({ icon: CheckSquare, }); +export const CodeItem = (editor: Editor): EditorMenuItem => ({ + name: "code", + isActive: () => editor?.isActive("code"), + command: () => toggleCodeBlock(editor), + icon: CodeIcon, +}); + export const NumberedListItem = (editor: Editor): EditorMenuItem => ({ name: "ordered-list", isActive: () => editor?.isActive("orderedList"), diff --git a/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx b/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx index a4fb0479c..9bca18ef0 100644 --- a/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx @@ -46,14 +46,14 @@ type EditorBubbleMenuProps = { }; export const FixedMenu = (props: EditorBubbleMenuProps) => { - const basicMarkItems: BubbleMenuItem[] = [ + const basicTextFormattingItems: BubbleMenuItem[] = [ BoldItem(props.editor), ItalicItem(props.editor), UnderLineItem(props.editor), StrikeThroughItem(props.editor), ]; - const listItems: BubbleMenuItem[] = [ + const listFormattingItems: BubbleMenuItem[] = [ BulletListItem(props.editor), NumberedListItem(props.editor), ]; @@ -103,7 +103,7 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
- {basicMarkItems.map((item, index) => ( + {basicTextFormattingItems.map((item, index) => ( {item.name}} @@ -130,7 +130,7 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => { ))}
- {listItems.map((item, index) => ( + {listFormattingItems.map((item, index) => ( {item.name}} diff --git a/packages/editor/rich-text-editor/package.json b/packages/editor/rich-text-editor/package.json index db793261c..743a7c9b4 100644 --- a/packages/editor/rich-text-editor/package.json +++ b/packages/editor/rich-text-editor/package.json @@ -30,14 +30,11 @@ }, "dependencies": { "@plane/editor-core": "*", - "@tiptap/extension-code-block-lowlight": "^2.1.11", "@tiptap/extension-horizontal-rule": "^2.1.11", "@tiptap/extension-placeholder": "^2.1.11", "@tiptap/suggestion": "^2.1.7", "class-variance-authority": "^0.7.0", "clsx": "^1.2.1", - "highlight.js": "^11.8.0", - "lowlight": "^3.0.0", "lucide-react": "^0.244.0" }, "devDependencies": { diff --git a/packages/editor/rich-text-editor/src/index.ts b/packages/editor/rich-text-editor/src/index.ts index 9ea7f9a39..0b854c0ae 100644 --- a/packages/editor/rich-text-editor/src/index.ts +++ b/packages/editor/rich-text-editor/src/index.ts @@ -1,5 +1,3 @@ -import "./styles/github-dark.css"; - export { RichTextEditor, RichTextEditorWithRef } from "./ui"; export { RichReadOnlyEditor, RichReadOnlyEditorWithRef } from "./ui/read-only"; export type { IMentionSuggestion, IMentionHighlight } from "./ui"; diff --git a/packages/editor/rich-text-editor/src/styles/github-dark.css b/packages/editor/rich-text-editor/src/styles/github-dark.css deleted file mode 100644 index 20a7f4e66..000000000 --- a/packages/editor/rich-text-editor/src/styles/github-dark.css +++ /dev/null @@ -1,2 +0,0 @@ -pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px} -.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c} diff --git a/packages/editor/rich-text-editor/src/ui/extensions/drag-drop.tsx b/packages/editor/rich-text-editor/src/ui/extensions/drag-drop.tsx new file mode 100644 index 000000000..69542c1ce --- /dev/null +++ b/packages/editor/rich-text-editor/src/ui/extensions/drag-drop.tsx @@ -0,0 +1,252 @@ +import { Extension } from "@tiptap/core"; + +import { PluginKey, NodeSelection, Plugin } from "@tiptap/pm/state"; +// @ts-ignore +import { __serializeForClipboard, EditorView } from "@tiptap/pm/view"; + +export interface DragHandleOptions { + dragHandleWidth: number; +} + +function absoluteRect(node: Element) { + const data = node.getBoundingClientRect(); + + return { + top: data.top, + left: data.left, + width: data.width, + }; +} + +function nodeDOMAtCoords(coords: { x: number; y: number }) { + return document + .elementsFromPoint(coords.x, coords.y) + .find((elem: Element) => { + return ( + elem.parentElement?.matches?.(".ProseMirror") || + elem.matches( + [ + "li", + "p:not(:first-child)", + "pre", + "blockquote", + "h1, h2, h3", + "[data-type=horizontalRule]", + ".tableWrapper", + ].join(", "), + ) + ); + }); +} + +function nodePosAtDOM(node: Element, view: EditorView) { + const boundingRect = node.getBoundingClientRect(); + + if (node.nodeName === "IMG") { + return view.posAtCoords({ + left: boundingRect.left + 1, + top: boundingRect.top + 1, + })?.pos; + } + + if (node.nodeName === "PRE") { + return ( + view.posAtCoords({ + left: boundingRect.left + 1, + top: boundingRect.top + 1, + })?.pos! - 1 + ); + } + + return view.posAtCoords({ + left: boundingRect.left + 1, + top: boundingRect.top + 1, + })?.inside; +} + +function DragHandle(options: DragHandleOptions) { + function handleDragStart(event: DragEvent, view: EditorView) { + view.focus(); + + if (!event.dataTransfer) return; + + const node = nodeDOMAtCoords({ + x: event.clientX + options.dragHandleWidth + 50, + y: event.clientY, + }); + + if (!(node instanceof Element)) return; + + const nodePos = nodePosAtDOM(node, view); + if (nodePos === null || nodePos === undefined || nodePos < 0) return; + + view.dispatch( + view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)), + ); + + const slice = view.state.selection.content(); + const { dom, text } = __serializeForClipboard(view, slice); + + event.dataTransfer.clearData(); + event.dataTransfer.setData("text/html", dom.innerHTML); + event.dataTransfer.setData("text/plain", text); + event.dataTransfer.effectAllowed = "copyMove"; + + event.dataTransfer.setDragImage(node, 0, 0); + + view.dragging = { slice, move: event.ctrlKey }; + } + + function handleClick(event: MouseEvent, view: EditorView) { + view.focus(); + + view.dom.classList.remove("dragging"); + + const node = nodeDOMAtCoords({ + x: event.clientX + 50 + options.dragHandleWidth, + y: event.clientY, + }); + + if (!(node instanceof Element)) return; + + const nodePos = nodePosAtDOM(node, view); + + if (nodePos === null || nodePos === undefined || nodePos < 0) return; + + view.dispatch( + view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)), + ); + } + + let dragHandleElement: HTMLElement | null = null; + + function hideDragHandle() { + if (dragHandleElement) { + dragHandleElement.classList.add("hidden"); + } + } + + function showDragHandle() { + if (dragHandleElement) { + dragHandleElement.classList.remove("hidden"); + } + } + + return new Plugin({ + key: new PluginKey("dragHandle"), + view: (view) => { + dragHandleElement = document.createElement("div"); + dragHandleElement.draggable = true; + dragHandleElement.dataset.dragHandle = ""; + dragHandleElement.classList.add("drag-handle"); + + const dragHandleContainer = document.createElement("div"); + dragHandleContainer.classList.add("drag-handle-container"); + dragHandleElement.appendChild(dragHandleContainer); + + const dotsContainer = document.createElement("div"); + dotsContainer.classList.add("drag-handle-dots"); + + for (let i = 0; i < 6; i++) { + const spanElement = document.createElement("span"); + spanElement.classList.add("drag-handle-dot"); + dotsContainer.appendChild(spanElement); + } + + dragHandleContainer.appendChild(dotsContainer); + dragHandleElement.addEventListener("dragstart", (e) => { + handleDragStart(e, view); + }); + dragHandleElement.addEventListener("click", (e) => { + handleClick(e, view); + }); + + dragHandleElement.addEventListener("dragstart", (e) => { + handleDragStart(e, view); + }); + dragHandleElement.addEventListener("click", (e) => { + handleClick(e, view); + }); + + hideDragHandle(); + + view?.dom?.parentElement?.appendChild(dragHandleElement); + + return { + destroy: () => { + dragHandleElement?.remove?.(); + dragHandleElement = null; + }, + }; + }, + props: { + handleDOMEvents: { + mousemove: (view, event) => { + if (!view.editable) { + return; + } + + const node = nodeDOMAtCoords({ + x: event.clientX + options.dragHandleWidth, + y: event.clientY, + }); + + if (!(node instanceof Element)) { + hideDragHandle(); + return; + } + + const compStyle = window.getComputedStyle(node); + const lineHeight = parseInt(compStyle.lineHeight, 10); + const paddingTop = parseInt(compStyle.paddingTop, 10); + + const rect = absoluteRect(node); + + rect.top += (lineHeight - 24) / 2; + rect.top += paddingTop; + // Li markers + if (node.matches("ul:not([data-type=taskList]) li, ol li")) { + rect.left -= options.dragHandleWidth; + } + rect.width = options.dragHandleWidth; + + if (!dragHandleElement) return; + + dragHandleElement.style.left = `${rect.left - rect.width}px`; + dragHandleElement.style.top = `${rect.top}px`; + showDragHandle(); + }, + keydown: () => { + hideDragHandle(); + }, + wheel: () => { + hideDragHandle(); + }, + // dragging className is used for CSS + dragstart: (view) => { + view.dom.classList.add("dragging"); + }, + drop: (view) => { + view.dom.classList.remove("dragging"); + }, + dragend: (view) => { + view.dom.classList.remove("dragging"); + }, + }, + }, + }); +} + +const DragAndDrop = Extension.create({ + name: "dragAndDrop", + + addProseMirrorPlugins() { + return [ + DragHandle({ + dragHandleWidth: 24, + }), + ]; + }, +}); + +export default DragAndDrop; diff --git a/packages/editor/rich-text-editor/src/ui/extensions/index.tsx b/packages/editor/rich-text-editor/src/ui/extensions/index.tsx index a28982da3..e1b1e9b3d 100644 --- a/packages/editor/rich-text-editor/src/ui/extensions/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/extensions/index.tsx @@ -1,50 +1,18 @@ -import HorizontalRule from "@tiptap/extension-horizontal-rule"; import Placeholder from "@tiptap/extension-placeholder"; -import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; -import { common, createLowlight } from "lowlight"; -import { InputRule } from "@tiptap/core"; - -import ts from "highlight.js/lib/languages/typescript"; import SlashCommand from "./slash-command"; import { UploadImage } from "../"; - -const lowlight = createLowlight(common); -lowlight.register("ts", ts); +import DragAndDrop from "./drag-drop"; export const RichTextEditorExtensions = ( uploadFile: UploadImage, setIsSubmitting?: ( isSubmitting: "submitting" | "submitted" | "saved", ) => void, + dragDropEnabled?: boolean, ) => [ - HorizontalRule.extend({ - addInputRules() { - return [ - new InputRule({ - find: /^(?:---|—-|___\s|\*\*\*\s)$/, - handler: ({ state, range, commands }) => { - commands.splitBlock(); - - const attributes = {}; - const { tr } = state; - const start = range.from; - const end = range.to; - // @ts-ignore - tr.replaceWith(start - 1, end, this.type.create(attributes)); - }, - }), - ]; - }, - }).configure({ - HTMLAttributes: { - class: "mb-6 border-t border-custom-border-300", - }, - }), SlashCommand(uploadFile, setIsSubmitting), - CodeBlockLowlight.configure({ - lowlight, - }), + dragDropEnabled === true && DragAndDrop, Placeholder.configure({ placeholder: ({ node }) => { if (node.type.name === "heading") { @@ -53,7 +21,9 @@ export const RichTextEditorExtensions = ( if (node.type.name === "image" || node.type.name === "table") { return ""; } - + if (node.type.name === "codeBlock") { + return "Type in your code here..."; + } return "Press '/' for commands..."; }, includeChildren: true, diff --git a/packages/editor/rich-text-editor/src/ui/index.tsx b/packages/editor/rich-text-editor/src/ui/index.tsx index 2e98a72aa..ba320a25c 100644 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/index.tsx @@ -11,6 +11,7 @@ import { RichTextEditorExtensions } from "./extensions"; export type UploadImage = (file: File) => Promise; export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; +export type ValidateImage = (assetUrlWithWorkspaceId: string) => Promise; export type IMentionSuggestion = { id: string; @@ -25,8 +26,10 @@ export type IMentionHighlight = string; interface IRichTextEditor { value: string; + dragDropEnabled?: boolean; uploadFile: UploadImage; deleteFile: DeleteImage; + validateFile?: ValidateImage; noBorder?: boolean; borderOnFocus?: boolean; cancelUploadImage?: () => any; @@ -54,6 +57,7 @@ interface EditorHandle { const RichTextEditor = ({ onChange, + dragDropEnabled, debouncedUpdatesEnabled, setIsSubmitting, setShouldShowAlert, @@ -61,6 +65,7 @@ const RichTextEditor = ({ value, uploadFile, deleteFile, + validateFile, noBorder, cancelUploadImage, borderOnFocus, @@ -77,9 +82,14 @@ const RichTextEditor = ({ value, uploadFile, cancelUploadImage, + validateFile, deleteFile, forwardedRef, - extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting), + extensions: RichTextEditorExtensions( + uploadFile, + setIsSubmitting, + dragDropEnabled, + ), mentionHighlights, mentionSuggestions, }); diff --git a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx index f9d830599..bb5d1b534 100644 --- a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx @@ -38,21 +38,8 @@ export const EditorBubbleMenu: FC = (props: any) => { const { selection } = state; const { empty } = selection; - const hasEditorFocus = view.hasFocus(); - - // if (typeof window !== "undefined") { - // const selection: any = window?.getSelection(); - // if (selection.rangeCount !== 0) { - // const range = selection.getRangeAt(0); - // if (findTableAncestor(range.startContainer)) { - // console.log("table"); - // return false; - // } - // } - // } if ( - !hasEditorFocus || empty || !editor.isEditable || editor.isActive("image") || @@ -116,6 +103,7 @@ export const EditorBubbleMenu: FC = (props: any) => { editor={props.editor!} isOpen={isNodeSelectorOpen} setIsOpen={() => { + console.log("setIsNodeSelectorOpen"); setIsNodeSelectorOpen(!isNodeSelectorOpen); setIsLinkSelectorOpen(false); }} @@ -125,6 +113,7 @@ export const EditorBubbleMenu: FC = (props: any) => { editor={props.editor!!} isOpen={isLinkSelectorOpen} setIsOpen={() => { + console.log("setIsLinkSelectorOpen"); setIsLinkSelectorOpen(!isLinkSelectorOpen); setIsNodeSelectorOpen(false); }} diff --git a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx index 965e7a42e..7681fbe5b 100644 --- a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx +++ b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx @@ -1,12 +1,12 @@ import { BulletListItem, cn, - CodeItem, HeadingOneItem, HeadingThreeItem, HeadingTwoItem, NumberedListItem, QuoteItem, + CodeItem, TodoListItem, } from "@plane/editor-core"; import { Editor } from "@tiptap/react"; diff --git a/web/components/issues/description-form.tsx b/web/components/issues/description-form.tsx index 8c6a75d30..e4bb37767 100644 --- a/web/components/issues/description-form.tsx +++ b/web/components/issues/description-form.tsx @@ -151,6 +151,7 @@ export const IssueDescriptionForm: FC = (props) => { value={value} setShouldShowAlert={setShowAlert} setIsSubmitting={setIsSubmitting} + dragDropEnabled={true} customClassName={isAllowed ? "min-h-[150px] shadow-sm" : "!p-0 !pt-2 text-custom-text-200"} noBorder={!isAllowed} onChange={(description: Object, description_html: string) => { diff --git a/web/components/issues/issue-peek-overview/issue-detail.tsx b/web/components/issues/issue-peek-overview/issue-detail.tsx index 875927907..66e267078 100644 --- a/web/components/issues/issue-peek-overview/issue-detail.tsx +++ b/web/components/issues/issue-peek-overview/issue-detail.tsx @@ -140,6 +140,7 @@ export const PeekOverviewIssueDetails: FC = (props) =
{errors.name ? errors.name.message : null} { + // console.log("bruh", assetUrlWithWorkspaceId); + // const res = await this.get(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/`); + // const data = res?.data; + // console.log("data inside fucntion"); + // return data.status; + // } + // getUploadFileFunction(workspaceSlug: string): (file: File) => Promise { return async (file: File) => { const formData = new FormData(); diff --git a/web/styles/editor.css b/web/styles/editor.css index 85d881eeb..981f53703 100644 --- a/web/styles/editor.css +++ b/web/styles/editor.css @@ -229,3 +229,101 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { .ProseMirror table * .is-empty::before { opacity: 0; } + +.ProseMirror pre { + background: rgba(var(--color-background-80)); + border-radius: 0.5rem; + color: rgba(var(--color-text-100)); + font-family: "JetBrainsMono", monospace; + padding: 0.75rem 1rem; +} + +.ProseMirror pre code { + background: none; + color: inherit; + font-size: 0.8rem; + padding: 0; +} + +.ProseMirror:not(.dragging) .ProseMirror-selectednode:not(img):not(pre) { + outline: none !important; + border-radius: 0.2rem; + background-color: rgb(var(--color-background-90)); + border: 1px solid #5abbf7; + padding: 4px 2px 4px 2px; + transition: background-color 0.2s; + box-shadow: none; +} + +.drag-handle { + position: fixed; + opacity: 1; + transition: opacity ease-in 0.2s; + display: grid; + place-items: center; + height: 20px; + width: 15px; + z-index: 10; + cursor: grab; + border-radius: 2px; + background-color: rgb(var(--color-background-90)); + &:hover { + background-color: rgb(var(--color-background-80)); + } + + &.hide { + opacity: 0; + pointer-events: none; + } +} + +.drag-handle:hover { + background-color: #0d0d0d 10; + transition: background-color 0.2s; +} + +.drag-handle.hidden { + opacity: 0; + pointer-events: none; +} + +@media screen and (max-width: 600px) { + .drag-handle { + display: none; + pointer-events: none; + } +} + +.drag-handle-container { + height: 20px; + width: 15px; + cursor: grab; + display: grid; + place-items: center; +} + +.drag-handle-dots { + height: 100%; + width: 12px; + display: grid; + grid-template-columns: repeat(2, 1fr); + place-items: center; +} + +.drag-handle-dot { + height: 2.75px; + width: 3px; + background-color: rgba(var(--color-text-100)); + border-radius: 50%; +} + +div[data-type="horizontalRule"] { + line-height: 0; + padding: 0.25rem 0; + margin-top: 0; + margin-bottom: 0; + + & > div { + border-bottom: 1px solid rgb(var(--color-text-100)); + } +} diff --git a/yarn.lock b/yarn.lock index f25d1cf7d..eef1655f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2376,7 +2376,7 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-2.1.12.tgz#7c905a577ce30ef2cb335870a23f9d24fd26f6aa" integrity sha512-vtD8vWtNlmAZX8LYqt2yU9w3mU9rPCiHmbp4hDXJs2kBnI0Ju/qAyXFx6iJ3C3XyuMnMbJdDI9ee0spAvFz7cQ== -"@tiptap/extension-code-block-lowlight@^2.1.11": +"@tiptap/extension-code-block-lowlight@^2.1.12": version "2.1.12" resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.1.12.tgz#ccbca5d0d92bee373dc8e2e2ae6c27f62f66437c" integrity sha512-dtIbpI9QrWa9TzNO4v5q/zf7+d83wpy5i9PEccdJAVtRZ0yOI8JIZAWzG5ex3zAoCA0CnQFdsPSVykYSDdxtDA== @@ -6497,7 +6497,7 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nanoid@^3.3.4, nanoid@^3.3.6: +nanoid@^3.1.30, nanoid@^3.3.4, nanoid@^3.3.6: version "3.3.7" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== @@ -7065,6 +7065,13 @@ property-information@^6.0.0: resolved "https://registry.yarnpkg.com/property-information/-/property-information-6.4.0.tgz#6bc4c618b0c2d68b3bb8b552cbb97f8e300a0f82" integrity sha512-9t5qARVofg2xQqKtytzt+lZ4d1Qvj8t5B8fEwXK6qOfgRLgH/b13QlgEyDh033NOS31nXeFbYv7CLUDG1CeifQ== +prosemirror-async-query@^0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/prosemirror-async-query/-/prosemirror-async-query-0.0.4.tgz#4fedbee082692e659ab1f472645aac7765133b1d" + integrity sha512-eliJ722n+fVuChcvoZeS3pE/mpN/TJnqMkhIfVSTAH8Vd9S7aGfT9t31idD+mwnptgIc7OUPy56UdYN+ph++TQ== + dependencies: + nanoid "^3.1.30" + prosemirror-changeset@^2.2.0: version "2.2.1" resolved "https://registry.yarnpkg.com/prosemirror-changeset/-/prosemirror-changeset-2.2.1.tgz#dae94b63aec618fac7bb9061648e6e2a79988383" From 878707f4443af51406178254b1a2964f62d1810e Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Sat, 18 Nov 2023 16:17:01 +0530 Subject: [PATCH 03/10] feat: Instance Registration and Configuration (#2793) * dev: remove default user * dev: initiate licensing * dev: remove migration file 0046 * feat: self hosted licensing initialize * dev: instance licenses * dev: change license response structure * dev: add default properties and issue mention migration * dev: reset migrations * dev: instance configuration * dev: instance configuration migration * dev: update instance configuration model to take null and empty values * dev: instance configuration variables * dev: set default values * dev: update instance configuration load * dev: email configuration settings moved to database * dev: instance configuration on instance bootup * dev: auto instance registration script * dev: instance admin * dev: enable instance configuration and instance admin roles * dev: instance owner fix * dev: instance configuration values * dev: fix instance permissions and serializer * dev: fix email senders * dev: remove deprecated variables * dev: fix current site domain registration * dev: update cors setup and local settings * dev: migrate instance registration and configuration to manage commands * dev: check email validity * dev: update script to use manage command * dev: default bucket creation script * dev: instance admin routes and initial set of screens * dev: admin api to check if the current user is admin * dev: instance admin unique constraints * dev: check magic link login * dev: fix email sending for ssl * dev: create instance activation route if the instance is not activated during startup * dev: removed DJANGO_SETTINGS_MODULE from environment files and deleted auto bucket create script * dev: environment configuration for backend * dev: fix access token variable error * feat: Instance Admin Panel: General Settings (#2792) --------- Co-authored-by: pablohashescobar Co-authored-by: Prateek Shourya --- .env.example | 15 +- Dockerfile | 2 - ENV_SETUP.md | 24 +- apiserver/.env.example | 27 +- apiserver/Dockerfile.api | 2 +- apiserver/bin/bucket_script.py | 83 ------ apiserver/bin/takeoff | 6 +- apiserver/bin/user_script.py | 28 -- apiserver/package.json | 4 + apiserver/plane/api/serializers/user.py | 5 +- apiserver/plane/api/urls/__init__.py | 8 +- apiserver/plane/api/urls/user.py | 9 + apiserver/plane/api/views/authentication.py | 2 +- apiserver/plane/api/views/base.py | 1 - apiserver/plane/api/views/config.py | 83 +++++- apiserver/plane/api/views/external.py | 29 ++- apiserver/plane/api/views/project.py | 2 +- apiserver/plane/api/views/user.py | 12 +- apiserver/plane/api/views/workspace.py | 2 +- .../plane/bgtasks/analytic_plot_export.py | 18 +- .../plane/bgtasks/email_verification_task.py | 19 +- .../plane/bgtasks/forgot_password_task.py | 17 +- .../plane/bgtasks/magic_link_code_task.py | 19 +- .../plane/bgtasks/project_invitation_task.py | 17 +- .../bgtasks/workspace_invitation_task.py | 36 ++- .../db/management/commands/create_bucket.py | 71 +++++ apiserver/plane/license/__init__.py | 0 apiserver/plane/license/api/__init__.py | 0 .../plane/license/api/permissions/__init__.py | 1 + .../plane/license/api/permissions/instance.py | 33 +++ .../plane/license/api/serializers/__init__.py | 1 + .../plane/license/api/serializers/instance.py | 42 +++ apiserver/plane/license/api/views/__init__.py | 6 + apiserver/plane/license/api/views/instance.py | 242 ++++++++++++++++++ apiserver/plane/license/apps.py | 5 + .../plane/license/management/__init__.py | 0 .../license/management/commands/__init__.py | 0 .../management/commands/configure_instance.py | 46 ++++ .../management/commands/register_instance.py | 104 ++++++++ .../plane/license/migrations/0001_initial.py | 83 ++++++ ...002_alter_instanceadmin_unique_together.py | 19 ++ .../plane/license/migrations/__init__.py | 0 apiserver/plane/license/models/__init__.py | 1 + apiserver/plane/license/models/instance.py | 73 ++++++ apiserver/plane/license/urls.py | 36 +++ apiserver/plane/license/utils/__init__.py | 0 .../plane/license/utils/instance_value.py | 6 + apiserver/plane/settings/common.py | 45 +--- apiserver/plane/settings/local.py | 8 - apiserver/plane/settings/production.py | 5 + apiserver/plane/urls.py | 4 +- apiserver/requirements/base.txt | 2 +- deploy/selfhost/docker-compose.yml | 15 +- deploy/selfhost/variables.env | 20 +- web/components/instance/general-form.tsx | 126 +++++++++ web/components/instance/help-section.tsx | 134 ++++++++++ web/components/instance/index.ts | 4 + web/components/instance/sidebar-dropdown.tsx | 148 +++++++++++ web/components/instance/sidebar-menu.tsx | 65 +++++ web/components/workspace/sidebar-dropdown.tsx | 15 +- web/layouts/admin-layout/header.tsx | 47 ++++ web/layouts/admin-layout/index.ts | 3 + web/layouts/admin-layout/layout.tsx | 32 +++ web/layouts/admin-layout/sidebar.tsx | 28 ++ web/layouts/auth-layout/user-wrapper.tsx | 6 +- web/pages/admin/ai.tsx | 16 ++ web/pages/admin/email.tsx | 16 ++ web/pages/admin/index.tsx | 28 ++ web/pages/admin/oauth.tsx | 16 ++ web/services/instance.service.ts | 37 +++ web/services/user.service.ts | 9 + web/services/workspace.service.ts | 6 +- web/store/instance/index.ts | 1 + web/store/instance/instance.store.ts | 111 ++++++++ web/store/root.ts | 5 + web/store/user.store.ts | 23 ++ web/types/instance.d.ts | 22 ++ web/types/users.d.ts | 4 + 78 files changed, 1950 insertions(+), 290 deletions(-) delete mode 100644 apiserver/bin/bucket_script.py delete mode 100644 apiserver/bin/user_script.py create mode 100644 apiserver/package.json create mode 100644 apiserver/plane/db/management/commands/create_bucket.py create mode 100644 apiserver/plane/license/__init__.py create mode 100644 apiserver/plane/license/api/__init__.py create mode 100644 apiserver/plane/license/api/permissions/__init__.py create mode 100644 apiserver/plane/license/api/permissions/instance.py create mode 100644 apiserver/plane/license/api/serializers/__init__.py create mode 100644 apiserver/plane/license/api/serializers/instance.py create mode 100644 apiserver/plane/license/api/views/__init__.py create mode 100644 apiserver/plane/license/api/views/instance.py create mode 100644 apiserver/plane/license/apps.py create mode 100644 apiserver/plane/license/management/__init__.py create mode 100644 apiserver/plane/license/management/commands/__init__.py create mode 100644 apiserver/plane/license/management/commands/configure_instance.py create mode 100644 apiserver/plane/license/management/commands/register_instance.py create mode 100644 apiserver/plane/license/migrations/0001_initial.py create mode 100644 apiserver/plane/license/migrations/0002_alter_instanceadmin_unique_together.py create mode 100644 apiserver/plane/license/migrations/__init__.py create mode 100644 apiserver/plane/license/models/__init__.py create mode 100644 apiserver/plane/license/models/instance.py create mode 100644 apiserver/plane/license/urls.py create mode 100644 apiserver/plane/license/utils/__init__.py create mode 100644 apiserver/plane/license/utils/instance_value.py create mode 100644 web/components/instance/general-form.tsx create mode 100644 web/components/instance/help-section.tsx create mode 100644 web/components/instance/index.ts create mode 100644 web/components/instance/sidebar-dropdown.tsx create mode 100644 web/components/instance/sidebar-menu.tsx create mode 100644 web/layouts/admin-layout/header.tsx create mode 100644 web/layouts/admin-layout/index.ts create mode 100644 web/layouts/admin-layout/layout.tsx create mode 100644 web/layouts/admin-layout/sidebar.tsx create mode 100644 web/pages/admin/ai.tsx create mode 100644 web/pages/admin/email.tsx create mode 100644 web/pages/admin/index.tsx create mode 100644 web/pages/admin/oauth.tsx create mode 100644 web/services/instance.service.ts create mode 100644 web/store/instance/index.ts create mode 100644 web/store/instance/instance.store.ts create mode 100644 web/types/instance.d.ts diff --git a/.env.example b/.env.example index b98adf171..90070de19 100644 --- a/.env.example +++ b/.env.example @@ -21,20 +21,15 @@ AWS_S3_BUCKET_NAME="uploads" FILE_SIZE_LIMIT=5242880 # GPT settings -OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint -OPENAI_API_KEY="sk-" # add your openai key here -GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access +OPENAI_API_BASE="https://api.openai.com/v1" # deprecated +OPENAI_API_KEY="sk-" # deprecated +GPT_ENGINE="gpt-3.5-turbo" # deprecated # Settings related to Docker -DOCKERIZED=1 +DOCKERIZED=1 # deprecated + # set to 1 If using the pre-configured minio setup USE_MINIO=1 # Nginx Configuration NGINX_PORT=80 - -# Set it to 0, to disable it -ENABLE_WEBHOOK=1 - -# Set it to 0, to disable it -ENABLE_API=1 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 388c5a4ef..0e5d2f118 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,8 +43,6 @@ FROM python:3.11.1-alpine3.17 AS backend ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONUNBUFFERED 1 ENV PIP_DISABLE_PIP_VERSION_CHECK=1 -ENV DJANGO_SETTINGS_MODULE plane.settings.production -ENV DOCKERIZED 1 WORKDIR /code diff --git a/ENV_SETUP.md b/ENV_SETUP.md index 23faf83f7..f1cc7cb1e 100644 --- a/ENV_SETUP.md +++ b/ENV_SETUP.md @@ -31,12 +31,10 @@ AWS_S3_BUCKET_NAME="uploads" FILE_SIZE_LIMIT=5242880 ​ # GPT settings -OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint -OPENAI_API_KEY="sk-" # add your openai key here -GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access +OPENAI_API_BASE="https://api.openai.com/v1" # deprecated +OPENAI_API_KEY="sk-" # deprecated +GPT_ENGINE="gpt-3.5-turbo" # deprecated ​ -# Settings related to Docker -DOCKERIZED=1 # set to 1 If using the pre-configured minio setup USE_MINIO=1 ​ @@ -78,7 +76,7 @@ NEXT_PUBLIC_ENABLE_OAUTH=0 # Backend # Debug value for api server use it as 0 for production use DEBUG=0 -DJANGO_SETTINGS_MODULE="plane.settings.selfhosted" +DJANGO_SETTINGS_MODULE="plane.settings.selfhosted" # deprecated ​ # Error logs SENTRY_DSN="" @@ -115,24 +113,22 @@ AWS_S3_BUCKET_NAME="uploads" FILE_SIZE_LIMIT=5242880 ​ # GPT settings -OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint -OPENAI_API_KEY="sk-" # add your openai key here -GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access +OPENAI_API_BASE="https://api.openai.com/v1" # deprecated +OPENAI_API_KEY="sk-" # deprecated +GPT_ENGINE="gpt-3.5-turbo" # deprecated ​ +# Settings related to Docker +DOCKERIZED=1 # Deprecated + # Github GITHUB_CLIENT_SECRET="" # For fetching release notes ​ -# Settings related to Docker -DOCKERIZED=1 # set to 1 If using the pre-configured minio setup USE_MINIO=1 ​ # Nginx Configuration NGINX_PORT=80 ​ -# Default Creds -DEFAULT_EMAIL="captain@plane.so" -DEFAULT_PASSWORD="password123" ​ # SignUps ENABLE_SIGNUP="1" diff --git a/apiserver/.env.example b/apiserver/.env.example index d0b4013a8..2078fc94a 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -1,7 +1,8 @@ # Backend # Debug value for api server use it as 0 for production use DEBUG=0 -CORS_ALLOWED_ORIGINS="http://localhost" +CORS_ALLOWED_ORIGINS="" +ENVIRONMENT="development" # Error logs SENTRY_DSN="" @@ -18,15 +19,6 @@ REDIS_HOST="plane-redis" REDIS_PORT="6379" REDIS_URL="redis://${REDIS_HOST}:6379/" -# Email Settings -EMAIL_HOST="" -EMAIL_HOST_USER="" -EMAIL_HOST_PASSWORD="" -EMAIL_PORT=587 -EMAIL_FROM="Team Plane " -EMAIL_USE_TLS="1" -EMAIL_USE_SSL="0" - # AWS Settings AWS_REGION="" AWS_ACCESS_KEY_ID="access-key" @@ -38,9 +30,9 @@ AWS_S3_BUCKET_NAME="uploads" FILE_SIZE_LIMIT=5242880 # GPT settings -OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint -OPENAI_API_KEY="sk-" # add your openai key here -GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access +OPENAI_API_BASE="https://api.openai.com/v1" # deprecated +OPENAI_API_KEY="sk-" # deprecated +GPT_ENGINE="gpt-3.5-turbo" # deprecated # Github GITHUB_CLIENT_SECRET="" # For fetching release notes @@ -53,9 +45,6 @@ USE_MINIO=1 # Nginx Configuration NGINX_PORT=80 -# Default Creds -DEFAULT_EMAIL="captain@plane.so" -DEFAULT_PASSWORD="password123" # SignUps ENABLE_SIGNUP="1" @@ -70,12 +59,6 @@ ENABLE_MAGIC_LINK_LOGIN="0" # Email redirections and minio domain settings WEB_URL="http://localhost" -# Set it to 0, to disable it -ENABLE_WEBHOOK=1 - -# Set it to 0, to disable it -ENABLE_API=1 - # Gunicorn Workers GUNICORN_WORKERS=2 diff --git a/apiserver/Dockerfile.api b/apiserver/Dockerfile.api index 15c3f53a9..a2ce4a7b2 100644 --- a/apiserver/Dockerfile.api +++ b/apiserver/Dockerfile.api @@ -43,7 +43,7 @@ USER captain COPY manage.py manage.py COPY plane plane/ COPY templates templates/ - +COPY package.json package.json COPY gunicorn.config.py ./ USER root RUN apk --no-cache add "bash~=5.2" diff --git a/apiserver/bin/bucket_script.py b/apiserver/bin/bucket_script.py deleted file mode 100644 index 89717d527..000000000 --- a/apiserver/bin/bucket_script.py +++ /dev/null @@ -1,83 +0,0 @@ -import os, sys -import boto3 -import json -from botocore.exceptions import ClientError - - -sys.path.append("/code") - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") -import django - -django.setup() - -def set_bucket_public_policy(s3_client, bucket_name): - public_policy = { - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Principal": "*", - "Action": ["s3:GetObject"], - "Resource": [f"arn:aws:s3:::{bucket_name}/*"] - }] - } - - try: - s3_client.put_bucket_policy( - Bucket=bucket_name, - Policy=json.dumps(public_policy) - ) - print(f"Public read access policy set for bucket '{bucket_name}'.") - except ClientError as e: - print(f"Error setting public read access policy: {e}") - - - -def create_bucket(): - try: - from django.conf import settings - - # Create a session using the credentials from Django settings - session = boto3.session.Session( - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, - ) - - # Create an S3 client using the session - s3_client = session.client('s3', endpoint_url=settings.AWS_S3_ENDPOINT_URL) - bucket_name = settings.AWS_STORAGE_BUCKET_NAME - - print("Checking bucket...") - - # Check if the bucket exists - s3_client.head_bucket(Bucket=bucket_name) - - # If head_bucket does not raise an exception, the bucket exists - print(f"Bucket '{bucket_name}' already exists.") - - set_bucket_public_policy(s3_client, bucket_name) - - except ClientError as e: - error_code = int(e.response['Error']['Code']) - bucket_name = settings.AWS_STORAGE_BUCKET_NAME - if error_code == 404: - # Bucket does not exist, create it - print(f"Bucket '{bucket_name}' does not exist. Creating bucket...") - try: - s3_client.create_bucket(Bucket=bucket_name) - print(f"Bucket '{bucket_name}' created successfully.") - set_bucket_public_policy(s3_client, bucket_name) - except ClientError as create_error: - print(f"Failed to create bucket: {create_error}") - elif error_code == 403: - # Access to the bucket is forbidden - print(f"Access to the bucket '{bucket_name}' is forbidden. Check permissions.") - else: - # Another ClientError occurred - print(f"Failed to check bucket: {e}") - except Exception as ex: - # Handle any other exception - print(f"An error occurred: {ex}") - -if __name__ == "__main__": - create_bucket() diff --git a/apiserver/bin/takeoff b/apiserver/bin/takeoff index 74980dd62..44f251155 100755 --- a/apiserver/bin/takeoff +++ b/apiserver/bin/takeoff @@ -3,8 +3,10 @@ set -e python manage.py wait_for_db python manage.py migrate -# Create a Default User -python bin/user_script.py +# Register instance +python manage.py register_instance +# Load the configuration variable +python manage.py configure_instance # Create the default bucket python bin/bucket_script.py diff --git a/apiserver/bin/user_script.py b/apiserver/bin/user_script.py deleted file mode 100644 index a356f2ec9..000000000 --- a/apiserver/bin/user_script.py +++ /dev/null @@ -1,28 +0,0 @@ -import os, sys -import uuid - -sys.path.append("/code") - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") -import django - -django.setup() - -from plane.db.models import User - - -def populate(): - default_email = os.environ.get("DEFAULT_EMAIL", "captain@plane.so") - default_password = os.environ.get("DEFAULT_PASSWORD", "password123") - - if not User.objects.filter(email=default_email).exists(): - user = User.objects.create(email=default_email, username=uuid.uuid4().hex) - user.set_password(default_password) - user.save() - print(f"User created with an email: {default_email}") - else: - print(f"User already exists with the default email: {default_email}") - - -if __name__ == "__main__": - populate() diff --git a/apiserver/package.json b/apiserver/package.json new file mode 100644 index 000000000..c622ae496 --- /dev/null +++ b/apiserver/package.json @@ -0,0 +1,4 @@ +{ + "name": "plane-api", + "version": "0.13.2" +} \ No newline at end of file diff --git a/apiserver/plane/api/serializers/user.py b/apiserver/plane/api/serializers/user.py index b8f9dedd4..687993fcc 100644 --- a/apiserver/plane/api/serializers/user.py +++ b/apiserver/plane/api/serializers/user.py @@ -4,6 +4,7 @@ from rest_framework import serializers # Module import from .base import BaseSerializer from plane.db.models import User, Workspace, WorkspaceMemberInvite +from plane.license.models import InstanceAdmin, Instance class UserSerializer(BaseSerializer): @@ -86,7 +87,9 @@ class UserMeSettingsSerializer(BaseSerializer): "last_workspace_id": obj.last_workspace_id, "last_workspace_slug": workspace.slug if workspace is not None else "", "fallback_workspace_id": obj.last_workspace_id, - "fallback_workspace_slug": workspace.slug if workspace is not None else "", + "fallback_workspace_slug": workspace.slug + if workspace is not None + else "", "invites": workspace_invites, } else: diff --git a/apiserver/plane/api/urls/__init__.py b/apiserver/plane/api/urls/__init__.py index 1e3c1cbca..e6088cb14 100644 --- a/apiserver/plane/api/urls/__init__.py +++ b/apiserver/plane/api/urls/__init__.py @@ -49,10 +49,6 @@ urlpatterns = [ *user_urls, *view_urls, *workspace_urls, + *api_urls, + *webhook_urls, ] - -if settings.ENABLE_WEBHOOK: - urlpatterns += webhook_urls - -if settings.ENABLE_API: - urlpatterns += api_urls diff --git a/apiserver/plane/api/urls/user.py b/apiserver/plane/api/urls/user.py index 00f95cd42..da794d59a 100644 --- a/apiserver/plane/api/urls/user.py +++ b/apiserver/plane/api/urls/user.py @@ -38,6 +38,15 @@ urlpatterns = [ ), name="users", ), + path( + "users/me/instance-admin/", + UserEndpoint.as_view( + { + "get": "retrieve_instance_admin", + } + ), + name="users", + ), path( "users/me/change-password/", ChangePasswordEndpoint.as_view(), diff --git a/apiserver/plane/api/views/authentication.py b/apiserver/plane/api/views/authentication.py index fe7b4c473..2ec241303 100644 --- a/apiserver/plane/api/views/authentication.py +++ b/apiserver/plane/api/views/authentication.py @@ -320,11 +320,11 @@ class SignInEndpoint(BaseAPIView): except RequestException as e: capture_exception(e) + access_token, refresh_token = get_tokens_for_user(user) data = { "access_token": access_token, "refresh_token": refresh_token, } - access_token, refresh_token = get_tokens_for_user(user) return Response(data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index 71f9c1842..de7bafd57 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -51,7 +51,6 @@ class WebhookMixin: self.webhook_event and self.request.method in ["POST", "PATCH", "DELETE"] and response.status_code in [200, 201, 204] - and settings.ENABLE_WEBHOOK ): send_webhook.delay( event=self.webhook_event, diff --git a/apiserver/plane/api/views/config.py b/apiserver/plane/api/views/config.py index d035c4740..237d8d6bf 100644 --- a/apiserver/plane/api/views/config.py +++ b/apiserver/plane/api/views/config.py @@ -12,6 +12,8 @@ from sentry_sdk import capture_exception # Module imports from .base import BaseAPIView +from plane.license.models import Instance, InstanceConfiguration +from plane.license.utils.instance_value import get_configuration_value class ConfigurationEndpoint(BaseAPIView): @@ -20,18 +22,75 @@ class ConfigurationEndpoint(BaseAPIView): ] def get(self, request): + instance_configuration = InstanceConfiguration.objects.values("key", "value") + data = {} - data["google_client_id"] = os.environ.get("GOOGLE_CLIENT_ID", None) - data["github_client_id"] = os.environ.get("GITHUB_CLIENT_ID", None) - data["github_app_name"] = os.environ.get("GITHUB_APP_NAME", None) - data["magic_login"] = ( - bool(settings.EMAIL_HOST_USER) and bool(settings.EMAIL_HOST_PASSWORD) - ) and os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0") == "1" - data["email_password_login"] = ( - os.environ.get("ENABLE_EMAIL_PASSWORD", "0") == "1" + # Authentication + data["google_client_id"] = get_configuration_value( + instance_configuration, + "GOOGLE_CLIENT_ID", + os.environ.get("GOOGLE_CLIENT_ID", None), ) - data["slack_client_id"] = os.environ.get("SLACK_CLIENT_ID", None) - data["posthog_api_key"] = os.environ.get("POSTHOG_API_KEY", None) - data["posthog_host"] = os.environ.get("POSTHOG_HOST", None) - data["has_unsplash_configured"] = bool(settings.UNSPLASH_ACCESS_KEY) + data["github_client_id"] = get_configuration_value( + instance_configuration, + "GITHUB_CLIENT_ID", + os.environ.get("GITHUB_CLIENT_ID", None), + ) + data["github_app_name"] = get_configuration_value( + instance_configuration, + "GITHUB_APP_NAME", + os.environ.get("GITHUB_APP_NAME", None), + ) + data["magic_login"] = ( + bool( + get_configuration_value( + instance_configuration, + "EMAIL_HOST_USER", + os.environ.get("GITHUB_APP_NAME", None), + ), + ) + and bool( + get_configuration_value( + instance_configuration, + "EMAIL_HOST_PASSWORD", + os.environ.get("GITHUB_APP_NAME", None), + ) + ) + ) and get_configuration_value( + instance_configuration, "ENABLE_MAGIC_LINK_LOGIN", "0" + ) == "1" + data["email_password_login"] = ( + get_configuration_value( + instance_configuration, "ENABLE_EMAIL_PASSWORD", "0" + ) + == "1" + ) + # Slack client + data["slack_client_id"] = get_configuration_value( + instance_configuration, + "SLACK_CLIENT_ID", + os.environ.get("SLACK_CLIENT_ID", None), + ) + + # Posthog + data["posthog_api_key"] = get_configuration_value( + instance_configuration, + "POSTHOG_API_KEY", + os.environ.get("POSTHOG_API_KEY", None), + ) + data["posthog_host"] = get_configuration_value( + instance_configuration, + "POSTHOG_HOST", + os.environ.get("POSTHOG_HOST", None), + ) + + # Unsplash + data["has_unsplash_configured"] = bool( + get_configuration_value( + instance_configuration, + "UNSPLASH_ACCESS_KEY", + os.environ.get("UNSPLASH_ACCESS_KEY", None), + ) + ) + return Response(data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/external.py b/apiserver/plane/api/views/external.py index a04495569..1953743a2 100644 --- a/apiserver/plane/api/views/external.py +++ b/apiserver/plane/api/views/external.py @@ -2,7 +2,7 @@ import requests # Third party imports -import openai +from openai import OpenAI from rest_framework.response import Response from rest_framework import status from rest_framework.permissions import AllowAny @@ -17,7 +17,8 @@ from plane.api.permissions import ProjectEntityPermission from plane.db.models import Workspace, Project from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer from plane.utils.integrations.github import get_release_notes - +from plane.license.models import InstanceConfiguration +from plane.license.utils.instance_value import get_configuration_value class GPTIntegrationEndpoint(BaseAPIView): permission_classes = [ @@ -25,7 +26,14 @@ class GPTIntegrationEndpoint(BaseAPIView): ] def post(self, request, slug, project_id): - if not settings.OPENAI_API_KEY or not settings.GPT_ENGINE: + + # Get the configuration value + instance_configuration = InstanceConfiguration.objects.values("key", "value") + api_key = get_configuration_value(instance_configuration, "OPENAI_API_KEY") + gpt_engine = get_configuration_value(instance_configuration, "GPT_ENGINE") + + # Check the keys + if not api_key or not gpt_engine: return Response( {"error": "OpenAI API key and engine is required"}, status=status.HTTP_400_BAD_REQUEST, @@ -41,12 +49,17 @@ class GPTIntegrationEndpoint(BaseAPIView): final_text = task + "\n" + prompt - openai.api_key = settings.OPENAI_API_KEY - response = openai.ChatCompletion.create( - model=settings.GPT_ENGINE, + instance_configuration = InstanceConfiguration.objects.values("key", "value") + + gpt_engine = get_configuration_value(instance_configuration, "GPT_ENGINE") + + client = OpenAI( + api_key=api_key, + ) + + response = client.chat.completions.create( + model=gpt_engine, messages=[{"role": "user", "content": final_text}], - temperature=0.7, - max_tokens=1024, ) workspace = Workspace.objects.get(slug=slug) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 08c7fee4d..ce7750105 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -427,7 +427,7 @@ class ProjectInvitationsViewset(BaseViewSet): project_invitations = ProjectMemberInvite.objects.bulk_create( project_invitations, batch_size=10, ignore_conflicts=True ) - current_site = f"{request.scheme}://{request.get_host()}", + current_site = request.META.get('HTTP_ORIGIN') # Send invitations for invitation in project_invitations: diff --git a/apiserver/plane/api/views/user.py b/apiserver/plane/api/views/user.py index 9b488489a..e6e742a63 100644 --- a/apiserver/plane/api/views/user.py +++ b/apiserver/plane/api/views/user.py @@ -14,6 +14,7 @@ from plane.api.serializers import ( from plane.api.views.base import BaseViewSet, BaseAPIView from plane.db.models import User, IssueActivity, WorkspaceMember +from plane.license.models import Instance, InstanceAdmin from plane.utils.paginator import BasePaginator @@ -35,12 +36,17 @@ class UserEndpoint(BaseViewSet): serialized_data = UserMeSettingsSerializer(request.user).data return Response(serialized_data, status=status.HTTP_200_OK) + def retrieve_instance_admin(self, request): + instance = Instance.objects.first() + is_admin = InstanceAdmin.objects.filter( + instance=instance, user=request.user + ).exists() + return Response({"is_instance_admin": is_admin}, status=status.HTTP_200_OK) + def deactivate(self, request): # Check all workspace user is active user = self.get_object() - if WorkspaceMember.objects.filter( - member=request.user, is_active=True - ).exists(): + if WorkspaceMember.objects.filter(member=request.user, is_active=True).exists(): return Response( { "error": "User cannot deactivate account as user is active in some workspaces" diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 3fc9b7bde..8804d48ef 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -319,7 +319,7 @@ class WorkspaceInvitationsViewset(BaseViewSet): workspace_invitations, batch_size=10, ignore_conflicts=True ) - current_site = f"{request.scheme}://{request.get_host()}", + current_site = request.META.get('HTTP_ORIGIN') # Send invitations for invitation in workspace_invitations: diff --git a/apiserver/plane/bgtasks/analytic_plot_export.py b/apiserver/plane/bgtasks/analytic_plot_export.py index a80770c37..8cccc2299 100644 --- a/apiserver/plane/bgtasks/analytic_plot_export.py +++ b/apiserver/plane/bgtasks/analytic_plot_export.py @@ -3,7 +3,7 @@ import csv import io # Django imports -from django.core.mail import EmailMultiAlternatives +from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags from django.conf import settings @@ -16,6 +16,8 @@ from sentry_sdk import capture_exception from plane.db.models import Issue from plane.utils.analytics_plot import build_graph_plot from plane.utils.issue_filters import issue_filters +from plane.license.models import InstanceConfiguration +from plane.license.utils.instance_value import get_configuration_value row_mapping = { "state__name": "State", @@ -47,7 +49,19 @@ def send_export_email(email, slug, csv_buffer): text_content = strip_tags(html_content) csv_buffer.seek(0) - msg = EmailMultiAlternatives(subject, text_content, settings.EMAIL_FROM, [email]) + + # Configure email connection from the database + instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value") + connection = get_connection( + host=get_configuration_value(instance_configuration, "EMAIL_HOST"), + port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")), + username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), + password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"), + use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")), + use_ssl=bool(get_configuration_value(instance_configuration, "EMAIL_USE_SSL", "0")), + ) + + msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection) msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue()) msg.send(fail_silently=False) diff --git a/apiserver/plane/bgtasks/email_verification_task.py b/apiserver/plane/bgtasks/email_verification_task.py index 9f9d06437..ba4ce6490 100644 --- a/apiserver/plane/bgtasks/email_verification_task.py +++ b/apiserver/plane/bgtasks/email_verification_task.py @@ -1,5 +1,5 @@ # Django imports -from django.core.mail import EmailMultiAlternatives +from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags from django.conf import settings @@ -11,8 +11,8 @@ from celery import shared_task from sentry_sdk import capture_exception # Module imports -from plane.db.models import User - +from plane.license.models import InstanceConfiguration +from plane.license.utils.instance_value import get_configuration_value @shared_task def email_verification(first_name, email, token, current_site): @@ -34,7 +34,18 @@ def email_verification(first_name, email, token, current_site): text_content = strip_tags(html_content) - msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + # Configure email connection from the database + instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value") + connection = get_connection( + host=get_configuration_value(instance_configuration, "EMAIL_HOST"), + port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")), + username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), + password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"), + use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")), + ) + + # Initiate email alternatives + msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection) msg.attach_alternative(html_content, "text/html") msg.send() return diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index de1390f01..b924ad3a2 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -1,5 +1,5 @@ # Django imports -from django.core.mail import EmailMultiAlternatives +from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags from django.conf import settings @@ -8,7 +8,9 @@ from django.conf import settings from celery import shared_task from sentry_sdk import capture_exception - +# Module imports +from plane.license.models import InstanceConfiguration +from plane.license.utils.instance_value import get_configuration_value @shared_task def forgot_password(first_name, email, uidb64, token, current_site): @@ -30,7 +32,16 @@ def forgot_password(first_name, email, uidb64, token, current_site): text_content = strip_tags(html_content) - msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value") + connection = get_connection( + host=get_configuration_value(instance_configuration, "EMAIL_HOST"), + port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")), + username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), + password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"), + use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")), + ) + # Initiate email alternatives + msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection) msg.attach_alternative(html_content, "text/html") msg.send() return diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py index 71f6db8da..372cafa6e 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -1,5 +1,5 @@ # Django imports -from django.core.mail import EmailMultiAlternatives +from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags from django.conf import settings @@ -8,6 +8,9 @@ from django.conf import settings from celery import shared_task from sentry_sdk import capture_exception +# Module imports +from plane.license.models import InstanceConfiguration +from plane.license.utils.instance_value import get_configuration_value @shared_task def magic_link(email, key, token, current_site): @@ -15,8 +18,6 @@ def magic_link(email, key, token, current_site): realtivelink = f"/magic-sign-in/?password={token}&key={key}" abs_url = current_site + realtivelink - from_email_string = settings.EMAIL_FROM - subject = "Login for Plane" context = {"magic_url": abs_url, "code": token} @@ -25,7 +26,17 @@ def magic_link(email, key, token, current_site): text_content = strip_tags(html_content) - msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value") + connection = get_connection( + host=get_configuration_value(instance_configuration, "EMAIL_HOST"), + port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")), + username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), + password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"), + use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")), + ) + + # Initiate email alternatives + msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection) msg.attach_alternative(html_content, "text/html") msg.send() return diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py index 41f6da3ca..311ccec0a 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -1,5 +1,5 @@ # Django imports -from django.core.mail import EmailMultiAlternatives +from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags from django.conf import settings @@ -10,7 +10,8 @@ from sentry_sdk import capture_exception # Module imports from plane.db.models import Project, User, ProjectMemberInvite - +from plane.license.models import InstanceConfiguration +from plane.license.utils.instance_value import get_configuration_value @shared_task def project_invitation(email, project_id, token, current_site, invitor): @@ -44,7 +45,17 @@ def project_invitation(email, project_id, token, current_site, invitor): project_member_invite.message = text_content project_member_invite.save() - msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + # Configure email connection from the database + instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value") + connection = get_connection( + host=get_configuration_value(instance_configuration, "EMAIL_HOST"), + port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")), + username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), + password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"), + use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")), + ) + # Initiate email alternatives + msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection) msg.attach_alternative(html_content, "text/html") msg.send() return diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index fca34a84d..7be1dbf60 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -1,5 +1,5 @@ # Django imports -from django.core.mail import EmailMultiAlternatives +from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags from django.conf import settings @@ -11,13 +11,14 @@ from slack_sdk import WebClient from slack_sdk.errors import SlackApiError # Module imports -from plane.db.models import User, Workspace, WorkspaceMemberInvite +from plane.db.models import Workspace, WorkspaceMemberInvite, User +from plane.license.models import InstanceConfiguration +from plane.license.utils.instance_value import get_configuration_value @shared_task def workspace_invitation(email, workspace_id, token, current_site, invitor): try: - user = User.objects.get(email=invitor) workspace = Workspace.objects.get(pk=workspace_id) @@ -26,9 +27,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): ) # Relative link - relative_link = ( - f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" - ) + relative_link = f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" # The complete url including the domain abs_url = current_site + relative_link @@ -55,7 +54,30 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): workspace_member_invite.message = text_content workspace_member_invite.save() - msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + instance_configuration = InstanceConfiguration.objects.filter( + key__startswith="EMAIL_" + ).values("key", "value") + connection = get_connection( + host=get_configuration_value(instance_configuration, "EMAIL_HOST"), + port=int( + get_configuration_value(instance_configuration, "EMAIL_PORT", "587") + ), + username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), + password=get_configuration_value( + instance_configuration, "EMAIL_HOST_PASSWORD" + ), + use_tls=bool( + get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1") + ), + ) + # Initiate email alternatives + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), + to=[email], + connection=connection, + ) msg.attach_alternative(html_content, "text/html") msg.send() diff --git a/apiserver/plane/db/management/commands/create_bucket.py b/apiserver/plane/db/management/commands/create_bucket.py new file mode 100644 index 000000000..054523bf9 --- /dev/null +++ b/apiserver/plane/db/management/commands/create_bucket.py @@ -0,0 +1,71 @@ +# Python imports +import boto3 +import json +from botocore.exceptions import ClientError + +# Django imports +from django.core.management import BaseCommand +from django.conf import settings + +class Command(BaseCommand): + help = "Create the default bucket for the instance" + + def set_bucket_public_policy(self, s3_client, bucket_name): + public_policy = { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": "*", + "Action": ["s3:GetObject"], + "Resource": [f"arn:aws:s3:::{bucket_name}/*"] + }] + } + + try: + s3_client.put_bucket_policy( + Bucket=bucket_name, + Policy=json.dumps(public_policy) + ) + self.stdout.write(self.style.SUCCESS(f"Public read access policy set for bucket '{bucket_name}'.")) + except ClientError as e: + self.stdout.write(self.style.ERROR(f"Error setting public read access policy: {e}")) + + + def handle(self, *args, **options): + # Create a session using the credentials from Django settings + try: + session = boto3.session.Session( + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + ) + # Create an S3 client using the session + s3_client = session.client('s3', endpoint_url=settings.AWS_S3_ENDPOINT_URL) + bucket_name = settings.AWS_STORAGE_BUCKET_NAME + + self.stdout.write(self.style.NOTICE("Checking bucket...")) + + # Check if the bucket exists + s3_client.head_bucket(Bucket=bucket_name) + + self.set_bucket_public_policy(s3_client, bucket_name) + except ClientError as e: + error_code = int(e.response['Error']['Code']) + bucket_name = settings.AWS_STORAGE_BUCKET_NAME + if error_code == 404: + # Bucket does not exist, create it + self.stdout.write(self.style.WARNING(f"Bucket '{bucket_name}' does not exist. Creating bucket...")) + try: + s3_client.create_bucket(Bucket=bucket_name) + self.stdout.write(self.style.SUCCESS(f"Bucket '{bucket_name}' created successfully.")) + self.set_bucket_public_policy(s3_client, bucket_name) + except ClientError as create_error: + self.stdout.write(self.style.ERROR(f"Failed to create bucket: {create_error}")) + elif error_code == 403: + # Access to the bucket is forbidden + self.stdout.write(self.style.ERROR(f"Access to the bucket '{bucket_name}' is forbidden. Check permissions.")) + else: + # Another ClientError occurred + self.stdout.write(self.style.ERROR(f"Failed to check bucket: {e}")) + except Exception as ex: + # Handle any other exception + self.stdout.write(self.style.ERROR(f"An error occurred: {ex}")) \ No newline at end of file diff --git a/apiserver/plane/license/__init__.py b/apiserver/plane/license/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/license/api/__init__.py b/apiserver/plane/license/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/license/api/permissions/__init__.py b/apiserver/plane/license/api/permissions/__init__.py new file mode 100644 index 000000000..392b228c0 --- /dev/null +++ b/apiserver/plane/license/api/permissions/__init__.py @@ -0,0 +1 @@ +from .instance import InstanceOwnerPermission, InstanceAdminPermission diff --git a/apiserver/plane/license/api/permissions/instance.py b/apiserver/plane/license/api/permissions/instance.py new file mode 100644 index 000000000..1d1845f12 --- /dev/null +++ b/apiserver/plane/license/api/permissions/instance.py @@ -0,0 +1,33 @@ +# Third party imports +from rest_framework.permissions import BasePermission + +# Module imports +from plane.license.models import Instance, InstanceAdmin + + +class InstanceOwnerPermission(BasePermission): + def has_permission(self, request, view): + + if request.user.is_anonymous: + return False + + instance = Instance.objects.first() + return InstanceAdmin.objects.filter( + role=20, + instance=instance, + user=request.user, + ).exists() + + +class InstanceAdminPermission(BasePermission): + def has_permission(self, request, view): + + if request.user.is_anonymous: + return False + + instance = Instance.objects.first() + return InstanceAdmin.objects.filter( + role__gte=15, + instance=instance, + user=request.user, + ).exists() diff --git a/apiserver/plane/license/api/serializers/__init__.py b/apiserver/plane/license/api/serializers/__init__.py new file mode 100644 index 000000000..b658ff148 --- /dev/null +++ b/apiserver/plane/license/api/serializers/__init__.py @@ -0,0 +1 @@ +from .instance import InstanceSerializer, InstanceAdminSerializer, InstanceConfigurationSerializer \ No newline at end of file diff --git a/apiserver/plane/license/api/serializers/instance.py b/apiserver/plane/license/api/serializers/instance.py new file mode 100644 index 000000000..b8c990522 --- /dev/null +++ b/apiserver/plane/license/api/serializers/instance.py @@ -0,0 +1,42 @@ +# Module imports +from plane.license.models import Instance, InstanceAdmin, InstanceConfiguration +from plane.api.serializers import BaseSerializer +from plane.api.serializers import UserAdminLiteSerializer + + +class InstanceSerializer(BaseSerializer): + primary_owner_details = UserAdminLiteSerializer(source="primary_owner", read_only=True) + + class Meta: + model = Instance + fields = "__all__" + read_only_fields = [ + "id", + "primary_owner", + "primary_email", + "instance_id", + "license_key", + "api_key", + "version", + "email", + "last_checked_at", + ] + + +class InstanceAdminSerializer(BaseSerializer): + user_detail = UserAdminLiteSerializer(source="user", read_only=True) + + class Meta: + model = InstanceAdmin + fields = "__all__" + read_only_fields = [ + "id", + "instance", + "user", + ] + +class InstanceConfigurationSerializer(BaseSerializer): + + class Meta: + model = InstanceConfiguration + fields = "__all__" diff --git a/apiserver/plane/license/api/views/__init__.py b/apiserver/plane/license/api/views/__init__.py new file mode 100644 index 000000000..4b925759a --- /dev/null +++ b/apiserver/plane/license/api/views/__init__.py @@ -0,0 +1,6 @@ +from .instance import ( + InstanceEndpoint, + TransferPrimaryOwnerEndpoint, + InstanceAdminEndpoint, + InstanceConfigurationEndpoint, +) diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py new file mode 100644 index 000000000..309b2b9da --- /dev/null +++ b/apiserver/plane/license/api/views/instance.py @@ -0,0 +1,242 @@ +# Python imports +import json +import os +import requests + +# Django imports +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.api.views import BaseAPIView +from plane.license.models import Instance, InstanceAdmin, InstanceConfiguration +from plane.license.api.serializers import ( + InstanceSerializer, + InstanceAdminSerializer, + InstanceConfigurationSerializer, +) +from plane.license.api.permissions import ( + InstanceOwnerPermission, + InstanceAdminPermission, +) +from plane.db.models import User + + +class InstanceEndpoint(BaseAPIView): + def get_permissions(self): + if self.request.method in ["POST", "PATCH"]: + self.permission_classes = [ + InstanceOwnerPermission, + ] + else: + self.permission_classes = [ + InstanceAdminPermission, + ] + return super(InstanceEndpoint, self).get_permissions() + + def post(self, request): + # Check if the instance is registered + instance = Instance.objects.first() + + # If instance is None then register this instance + if instance is None: + with open("package.json", "r") as file: + # Load JSON content from the file + data = json.load(file) + + license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL") + + if not license_engine_base_url: + raise Response( + {"error": "LICENSE_ENGINE_BASE_URL is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + headers = {"Content-Type": "application/json"} + + payload = { + "email": request.user.email, + "version": data.get("version", 0.1), + } + + response = requests.post( + f"{license_engine_base_url}/api/instances", + headers=headers, + data=json.dumps(payload), + ) + + if response.status_code == 201: + data = response.json() + # Create instance + instance = Instance.objects.create( + instance_name="Plane Free", + instance_id=data.get("id"), + license_key=data.get("license_key"), + api_key=data.get("api_key"), + version=data.get("version"), + primary_email=data.get("email"), + primary_owner=request.user, + last_checked_at=timezone.now(), + ) + # Create instance admin + _ = InstanceAdmin.objects.create( + user=request.user, + instance=instance, + role=20, + ) + + return Response( + { + "message": f"Instance succesfully registered with owner: {instance.primary_owner.email}" + }, + status=status.HTTP_201_CREATED, + ) + return Response( + {"error": "Instance could not be registered"}, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + return Response( + { + "message": f"Instance already registered with instance owner: {instance.primary_owner.email}" + }, + status=status.HTTP_200_OK, + ) + + def get(self, request): + instance = Instance.objects.first() + # get the instance + if instance is None: + return Response({"activated": False}, status=status.HTTP_400_BAD_REQUEST) + # Return instance + serializer = InstanceSerializer(instance) + serializer.data["activated"] = True + return Response(serializer.data, status=status.HTTP_200_OK) + + def patch(self, request): + # Get the instance + instance = Instance.objects.first() + serializer = InstanceSerializer(instance, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class TransferPrimaryOwnerEndpoint(BaseAPIView): + permission_classes = [ + InstanceOwnerPermission, + ] + + # Transfer the owner of the instance + def post(self, request): + instance = Instance.objects.first() + + # Get the email of the new user + email = request.data.get("email", False) + if not email: + return Response( + {"error": "User is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + # Get users + user = User.objects.get(email=email) + + # Save the instance user + instance.primary_owner = user + instance.primary_email = user.email + instance.save(update_fields=["owner", "email"]) + + # Add the user to admin + _ = InstanceAdmin.objects.get_or_create( + instance=instance, + user=user, + role=20, + ) + + return Response( + {"message": "Owner successfully updated"}, status=status.HTTP_200_OK + ) + + +class InstanceAdminEndpoint(BaseAPIView): + def get_permissions(self): + if self.request.method in ["POST", "DELETE"]: + self.permission_classes = [ + InstanceOwnerPermission, + ] + else: + self.permission_classes = [ + InstanceAdminPermission, + ] + return super(InstanceAdminEndpoint, self).get_permissions() + + # Create an instance admin + def post(self, request): + email = request.data.get("email", False) + role = request.data.get("role", 15) + + if not email: + return Response( + {"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + instance = Instance.objects.first() + if instance is None: + return Response( + {"error": "Instance is not registered yet"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Fetch the user + user = User.objects.get(email=email) + + instance_admin = InstanceAdmin.objects.create( + instance=instance, + user=user, + role=role, + ) + serializer = InstanceAdminSerializer(instance_admin) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def get(self, request): + instance = Instance.objects.first() + if instance is None: + return Response( + {"error": "Instance is not registered yet"}, + status=status.HTTP_403_FORBIDDEN, + ) + instance_admins = InstanceAdmin.objects.filter(instance=instance) + serializer = InstanceAdminSerializer(instance_admins, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def delete(self, request, pk): + instance = Instance.objects.first() + instance_admin = InstanceAdmin.objects.filter(instance=instance, pk=pk).delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class InstanceConfigurationEndpoint(BaseAPIView): + permission_classes = [ + InstanceAdminPermission, + ] + + def get(self, request): + instance_configurations = InstanceConfiguration.objects.all() + serializer = InstanceConfigurationSerializer(instance_configurations, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def patch(self, request): + key = request.data.get("key", False) + if not key: + return Response( + {"error": "Key is required"}, status=status.HTTP_400_BAD_REQUEST + ) + configuration = InstanceConfiguration.objects.get(key=key) + configuration.value = request.data.get("value") + configuration.save() + serializer = InstanceConfigurationSerializer(configuration) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/license/apps.py b/apiserver/plane/license/apps.py new file mode 100644 index 000000000..400e98155 --- /dev/null +++ b/apiserver/plane/license/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class LicenseConfig(AppConfig): + name = "plane.license" diff --git a/apiserver/plane/license/management/__init__.py b/apiserver/plane/license/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/license/management/commands/__init__.py b/apiserver/plane/license/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py new file mode 100644 index 000000000..d71d9f590 --- /dev/null +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -0,0 +1,46 @@ +# Python imports +import os + +# Django imports +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +# Module imports +from plane.license.models import InstanceConfiguration + +class Command(BaseCommand): + help = "Configure instance variables" + + def handle(self, *args, **options): + config_keys = { + # Authentication Settings + "GOOGLE_CLIENT_ID": os.environ.get("GOOGLE_CLIENT_ID"), + "GITHUB_CLIENT_ID": os.environ.get("GITHUB_CLIENT_ID"), + "GITHUB_CLIENT_SECRET": os.environ.get("GITHUB_CLIENT_SECRET"), + "ENABLE_SIGNUP": os.environ.get("ENABLE_SIGNUP", "1"), + "ENABLE_EMAIL_PASSWORD": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), + "ENABLE_MAGIC_LINK_LOGIN": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0"), + # Email Settings + "EMAIL_HOST": os.environ.get("EMAIL_HOST", ""), + "EMAIL_HOST_USER": os.environ.get("EMAIL_HOST_USER", ""), + "EMAIL_HOST_PASSWORD": os.environ.get("EMAIL_HOST_PASSWORD"), + "EMAIL_PORT": os.environ.get("EMAIL_PORT", "587"), + "EMAIL_FROM": os.environ.get("EMAIL_FROM", ""), + "EMAIL_USE_TLS": os.environ.get("EMAIL_USE_TLS", "1"), + "EMAIL_USE_SSL": os.environ.get("EMAIL_USE_SSL", "0"), + # Open AI Settings + "OPENAI_API_BASE": os.environ.get("", "https://api.openai.com/v1"), + "OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", "sk-"), + "GPT_ENGINE": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), + } + + for key, value in config_keys.items(): + obj, created = InstanceConfiguration.objects.get_or_create( + key=key + ) + if created: + obj.value = value + obj.save() + self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable.")) + else: + self.stdout.write(self.style.WARNING(f"{key} configuration already exists")) \ No newline at end of file diff --git a/apiserver/plane/license/management/commands/register_instance.py b/apiserver/plane/license/management/commands/register_instance.py new file mode 100644 index 000000000..855a3a035 --- /dev/null +++ b/apiserver/plane/license/management/commands/register_instance.py @@ -0,0 +1,104 @@ +# Python imports +import json +import os +import requests +import uuid + +# Django imports +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone +from django.core.exceptions import ValidationError +from django.core.validators import validate_email + +# Module imports +from plane.db.models import User +from plane.license.models import Instance, InstanceAdmin + + +class Command(BaseCommand): + help = "Check if instance in registered else register" + + def handle(self, *args, **options): + # Check if the instance is registered + instance = Instance.objects.first() + + # If instance is None then register this instance + if instance is None: + with open("package.json", "r") as file: + # Load JSON content from the file + data = json.load(file) + + admin_email = os.environ.get("ADMIN_EMAIL") + + try: + validate_email(admin_email) + except ValidationError: + CommandError(f"{admin_email} is not a valid ADMIN_EMAIL") + + # Raise an exception if the admin email is not provided + if not admin_email: + raise CommandError("ADMIN_EMAIL is required") + + # Check if the admin email user exists + user = User.objects.filter(email=admin_email).first() + + # If the user does not exist create the user and add him to the database + if user is None: + user = User.objects.create(email=admin_email, username=uuid.uuid4().hex) + user.set_password(uuid.uuid4().hex) + user.save() + + license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL") + + if not license_engine_base_url: + raise CommandError("LICENSE_ENGINE_BASE_URL is required") + + headers = {"Content-Type": "application/json"} + + payload = { + "email": user.email, + "version": data.get("version", 0.1), + } + + response = requests.post( + f"{license_engine_base_url}/api/instances", + headers=headers, + data=json.dumps(payload), + ) + + if response.status_code == 201: + data = response.json() + # Create instance + instance = Instance.objects.create( + instance_name="Plane Free", + instance_id=data.get("id"), + license_key=data.get("license_key"), + api_key=data.get("api_key"), + version=data.get("version"), + primary_email=data.get("email"), + primary_owner=user, + last_checked_at=timezone.now(), + ) + # Create instance admin + _ = InstanceAdmin.objects.create( + user=user, + instance=instance, + role=20, + ) + + self.stdout.write( + self.style.SUCCESS( + f"Instance succesfully registered with owner: {instance.primary_owner.email}" + ) + ) + return + + self.stdout.write(self.style.WARNING("Instance could not be registered")) + return + else: + self.stdout.write( + self.style.SUCCESS( + f"Instance already registered with instance owner: {instance.primary_owner.email}" + ) + ) + return diff --git a/apiserver/plane/license/migrations/0001_initial.py b/apiserver/plane/license/migrations/0001_initial.py new file mode 100644 index 000000000..db620a18e --- /dev/null +++ b/apiserver/plane/license/migrations/0001_initial.py @@ -0,0 +1,83 @@ +# Generated by Django 4.2.5 on 2023-11-15 14:22 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Instance', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('instance_name', models.CharField(max_length=255)), + ('whitelist_emails', models.TextField(blank=True, null=True)), + ('instance_id', models.CharField(max_length=25, unique=True)), + ('license_key', models.CharField(blank=True, max_length=256, null=True)), + ('api_key', models.CharField(max_length=16)), + ('version', models.CharField(max_length=10)), + ('primary_email', models.CharField(max_length=256)), + ('last_checked_at', models.DateTimeField()), + ('namespace', models.CharField(blank=True, max_length=50, null=True)), + ('is_telemetry_enabled', models.BooleanField(default=True)), + ('is_support_required', models.BooleanField(default=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('primary_owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='instance_primary_owner', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'Instance', + 'verbose_name_plural': 'Instances', + 'db_table': 'instances', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='InstanceConfiguration', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('key', models.CharField(max_length=100, unique=True)), + ('value', models.TextField(blank=True, default=None, null=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'Instance Configuration', + 'verbose_name_plural': 'Instance Configurations', + 'db_table': 'instance_configurations', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='InstanceAdmin', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('role', models.PositiveIntegerField(choices=[(20, 'Owner'), (15, 'Admin')], default=15)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='admins', to='license.instance')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='instance_owner', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Instance Admin', + 'verbose_name_plural': 'Instance Admins', + 'db_table': 'instance_admins', + 'ordering': ('-created_at',), + }, + ), + ] diff --git a/apiserver/plane/license/migrations/0002_alter_instanceadmin_unique_together.py b/apiserver/plane/license/migrations/0002_alter_instanceadmin_unique_together.py new file mode 100644 index 000000000..21d4baaf1 --- /dev/null +++ b/apiserver/plane/license/migrations/0002_alter_instanceadmin_unique_together.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.5 on 2023-11-16 09:45 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('license', '0001_initial'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='instanceadmin', + unique_together={('instance', 'user')}, + ), + ] diff --git a/apiserver/plane/license/migrations/__init__.py b/apiserver/plane/license/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/license/models/__init__.py b/apiserver/plane/license/models/__init__.py new file mode 100644 index 000000000..28f2c4352 --- /dev/null +++ b/apiserver/plane/license/models/__init__.py @@ -0,0 +1 @@ +from .instance import Instance, InstanceAdmin, InstanceConfiguration \ No newline at end of file diff --git a/apiserver/plane/license/models/instance.py b/apiserver/plane/license/models/instance.py new file mode 100644 index 000000000..5f96b6b90 --- /dev/null +++ b/apiserver/plane/license/models/instance.py @@ -0,0 +1,73 @@ +# Django imports +from django.db import models +from django.conf import settings + +# Module imports +from plane.db.models import BaseModel +from plane.db.mixins import AuditModel + +ROLE_CHOICES = ( + (20, "Owner"), + (15, "Admin"), +) + + +class Instance(BaseModel): + # General informations + instance_name = models.CharField(max_length=255) + whitelist_emails = models.TextField(blank=True, null=True) + instance_id = models.CharField(max_length=25, unique=True) + license_key = models.CharField(max_length=256, null=True, blank=True) + api_key = models.CharField(max_length=16) + version = models.CharField(max_length=10) + # User information + primary_email = models.CharField(max_length=256) + primary_owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name="instance_primary_owner", + ) + # Instnace specifics + last_checked_at = models.DateTimeField() + namespace = models.CharField(max_length=50, blank=True, null=True) + # telemetry and support + is_telemetry_enabled = models.BooleanField(default=True) + is_support_required = models.BooleanField(default=True) + + class Meta: + verbose_name = "Instance" + verbose_name_plural = "Instances" + db_table = "instances" + ordering = ("-created_at",) + + +class InstanceAdmin(BaseModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name="instance_owner", + ) + instance = models.ForeignKey(Instance, on_delete=models.CASCADE, related_name="admins") + role = models.PositiveIntegerField(choices=ROLE_CHOICES, default=15) + + class Meta: + unique_together = ["instance", "user"] + verbose_name = "Instance Admin" + verbose_name_plural = "Instance Admins" + db_table = "instance_admins" + ordering = ("-created_at",) + + +class InstanceConfiguration(BaseModel): + # The instance configuration variables + key = models.CharField(max_length=100, unique=True) + value = models.TextField(null=True, blank=True, default=None) + + class Meta: + verbose_name = "Instance Configuration" + verbose_name_plural = "Instance Configurations" + db_table = "instance_configurations" + ordering = ("-created_at",) + diff --git a/apiserver/plane/license/urls.py b/apiserver/plane/license/urls.py new file mode 100644 index 000000000..6e95329bd --- /dev/null +++ b/apiserver/plane/license/urls.py @@ -0,0 +1,36 @@ +from django.urls import path + +from plane.license.api.views import ( + InstanceEndpoint, + TransferPrimaryOwnerEndpoint, + InstanceAdminEndpoint, + InstanceConfigurationEndpoint, +) + +urlpatterns = [ + path( + "instances/", + InstanceEndpoint.as_view(), + name="instance", + ), + path( + "instances/transfer-primary-owner/", + TransferPrimaryOwnerEndpoint.as_view(), + name="instance", + ), + path( + "instances/admins/", + InstanceAdminEndpoint.as_view(), + name="instance-admins", + ), + path( + "instances/admins//", + InstanceAdminEndpoint.as_view(), + name="instance-admins", + ), + path( + "instances/configurations/", + InstanceConfigurationEndpoint.as_view(), + name="instance-configuration", + ), +] diff --git a/apiserver/plane/license/utils/__init__.py b/apiserver/plane/license/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/license/utils/instance_value.py b/apiserver/plane/license/utils/instance_value.py new file mode 100644 index 000000000..efca2799c --- /dev/null +++ b/apiserver/plane/license/utils/instance_value.py @@ -0,0 +1,6 @@ +# Helper function to return value from the passed key +def get_configuration_value(query, key, default=None): + for item in query: + if item['key'] == key: + return item.get("value", default) + return default diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 74bf59be3..0ef96717f 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -5,6 +5,7 @@ import ssl import certifi from datetime import timedelta from urllib.parse import urlparse + # Django imports from django.core.management.utils import get_random_secret_key @@ -26,12 +27,6 @@ DEBUG = False # Allowed Hosts ALLOWED_HOSTS = ["*"] -# To access webhook -ENABLE_WEBHOOK = os.environ.get("ENABLE_WEBHOOK", "1") == "1" - -# To access plane api through api tokens -ENABLE_API = os.environ.get("ENABLE_API", "1") == "1" - # Redirect if / is not present APPEND_SLASH = True @@ -48,6 +43,7 @@ INSTALLED_APPS = [ "plane.utils", "plane.web", "plane.middleware", + "plane.license", "plane.proxy", # Third-party things "rest_framework", @@ -118,7 +114,13 @@ CSRF_COOKIE_SECURE = True # CORS Settings CORS_ALLOW_CREDENTIALS = True -CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "").split(",") +cors_origins_raw = os.environ.get("CORS_ALLOWED_ORIGINS", "") +# filter out empty strings +cors_allowed_origins = [origin.strip() for origin in cors_origins_raw.split(",") if origin.strip()] +if cors_allowed_origins: + CORS_ALLOWED_ORIGINS = cors_allowed_origins +else: + CORS_ALLOW_ALL_ORIGINS = True # Application Settings WSGI_APPLICATION = "plane.wsgi.application" @@ -212,16 +214,6 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # Email settings EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" -# Host for sending e-mail. -EMAIL_HOST = os.environ.get("EMAIL_HOST") -# Port for sending e-mail. -EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 587)) -# Optional SMTP authentication information for EMAIL_HOST. -EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER") -EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD") -EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "1") == "1" -EMAIL_USE_SSL = os.environ.get("EMAIL_USE_SSL", "0") == "1" -EMAIL_FROM = os.environ.get("EMAIL_FROM", "Team Plane ") # Storage Settings STORAGES = { @@ -229,7 +221,9 @@ STORAGES = { "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", }, } -STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"} +STORAGES["default"] = { + "BACKEND": "storages.backends.s3boto3.S3Boto3Storage", +} AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key") AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key") AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads") @@ -245,7 +239,6 @@ if AWS_S3_ENDPOINT_URL: AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:" - # JWT Auth Configuration SIMPLE_JWT = { "ACCESS_TOKEN_LIFETIME": timedelta(minutes=10080), @@ -328,17 +321,5 @@ GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False) ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False) -# Open AI Settings -OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1") -OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False) -GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo") - -# Scout Settings -SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False) -SCOUT_KEY = os.environ.get("SCOUT_KEY", "") -SCOUT_NAME = "Plane" - -# Set the variable true if running in docker environment -DOCKERIZED = int(os.environ.get("DOCKERIZED", 1)) == 1 +# Use Minio settings USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1 - diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index 9fa5ed0aa..8f27d4234 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -3,10 +3,6 @@ from .common import * # noqa DEBUG = True -ALLOWED_HOSTS = [ - "*", -] - # Debug Toolbar settings INSTALLED_APPS += ("debug_toolbar",) MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",) @@ -24,13 +20,9 @@ CACHES = { INTERNAL_IPS = ("127.0.0.1",) -CORS_ORIGIN_ALLOW_ALL = True - MEDIA_URL = "/uploads/" MEDIA_ROOT = os.path.join(BASE_DIR, "uploads") -# For local settings -CORS_ALLOW_ALL_ORIGINS = True CORS_ALLOWED_ORIGINS = [ "http://localhost:3000", "http://127.0.0.1:3000", diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index b230bbc32..90eb04dd5 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -11,3 +11,8 @@ INSTALLED_APPS += ("scout_apm.django",) # Honor the 'X-Forwarded-Proto' header for request.is_secure() SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +# Scout Settings +SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False) +SCOUT_KEY = os.environ.get("SCOUT_KEY", "") +SCOUT_NAME = "Plane" diff --git a/apiserver/plane/urls.py b/apiserver/plane/urls.py index aabc6a75a..66f6714fb 100644 --- a/apiserver/plane/urls.py +++ b/apiserver/plane/urls.py @@ -11,11 +11,11 @@ from django.conf import settings urlpatterns = [ path("", TemplateView.as_view(template_name="index.html")), path("api/", include("plane.api.urls")), + path("api/licenses/", include("plane.license.urls")), + path("api/v1/", include("plane.proxy.urls")), path("", include("plane.web.urls")), ] -if settings.ENABLE_API: - urlpatterns += path("api/v1/", include("plane.proxy.urls")), if settings.DEBUG: import debug_toolbar diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index f870b998b..7c5f1cf28 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -26,7 +26,7 @@ google-api-python-client==2.97.0 django-redis==5.3.0 uvicorn==0.23.2 channels==4.0.0 -openai==0.28.0 +openai==1.2.4 slack-sdk==3.21.3 celery==5.3.4 django_celery_beat==2.5.0 diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 8ac4a7277..74377aef0 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -5,18 +5,15 @@ x-app-env : &app-env - NGINX_PORT=${NGINX_PORT:-80} - WEB_URL=${WEB_URL:-http://localhost} - DEBUG=${DEBUG:-0} - - DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.selfhosted} + - DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.selfhosted} # deprecated - NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0} - NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces} - SENTRY_DSN=${SENTRY_DSN:-""} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""} - - DOCKERIZED=${DOCKERIZED:-1} - # BASE WEBHOOK - - ENABLE_WEBHOOK=${ENABLE_WEBHOOK:-1} - # BASE API - - ENABLE_API=${ENABLE_API:-1} - - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-http://localhost} - # Gunicorn Workers + - DOCKERIZED=${DOCKERIZED:-1} # deprecated + - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-""} + - ENVIRONMENT=${ENVIRONMENT:-"production"} + # Gunicorn Workers - GUNICORN_WORKERS=${GUNICORN_WORKERS:-2} #DB SETTINGS - PGHOST=${PGHOST:-plane-db} @@ -40,7 +37,7 @@ x-app-env : &app-env - EMAIL_USE_SSL=${EMAIL_USE_SSL:-0} - DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so} - DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123} - # OPENAI SETTINGS + # OPENAI SETTINGS - Deprecated can be configured through admin panel - OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1} - OPENAI_API_KEY=${OPENAI_API_KEY:-"sk-"} - GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"} diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env index b2547cbbe..f74c838e7 100644 --- a/deploy/selfhost/variables.env +++ b/deploy/selfhost/variables.env @@ -7,18 +7,14 @@ API_REPLICAS=1 NGINX_PORT=80 WEB_URL=http://localhost DEBUG=0 -DJANGO_SETTINGS_MODULE=plane.settings.selfhosted +DJANGO_SETTINGS_MODULE=plane.settings.selfhosted # deprecated NEXT_PUBLIC_ENABLE_OAUTH=0 NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces SENTRY_DSN="" GITHUB_CLIENT_SECRET="" -DOCKERIZED=1 -CORS_ALLOWED_ORIGINS="http://localhost" - -# Webhook -ENABLE_WEBHOOK=1 -# API -ENABLE_API=1 +DOCKERIZED=1 # deprecated +CORS_ALLOWED_ORIGINS="" +ENVIRONMENT="production" #DB SETTINGS PGHOST=plane-db @@ -42,13 +38,11 @@ EMAIL_PORT=587 EMAIL_FROM="Team Plane <team@mailer.plane.so>" EMAIL_USE_TLS=1 EMAIL_USE_SSL=0 -DEFAULT_EMAIL=captain@plane.so -DEFAULT_PASSWORD=password123 # OPENAI SETTINGS -OPENAI_API_BASE=https://api.openai.com/v1 -OPENAI_API_KEY="sk-" -GPT_ENGINE="gpt-3.5-turbo" +OPENAI_API_BASE=https://api.openai.com/v1 # deprecated +OPENAI_API_KEY="sk-" # deprecated +GPT_ENGINE="gpt-3.5-turbo" # deprecated # LOGIN/SIGNUP SETTINGS ENABLE_SIGNUP=1 diff --git a/web/components/instance/general-form.tsx b/web/components/instance/general-form.tsx new file mode 100644 index 000000000..87a268fd2 --- /dev/null +++ b/web/components/instance/general-form.tsx @@ -0,0 +1,126 @@ +import { FC } from "react"; +import { Controller, useForm } from "react-hook-form"; +// ui +import { Button, Input, ToggleSwitch } from "@plane/ui"; +// types +import { IInstance } from "types/instance"; +// hooks +import useToast from "hooks/use-toast"; +import { useMobxStore } from "lib/mobx/store-provider"; + +export interface IInstanceGeneralForm { + instance: IInstance; +} + +export interface GeneralFormValues { + instance_name: string; + is_telemetry_enabled: boolean; +} + +export const InstanceGeneralForm: FC = (props) => { + const { instance } = props; + // store + const { instance: instanceStore } = useMobxStore(); + // toast + const { setToastAlert } = useToast(); + // form data + const { + handleSubmit, + control, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + instance_name: instance.instance_name, + is_telemetry_enabled: instance.is_telemetry_enabled, + }, + }); + + const onSubmit = async (formData: GeneralFormValues) => { + const payload: Partial = { ...formData }; + + await instanceStore + .updateInstanceInfo(payload) + .then(() => + setToastAlert({ + title: "Success", + type: "success", + message: "Settings updated successfully", + }) + ) + .catch((err) => console.error(err)); + }; + + return ( +
+
+
+

Name of instance

+ ( + + )} + /> +
+ +
+

Admin Email

+ +
+ +
+

Instance Id

+ +
+
+ +
+
+
Share anonymous usage instance
+
+ Help us understand how you use Plane so we can build better for you. +
+
+
+ } + /> +
+
+ +
+ +
+
+ ); +}; diff --git a/web/components/instance/help-section.tsx b/web/components/instance/help-section.tsx new file mode 100644 index 000000000..4093f9ffd --- /dev/null +++ b/web/components/instance/help-section.tsx @@ -0,0 +1,134 @@ +import { FC, useState, useRef } from "react"; +import { Transition } from "@headlessui/react"; +import Link from "next/link"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// icons +import { FileText, HelpCircle, MessagesSquare, MoveLeft } from "lucide-react"; +import { DiscordIcon, GithubIcon } from "@plane/ui"; +// assets +import packageJson from "package.json"; + +const helpOptions = [ + { + name: "Documentation", + href: "https://docs.plane.so/", + Icon: FileText, + }, + { + name: "Join our Discord", + href: "https://discord.com/invite/A92xrEGCge", + Icon: DiscordIcon, + }, + { + name: "Report a bug", + href: "https://github.com/makeplane/plane/issues/new/choose", + Icon: GithubIcon, + }, + { + name: "Chat with us", + href: null, + onClick: () => (window as any).$crisp.push(["do", "chat:show"]), + Icon: MessagesSquare, + }, +]; + +export const InstanceHelpSection: FC = () => { + // states + const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false); + // store + const { + theme: { sidebarCollapsed, toggleSidebar }, + } = useMobxStore(); + // refs + const helpOptionsRef = useRef(null); + + return ( +
+
+ + + +
+ +
+ +
+
+ {helpOptions.map(({ name, Icon, href, onClick }) => { + if (href) + return ( + + +
+ +
+ {name} +
+ + ); + else + return ( + + ); + })} +
+
Version: v{packageJson.version}
+
+
+
+
+ ); +}; diff --git a/web/components/instance/index.ts b/web/components/instance/index.ts new file mode 100644 index 000000000..c4840736a --- /dev/null +++ b/web/components/instance/index.ts @@ -0,0 +1,4 @@ +export * from "./help-section"; +export * from "./sidebar-menu"; +export * from "./sidebar-dropdown"; +export * from "./general-form"; diff --git a/web/components/instance/sidebar-dropdown.tsx b/web/components/instance/sidebar-dropdown.tsx new file mode 100644 index 000000000..923dd8d21 --- /dev/null +++ b/web/components/instance/sidebar-dropdown.tsx @@ -0,0 +1,148 @@ +import { Fragment } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +import Link from "next/link"; +import { Menu, Transition } from "@headlessui/react"; +import { LogOut, Settings, Shield, UserCircle2 } from "lucide-react"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import useToast from "hooks/use-toast"; +// services +import { AuthService } from "services/auth.service"; +// ui +import { Avatar } from "@plane/ui"; + +// Static Data +const profileLinks = (workspaceSlug: string, userId: string) => [ + { + name: "View profile", + icon: UserCircle2, + link: `/${workspaceSlug}/profile/${userId}`, + }, + { + name: "Settings", + icon: Settings, + link: `/${workspaceSlug}/me/profile`, + }, +]; + +const authService = new AuthService(); + +export const InstanceSidebarDropdown = observer(() => { + const router = useRouter(); + // store + const { + theme: { sidebarCollapsed }, + workspace: { workspaceSlug }, + user: { currentUser, currentUserSettings }, + } = useMobxStore(); + // hooks + const { setToastAlert } = useToast(); + + // redirect url for normal mode + const redirectWorkspaceSlug = + workspaceSlug || + currentUserSettings?.workspace?.last_workspace_slug || + currentUserSettings?.workspace?.fallback_workspace_slug || + ""; + + const handleSignOut = async () => { + await authService + .signOut() + .then(() => { + router.push("/"); + }) + .catch(() => + setToastAlert({ + type: "error", + title: "Error!", + message: "Failed to sign out. Please try again.", + }) + ); + }; + + return ( +
+
+
+
+ +
+ + {!sidebarCollapsed && ( +

Instance Admin Settings

+ )} +
+
+ + {!sidebarCollapsed && ( + + + + + + + +
+ {currentUser?.email} + {profileLinks(workspaceSlug?.toString() ?? "", currentUser?.id ?? "").map((link, index) => ( + + + + + {link.name} + + + + ))} +
+
+ + + Sign out + +
+ +
+ + + + Normal Mode + + + +
+
+
+
+ )} +
+ ); +}); diff --git a/web/components/instance/sidebar-menu.tsx b/web/components/instance/sidebar-menu.tsx new file mode 100644 index 000000000..dbb697efb --- /dev/null +++ b/web/components/instance/sidebar-menu.tsx @@ -0,0 +1,65 @@ +import Link from "next/link"; +import { useRouter } from "next/router"; +import { BarChart2, Briefcase, CheckCircle, LayoutGrid } from "lucide-react"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// ui +import { Tooltip } from "@plane/ui"; + +const INSTANCE_ADMIN_LINKS = [ + { + Icon: LayoutGrid, + name: "General", + href: `/admin`, + }, + { + Icon: BarChart2, + name: "OAuth", + href: `/admin/oauth`, + }, + { + Icon: Briefcase, + name: "Email", + href: `/admin/email`, + }, + { + Icon: CheckCircle, + name: "AI", + href: `/admin/ai`, + }, +]; + +export const InstanceAdminSidebarMenu = () => { + const { + theme: { sidebarCollapsed }, + } = useMobxStore(); + // router + const router = useRouter(); + + return ( +
+ {INSTANCE_ADMIN_LINKS.map((item, index) => { + const isActive = item.name === "Settings" ? router.asPath.includes(item.href) : router.asPath === item.href; + + return ( + + + +
+ {} + {!sidebarCollapsed && item.name} +
+
+
+ + ); + })} +
+ ); +}; diff --git a/web/components/workspace/sidebar-dropdown.tsx b/web/components/workspace/sidebar-dropdown.tsx index 58879e968..6fa950a84 100644 --- a/web/components/workspace/sidebar-dropdown.tsx +++ b/web/components/workspace/sidebar-dropdown.tsx @@ -53,7 +53,7 @@ export const WorkspaceSidebarDropdown = observer(() => { const { theme: { sidebarCollapsed }, workspace: { workspaces, currentWorkspace: activeWorkspace }, - user: { currentUser, updateCurrentUser }, + user: { currentUser, updateCurrentUser, isUserInstanceAdmin }, } = useMobxStore(); // hooks const { setToastAlert } = useToast(); @@ -286,7 +286,7 @@ export const WorkspaceSidebarDropdown = observer(() => { ))}
-
+
{ Sign out
+ {isUserInstanceAdmin && ( +
+ + + + God Mode + + + +
+ )} diff --git a/web/layouts/admin-layout/header.tsx b/web/layouts/admin-layout/header.tsx new file mode 100644 index 000000000..a111222f3 --- /dev/null +++ b/web/layouts/admin-layout/header.tsx @@ -0,0 +1,47 @@ +import { FC } from "react"; +// next +import Link from "next/link"; +// mobx +import { observer } from "mobx-react-lite"; +// ui +import { Breadcrumbs } from "@plane/ui"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// icons +import { ArrowLeftToLine, Settings } from "lucide-react"; + +export const InstanceAdminHeader: FC = observer(() => { + const { + workspace: { workspaceSlug }, + user: { currentUserSettings }, + } = useMobxStore(); + + const redirectWorkspaceSlug = + workspaceSlug || + currentUserSettings?.workspace?.last_workspace_slug || + currentUserSettings?.workspace?.fallback_workspace_slug || + ""; + + return ( +
+
+
+ + } + label="General" + /> + +
+
+
+ + + + + +
+
+ ); +}); diff --git a/web/layouts/admin-layout/index.ts b/web/layouts/admin-layout/index.ts new file mode 100644 index 000000000..8a235ad00 --- /dev/null +++ b/web/layouts/admin-layout/index.ts @@ -0,0 +1,3 @@ +export * from "./layout"; +export * from "./sidebar"; +export * from "./header"; diff --git a/web/layouts/admin-layout/layout.tsx b/web/layouts/admin-layout/layout.tsx new file mode 100644 index 000000000..1a1dbfa63 --- /dev/null +++ b/web/layouts/admin-layout/layout.tsx @@ -0,0 +1,32 @@ +import { FC, ReactNode } from "react"; +// layouts +import { UserAuthWrapper } from "layouts/auth-layout"; +// components +import { InstanceAdminSidebar } from "./sidebar"; +import { InstanceAdminHeader } from "./header"; + +export interface IInstanceAdminLayout { + children: ReactNode; +} + +export const InstanceAdminLayout: FC = (props) => { + const { children } = props; + + return ( + <> + +
+ +
+ +
+
+ <>{children} +
+
+
+
+
+ + ); +}; diff --git a/web/layouts/admin-layout/sidebar.tsx b/web/layouts/admin-layout/sidebar.tsx new file mode 100644 index 000000000..d3a9ecfa1 --- /dev/null +++ b/web/layouts/admin-layout/sidebar.tsx @@ -0,0 +1,28 @@ +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { InstanceAdminSidebarMenu, InstanceHelpSection, InstanceSidebarDropdown } from "components/instance"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; + +export interface IInstanceAdminSidebar {} + +export const InstanceAdminSidebar: FC = observer(() => { + // store + const { theme: themStore } = useMobxStore(); + + return ( +
+
+ + + +
+
+ ); +}); diff --git a/web/layouts/auth-layout/user-wrapper.tsx b/web/layouts/auth-layout/user-wrapper.tsx index 6072f1673..6b64099fa 100644 --- a/web/layouts/auth-layout/user-wrapper.tsx +++ b/web/layouts/auth-layout/user-wrapper.tsx @@ -14,7 +14,7 @@ export const UserAuthWrapper: FC = (props) => { const { children } = props; // store const { - user: { fetchCurrentUser, fetchCurrentUserSettings }, + user: { fetchCurrentUser, fetchCurrentUserInstanceAdminStatus, fetchCurrentUserSettings }, workspace: { fetchWorkspaces }, } = useMobxStore(); // router @@ -23,6 +23,10 @@ export const UserAuthWrapper: FC = (props) => { const { data: currentUser, error } = useSWR("CURRENT_USER_DETAILS", () => fetchCurrentUser(), { shouldRetryOnError: false, }); + // fetching current user instance admin status + useSWR("CURRENT_USER_INSTANCE_ADMIN_STATUS", () => fetchCurrentUserInstanceAdminStatus(), { + shouldRetryOnError: false, + }); // fetching user settings useSWR("CURRENT_USER_SETTINGS", () => fetchCurrentUserSettings(), { shouldRetryOnError: false, diff --git a/web/pages/admin/ai.tsx b/web/pages/admin/ai.tsx new file mode 100644 index 000000000..49557c8ce --- /dev/null +++ b/web/pages/admin/ai.tsx @@ -0,0 +1,16 @@ +import { ReactElement } from "react"; +// layouts +import { InstanceAdminLayout } from "layouts/admin-layout"; +// types +import { NextPageWithLayout } from "types/app"; + +const InstanceAdminAIPage: NextPageWithLayout = () => { + console.log("admin page"); + return
Admin AI Page
; +}; + +InstanceAdminAIPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default InstanceAdminAIPage; diff --git a/web/pages/admin/email.tsx b/web/pages/admin/email.tsx new file mode 100644 index 000000000..9fc572b44 --- /dev/null +++ b/web/pages/admin/email.tsx @@ -0,0 +1,16 @@ +import { ReactElement } from "react"; +// layouts +import { InstanceAdminLayout } from "layouts/admin-layout"; +// types +import { NextPageWithLayout } from "types/app"; + +const InstanceAdminEmailPage: NextPageWithLayout = () => { + console.log("admin page"); + return
Admin Email Page
; +}; + +InstanceAdminEmailPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default InstanceAdminEmailPage; diff --git a/web/pages/admin/index.tsx b/web/pages/admin/index.tsx new file mode 100644 index 000000000..70ffd0cc1 --- /dev/null +++ b/web/pages/admin/index.tsx @@ -0,0 +1,28 @@ +import { ReactElement } from "react"; +import useSWR from "swr"; +import { observer } from "mobx-react-lite"; +// layouts +import { InstanceAdminLayout } from "layouts/admin-layout"; +// types +import { NextPageWithLayout } from "types/app"; +// store +import { useMobxStore } from "lib/mobx/store-provider"; +// components +import { InstanceGeneralForm } from "components/instance"; + +const InstanceAdminPage: NextPageWithLayout = observer(() => { + // store + const { + instance: { fetchInstanceInfo, instance }, + } = useMobxStore(); + + useSWR("INSTANCE_INFO", () => fetchInstanceInfo()); + + return
{instance && }
; +}); + +InstanceAdminPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default InstanceAdminPage; diff --git a/web/pages/admin/oauth.tsx b/web/pages/admin/oauth.tsx new file mode 100644 index 000000000..56bb8fc17 --- /dev/null +++ b/web/pages/admin/oauth.tsx @@ -0,0 +1,16 @@ +import { ReactElement } from "react"; +// layouts +import { InstanceAdminLayout } from "layouts/admin-layout"; +// types +import { NextPageWithLayout } from "types/app"; + +const InstanceAdminOAuthPage: NextPageWithLayout = () => { + console.log("admin page"); + return
Admin oauth Page
; +}; + +InstanceAdminOAuthPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default InstanceAdminOAuthPage; diff --git a/web/services/instance.service.ts b/web/services/instance.service.ts new file mode 100644 index 000000000..74c32aa5f --- /dev/null +++ b/web/services/instance.service.ts @@ -0,0 +1,37 @@ +import { APIService } from "services/api.service"; +// helpers +import { API_BASE_URL } from "helpers/common.helper"; +// types +import type { IInstance } from "types/instance"; + +export class InstanceService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async getInstanceInfo(): Promise { + return this.get("/api/licenses/instances/") + .then((response) => response.data) + .catch((error) => { + throw error; + }); + } + + async updateInstanceInfo( + data: Partial + ): Promise { + return this.patch("/api/licenses/instances/", data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }) + } + + async getInstanceConfigurations() { + return this.get("/api/licenses/instances/configurations/") + .then((response) => response.data) + .catch((error) => { + throw error; + }); + } +} diff --git a/web/services/user.service.ts b/web/services/user.service.ts index f5c4ac17e..a2cd74697 100644 --- a/web/services/user.service.ts +++ b/web/services/user.service.ts @@ -6,6 +6,7 @@ import type { IIssue, IUser, IUserActivityResponse, + IInstanceAdminStatus, IUserProfileData, IUserProfileProjectSegregation, IUserSettings, @@ -54,6 +55,14 @@ export class UserService extends APIService { }); } + async currentUserInstanceAdminStatus(): Promise { + return this.get("/api/users/me/instance-admin/") + .then((respone) => respone?.data) + .catch((error) => { + throw error?.response; + }); + } + async currentUserSettings(): Promise { return this.get("/api/users/me/settings/") .then((response) => response?.data) diff --git a/web/services/workspace.service.ts b/web/services/workspace.service.ts index 30126c2ee..98d85ec8a 100644 --- a/web/services/workspace.service.ts +++ b/web/services/workspace.service.ts @@ -96,7 +96,7 @@ export class WorkspaceService extends APIService { } async joinWorkspace(workspaceSlug: string, invitationId: string, data: any, user: IUser | undefined): Promise { - return this.post(`/api/users/me/invitations/workspaces/${workspaceSlug}/${invitationId}/join/`, data, { + return this.post(`/api/workspaces/${workspaceSlug}/invitations/${invitationId}/join/`, data, { headers: {}, }) .then((response) => { @@ -109,7 +109,7 @@ export class WorkspaceService extends APIService { } async joinWorkspaces(data: any): Promise { - return this.post("/api/users/me/invitations/workspaces/", data) + return this.post("/api/users/me/workspaces/invitations/", data) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; @@ -125,7 +125,7 @@ export class WorkspaceService extends APIService { } async userWorkspaceInvitations(): Promise { - return this.get("/api/users/me/invitations/workspaces/") + return this.get("/api/users/me/workspaces/invitations/") .then((response) => response?.data) .catch((error) => { throw error?.response?.data; diff --git a/web/store/instance/index.ts b/web/store/instance/index.ts new file mode 100644 index 000000000..96a0e600f --- /dev/null +++ b/web/store/instance/index.ts @@ -0,0 +1 @@ +export * from "./instance.store"; diff --git a/web/store/instance/instance.store.ts b/web/store/instance/instance.store.ts new file mode 100644 index 000000000..bd37110a1 --- /dev/null +++ b/web/store/instance/instance.store.ts @@ -0,0 +1,111 @@ +import { observable, action, computed, makeObservable, runInAction } from "mobx"; +// store +import { RootStore } from "../root"; +// types +import { IInstance } from "types/instance"; +// services +import { InstanceService } from "services/instance.service"; + +export interface IInstanceStore { + loader: boolean; + error: any | null; + // issues + instance: IInstance | null; + configurations: any | null; + // computed + // action + fetchInstanceInfo: () => Promise; + updateInstanceInfo: (data: Partial) => Promise; + fetchInstanceConfigurations: () => Promise; +} + +export class InstanceStore implements IInstanceStore { + loader: boolean = false; + error: any | null = null; + instance: IInstance | null = null; + configurations: any | null = null; + // service + instanceService; + rootStore; + + constructor(_rootStore: RootStore) { + makeObservable(this, { + // observable + loader: observable.ref, + error: observable.ref, + instance: observable.ref, + configurations: observable.ref, + // computed + // getIssueType: computed, + // actions + fetchInstanceInfo: action, + updateInstanceInfo: action, + fetchInstanceConfigurations: action, + }); + + this.rootStore = _rootStore; + this.instanceService = new InstanceService(); + } + + /** + * fetch instace info from API + */ + fetchInstanceInfo = async () => { + try { + const instance = await this.instanceService.getInstanceInfo(); + runInAction(() => { + this.instance = instance; + }); + return instance; + } catch (error) { + console.log("Error while fetching the instance"); + throw error; + } + }; + + /** + * update instance info + * @param data + */ + updateInstanceInfo = async (data: Partial) => { + try { + runInAction(() => { + this.loader = true; + this.error = null; + }); + + const response = await this.instanceService.updateInstanceInfo(data); + + runInAction(() => { + this.loader = false; + this.error = null; + this.instance = response; + }); + + return response; + } catch (error) { + runInAction(() => { + this.loader = false; + this.error = error; + }); + + throw error; + } + }; + + /** + * fetch instace configurations from API + */ + fetchInstanceConfigurations = async () => { + try { + const configurations = await this.instanceService.getInstanceConfigurations(); + runInAction(() => { + this.configurations = configurations; + }); + return configurations; + } catch (error) { + console.log("Error while fetching the instance"); + throw error; + } + }; +} diff --git a/web/store/root.ts b/web/store/root.ts index f7c3f49c4..3bebdcd70 100644 --- a/web/store/root.ts +++ b/web/store/root.ts @@ -1,5 +1,6 @@ import { enableStaticRendering } from "mobx-react-lite"; // store imports +import { InstanceStore, IInstanceStore } from "./instance"; import AppConfigStore, { IAppConfigStore } from "./app-config.store"; import CommandPaletteStore, { ICommandPaletteStore } from "./command-palette.store"; import UserStore, { IUserStore } from "store/user.store"; @@ -116,6 +117,8 @@ import { IMentionsStore, MentionsStore } from "store/editor"; enableStaticRendering(typeof window === "undefined"); export class RootStore { + instance: IInstanceStore; + user: IUserStore; theme: IThemeStore; appConfig: IAppConfigStore; @@ -184,6 +187,8 @@ export class RootStore { mentionsStore: IMentionsStore; constructor() { + this.instance = new InstanceStore(this); + this.appConfig = new AppConfigStore(this); this.commandPalette = new CommandPaletteStore(this); this.user = new UserStore(this); diff --git a/web/store/user.store.ts b/web/store/user.store.ts index c1d91904f..6b7e41548 100644 --- a/web/store/user.store.ts +++ b/web/store/user.store.ts @@ -14,6 +14,7 @@ export interface IUserStore { isUserLoggedIn: boolean | null; currentUser: IUser | null; + isUserInstanceAdmin: boolean | null; currentUserSettings: IUserSettings | null; dashboardInfo: any; @@ -41,6 +42,7 @@ export interface IUserStore { hasPermissionToCurrentProject: boolean | undefined; fetchCurrentUser: () => Promise; + fetchCurrentUserInstanceAdminStatus: () => Promise; fetchCurrentUserSettings: () => Promise; fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise; @@ -58,6 +60,7 @@ class UserStore implements IUserStore { isUserLoggedIn: boolean | null = null; currentUser: IUser | null = null; + isUserInstanceAdmin: boolean | null = null; currentUserSettings: IUserSettings | null = null; dashboardInfo: any = null; @@ -87,7 +90,9 @@ class UserStore implements IUserStore { makeObservable(this, { // observable loader: observable.ref, + isUserLoggedIn: observable.ref, currentUser: observable.ref, + isUserInstanceAdmin: observable.ref, currentUserSettings: observable.ref, dashboardInfo: observable.ref, workspaceMemberInfo: observable.ref, @@ -96,6 +101,7 @@ class UserStore implements IUserStore { hasPermissionToProject: observable.ref, // action fetchCurrentUser: action, + fetchCurrentUserInstanceAdminStatus: action, fetchCurrentUserSettings: action, fetchUserDashboardInfo: action, fetchUserWorkspaceInfo: action, @@ -167,6 +173,23 @@ class UserStore implements IUserStore { } }; + fetchCurrentUserInstanceAdminStatus = async () => { + try { + const response = await this.userService.currentUserInstanceAdminStatus(); + if (response) { + runInAction(() => { + this.isUserInstanceAdmin = response.is_instance_admin; + }) + } + return response.is_instance_admin; + } catch (error) { + runInAction(() => { + this.isUserInstanceAdmin = false; + }); + throw error; + } + }; + fetchCurrentUserSettings = async () => { try { const response = await this.userService.currentUserSettings(); diff --git a/web/types/instance.d.ts b/web/types/instance.d.ts new file mode 100644 index 000000000..6ba32b138 --- /dev/null +++ b/web/types/instance.d.ts @@ -0,0 +1,22 @@ +import { IUserLite } from "./users"; + +export interface IInstance { + id: string; + primary_owner_details: IUserLite; + created_at: string; + updated_at: string; + instance_name: string; + whitelist_emails: string | null; + instance_id: string; + license_key: string | null; + api_key: string; + version: string; + primary_email: string; + last_checked_at: string; + namespace: string | null; + is_telemetry_enabled: boolean; + is_support_required: boolean; + created_by: string | null; + updated_by: string | null; + primary_owner: string; +} diff --git a/web/types/users.d.ts b/web/types/users.d.ts index 2c93ff764..c9dbd6cbd 100644 --- a/web/types/users.d.ts +++ b/web/types/users.d.ts @@ -29,6 +29,10 @@ export interface IUser { theme: IUserTheme; } +export interface IInstanceAdminStatus { + is_instance_admin: boolean; +} + export interface IUserSettings { id: string; email: string; From a987df38f4ec5536b66f5e7f8bf0b1af80859604 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Sat, 18 Nov 2023 16:18:06 +0530 Subject: [PATCH 04/10] chore: user onboarding workflow (#2791) --- ..._case_alter_workspace_organization_size.py | 23 +++++++++++++++++++ apiserver/plane/db/models/user.py | 1 + apiserver/plane/db/models/workspace.py | 2 +- 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py diff --git a/apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py b/apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py new file mode 100644 index 000000000..3f00f0ed9 --- /dev/null +++ b/apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.5 on 2023-11-17 08:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0049_auto_20231116_0713'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='use_case', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='workspace', + name='organization_size', + field=models.CharField(blank=True, max_length=20, null=True), + ), + ] diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index e90e19c5e..fe75a6a26 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -86,6 +86,7 @@ class User(AbstractBaseUser, PermissionsMixin): display_name = models.CharField(max_length=255, default="") is_tour_completed = models.BooleanField(default=False) onboarding_step = models.JSONField(default=get_default_onboarding) + use_case = models.TextField(blank=True, null=True) USERNAME_FIELD = "email" diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index 3b694062b..cbf4d97df 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -72,7 +72,7 @@ class Workspace(BaseModel): related_name="owner_workspace", ) slug = models.SlugField(max_length=48, db_index=True, unique=True) - organization_size = models.CharField(max_length=20) + organization_size = models.CharField(max_length=20, blank=True, null=True) def __str__(self): """Return name of the Workspace""" From 0c63f21718520d49709aa9163f4bb7eacbfbe39f Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Sat, 18 Nov 2023 16:20:35 +0530 Subject: [PATCH 05/10] fix: Task List Behaviour in Editor (#2789) * better variable names and comments * drag drop migrated * custom horizontal rule created * init transaction hijack * fixed code block with better contrast, keyboard tripple enter press disabled and syntax highlighting * fixed link selector closing on open behaviour * added better keymaps and syntax highlights * made drag and drop working for code blocks * fixed drag drop for code blocks * moved drag drop only to rich text editor * fixed drag and drop only for description * enabled drag handles for peek overview and main issues * got images to old state * fixed task lists to be smaller * removed validate image functions and uncessary imports * table icons svg attributes fixed * custom list keymap extension added * more uncessary imports of validate image removed * removed console logs * fixed drag-handle styles * space styles updated for the editor * removed showing quotes from blockquotes * removed validateImage for now * added better comments and improved redundant renders * removed uncessary console logs * created util for creating the drag handle element * fixed file names --- packages/editor/core/package.json | 5 +- .../editor/core/src/types/validate-image.ts | 1 - .../ui/extensions/custom-list-keymap/index.ts | 1 + .../list-helpers/find-list-item-pos.ts | 30 +++++ .../list-helpers/get-next-list-depth.ts | 20 ++++ .../list-helpers/handle-backspace.ts | 78 +++++++++++++ .../list-helpers/handle-delete.ts | 34 ++++++ .../list-helpers/has-list-before.ts | 15 +++ .../list-helpers/has-list-item-after.ts | 17 +++ .../list-helpers/has-list-item-before.ts | 17 +++ .../custom-list-keymap/list-helpers/index.ts | 9 ++ .../list-helpers/next-list-is-deeper.ts | 19 ++++ .../list-helpers/next-list-is-higher.ts | 19 ++++ .../custom-list-keymap/list-keymap.ts | 94 ++++++++++++++++ .../editor/core/src/ui/extensions/index.tsx | 7 +- .../src/ui/extensions/table/table/icons.ts | 18 ++- .../editor/core/src/ui/hooks/useEditor.tsx | 11 +- .../src/lib/utils/DragHandleElement.ts | 23 ++++ .../src/ui/extensions/drag-drop.tsx | 23 +--- .../editor/rich-text-editor/src/ui/index.tsx | 4 - .../src/ui/menus/bubble-menu/index.tsx | 2 - space/styles/editor.css | 103 +++++++++++++++++- web/services/file.service.ts | 9 -- web/styles/editor.css | 37 +++---- 24 files changed, 508 insertions(+), 88 deletions(-) delete mode 100644 packages/editor/core/src/types/validate-image.ts create mode 100644 packages/editor/core/src/ui/extensions/custom-list-keymap/index.ts create mode 100644 packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos.ts create mode 100644 packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth.ts create mode 100644 packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-backspace.ts create mode 100644 packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-delete.ts create mode 100644 packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-before.ts create mode 100644 packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-after.ts create mode 100644 packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-before.ts create mode 100644 packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/index.ts create mode 100644 packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper.ts create mode 100644 packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher.ts create mode 100644 packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts create mode 100644 packages/editor/rich-text-editor/src/lib/utils/DragHandleElement.ts diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index 072dc28c6..04100a729 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/core/package.json @@ -31,11 +31,10 @@ "@blueprintjs/popover2": "^2.0.10", "@tiptap/core": "^2.1.7", "@tiptap/extension-code-block-lowlight": "^2.1.12", - "highlight.js": "^11.8.0", - "lowlight": "^3.0.0", "@tiptap/extension-color": "^2.1.11", "@tiptap/extension-image": "^2.1.7", "@tiptap/extension-link": "^2.1.7", + "@tiptap/extension-list-item": "^2.1.12", "@tiptap/extension-mention": "^2.1.12", "@tiptap/extension-table": "^2.1.6", "@tiptap/extension-table-cell": "^2.1.6", @@ -58,7 +57,9 @@ "eslint": "8.36.0", "eslint-config-next": "13.2.4", "eventsource-parser": "^0.1.0", + "highlight.js": "^11.8.0", "jsx-dom-cjs": "^8.0.3", + "lowlight": "^3.0.0", "lucide-react": "^0.244.0", "prosemirror-async-query": "^0.0.4", "react-markdown": "^8.0.7", diff --git a/packages/editor/core/src/types/validate-image.ts b/packages/editor/core/src/types/validate-image.ts deleted file mode 100644 index d976d5cdb..000000000 --- a/packages/editor/core/src/types/validate-image.ts +++ /dev/null @@ -1 +0,0 @@ -export type ValidateImage = (assetUrlWithWorkspaceId: string) => Promise; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/index.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/index.ts new file mode 100644 index 000000000..b91209e92 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/index.ts @@ -0,0 +1 @@ +export * from "./list-keymap"; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos.ts new file mode 100644 index 000000000..17e80b6af --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos.ts @@ -0,0 +1,30 @@ +import { getNodeType } from '@tiptap/core' +import { NodeType } from '@tiptap/pm/model' +import { EditorState } from '@tiptap/pm/state' + +export const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => { + const { $from } = state.selection + const nodeType = getNodeType(typeOrName, state.schema) + + let currentNode = null + let currentDepth = $from.depth + let currentPos = $from.pos + let targetDepth: number | null = null + + while (currentDepth > 0 && targetDepth === null) { + currentNode = $from.node(currentDepth) + + if (currentNode.type === nodeType) { + targetDepth = currentDepth + } else { + currentDepth -= 1 + currentPos -= 1 + } + } + + if (targetDepth === null) { + return null + } + + return { $pos: state.doc.resolve(currentPos), depth: targetDepth } +} diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth.ts new file mode 100644 index 000000000..e81b19592 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth.ts @@ -0,0 +1,20 @@ +import { getNodeAtPosition } from "@tiptap/core"; +import { EditorState } from "@tiptap/pm/state"; + +import { findListItemPos } from "./find-list-item-pos"; + +export const getNextListDepth = (typeOrName: string, state: EditorState) => { + const listItemPos = findListItemPos(typeOrName, state); + + if (!listItemPos) { + return false; + } + + const [, depth] = getNodeAtPosition( + state, + typeOrName, + listItemPos.$pos.pos + 4, + ); + + return depth; +}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-backspace.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-backspace.ts new file mode 100644 index 000000000..1eac3ae4a --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-backspace.ts @@ -0,0 +1,78 @@ +import { Editor, isAtStartOfNode, isNodeActive } from "@tiptap/core"; +import { Node } from "@tiptap/pm/model"; + +import { findListItemPos } from "./find-list-item-pos"; +import { hasListBefore } from "./has-list-before"; + +export const handleBackspace = ( + editor: Editor, + name: string, + parentListTypes: string[], +) => { + // this is required to still handle the undo handling + if (editor.commands.undoInputRule()) { + return true; + } + + // if the cursor is not at the start of a node + // do nothing and proceed + if (!isAtStartOfNode(editor.state)) { + return false; + } + + // if the current item is NOT inside a list item & + // the previous item is a list (orderedList or bulletList) + // move the cursor into the list and delete the current item + if ( + !isNodeActive(editor.state, name) && + hasListBefore(editor.state, name, parentListTypes) + ) { + const { $anchor } = editor.state.selection; + + const $listPos = editor.state.doc.resolve($anchor.before() - 1); + + const listDescendants: Array<{ node: Node; pos: number }> = []; + + $listPos.node().descendants((node, pos) => { + if (node.type.name === name) { + listDescendants.push({ node, pos }); + } + }); + + const lastItem = listDescendants.at(-1); + + if (!lastItem) { + return false; + } + + const $lastItemPos = editor.state.doc.resolve( + $listPos.start() + lastItem.pos + 1, + ); + + return editor + .chain() + .cut( + { from: $anchor.start() - 1, to: $anchor.end() + 1 }, + $lastItemPos.end(), + ) + .joinForward() + .run(); + } + + // if the cursor is not inside the current node type + // do nothing and proceed + if (!isNodeActive(editor.state, name)) { + return false; + } + + const listItemPos = findListItemPos(name, editor.state); + + if (!listItemPos) { + return false; + } + + // if current node is a list item and cursor it at start of a list node, + // simply lift the list item i.e. remove it as a list item (task/bullet/ordered) + // irrespective of above node being a list or not + return editor.chain().liftListItem(name).run(); +}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-delete.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-delete.ts new file mode 100644 index 000000000..5f47baf9d --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-delete.ts @@ -0,0 +1,34 @@ +import { Editor, isAtEndOfNode, isNodeActive } from "@tiptap/core"; + +import { nextListIsDeeper } from "./next-list-is-deeper"; +import { nextListIsHigher } from "./next-list-is-higher"; + +export const handleDelete = (editor: Editor, name: string) => { + // if the cursor is not inside the current node type + // do nothing and proceed + if (!isNodeActive(editor.state, name)) { + return false; + } + + // if the cursor is not at the end of a node + // do nothing and proceed + if (!isAtEndOfNode(editor.state, name)) { + return false; + } + + // check if the next node is a list with a deeper depth + if (nextListIsDeeper(name, editor.state)) { + return editor + .chain() + .focus(editor.state.selection.from + 4) + .lift(name) + .joinBackward() + .run(); + } + + if (nextListIsHigher(name, editor.state)) { + return editor.chain().joinForward().joinBackward().run(); + } + + return editor.commands.joinItemForward(); +}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-before.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-before.ts new file mode 100644 index 000000000..f8ae97462 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-before.ts @@ -0,0 +1,15 @@ +import { EditorState } from '@tiptap/pm/state' + +export const hasListBefore = (editorState: EditorState, name: string, parentListTypes: string[]) => { + const { $anchor } = editorState.selection + + const previousNodePos = Math.max(0, $anchor.pos - 2) + + const previousNode = editorState.doc.resolve(previousNodePos).node() + + if (!previousNode || !parentListTypes.includes(previousNode.type.name)) { + return false + } + + return true +} diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-after.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-after.ts new file mode 100644 index 000000000..6a4445514 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-after.ts @@ -0,0 +1,17 @@ +import { EditorState } from '@tiptap/pm/state' + +export const hasListItemAfter = (typeOrName: string, state: EditorState): boolean => { + const { $anchor } = state.selection + + const $targetPos = state.doc.resolve($anchor.pos - $anchor.parentOffset - 2) + + if ($targetPos.index() === $targetPos.parent.childCount - 1) { + return false + } + + if ($targetPos.nodeAfter?.type.name !== typeOrName) { + return false + } + + return true +} diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-before.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-before.ts new file mode 100644 index 000000000..c5d413cb3 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-before.ts @@ -0,0 +1,17 @@ +import { EditorState } from '@tiptap/pm/state' + +export const hasListItemBefore = (typeOrName: string, state: EditorState): boolean => { + const { $anchor } = state.selection + + const $targetPos = state.doc.resolve($anchor.pos - 2) + + if ($targetPos.index() === 0) { + return false + } + + if ($targetPos.nodeBefore?.type.name !== typeOrName) { + return false + } + + return true +} diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/index.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/index.ts new file mode 100644 index 000000000..644953b92 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/index.ts @@ -0,0 +1,9 @@ +export * from "./find-list-item-pos"; +export * from "./get-next-list-depth"; +export * from "./handle-backspace"; +export * from "./handle-delete"; +export * from "./has-list-before"; +export * from "./has-list-item-after"; +export * from "./has-list-item-before"; +export * from "./next-list-is-deeper"; +export * from "./next-list-is-higher"; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper.ts new file mode 100644 index 000000000..425458b2a --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper.ts @@ -0,0 +1,19 @@ +import { EditorState } from "@tiptap/pm/state"; + +import { findListItemPos } from "./find-list-item-pos"; +import { getNextListDepth } from "./get-next-list-depth"; + +export const nextListIsDeeper = (typeOrName: string, state: EditorState) => { + const listDepth = getNextListDepth(typeOrName, state); + const listItemPos = findListItemPos(typeOrName, state); + + if (!listItemPos || !listDepth) { + return false; + } + + if (listDepth > listItemPos.depth) { + return true; + } + + return false; +}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher.ts new file mode 100644 index 000000000..8b853b5af --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher.ts @@ -0,0 +1,19 @@ +import { EditorState } from "@tiptap/pm/state"; + +import { findListItemPos } from "./find-list-item-pos"; +import { getNextListDepth } from "./get-next-list-depth"; + +export const nextListIsHigher = (typeOrName: string, state: EditorState) => { + const listDepth = getNextListDepth(typeOrName, state); + const listItemPos = findListItemPos(typeOrName, state); + + if (!listItemPos || !listDepth) { + return false; + } + + if (listDepth < listItemPos.depth) { + return true; + } + + return false; +}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts new file mode 100644 index 000000000..b61695973 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts @@ -0,0 +1,94 @@ +import { Extension } from "@tiptap/core"; + +import { handleBackspace, handleDelete } from "./list-helpers"; + +export type ListKeymapOptions = { + listTypes: Array<{ + itemName: string; + wrapperNames: string[]; + }>; +}; + +export const ListKeymap = Extension.create({ + name: "listKeymap", + + addOptions() { + return { + listTypes: [ + { + itemName: "listItem", + wrapperNames: ["bulletList", "orderedList"], + }, + { + itemName: "taskItem", + wrapperNames: ["taskList"], + }, + ], + }; + }, + + addKeyboardShortcuts() { + return { + Delete: ({ editor }) => { + let handled = false; + + this.options.listTypes.forEach(({ itemName }) => { + if (editor.state.schema.nodes[itemName] === undefined) { + return; + } + + if (handleDelete(editor, itemName)) { + handled = true; + } + }); + + return handled; + }, + "Mod-Delete": ({ editor }) => { + let handled = false; + + this.options.listTypes.forEach(({ itemName }) => { + if (editor.state.schema.nodes[itemName] === undefined) { + return; + } + + if (handleDelete(editor, itemName)) { + handled = true; + } + }); + + return handled; + }, + Backspace: ({ editor }) => { + let handled = false; + + this.options.listTypes.forEach(({ itemName, wrapperNames }) => { + if (editor.state.schema.nodes[itemName] === undefined) { + return; + } + + if (handleBackspace(editor, itemName, wrapperNames)) { + handled = true; + } + }); + + return handled; + }, + "Mod-Backspace": ({ editor }) => { + let handled = false; + + this.options.listTypes.forEach(({ itemName, wrapperNames }) => { + if (editor.state.schema.nodes[itemName] === undefined) { + return; + } + + if (handleBackspace(editor, itemName, wrapperNames)) { + handled = true; + } + }); + + return handled; + }, + }; + }, +}); diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index 8106bbd8f..2c6d51ad9 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -11,7 +11,6 @@ import TableHeader from "./table/table-header/table-header"; import Table from "./table/table"; import TableCell from "./table/table-cell/table-cell"; import TableRow from "./table/table-row/table-row"; -import DragDrop from "./drag-drop"; import HorizontalRule from "./horizontal-rule"; import ImageExtension from "./image"; @@ -20,10 +19,10 @@ import { DeleteImage } from "../../types/delete-image"; import { isValidHttpUrl } from "../../lib/utils"; import { IMentionSuggestion } from "../../types/mention-suggestion"; import { Mentions } from "../mentions"; -import { ValidateImage } from "../../types/validate-image"; import { CustomKeymap } from "./keymap"; import { CustomCodeBlock } from "./code"; +import { ListKeymap } from "./custom-list-keymap"; export const CoreEditorExtensions = ( mentionConfig: { @@ -31,7 +30,6 @@ export const CoreEditorExtensions = ( mentionHighlights: string[]; }, deleteFile: DeleteImage, - validateFile?: ValidateImage, cancelUploadImage?: () => any, ) => [ StarterKit.configure({ @@ -64,6 +62,7 @@ export const CoreEditorExtensions = ( }, }), CustomKeymap, + ListKeymap, TiptapLink.configure({ protocols: ["http", "https"], validate: (url) => isValidHttpUrl(url), @@ -72,7 +71,7 @@ export const CoreEditorExtensions = ( "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", }, }), - ImageExtension(deleteFile, validateFile, cancelUploadImage).configure({ + ImageExtension(deleteFile, cancelUploadImage).configure({ HTMLAttributes: { class: "rounded-lg border border-custom-border-300", }, diff --git a/packages/editor/core/src/ui/extensions/table/table/icons.ts b/packages/editor/core/src/ui/extensions/table/table/icons.ts index eda520759..65e8b8540 100644 --- a/packages/editor/core/src/ui/extensions/table/table/icons.ts +++ b/packages/editor/core/src/ui/extensions/table/table/icons.ts @@ -1,11 +1,10 @@ const icons = { - colorPicker: ``, - deleteColumn: ``, - deleteRow: ``, + colorPicker: ``, + deleteColumn: ``, + deleteRow: ``, insertLeftTableIcon: ` void; @@ -37,7 +30,6 @@ interface CustomEditorProps { export const useEditor = ({ uploadFile, deleteFile, - validateFile, cancelUploadImage, editorProps = {}, value, @@ -62,7 +54,6 @@ export const useEditor = ({ mentionHighlights: mentionHighlights ?? [], }, deleteFile, - validateFile, cancelUploadImage, ), ...extensions, diff --git a/packages/editor/rich-text-editor/src/lib/utils/DragHandleElement.ts b/packages/editor/rich-text-editor/src/lib/utils/DragHandleElement.ts new file mode 100644 index 000000000..a84ca1bb0 --- /dev/null +++ b/packages/editor/rich-text-editor/src/lib/utils/DragHandleElement.ts @@ -0,0 +1,23 @@ +export function createDragHandleElement(): HTMLElement { + let dragHandleElement = document.createElement("div"); + dragHandleElement.draggable = true; + dragHandleElement.dataset.dragHandle = ""; + dragHandleElement.classList.add("drag-handle"); + + const dragHandleContainer = document.createElement("div"); + dragHandleContainer.classList.add("drag-handle-container"); + dragHandleElement.appendChild(dragHandleContainer); + + const dotsContainer = document.createElement("div"); + dotsContainer.classList.add("drag-handle-dots"); + + for (let i = 0; i < 6; i++) { + const spanElement = document.createElement("span"); + spanElement.classList.add("drag-handle-dot"); + dotsContainer.appendChild(spanElement); + } + + dragHandleContainer.appendChild(dotsContainer); + + return dragHandleElement; +} diff --git a/packages/editor/rich-text-editor/src/ui/extensions/drag-drop.tsx b/packages/editor/rich-text-editor/src/ui/extensions/drag-drop.tsx index 69542c1ce..60153daa9 100644 --- a/packages/editor/rich-text-editor/src/ui/extensions/drag-drop.tsx +++ b/packages/editor/rich-text-editor/src/ui/extensions/drag-drop.tsx @@ -3,6 +3,7 @@ import { Extension } from "@tiptap/core"; import { PluginKey, NodeSelection, Plugin } from "@tiptap/pm/state"; // @ts-ignore import { __serializeForClipboard, EditorView } from "@tiptap/pm/view"; +import { createDragHandleElement } from "../../lib/utils/DragHandleElement"; export interface DragHandleOptions { dragHandleWidth: number; @@ -135,25 +136,7 @@ function DragHandle(options: DragHandleOptions) { return new Plugin({ key: new PluginKey("dragHandle"), view: (view) => { - dragHandleElement = document.createElement("div"); - dragHandleElement.draggable = true; - dragHandleElement.dataset.dragHandle = ""; - dragHandleElement.classList.add("drag-handle"); - - const dragHandleContainer = document.createElement("div"); - dragHandleContainer.classList.add("drag-handle-container"); - dragHandleElement.appendChild(dragHandleContainer); - - const dotsContainer = document.createElement("div"); - dotsContainer.classList.add("drag-handle-dots"); - - for (let i = 0; i < 6; i++) { - const spanElement = document.createElement("span"); - spanElement.classList.add("drag-handle-dot"); - dotsContainer.appendChild(spanElement); - } - - dragHandleContainer.appendChild(dotsContainer); + dragHandleElement = createDragHandleElement(); dragHandleElement.addEventListener("dragstart", (e) => { handleDragStart(e, view); }); @@ -213,7 +196,7 @@ function DragHandle(options: DragHandleOptions) { if (!dragHandleElement) return; dragHandleElement.style.left = `${rect.left - rect.width}px`; - dragHandleElement.style.top = `${rect.top}px`; + dragHandleElement.style.top = `${rect.top + 3}px`; showDragHandle(); }, keydown: () => { diff --git a/packages/editor/rich-text-editor/src/ui/index.tsx b/packages/editor/rich-text-editor/src/ui/index.tsx index ba320a25c..81bbdb597 100644 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/index.tsx @@ -11,7 +11,6 @@ import { RichTextEditorExtensions } from "./extensions"; export type UploadImage = (file: File) => Promise; export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; -export type ValidateImage = (assetUrlWithWorkspaceId: string) => Promise; export type IMentionSuggestion = { id: string; @@ -29,7 +28,6 @@ interface IRichTextEditor { dragDropEnabled?: boolean; uploadFile: UploadImage; deleteFile: DeleteImage; - validateFile?: ValidateImage; noBorder?: boolean; borderOnFocus?: boolean; cancelUploadImage?: () => any; @@ -65,7 +63,6 @@ const RichTextEditor = ({ value, uploadFile, deleteFile, - validateFile, noBorder, cancelUploadImage, borderOnFocus, @@ -82,7 +79,6 @@ const RichTextEditor = ({ value, uploadFile, cancelUploadImage, - validateFile, deleteFile, forwardedRef, extensions: RichTextEditorExtensions( diff --git a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx index bb5d1b534..a6b90bdde 100644 --- a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx @@ -103,7 +103,6 @@ export const EditorBubbleMenu: FC = (props: any) => { editor={props.editor!} isOpen={isNodeSelectorOpen} setIsOpen={() => { - console.log("setIsNodeSelectorOpen"); setIsNodeSelectorOpen(!isNodeSelectorOpen); setIsLinkSelectorOpen(false); }} @@ -113,7 +112,6 @@ export const EditorBubbleMenu: FC = (props: any) => { editor={props.editor!!} isOpen={isLinkSelectorOpen} setIsOpen={() => { - console.log("setIsLinkSelectorOpen"); setIsLinkSelectorOpen(!isLinkSelectorOpen); setIsNodeSelectorOpen(false); }} diff --git a/space/styles/editor.css b/space/styles/editor.css index 85d881eeb..9f5623874 100644 --- a/space/styles/editor.css +++ b/space/styles/editor.css @@ -53,11 +53,12 @@ ul[data-type="taskList"] li > label input[type="checkbox"] { background-color: rgb(var(--color-background-100)); margin: 0; cursor: pointer; - width: 1.2rem; - height: 1.2rem; + width: 0.8rem; + height: 0.8rem; position: relative; - border: 2px solid rgb(var(--color-text-100)); - margin-right: 0.3rem; + border: 1.5px solid rgb(var(--color-text-100)); + margin-right: 0.2rem; + margin-top: 0.15rem; display: grid; place-content: center; @@ -71,8 +72,8 @@ ul[data-type="taskList"] li > label input[type="checkbox"] { &::before { content: ""; - width: 0.65em; - height: 0.65em; + width: 0.5em; + height: 0.5em; transform: scale(0); transition: 120ms transform ease-in-out; box-shadow: inset 1em 1em; @@ -229,3 +230,93 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { .ProseMirror table * .is-empty::before { opacity: 0; } + +.ProseMirror pre { + background: rgba(var(--color-background-80)); + border-radius: 0.5rem; + color: rgba(var(--color-text-100)); + font-family: "JetBrainsMono", monospace; + padding: 0.75rem 1rem; +} + +.ProseMirror pre code { + background: none; + color: inherit; + font-size: 0.8rem; + padding: 0; +} + +.ProseMirror:not(.dragging) .ProseMirror-selectednode:not(img):not(pre) { + outline: none !important; + border-radius: 0.2rem; + background-color: rgb(var(--color-background-90)); + border: 1px solid #5abbf7; + padding: 4px 2px 4px 2px; + transition: background-color 0.2s; + box-shadow: none; +} + +.drag-handle { + position: fixed; + opacity: 1; + transition: opacity ease-in 0.2s; + height: 18px; + width: 15px; + display: grid; + place-items: center; + z-index: 10; + cursor: grab; + border-radius: 2px; + background-color: rgb(var(--color-background-90)); +} + +.drag-handle:hover { + background-color: rgb(var(--color-background-80)); + transition: background-color 0.2s; +} + +.drag-handle.hidden { + opacity: 0; + pointer-events: none; +} + +@media screen and (max-width: 600px) { + .drag-handle { + display: none; + pointer-events: none; + } +} + +.drag-handle-container { + height: 15px; + width: 15px; + cursor: grab; + display: grid; + place-items: center; +} + +.drag-handle-dots { + height: 100%; + width: 12px; + display: grid; + grid-template-columns: repeat(2, 1fr); + place-items: center; +} + +.drag-handle-dot { + height: 2.75px; + width: 3px; + background-color: rgba(var(--color-text-200)); + border-radius: 50%; +} + +div[data-type="horizontalRule"] { + line-height: 0; + padding: 0.25rem 0; + margin-top: 0; + margin-bottom: 0; + + & > div { + border-bottom: 1px solid rgb(var(--color-text-100)); + } +} diff --git a/web/services/file.service.ts b/web/services/file.service.ts index cd0d6f023..d92c55e18 100644 --- a/web/services/file.service.ts +++ b/web/services/file.service.ts @@ -34,7 +34,6 @@ export class FileService extends APIService { constructor() { super(API_BASE_URL); this.uploadFile = this.uploadFile.bind(this); - // this.validateFile = this.validateFile.bind(this); this.deleteImage = this.deleteImage.bind(this); this.cancelUpload = this.cancelUpload.bind(this); } @@ -63,14 +62,6 @@ export class FileService extends APIService { this.cancelSource.cancel("Upload cancelled"); } - // async validateFile(assetUrlWithWorkspaceId: string): Promise { - // console.log("bruh", assetUrlWithWorkspaceId); - // const res = await this.get(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/`); - // const data = res?.data; - // console.log("data inside fucntion"); - // return data.status; - // } - // getUploadFileFunction(workspaceSlug: string): (file: File) => Promise { return async (file: File) => { const formData = new FormData(); diff --git a/web/styles/editor.css b/web/styles/editor.css index 981f53703..d5e4cf8a9 100644 --- a/web/styles/editor.css +++ b/web/styles/editor.css @@ -6,6 +6,12 @@ height: 0; } +/* block quotes */ +.ProseMirror blockquote p::before, +.ProseMirror blockquote p::after { + display: none; +} + .ProseMirror .is-empty::before { content: attr(data-placeholder); float: left; @@ -53,11 +59,12 @@ ul[data-type="taskList"] li > label input[type="checkbox"] { background-color: rgb(var(--color-background-100)); margin: 0; cursor: pointer; - width: 1.2rem; - height: 1.2rem; + width: 0.8rem; + height: 0.8rem; position: relative; - border: 2px solid rgb(var(--color-text-100)); - margin-right: 0.3rem; + border: 1.5px solid rgb(var(--color-text-100)); + margin-right: 0.2rem; + margin-top: 0.15rem; display: grid; place-content: center; @@ -71,8 +78,8 @@ ul[data-type="taskList"] li > label input[type="checkbox"] { &::before { content: ""; - width: 0.65em; - height: 0.65em; + width: 0.5em; + height: 0.5em; transform: scale(0); transition: 120ms transform ease-in-out; box-shadow: inset 1em 1em; @@ -259,26 +266,18 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { position: fixed; opacity: 1; transition: opacity ease-in 0.2s; + height: 18px; + width: 15px; display: grid; place-items: center; - height: 20px; - width: 15px; z-index: 10; cursor: grab; border-radius: 2px; background-color: rgb(var(--color-background-90)); - &:hover { - background-color: rgb(var(--color-background-80)); - } - - &.hide { - opacity: 0; - pointer-events: none; - } } .drag-handle:hover { - background-color: #0d0d0d 10; + background-color: rgb(var(--color-background-80)); transition: background-color 0.2s; } @@ -295,7 +294,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { } .drag-handle-container { - height: 20px; + height: 15px; width: 15px; cursor: grab; display: grid; @@ -313,7 +312,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { .drag-handle-dot { height: 2.75px; width: 3px; - background-color: rgba(var(--color-text-100)); + background-color: rgba(var(--color-text-200)); border-radius: 50%; } From 2b6c48951374fb1eada2299b19317d20c1ce615b Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Sat, 18 Nov 2023 16:30:35 +0530 Subject: [PATCH 06/10] feat: v3 endpoint for module and cycle (#2786) * feat: v3 endpoint for module and cycle * fix: removed the str --- apiserver/plane/api/serializers/issue.py | 2 +- apiserver/plane/api/urls/cycle.py | 6 +++ apiserver/plane/api/urls/module.py | 6 +++ apiserver/plane/api/views/__init__.py | 2 + apiserver/plane/api/views/cycle.py | 50 +++++++++++++++++++++++ apiserver/plane/api/views/module.py | 51 +++++++++++++++++++++++- 6 files changed, 115 insertions(+), 2 deletions(-) diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index ae033969f..4477d9328 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -505,7 +505,7 @@ class IssueStateFlatSerializer(BaseSerializer): # Issue Serializer with state details -class IssueStateSerializer(BaseSerializer): +class IssueStateSerializer(DynamicBaseSerializer): label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) state_detail = StateLiteSerializer(read_only=True, source="state") project_detail = ProjectLiteSerializer(read_only=True, source="project") diff --git a/apiserver/plane/api/urls/cycle.py b/apiserver/plane/api/urls/cycle.py index 068276361..7e6f014fc 100644 --- a/apiserver/plane/api/urls/cycle.py +++ b/apiserver/plane/api/urls/cycle.py @@ -7,6 +7,7 @@ from plane.api.views import ( CycleDateCheckEndpoint, CycleFavoriteViewSet, TransferCycleIssueEndpoint, + CycleIssueGroupedEndpoint, ) @@ -43,6 +44,11 @@ urlpatterns = [ ), name="project-issue-cycle", ), + path( + "v3/workspaces//projects//cycles//cycle-issues/", + CycleIssueGroupedEndpoint.as_view(), + name="project-issue-cycle", + ), path( "workspaces//projects//cycles//cycle-issues//", CycleIssueViewSet.as_view( diff --git a/apiserver/plane/api/urls/module.py b/apiserver/plane/api/urls/module.py index 3239af1e4..d9ca849ed 100644 --- a/apiserver/plane/api/urls/module.py +++ b/apiserver/plane/api/urls/module.py @@ -7,6 +7,7 @@ from plane.api.views import ( ModuleLinkViewSet, ModuleFavoriteViewSet, BulkImportModulesEndpoint, + ModuleIssueGroupedEndpoint, ) @@ -43,6 +44,11 @@ urlpatterns = [ ), name="project-module-issues", ), + path( + "v3/workspaces//projects//modules//module-issues/", + ModuleIssueGroupedEndpoint.as_view(), + name="project-issue-cycle", + ), path( "workspaces//projects//modules//module-issues//", ModuleIssueViewSet.as_view( diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 46e88b0bc..12b569523 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -60,6 +60,7 @@ from .cycle import ( CycleDateCheckEndpoint, CycleFavoriteViewSet, TransferCycleIssueEndpoint, + CycleIssueGroupedEndpoint, ) from .asset import FileAssetEndpoint, UserAssetsEndpoint from .issue import ( @@ -113,6 +114,7 @@ from .module import ( ModuleIssueViewSet, ModuleLinkViewSet, ModuleFavoriteViewSet, + ModuleIssueGroupedEndpoint, ) from .api import ApiTokenEndpoint diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 2a62ab8ac..06df22077 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -707,6 +707,56 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) +class CycleIssueGroupedEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + def get(self, request, slug, project_id, cycle_id): + filters = issue_filters(request.query_params, "GET") + fields = [field for field in request.GET.get("fields", "").split(",") if field] + + issues = ( + Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate(bridge_id=F("issue_cycle__id")) + .filter(project_id=project_id) + .filter(workspace__slug=slug) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .filter(**filters) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) + + issues = IssueStateSerializer(issues, many=True, fields=fields if fields else None).data + issue_dict = {str(issue["id"]): issue for issue in issues} + return Response( + issue_dict, + status=status.HTTP_200_OK, + ) + + class CycleDateCheckEndpoint(BaseAPIView): permission_classes = [ ProjectEntityPermission, diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 173526a2c..f8d74dbed 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -15,7 +15,7 @@ from rest_framework import status from sentry_sdk import capture_exception # Module imports -from . import BaseViewSet, WebhookMixin +from . import BaseViewSet, BaseAPIView, WebhookMixin from plane.api.serializers import ( ModuleWriteSerializer, ModuleSerializer, @@ -481,6 +481,55 @@ class ModuleIssueViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) +class ModuleIssueGroupedEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + def get(self, request, slug, project_id, module_id): + filters = issue_filters(request.query_params, "GET") + fields = [field for field in request.GET.get("fields", "").split(",") if field] + + issues = ( + Issue.issue_objects.filter(issue_module__module_id=module_id) + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate(bridge_id=F("issue_module__id")) + .filter(project_id=project_id) + .filter(workspace__slug=slug) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .filter(**filters) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) + + issues = IssueStateSerializer(issues, many=True, fields=fields if fields else None).data + issue_dict = {str(issue["id"]): issue for issue in issues} + return Response( + issue_dict, + status=status.HTTP_200_OK, + ) + class ModuleLinkViewSet(BaseViewSet): permission_classes = [ ProjectEntityPermission, From c9ffc9465f7e0a8e8063106f72833e7721000a0e Mon Sep 17 00:00:00 2001 From: Lakhan Baheti <94619783+1akhanBaheti@users.noreply.github.com> Date: Sun, 19 Nov 2023 01:46:11 +0530 Subject: [PATCH 07/10] fix: Labels delete & reordering (#2729) * fix: Labels reordering inconsistency * fix: Delete child labels * feat: multi-select while grouping labels * refactor: label sorting in mobx computed function * feat: drag & drop label grouping, un-grouping * chore: removed label select modal * fix: moving labels from project store to project label store * fix: typo changes and build tree function added * labels feature * disable dropping group into a group * fix build errors * fix more issues * chore: added combining state UI, fixed scroll issue for label groups * chore: group icon for label groups * fix: group cannot be dropped in another group --------- Co-authored-by: sriram veeraghanta Co-authored-by: rahulramesha Co-authored-by: Aaryan Khandelwal --- web/components/headers/cycle-issues.tsx | 6 +- web/components/headers/module-issues.tsx | 6 +- .../headers/project-archived-issues.tsx | 7 +- web/components/headers/project-issues.tsx | 6 +- .../headers/project-view-issues.tsx | 7 +- .../filters/applied-filters/filters-list.tsx | 4 +- .../filters/applied-filters/label.tsx | 4 +- .../applied-filters/roots/archived-issue.tsx | 4 +- .../applied-filters/roots/cycle-root.tsx | 4 +- .../applied-filters/roots/module-root.tsx | 4 +- .../applied-filters/roots/project-root.tsx | 4 +- .../roots/project-view-root.tsx | 4 +- .../header/filters/filters-selection.tsx | 4 +- .../filters/header/filters/labels.tsx | 4 +- .../issue-layouts/kanban/roots/cycle-root.tsx | 6 +- .../kanban/roots/module-root.tsx | 13 +- .../kanban/roots/project-root.tsx | 22 +- .../kanban/roots/project-view-root.tsx | 2 +- .../issues/issue-layouts/kanban/swimlanes.tsx | 6 +- .../issues/issue-layouts/list/default.tsx | 4 +- .../list/roots/archived-issue-root.tsx | 4 +- .../issue-layouts/list/roots/cycle-root.tsx | 4 +- .../issue-layouts/list/roots/module-root.tsx | 4 +- .../issue-layouts/list/roots/project-root.tsx | 4 +- .../list/roots/project-view-root.tsx | 2 +- .../issue-layouts/properties/labels.tsx | 16 +- .../spreadsheet/columns/columns-list.tsx | 4 +- .../spreadsheet/columns/label-column.tsx | 4 +- .../spreadsheet/roots/cycle-root.tsx | 5 +- .../spreadsheet/roots/module-root.tsx | 4 +- .../spreadsheet/roots/project-root.tsx | 4 +- .../spreadsheet/roots/project-view-root.tsx | 5 +- .../spreadsheet/spreadsheet-column.tsx | 4 +- .../spreadsheet/spreadsheet-view.tsx | 4 +- web/components/issues/select/label.tsx | 2 +- .../issues/sidebar-select/label.tsx | 10 +- web/components/labels/create-label-modal.tsx | 8 +- .../labels/create-update-label-inline.tsx | 20 +- web/components/labels/delete-label-modal.tsx | 4 +- web/components/labels/index.ts | 2 +- .../labels/label-block/drag-handle.tsx | 24 ++ .../labels/label-block/label-item-block.tsx | 80 ++++++ .../labels/label-block/label-name.tsx | 27 ++ web/components/labels/label-select.tsx | 4 +- web/components/labels/labels-list-modal.tsx | 27 +- .../labels/project-setting-label-group.tsx | 248 ++++++++++-------- .../labels/project-setting-label-item.tsx | 91 +++++++ .../project-setting-label-list-item.tsx | 73 ------ .../labels/project-setting-label-list.tsx | 234 +++++++++++------ web/components/project/member-list.tsx | 1 - web/components/ui/labels-list.tsx | 4 +- web/components/views/form.tsx | 6 +- web/helpers/array.helper.ts | 16 ++ web/hooks/use-draggable-portal.ts | 31 +++ web/layouts/auth-layout/project-wrapper.tsx | 3 +- .../projects/[projectId]/pages/[pageId].tsx | 4 +- web/services/issue/issue_label.service.ts | 10 +- web/store/project/project-label.store.ts | 187 ++++++++++--- web/store/project/project.store.ts | 56 +--- web/store/workspace/workspace.store.ts | 10 +- web/types/issues.d.ts | 7 +- web/types/pages.d.ts | 4 +- 62 files changed, 862 insertions(+), 520 deletions(-) create mode 100644 web/components/labels/label-block/drag-handle.tsx create mode 100644 web/components/labels/label-block/label-item-block.tsx create mode 100644 web/components/labels/label-block/label-name.tsx create mode 100644 web/components/labels/project-setting-label-item.tsx delete mode 100644 web/components/labels/project-setting-label-list-item.tsx create mode 100644 web/hooks/use-draggable-portal.ts diff --git a/web/components/headers/cycle-issues.tsx b/web/components/headers/cycle-issues.tsx index 3f177cded..fea9e7019 100644 --- a/web/components/headers/cycle-issues.tsx +++ b/web/components/headers/cycle-issues.tsx @@ -30,12 +30,12 @@ export const CycleIssuesHeader: React.FC = observer(() => { issueFilter: issueFilterStore, cycle: cycleStore, cycleIssueFilter: cycleIssueFilterStore, - project: projectStore, + project: { currentProjectDetails }, + projectLabel: { projectLabels }, projectMember: { projectMembers }, projectState: projectStateStore, commandPalette: commandPaletteStore, } = useMobxStore(); - const { currentProjectDetails } = projectStore; const activeLayout = issueFilterStore.userDisplayFilters.layout; @@ -178,7 +178,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { layoutDisplayFiltersOptions={ activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined } - labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? undefined} + labels={projectLabels ?? undefined} members={projectMembers?.map((m) => m.member)} states={projectStateStore.states?.[projectId?.toString() ?? ""] ?? undefined} /> diff --git a/web/components/headers/module-issues.tsx b/web/components/headers/module-issues.tsx index cea8093bf..6bb5ba84e 100644 --- a/web/components/headers/module-issues.tsx +++ b/web/components/headers/module-issues.tsx @@ -30,13 +30,13 @@ export const ModuleIssuesHeader: React.FC = observer(() => { issueFilter: issueFilterStore, module: moduleStore, moduleFilter: moduleFilterStore, - project: projectStore, + project: { currentProjectDetails }, + projectLabel: { projectLabels }, projectMember: { projectMembers }, projectState: projectStateStore, commandPalette: commandPaletteStore, } = useMobxStore(); const activeLayout = issueFilterStore.userDisplayFilters.layout; - const { currentProjectDetails } = projectStore; const { setValue, storedValue } = useLocalStorage("module_sidebar_collapsed", "false"); @@ -177,7 +177,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => { layoutDisplayFiltersOptions={ activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined } - labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? undefined} + labels={projectLabels ?? undefined} members={projectMembers?.map((m) => m.member)} states={projectStateStore.states?.[projectId?.toString() ?? ""] ?? undefined} /> diff --git a/web/components/headers/project-archived-issues.tsx b/web/components/headers/project-archived-issues.tsx index 4c2d9f354..8f8a4063d 100644 --- a/web/components/headers/project-archived-issues.tsx +++ b/web/components/headers/project-archived-issues.tsx @@ -21,14 +21,13 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => { const { workspaceSlug, projectId } = router.query; const { - project: projectStore, + project: { currentProjectDetails }, + projectLabel: { projectLabels }, projectMember: { projectMembers }, archivedIssueFilters: archivedIssueFiltersStore, projectState: projectStateStore, } = useMobxStore(); - const { currentProjectDetails } = projectStore; - // for archived issues list layout is the only option const activeLayout = "list"; @@ -119,7 +118,7 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => { layoutDisplayFiltersOptions={ activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.archived_issues[activeLayout] : undefined } - labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? undefined} + labels={projectLabels ?? undefined} members={projectMembers?.map((m) => m.member)} states={projectStateStore.states?.[projectId?.toString() ?? ""] ?? undefined} /> diff --git a/web/components/headers/project-issues.tsx b/web/components/headers/project-issues.tsx index 1b418d6f4..bb816ff87 100644 --- a/web/components/headers/project-issues.tsx +++ b/web/components/headers/project-issues.tsx @@ -25,7 +25,8 @@ export const ProjectIssuesHeader: React.FC = observer(() => { const { issueFilter: issueFilterStore, - project: projectStore, + project: { currentProjectDetails }, + projectLabel: { projectLabels }, projectMember: { projectMembers }, projectState: projectStateStore, inbox: inboxStore, @@ -92,7 +93,6 @@ export const ProjectIssuesHeader: React.FC = observer(() => { }, [issueFilterStore, projectId, workspaceSlug] ); - const { currentProjectDetails } = projectStore; const inboxDetails = projectId ? inboxStore.inboxesList?.[projectId.toString()]?.[0] : undefined; @@ -178,7 +178,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { layoutDisplayFiltersOptions={ activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined } - labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? undefined} + labels={projectLabels ?? undefined} members={projectMembers?.map((m) => m.member)} states={projectStateStore.states?.[projectId?.toString() ?? ""] ?? undefined} /> diff --git a/web/components/headers/project-view-issues.tsx b/web/components/headers/project-view-issues.tsx index 7e899d851..6a4742e99 100644 --- a/web/components/headers/project-view-issues.tsx +++ b/web/components/headers/project-view-issues.tsx @@ -22,14 +22,13 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { const { issueFilter: issueFilterStore, projectViewFilters: projectViewFiltersStore, - project: projectStore, + project: { currentProjectDetails }, + projectLabel: { projectLabels }, projectMember: { projectMembers }, projectState: projectStateStore, projectViews: projectViewsStore, } = useMobxStore(); - const { currentProjectDetails } = projectStore; - const storedFilters = viewId ? projectViewFiltersStore.storedFilters[viewId.toString()] : undefined; const activeLayout = issueFilterStore.userDisplayFilters.layout; @@ -163,7 +162,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { layoutDisplayFiltersOptions={ activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined } - labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? undefined} + labels={projectLabels ?? undefined} members={projectMembers?.map((m) => m.member)} states={projectStateStore.states?.[projectId?.toString() ?? ""] ?? undefined} /> diff --git a/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx b/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx index f7672ac75..89f9e41c0 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx @@ -15,13 +15,13 @@ import { X } from "lucide-react"; // helpers import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; // types -import { IIssueFilterOptions, IIssueLabels, IProject, IState, IUserLite } from "types"; +import { IIssueFilterOptions, IIssueLabel, IProject, IState, IUserLite } from "types"; type Props = { appliedFilters: IIssueFilterOptions; handleClearAllFilters: () => void; handleRemoveFilter: (key: keyof IIssueFilterOptions, value: string | null) => void; - labels?: IIssueLabels[] | undefined; + labels?: IIssueLabel[] | undefined; members?: IUserLite[] | undefined; projects?: IProject[] | undefined; states?: IState[] | undefined; diff --git a/web/components/issues/issue-layouts/filters/applied-filters/label.tsx b/web/components/issues/issue-layouts/filters/applied-filters/label.tsx index ee597575f..d96bb4fd1 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/label.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/label.tsx @@ -3,11 +3,11 @@ import { observer } from "mobx-react-lite"; // icons import { X } from "lucide-react"; // types -import { IIssueLabels } from "types"; +import { IIssueLabel } from "types"; type Props = { handleRemove: (val: string) => void; - labels: IIssueLabels[] | undefined; + labels: IIssueLabel[] | undefined; values: string[]; }; diff --git a/web/components/issues/issue-layouts/filters/applied-filters/roots/archived-issue.tsx b/web/components/issues/issue-layouts/filters/applied-filters/roots/archived-issue.tsx index 32e5a4a21..f733d3a87 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/roots/archived-issue.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/roots/archived-issue.tsx @@ -14,7 +14,7 @@ export const ArchivedIssueAppliedFiltersRoot: React.FC = observer(() => { const { archivedIssueFilters: archivedIssueFiltersStore, - project: projectStore, + projectLabel: { projectLabels }, projectMember: { projectMembers }, projectState: projectStateStore, } = useMobxStore(); @@ -77,7 +77,7 @@ export const ArchivedIssueAppliedFiltersRoot: React.FC = observer(() => { appliedFilters={appliedFilters} handleClearAllFilters={handleClearAllFilters} handleRemoveFilter={handleRemoveFilter} - labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? []} + labels={projectLabels ?? []} members={projectMembers?.map((m) => m.member)} states={projectStateStore.states?.[projectId?.toString() ?? ""]} /> diff --git a/web/components/issues/issue-layouts/filters/applied-filters/roots/cycle-root.tsx b/web/components/issues/issue-layouts/filters/applied-filters/roots/cycle-root.tsx index 2bd0ffdfe..5e71dc96b 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/roots/cycle-root.tsx @@ -12,7 +12,7 @@ export const CycleAppliedFiltersRoot: React.FC = observer(() => { const { workspaceSlug, projectId, cycleId } = router.query; const { - project: projectStore, + projectLabel: { projectLabels }, projectMember: { projectMembers }, cycleIssueFilter: cycleIssueFilterStore, projectState: projectStateStore, @@ -72,7 +72,7 @@ export const CycleAppliedFiltersRoot: React.FC = observer(() => { appliedFilters={appliedFilters} handleClearAllFilters={handleClearAllFilters} handleRemoveFilter={handleRemoveFilter} - labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? []} + labels={projectLabels ?? []} members={projectMembers?.map((m) => m.member)} states={projectStateStore.states?.[projectId?.toString() ?? ""]} /> diff --git a/web/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx b/web/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx index 6a5cd6944..bb7e924ff 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx @@ -13,7 +13,7 @@ export const ModuleAppliedFiltersRoot: React.FC = observer(() => { const { workspaceSlug, projectId, moduleId } = router.query; const { - project: projectStore, + projectLabel: { projectLabels }, moduleFilter: moduleFilterStore, projectState: projectStateStore, projectMember: { projectMembers }, @@ -73,7 +73,7 @@ export const ModuleAppliedFiltersRoot: React.FC = observer(() => { appliedFilters={appliedFilters} handleClearAllFilters={handleClearAllFilters} handleRemoveFilter={handleRemoveFilter} - labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? []} + labels={projectLabels ?? []} members={projectMembers?.map((m) => m.member)} states={projectStateStore.states?.[projectId?.toString() ?? ""]} /> diff --git a/web/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx b/web/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx index 26f475733..8b12b9b11 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx @@ -14,7 +14,7 @@ export const ProjectAppliedFiltersRoot: React.FC = observer(() => { const { issueFilter: issueFilterStore, - project: projectStore, + projectLabel: { projectLabels }, projectState: projectStateStore, projectMember: { projectMembers }, } = useMobxStore(); @@ -77,7 +77,7 @@ export const ProjectAppliedFiltersRoot: React.FC = observer(() => { appliedFilters={appliedFilters} handleClearAllFilters={handleClearAllFilters} handleRemoveFilter={handleRemoveFilter} - labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? []} + labels={projectLabels ?? []} members={projectMembers?.map((m) => m.member)} states={projectStateStore.states?.[projectId?.toString() ?? ""]} /> diff --git a/web/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx b/web/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx index 5f797a6ce..65462d277 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx @@ -18,7 +18,7 @@ export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => { const { workspaceSlug, projectId, viewId } = router.query; const { - project: projectStore, + projectLabel: { projectLabels }, projectMember: { projectMembers }, projectState: projectStateStore, projectViews: projectViewsStore, @@ -99,7 +99,7 @@ export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => { appliedFilters={appliedFilters} handleClearAllFilters={handleClearAllFilters} handleRemoveFilter={handleRemoveFilter} - labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? []} + labels={projectLabels ?? []} members={projectMembers?.map((m) => m.member)} states={projectStateStore.states?.[projectId?.toString() ?? ""]} /> diff --git a/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx b/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx index 781e7fcd5..612b6fcdb 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx @@ -15,7 +15,7 @@ import { FilterTargetDate, } from "components/issues"; // types -import { IIssueFilterOptions, IIssueLabels, IProject, IState, IUserLite } from "types"; +import { IIssueFilterOptions, IIssueLabel, IProject, IState, IUserLite } from "types"; // constants import { ILayoutDisplayFiltersOptions } from "constants/issue"; @@ -23,7 +23,7 @@ type Props = { filters: IIssueFilterOptions; handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined; - labels?: IIssueLabels[] | undefined; + labels?: IIssueLabel[] | undefined; members?: IUserLite[] | undefined; projects?: IProject[] | undefined; states?: IState[] | undefined; diff --git a/web/components/issues/issue-layouts/filters/header/filters/labels.tsx b/web/components/issues/issue-layouts/filters/header/filters/labels.tsx index 764d29951..a12659cdd 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/labels.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/labels.tsx @@ -5,7 +5,7 @@ import { FilterHeader, FilterOption } from "components/issues"; // ui import { Loader } from "@plane/ui"; // types -import { IIssueLabels } from "types"; +import { IIssueLabel } from "types"; const LabelIcons = ({ color }: { color: string }) => ( @@ -14,7 +14,7 @@ const LabelIcons = ({ color }: { color: string }) => ( type Props = { appliedFilters: string[] | null; handleUpdate: (val: string) => void; - labels: IIssueLabels[] | undefined; + labels: IIssueLabel[] | undefined; searchQuery: string; }; diff --git a/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx b/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx index a060ed213..fad4d814e 100644 --- a/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx @@ -23,6 +23,7 @@ export const CycleKanBanLayout: React.FC = observer(() => { // store const { project: projectStore, + projectLabel: { projectLabels }, projectMember: { projectMembers }, projectState: projectStateStore, cycleIssue: cycleIssueStore, @@ -99,7 +100,6 @@ export const CycleKanBanLayout: React.FC = observer(() => { const states = projectStateStore?.projectStates || null; const priorities = ISSUE_PRIORITIES || null; - const labels = projectStore?.projectLabels || null; const stateGroups = ISSUE_STATE_GROUPS || null; const projects = workspaceSlug ? projectStore?.projects[workspaceSlug.toString()] || null : null; // const estimates = @@ -137,7 +137,7 @@ export const CycleKanBanLayout: React.FC = observer(() => { states={states} stateGroups={stateGroups} priorities={priorities} - labels={labels} + labels={projectLabels} members={projectMembers?.map((m) => m.member) ?? null} projects={projects} showEmptyGroup={userDisplayFilters?.show_empty_groups || true} @@ -164,7 +164,7 @@ export const CycleKanBanLayout: React.FC = observer(() => { states={states} stateGroups={stateGroups} priorities={priorities} - labels={labels} + labels={projectLabels} members={projectMembers?.map((m) => m.member) ?? null} projects={projects} showEmptyGroup={userDisplayFilters?.show_empty_groups || true} diff --git a/web/components/issues/issue-layouts/kanban/roots/module-root.tsx b/web/components/issues/issue-layouts/kanban/roots/module-root.tsx index 7a25f835c..a3429ef64 100644 --- a/web/components/issues/issue-layouts/kanban/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/module-root.tsx @@ -21,7 +21,8 @@ export const ModuleKanBanLayout: React.FC = observer(() => { const { workspaceSlug, moduleId } = router.query; // store const { - project: projectStore, + project: { workspaceProjects }, + projectLabel: { projectLabels }, projectMember: { projectMembers }, projectState: projectStateStore, moduleIssue: moduleIssueStore, @@ -97,9 +98,7 @@ export const ModuleKanBanLayout: React.FC = observer(() => { const states = projectStateStore?.projectStates || null; const priorities = ISSUE_PRIORITIES || null; - const labels = projectStore?.projectLabels || null; const stateGroups = ISSUE_STATE_GROUPS || null; - const projects = workspaceSlug ? projectStore?.projects[workspaceSlug.toString()] || null : null; // const estimates = // currentProjectDetails?.estimate !== null // ? projectStore.projectEstimates?.find((e) => e.id === currentProjectDetails?.estimate) || null @@ -135,9 +134,9 @@ export const ModuleKanBanLayout: React.FC = observer(() => { states={states} stateGroups={stateGroups} priorities={priorities} - labels={labels} + labels={projectLabels} members={projectMembers?.map((m) => m.member) ?? null} - projects={projects} + projects={workspaceProjects} showEmptyGroup={userDisplayFilters?.show_empty_groups || true} isDragStarted={isDragStarted} /> @@ -162,9 +161,9 @@ export const ModuleKanBanLayout: React.FC = observer(() => { states={states} stateGroups={stateGroups} priorities={priorities} - labels={labels} + labels={projectLabels} members={projectMembers?.map((m) => m.member) ?? null} - projects={projects} + projects={workspaceProjects} showEmptyGroup={userDisplayFilters?.show_empty_groups || true} isDragStarted={isDragStarted} /> diff --git a/web/components/issues/issue-layouts/kanban/roots/project-root.tsx b/web/components/issues/issue-layouts/kanban/roots/project-root.tsx index 8f34dee71..070d13a89 100644 --- a/web/components/issues/issue-layouts/kanban/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/project-root.tsx @@ -21,7 +21,8 @@ export const KanBanLayout: React.FC = observer(() => { const { workspaceSlug } = router.query as { workspaceSlug: string }; const { - project: projectStore, + project: { workspaceProjects }, + projectLabel: { projectLabels }, projectMember: { projectMembers }, projectState: projectStateStore, issue: issueStore, @@ -29,7 +30,6 @@ export const KanBanLayout: React.FC = observer(() => { issueKanBanView: issueKanBanViewStore, issueDetail: issueDetailStore, } = useMobxStore(); - const { currentProjectDetails } = projectStore; const issues = issueStore?.getIssues; @@ -92,13 +92,11 @@ export const KanBanLayout: React.FC = observer(() => { const states = projectStateStore?.projectStates || null; const priorities = ISSUE_PRIORITIES || null; - const labels = projectStore?.projectLabels || null; const stateGroups = ISSUE_STATE_GROUPS || null; - const projects = workspaceSlug ? projectStore?.projects?.[workspaceSlug] || null : null; - const estimates = - currentProjectDetails?.estimate !== null - ? projectStore.projectEstimates?.find((e) => e.id === currentProjectDetails?.estimate) || null - : null; + // const estimates = + // currentProjectDetails?.estimate !== null + // ? projectStore.projectEstimates?.find((e) => e.id === currentProjectDetails?.estimate) || null + // : null; return ( <> @@ -129,9 +127,9 @@ export const KanBanLayout: React.FC = observer(() => { states={states} stateGroups={stateGroups} priorities={priorities} - labels={labels} + labels={projectLabels} members={projectMembers?.map((m) => m.member) ?? null} - projects={projects} + projects={workspaceProjects} enableQuickIssueCreate showEmptyGroup={userDisplayFilters?.show_empty_groups || true} isDragStarted={isDragStarted} @@ -156,9 +154,9 @@ export const KanBanLayout: React.FC = observer(() => { states={states} stateGroups={stateGroups} priorities={priorities} - labels={labels} + labels={projectLabels} members={projectMembers?.map((m) => m.member) ?? null} - projects={projects} + projects={workspaceProjects} showEmptyGroup={userDisplayFilters?.show_empty_groups || true} isDragStarted={isDragStarted} /> diff --git a/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx b/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx index 6751f3145..c03b50934 100644 --- a/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx @@ -56,7 +56,7 @@ export const ProjectViewKanBanLayout: React.FC = observer(() => { const states = projectStateStore?.projectStates || null; const priorities = ISSUE_PRIORITIES || null; - const labels = projectStore?.projectLabels || null; + // const labels = projectStore?.projectLabels || null; const stateGroups = ISSUE_STATE_GROUPS || null; const projects = projectStateStore?.projectStates || null; const estimates = null; diff --git a/web/components/issues/issue-layouts/kanban/swimlanes.tsx b/web/components/issues/issue-layouts/kanban/swimlanes.tsx index e25ddea3d..7162025e0 100644 --- a/web/components/issues/issue-layouts/kanban/swimlanes.tsx +++ b/web/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -5,7 +5,7 @@ import { KanBanGroupByHeaderRoot } from "./headers/group-by-root"; import { KanBanSubGroupByHeaderRoot } from "./headers/sub-group-by-root"; import { KanBan } from "./default"; // types -import { IIssue, IIssueDisplayProperties, IIssueLabels, IProject, IState, IUserLite } from "types"; +import { IIssue, IIssueDisplayProperties, IIssueLabel, IProject, IState, IUserLite } from "types"; // constants import { getValueFromObject } from "constants/issue"; @@ -63,7 +63,7 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader { states: IState[] | null; stateGroups: any; priorities: any; - labels: IIssueLabels[] | null; + labels: IIssueLabel[] | null; members: IUserLite[] | null; projects: IProject[] | null; issues: any; @@ -181,7 +181,7 @@ export interface IKanBanSwimLanes { states: IState[] | null; stateGroups: any; priorities: any; - labels: IIssueLabels[] | null; + labels: IIssueLabel[] | null; members: IUserLite[] | null; projects: IProject[] | null; isDragStarted?: boolean; diff --git a/web/components/issues/issue-layouts/list/default.tsx b/web/components/issues/issue-layouts/list/default.tsx index b9d13a92b..4f2d215db 100644 --- a/web/components/issues/issue-layouts/list/default.tsx +++ b/web/components/issues/issue-layouts/list/default.tsx @@ -4,7 +4,7 @@ import { observer } from "mobx-react-lite"; import { ListGroupByHeaderRoot } from "./headers/group-by-root"; import { IssueBlocksList, ListInlineCreateIssueForm } from "components/issues"; // types -import { IEstimatePoint, IIssue, IIssueDisplayProperties, IIssueLabels, IProject, IState, IUserLite } from "types"; +import { IEstimatePoint, IIssue, IIssueDisplayProperties, IIssueLabel, IProject, IState, IUserLite } from "types"; // constants import { getValueFromObject } from "constants/issue"; @@ -88,7 +88,7 @@ export interface IList { quickActions: (group_by: string | null, issue: IIssue) => React.ReactNode; displayProperties: IIssueDisplayProperties; states: IState[] | null; - labels: IIssueLabels[] | null; + labels: IIssueLabel[] | null; members: IUserLite[] | null; projects: IProject[] | null; stateGroups: any; diff --git a/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx b/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx index 396f7b508..a029ceda2 100644 --- a/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx @@ -19,6 +19,7 @@ export const ArchivedIssueListLayout: FC = observer(() => { const { project: projectStore, + projectLabel: { projectLabels }, projectMember: { projectMembers }, projectState: projectStateStore, archivedIssues: archivedIssueStore, @@ -42,7 +43,6 @@ export const ArchivedIssueListLayout: FC = observer(() => { const states = projectStateStore?.projectStates || null; const priorities = ISSUE_PRIORITIES || null; - const labels = projectStore?.projectLabels || null; const stateGroups = ISSUE_STATE_GROUPS || null; const projects = workspaceSlug ? projectStore?.projects[workspaceSlug.toString()] || null : null; const estimates = @@ -64,7 +64,7 @@ export const ArchivedIssueListLayout: FC = observer(() => { states={states} stateGroups={stateGroups} priorities={priorities} - labels={labels} + labels={projectLabels} members={projectMembers?.map((m) => m.member) ?? null} projects={projects} estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null} diff --git a/web/components/issues/issue-layouts/list/roots/cycle-root.tsx b/web/components/issues/issue-layouts/list/roots/cycle-root.tsx index 608607ab8..b92a57fa8 100644 --- a/web/components/issues/issue-layouts/list/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/cycle-root.tsx @@ -21,6 +21,7 @@ export const CycleListLayout: React.FC = observer(() => { // store const { project: projectStore, + projectLabel: { projectLabels }, projectMember: { projectMembers }, projectState: projectStateStore, issueFilter: issueFilterStore, @@ -59,7 +60,6 @@ export const CycleListLayout: React.FC = observer(() => { const states = projectStateStore?.projectStates || null; const priorities = ISSUE_PRIORITIES || null; - const labels = projectStore?.projectLabels || null; const stateGroups = ISSUE_STATE_GROUPS || null; const projects = workspaceSlug ? projectStore?.projects[workspaceSlug.toString()] || null : null; const estimates = @@ -85,7 +85,7 @@ export const CycleListLayout: React.FC = observer(() => { states={states} stateGroups={stateGroups} priorities={priorities} - labels={labels} + labels={projectLabels} members={projectMembers?.map((m) => m.member) ?? null} projects={projects} estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null} diff --git a/web/components/issues/issue-layouts/list/roots/module-root.tsx b/web/components/issues/issue-layouts/list/roots/module-root.tsx index 1619112f1..7fa1f4718 100644 --- a/web/components/issues/issue-layouts/list/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/module-root.tsx @@ -21,6 +21,7 @@ export const ModuleListLayout: React.FC = observer(() => { const { project: projectStore, + projectLabel: { projectLabels }, projectMember: { projectMembers }, projectState: projectStateStore, issueFilter: issueFilterStore, @@ -59,7 +60,6 @@ export const ModuleListLayout: React.FC = observer(() => { const states = projectStateStore?.projectStates || null; const priorities = ISSUE_PRIORITIES || null; - const labels = projectStore?.projectLabels || null; const stateGroups = ISSUE_STATE_GROUPS || null; const projects = workspaceSlug ? projectStore?.projects[workspaceSlug.toString()] || null : null; const estimates = @@ -85,7 +85,7 @@ export const ModuleListLayout: React.FC = observer(() => { states={states} stateGroups={stateGroups} priorities={priorities} - labels={labels} + labels={projectLabels} members={projectMembers?.map((m) => m.member) ?? null} projects={projects} estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null} diff --git a/web/components/issues/issue-layouts/list/roots/project-root.tsx b/web/components/issues/issue-layouts/list/roots/project-root.tsx index beec14008..cc78145f0 100644 --- a/web/components/issues/issue-layouts/list/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/project-root.tsx @@ -20,6 +20,7 @@ export const ListLayout: FC = observer(() => { // store const { project: projectStore, + projectLabel: { projectLabels }, projectMember: { projectMembers }, projectState: projectStateStore, issue: issueStore, @@ -49,7 +50,6 @@ export const ListLayout: FC = observer(() => { const states = projectStateStore?.projectStates || null; const priorities = ISSUE_PRIORITIES || null; - const labels = projectStore?.projectLabels || null; const stateGroups = ISSUE_STATE_GROUPS || null; const projects = workspaceSlug ? projectStore?.projects[workspaceSlug.toString()] || null : null; const estimates = @@ -80,7 +80,7 @@ export const ListLayout: FC = observer(() => { states={states} stateGroups={stateGroups} priorities={priorities} - labels={labels} + labels={projectLabels} members={projectMembers?.map((m) => m.member) ?? null} projects={projects} enableQuickIssueCreate diff --git a/web/components/issues/issue-layouts/list/roots/project-view-root.tsx b/web/components/issues/issue-layouts/list/roots/project-view-root.tsx index a5dc76352..66c2828a8 100644 --- a/web/components/issues/issue-layouts/list/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/project-view-root.tsx @@ -30,7 +30,7 @@ export const ProjectViewListLayout: React.FC = observer(() => { const states = projectStateStore?.projectStates || null; const priorities = ISSUE_PRIORITIES || null; - const labels = projectStore?.projectLabels || null; + // const labels = projectStore?.projectLabels || null; const stateGroups = ISSUE_STATE_GROUPS || null; const projects = projectStateStore?.projectStates || null; const estimates = null; diff --git a/web/components/issues/issue-layouts/properties/labels.tsx b/web/components/issues/issue-layouts/properties/labels.tsx index 16589bf2d..2d1411196 100644 --- a/web/components/issues/issue-layouts/properties/labels.tsx +++ b/web/components/issues/issue-layouts/properties/labels.tsx @@ -1,8 +1,6 @@ import { Fragment, useState } from "react"; - import { observer } from "mobx-react-lite"; import { useMobxStore } from "lib/mobx/store-provider"; - // hooks import { usePopper } from "react-popper"; // components @@ -44,7 +42,10 @@ export const IssuePropertyLabels: React.FC = observer((pro noLabelBorder = false, } = props; - const { workspace: workspaceStore, project: projectStore }: RootStore = useMobxStore(); + const { + workspace: workspaceStore, + projectLabel: { fetchProjectLabels, projectLabels }, + }: RootStore = useMobxStore(); const workspaceSlug = workspaceStore?.workspaceSlug; const [query, setQuery] = useState(""); @@ -53,12 +54,9 @@ export const IssuePropertyLabels: React.FC = observer((pro const [popperElement, setPopperElement] = useState(null); const [isLoading, setIsLoading] = useState(false); - const projectLabels = projectId && projectStore?.labels?.[projectId]; - - const fetchProjectLabels = () => { + const fetchLabels = () => { setIsLoading(true); - if (workspaceSlug && projectId) - projectStore.fetchProjectLabels(workspaceSlug, projectId).then(() => setIsLoading(false)); + if (workspaceSlug && projectId) fetchProjectLabels(workspaceSlug, projectId).then(() => setIsLoading(false)); }; const options = (projectLabels ? projectLabels : []).map((label) => ({ @@ -169,7 +167,7 @@ export const IssuePropertyLabels: React.FC = observer((pro ? "cursor-pointer" : "cursor-pointer hover:bg-custom-background-80" } ${buttonClassName}`} - onClick={() => !projectLabels && fetchProjectLabels()} + onClick={() => !projectLabels && fetchLabels()} > {label} {!hideDropdownArrow && !disabled &&
- - + )} + )} diff --git a/web/components/labels/project-setting-label-item.tsx b/web/components/labels/project-setting-label-item.tsx new file mode 100644 index 000000000..29ac427af --- /dev/null +++ b/web/components/labels/project-setting-label-item.tsx @@ -0,0 +1,91 @@ +import React, { Dispatch, SetStateAction, useState } from "react"; +import { useRouter } from "next/router"; +import { useMobxStore } from "lib/mobx/store-provider"; +import { DraggableProvidedDragHandleProps, DraggableStateSnapshot } from "@hello-pangea/dnd"; +// types +import { IIssueLabel } from "types"; +//icons +import { X, Pencil } from "lucide-react"; +//components +import { ICustomMenuItem, LabelItemBlock } from "./label-block/label-item-block"; +import { CreateUpdateLabelInline } from "./create-update-label-inline"; + +type Props = { + label: IIssueLabel; + handleLabelDelete: (label: IIssueLabel) => void; + draggableSnapshot: DraggableStateSnapshot; + dragHandleProps: DraggableProvidedDragHandleProps; + setIsUpdating: Dispatch>; + isChild: boolean; +}; + +export const ProjectSettingLabelItem: React.FC = (props) => { + const { label, setIsUpdating, handleLabelDelete, draggableSnapshot, dragHandleProps, isChild } = props; + + const { combineTargetFor, isDragging } = draggableSnapshot; + + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + // store + const { projectLabel: projectLabelStore } = useMobxStore(); + + //state + const [isEditLabelForm, setEditLabelForm] = useState(false); + + const removeFromGroup = (label: IIssueLabel) => { + if (!workspaceSlug || !projectId) return; + + projectLabelStore.updateLabel(workspaceSlug.toString(), projectId.toString(), label.id, { + parent: null, + }); + }; + + const customMenuItems: ICustomMenuItem[] = [ + { + CustomIcon: X, + onClick: removeFromGroup, + isVisible: !!label.parent, + text: "Remove from group", + }, + { + CustomIcon: Pencil, + onClick: () => { + setEditLabelForm(true); + setIsUpdating(true); + }, + isVisible: true, + text: "Edit label", + }, + ]; + + return ( +
+ {isEditLabelForm ? ( + { + setEditLabelForm(false); + setIsUpdating(false); + }} + /> + ) : ( + + )} +
+ ); +}; diff --git a/web/components/labels/project-setting-label-list-item.tsx b/web/components/labels/project-setting-label-list-item.tsx deleted file mode 100644 index fec3bcd2e..000000000 --- a/web/components/labels/project-setting-label-list-item.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { useRef, useState } from "react"; - -//hook -import useOutsideClickDetector from "hooks/use-outside-click-detector"; -// ui -import { CustomMenu } from "@plane/ui"; -// types -import { IIssueLabels } from "types"; -//icons -import { Component, X, Pencil } from "lucide-react"; - -type Props = { - label: IIssueLabels; - addLabelToGroup: (parentLabel: IIssueLabels) => void; - editLabel: (label: IIssueLabels) => void; - handleLabelDelete: () => void; -}; - -export const ProjectSettingLabelItem: React.FC = (props) => { - const { label, addLabelToGroup, editLabel, handleLabelDelete } = props; - - const [isMenuActive, setIsMenuActive] = useState(false); - const actionSectionRef = useRef(null); - - useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false)); - - return ( -
-
- -
{label.name}
-
- -
- setIsMenuActive(!isMenuActive)}> - -
- } - > - addLabelToGroup(label)}> - - - Convert to group - - - editLabel(label)}> - - - Edit label - - - -
- -
-
-
- ); -}; diff --git a/web/components/labels/project-setting-label-list.tsx b/web/components/labels/project-setting-label-list.tsx index 84bda8f97..48ca0f07f 100644 --- a/web/components/labels/project-setting-label-list.tsx +++ b/web/components/labels/project-setting-label-list.tsx @@ -1,75 +1,96 @@ import React, { useState, useRef } from "react"; import { useRouter } from "next/router"; import useSWR from "swr"; +import { observer } from "mobx-react-lite"; +import { + DragDropContext, + Draggable, + DraggableProvided, + DraggableStateSnapshot, + DropResult, + Droppable, +} from "@hello-pangea/dnd"; // store -import { observer } from "mobx-react-lite"; import { useMobxStore } from "lib/mobx/store-provider"; // components -import { - CreateUpdateLabelInline, - DeleteLabelModal, - LabelsListModal, - ProjectSettingLabelItem, - ProjectSettingLabelGroup, -} from "components/labels"; +import { CreateUpdateLabelInline, DeleteLabelModal, ProjectSettingLabelGroup } from "components/labels"; // ui import { Button, Loader } from "@plane/ui"; import { EmptyState } from "components/common"; // images import emptyLabel from "public/empty-state/label.svg"; // types -import { IIssueLabels } from "types"; +import { IIssueLabel } from "types"; +//component +import { ProjectSettingLabelItem } from "./project-setting-label-item"; +import useDraggableInPortal from "hooks/use-draggable-portal"; + +const LABELS_ROOT = "labels.root"; export const ProjectSettingsLabelList: React.FC = observer(() => { // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; + const renderDraggable = useDraggableInPortal(); + // store - const { project: projectStore } = useMobxStore(); - + const { + projectLabel: { fetchProjectLabels, projectLabels, updateLabelPosition, projectLabelsTree }, + } = useMobxStore(); // states - const [labelForm, setLabelForm] = useState(false); + const [showLabelForm, setLabelForm] = useState(false); const [isUpdating, setIsUpdating] = useState(false); - const [labelsListModal, setLabelsListModal] = useState(false); - const [labelToUpdate, setLabelToUpdate] = useState(null); - const [parentLabel, setParentLabel] = useState(undefined); - const [selectDeleteLabel, setSelectDeleteLabel] = useState(null); - + const [selectDeleteLabel, setSelectDeleteLabel] = useState(null); + const [isDraggingGroup, setIsDraggingGroup] = useState(false); // ref const scrollToRef = useRef(null); // api call to fetch project details useSWR( workspaceSlug && projectId ? "PROJECT_LABELS" : null, - workspaceSlug && projectId - ? () => projectStore.fetchProjectLabels(workspaceSlug.toString(), projectId.toString()) - : null + workspaceSlug && projectId ? () => fetchProjectLabels(workspaceSlug.toString(), projectId.toString()) : null ); - // derived values - const issueLabels = projectStore.labels?.[projectId?.toString()!] ?? null; - const newLabel = () => { setIsUpdating(false); setLabelForm(true); }; - const addLabelToGroup = (parentLabel: IIssueLabels) => { - setLabelsListModal(true); - setParentLabel(parentLabel); - }; + const onDragEnd = (result: DropResult) => { + const { combine, draggableId, destination, source } = result; - const editLabel = (label: IIssueLabels) => { - setLabelForm(true); - setIsUpdating(true); - setLabelToUpdate(label); + // return if dropped outside the DragDropContext + if (!combine && !destination) return; + + const childLabel = draggableId.split(".")[2]; + let parentLabel: string | undefined | null = destination?.droppableId?.split(".")[3]; + const index = destination?.index || 0; + + const prevParentLabel: string | undefined | null = source?.droppableId?.split(".")[3]; + const prevIndex = source?.index; + + if (combine && combine.draggableId) parentLabel = combine?.draggableId?.split(".")[2]; + + if (destination?.droppableId === LABELS_ROOT) parentLabel = null; + + if (result.reason == "DROP" && childLabel != parentLabel) { + updateLabelPosition( + workspaceSlug?.toString()!, + projectId?.toString()!, + childLabel, + parentLabel, + index, + prevParentLabel == parentLabel, + prevIndex + ); + return; + } }; return ( <> - setLabelsListModal(false)} /> { Add label -
- {labelForm && ( - { - setLabelForm(false); - setIsUpdating(false); - setLabelToUpdate(null); - }} - /> +
+ {showLabelForm && ( +
+ { + setLabelForm(false); + setIsUpdating(false); + }} + /> +
)} - {/* labels */} - {issueLabels && - issueLabels.map((label) => { - const children = issueLabels?.filter((l) => l.parent === label.id); + <> + {projectLabelsTree && ( + + + {(droppableProvided, droppableSnapshot) => ( +
+ {projectLabelsTree.map((label, index) => { + if (label.children && label.children.length) { + return ( + + {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => { + const isGroup = droppableSnapshot.draggingFromThisWith?.split(".")[3] === "group"; + setIsDraggingGroup(isGroup); - if (children && children.length === 0) { - if (!label.parent) - return ( - addLabelToGroup(label)} - editLabel={(label) => { - editLabel(label); - scrollToRef.current?.scrollIntoView({ - behavior: "smooth", - }); - }} - handleLabelDelete={() => setSelectDeleteLabel(label)} - /> - ); - } else { - return ( - { - editLabel(label); - scrollToRef.current?.scrollIntoView({ - behavior: "smooth", - }); - }} - handleLabelDelete={() => setSelectDeleteLabel(label)} - /> - ); - } - })} + return ( +
+ setSelectDeleteLabel(label)} + draggableSnapshot={snapshot} + isUpdating={isUpdating} + setIsUpdating={setIsUpdating} + /> +
+ ); + }} +
+ ); + } + return ( + + {renderDraggable((provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( +
+ setSelectDeleteLabel(label)} + isChild={false} + /> +
+ ))} +
+ ); + })} + {droppableProvided.placeholder} +
+ )} +
+
+ )} + {/* loading state */} - {!issueLabels && ( + {!projectLabels && ( @@ -149,7 +211,7 @@ export const ProjectSettingsLabelList: React.FC = observer(() => { )} {/* empty state */} - {issueLabels && issueLabels.length === 0 && ( + {projectLabels && projectLabels.length === 0 && ( { // store const { - project: projectStore, projectMember: { projectMembers, fetchProjectMembers }, } = useMobxStore(); diff --git a/web/components/ui/labels-list.tsx b/web/components/ui/labels-list.tsx index c7e4306a2..9fdf7b326 100644 --- a/web/components/ui/labels-list.tsx +++ b/web/components/ui/labels-list.tsx @@ -2,10 +2,10 @@ import { FC } from "react"; // ui import { Tooltip } from "@plane/ui"; // types -import { IIssueLabels } from "types"; +import { IIssueLabel } from "types"; type IssueLabelsListProps = { - labels?: (IIssueLabels | undefined)[]; + labels?: (IIssueLabel | undefined)[]; length?: number; showLength?: boolean; }; diff --git a/web/components/views/form.tsx b/web/components/views/form.tsx index 69557a7c1..3570353cc 100644 --- a/web/components/views/form.tsx +++ b/web/components/views/form.tsx @@ -27,7 +27,7 @@ const defaultValues: Partial = { export const ProjectViewForm: React.FC = observer(({ handleFormSubmit, handleClose, data, preLoadedData }) => { const { - project: projectStore, + projectLabel: { projectLabels }, projectState: projectStateStore, projectMember: { projectMembers }, } = useMobxStore(); @@ -167,7 +167,7 @@ export const ProjectViewForm: React.FC = observer(({ handleFormSubmit, ha }); }} layoutDisplayFiltersOptions={ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues.list} - labels={projectStore.projectLabels ?? undefined} + labels={projectLabels ?? undefined} members={projectMembers?.map((m) => m.member) ?? undefined} states={projectStateStore.projectStates ?? undefined} /> @@ -181,7 +181,7 @@ export const ProjectViewForm: React.FC = observer(({ handleFormSubmit, ha appliedFilters={selectedFilters} handleClearAllFilters={clearAllFilters} handleRemoveFilter={handleRemoveFilter} - labels={projectStore.projectLabels ?? []} + labels={projectLabels ?? []} members={projectMembers?.map((m) => m.member) ?? []} states={projectStateStore.projectStates ?? []} /> diff --git a/web/helpers/array.helper.ts b/web/helpers/array.helper.ts index a55ad8fd9..7aa434755 100644 --- a/web/helpers/array.helper.ts +++ b/web/helpers/array.helper.ts @@ -1,3 +1,5 @@ +import { IIssueLabelTree } from "types"; + export const groupBy = (array: any[], key: string) => { const innerKey = key.split("."); // split the key by dot return array.reduce((result, currentValue) => { @@ -74,3 +76,17 @@ export const orderGroupedDataByField = (groupedData: GroupedItems, orderBy } return groupedData; }; + +export const buildTree = (array: any[], parent = null) => { + const tree: IIssueLabelTree[] = []; + + array.forEach((item: any) => { + if (item.parent === parent) { + const children = buildTree(array, item.id); + item.children = children; + tree.push(item); + } + }); + + return tree; +}; diff --git a/web/hooks/use-draggable-portal.ts b/web/hooks/use-draggable-portal.ts new file mode 100644 index 000000000..383c277f3 --- /dev/null +++ b/web/hooks/use-draggable-portal.ts @@ -0,0 +1,31 @@ +import { createPortal } from "react-dom"; +import { useEffect, useRef } from "react"; +import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd"; + +const useDraggableInPortal = () => { + const self = useRef(); + + useEffect(() => { + const div = document.createElement("div"); + div.style.position = "absolute"; + div.style.pointerEvents = "none"; + div.style.top = "0"; + div.style.width = "100%"; + div.style.height = "100%"; + self.current = div; + document.body.appendChild(div); + return () => { + document.body.removeChild(div); + }; + }, [self.current]); + + return (render: any) => (provided: DraggableProvided, snapshot: DraggableStateSnapshot) => { + const element = render(provided, snapshot); + if (self.current && snapshot?.isDragging) { + return createPortal(element, self.current); + } + return element; + }; +}; + +export default useDraggableInPortal; diff --git a/web/layouts/auth-layout/project-wrapper.tsx b/web/layouts/auth-layout/project-wrapper.tsx index 225e92f2a..8e7d27f19 100644 --- a/web/layouts/auth-layout/project-wrapper.tsx +++ b/web/layouts/auth-layout/project-wrapper.tsx @@ -20,7 +20,8 @@ export const ProjectAuthWrapper: FC = observer((props) => { // store const { user: { fetchUserProjectInfo, projectMemberInfo, hasPermissionToProject }, - project: { fetchProjectDetails, fetchProjectLabels, fetchProjectEstimates, workspaceProjects }, + project: { fetchProjectDetails, fetchProjectEstimates, workspaceProjects }, + projectLabel: { fetchProjectLabels }, projectMember: { fetchProjectMembers }, projectState: { fetchProjectStates }, cycle: { fetchCycles }, diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index 40391234d..dd1efde15 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -33,7 +33,7 @@ import { copyTextToClipboard } from "helpers/string.helper"; import { orderArrayBy } from "helpers/array.helper"; // types import { NextPageWithLayout } from "types/app"; -import { IIssueLabels, IPage, IPageBlock, IProjectMember } from "types"; +import { IIssueLabel, IPage, IPageBlock, IProjectMember } from "types"; // fetch-keys import { PAGE_BLOCKS_LIST, @@ -86,7 +86,7 @@ const PageDetailsPage: NextPageWithLayout = () => { : null ); - const { data: labels } = useSWR( + const { data: labels } = useSWR( workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null, workspaceSlug && projectId ? () => issueLabelService.getProjectIssueLabels(workspaceSlug as string, projectId as string) diff --git a/web/services/issue/issue_label.service.ts b/web/services/issue/issue_label.service.ts index 00b945d01..d6bab8348 100644 --- a/web/services/issue/issue_label.service.ts +++ b/web/services/issue/issue_label.service.ts @@ -3,7 +3,7 @@ import { API_BASE_URL } from "helpers/common.helper"; import { APIService } from "services/api.service"; import { TrackEventService } from "services/track_event.service"; // types -import { IIssueLabels, IUser } from "types"; +import { IIssueLabel, IUser } from "types"; const trackEventServices = new TrackEventService(); @@ -12,7 +12,7 @@ export class IssueLabelService extends APIService { super(API_BASE_URL); } - async getWorkspaceIssueLabels(workspaceSlug: string): Promise { + async getWorkspaceIssueLabels(workspaceSlug: string): Promise { return this.get(`/api/workspaces/${workspaceSlug}/labels/`) .then((response) => response?.data) .catch((error) => { @@ -20,7 +20,7 @@ export class IssueLabelService extends APIService { }); } - async getProjectIssueLabels(workspaceSlug: string, projectId: string): Promise { + async getProjectIssueLabels(workspaceSlug: string, projectId: string): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-labels/`) .then((response) => response?.data) .catch((error) => { @@ -33,9 +33,9 @@ export class IssueLabelService extends APIService { projectId: string, data: any, user: IUser | undefined - ): Promise { + ): Promise { return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-labels/`, data) - .then((response: { data: IIssueLabels; [key: string]: any }) => { + .then((response: { data: IIssueLabel; [key: string]: any }) => { trackEventServices.trackIssueLabelEvent( { workSpaceId: response?.data?.workspace_detail?.id, diff --git a/web/store/project/project-label.store.ts b/web/store/project/project-label.store.ts index f4ea6892a..d6a804a60 100644 --- a/web/store/project/project-label.store.ts +++ b/web/store/project/project-label.store.ts @@ -1,30 +1,49 @@ -import { observable, action, makeObservable, runInAction } from "mobx"; +import { observable, action, makeObservable, runInAction, computed } from "mobx"; // types import { RootStore } from "../root"; -import { IIssueLabels } from "types"; +import { IIssueLabel, IIssueLabelTree } from "types"; // services import { IssueLabelService } from "services/issue"; import { ProjectService } from "services/project"; +import { buildTree } from "helpers/array.helper"; export interface IProjectLabelStore { loader: boolean; error: any | null; - - // labels - createLabel: (workspaceSlug: string, projectId: string, data: Partial) => Promise; + labels: { + [projectId: string]: IIssueLabel[] | null; // project_id: labels + } | null; + // computed + projectLabels: IIssueLabel[] | null; + projectLabelsTree: IIssueLabelTree[] | null; + // actions + getProjectLabelById: (labelId: string) => IIssueLabel | null; + fetchProjectLabels: (workspaceSlug: string, projectId: string) => Promise; + createLabel: (workspaceSlug: string, projectId: string, data: Partial) => Promise; updateLabel: ( workspaceSlug: string, projectId: string, labelId: string, - data: Partial - ) => Promise; + data: Partial + ) => Promise; + updateLabelPosition: ( + workspaceSlug: string, + projectId: string, + labelId: string, + parentId: string | null | undefined, + index: number, + isSameParent: boolean, + prevIndex: number | undefined + ) => Promise; deleteLabel: (workspaceSlug: string, projectId: string, labelId: string) => Promise; } export class ProjectLabelStore implements IProjectLabelStore { loader: boolean = false; error: any | null = null; - + labels: { + [projectId: string]: IIssueLabel[]; // projectId: labels + } | null = {}; // root store rootStore; // service @@ -34,12 +53,18 @@ export class ProjectLabelStore implements IProjectLabelStore { constructor(_rootStore: RootStore) { makeObservable(this, { // observable - loader: observable, - error: observable, - - // labels + loader: observable.ref, + error: observable.ref, + labels: observable.ref, + // computed + projectLabels: computed, + projectLabelsTree: computed, + // actions + getProjectLabelById: action, + fetchProjectLabels: action, createLabel: action, updateLabel: action, + updateLabelPosition: action, deleteLabel: action, }); @@ -48,7 +73,51 @@ export class ProjectLabelStore implements IProjectLabelStore { this.issueLabelService = new IssueLabelService(); } - createLabel = async (workspaceSlug: string, projectId: string, data: Partial) => { + get projectLabels() { + if (!this.rootStore.project.projectId) return null; + return this.labels?.[this.rootStore.project.projectId]?.sort((a, b) => a.name.localeCompare(b.name)) || null; + } + + get projectLabelsTree() { + if (!this.rootStore.project.projectId) return null; + const currentProjectLabels = this.labels?.[this.rootStore.project.projectId]; + if (!currentProjectLabels) return null; + + currentProjectLabels.sort((labelA: IIssueLabel, labelB: IIssueLabel) => labelB.sort_order - labelA.sort_order); + return buildTree(currentProjectLabels); + } + + getProjectLabelById = (labelId: string) => { + if (!this.rootStore.project.projectId) return null; + const labels = this.projectLabels; + if (!labels) return null; + const labelInfo: IIssueLabel | null = labels.find((label) => label.id === labelId) || null; + return labelInfo; + }; + + fetchProjectLabels = async (workspaceSlug: string, projectId: string) => { + try { + this.loader = true; + this.error = null; + + const labelResponse = await this.issueLabelService.getProjectIssueLabels(workspaceSlug, projectId); + + runInAction(() => { + this.labels = { + ...this.labels, + [projectId]: labelResponse, + }; + this.loader = false; + this.error = null; + }); + } catch (error) { + console.error(error); + this.loader = false; + this.error = error; + } + }; + + createLabel = async (workspaceSlug: string, projectId: string, data: Partial) => { try { const response = await this.issueLabelService.createIssueLabel( workspaceSlug, @@ -58,9 +127,9 @@ export class ProjectLabelStore implements IProjectLabelStore { ); runInAction(() => { - this.rootStore.project.labels = { - ...this.rootStore.project.labels, - [projectId]: [response, ...(this.rootStore.project.labels?.[projectId] || [])], + this.labels = { + ...this.labels, + [projectId]: [response, ...(this.labels?.[projectId] || [])], }; }); @@ -71,16 +140,70 @@ export class ProjectLabelStore implements IProjectLabelStore { } }; - updateLabel = async (workspaceSlug: string, projectId: string, labelId: string, data: Partial) => { - const originalLabel = this.rootStore.project.getProjectLabelById(labelId); + updateLabelPosition = async ( + workspaceSlug: string, + projectId: string, + labelId: string, + parentId: string | null | undefined, + index: number, + isSameParent: boolean, + prevIndex: number | undefined + ) => { + const labels = this.labels; + const currLabel = labels?.[projectId]?.find((label) => label.id === labelId); + const labelTree = this.projectLabelsTree; + + let currentArray: IIssueLabel[]; + + if (!currLabel || !labelTree) return; + + const data: Partial = { parent: parentId }; + //find array in which the label is to be added + if (!parentId) currentArray = labelTree; + else currentArray = labelTree?.find((label) => label.id === parentId)?.children || []; + + //Add the array at the destination + if (isSameParent && prevIndex !== undefined) currentArray.splice(prevIndex, 1); + + currentArray.splice(index, 0, currLabel); + + //if currently adding to a new array, then let backend assign a sort order + if (currentArray.length > 1) { + let prevSortOrder: number | undefined, nextSortOrder: number | undefined; + + if (typeof currentArray[index - 1] !== "undefined") { + prevSortOrder = currentArray[index - 1].sort_order; + } + + if (typeof currentArray[index + 1] !== "undefined") { + nextSortOrder = currentArray[index + 1].sort_order; + } + + let sortOrder: number; + + //based on the next and previous labels calculate current sort order + if (prevSortOrder && nextSortOrder) { + sortOrder = (prevSortOrder + nextSortOrder) / 2; + } else if (nextSortOrder) { + sortOrder = nextSortOrder + 10000; + } else { + sortOrder = prevSortOrder! / 2; + } + + data.sort_order = sortOrder; + } + + return this.updateLabel(workspaceSlug, projectId, labelId, data); + }; + + updateLabel = async (workspaceSlug: string, projectId: string, labelId: string, data: Partial) => { + const originalLabel = this.getProjectLabelById(labelId); runInAction(() => { - this.rootStore.project.labels = { - ...this.rootStore.project.labels, + this.labels = { + ...this.labels, [projectId]: - this.rootStore.project.labels?.[projectId]?.map((label) => - label.id === labelId ? { ...label, ...data } : label - ) || [], + this.labels?.[projectId]?.map((label) => (label.id === labelId ? { ...label, ...data } : label)) || [], }; }); @@ -97,9 +220,9 @@ export class ProjectLabelStore implements IProjectLabelStore { } catch (error) { console.log("Failed to update label from project store"); runInAction(() => { - this.rootStore.project.labels = { - ...this.rootStore.project.labels, - [projectId]: (this.rootStore.project.labels?.[projectId] || [])?.map((label) => + this.labels = { + ...this.labels, + [projectId]: (this.labels?.[projectId] || [])?.map((label) => label.id === labelId ? { ...label, ...originalLabel } : label ), }; @@ -109,12 +232,12 @@ export class ProjectLabelStore implements IProjectLabelStore { }; deleteLabel = async (workspaceSlug: string, projectId: string, labelId: string) => { - const originalLabelList = this.rootStore.project.projectLabels; + const originalLabelList = this.projectLabels; runInAction(() => { - this.rootStore.project.labels = { - ...this.rootStore.project.labels, - [projectId]: (this.rootStore.project.labels?.[projectId] || [])?.filter((label) => label.id !== labelId), + this.labels = { + ...this.labels, + [projectId]: (this.labels?.[projectId] || [])?.filter((label) => label.id !== labelId), }; }); @@ -130,8 +253,8 @@ export class ProjectLabelStore implements IProjectLabelStore { console.log("Failed to delete label from project store"); // reverting back to original label list runInAction(() => { - this.rootStore.project.labels = { - ...this.rootStore.project.labels, + this.labels = { + ...this.labels, [projectId]: originalLabelList || [], }; }); diff --git a/web/store/project/project.store.ts b/web/store/project/project.store.ts index 9997de5c1..d7979f3f5 100644 --- a/web/store/project/project.store.ts +++ b/web/store/project/project.store.ts @@ -1,7 +1,7 @@ import { observable, action, computed, makeObservable, runInAction } from "mobx"; // types import { RootStore } from "../root"; -import { IProject, IIssueLabels, IEstimate } from "types"; +import { IProject, IEstimate } from "types"; // services import { ProjectService, ProjectStateService, ProjectEstimateService } from "services/project"; import { IssueService, IssueLabelService } from "services/issue"; @@ -16,9 +16,6 @@ export interface IProjectStore { project_details: { [projectId: string]: IProject; // projectId: project Info }; - labels: { - [projectId: string]: IIssueLabels[] | null; // project_id: labels - } | null; estimates: { [projectId: string]: IEstimate[] | null; // project_id: members } | null; @@ -26,12 +23,9 @@ export interface IProjectStore { // computed searchedProjects: IProject[]; workspaceProjects: IProject[] | null; - projectLabels: IIssueLabels[] | null; projectEstimates: IEstimate[] | null; - joinedProjects: IProject[]; favoriteProjects: IProject[]; - currentProjectDetails: IProject | undefined; // actions @@ -39,12 +33,10 @@ export interface IProjectStore { setSearchQuery: (query: string) => void; getProjectById: (workspaceSlug: string, projectId: string) => IProject | null; - getProjectLabelById: (labelId: string) => IIssueLabels | null; - getProjectEstimateById: (estimateId: string) => IEstimate | null; + getProjectEstimateById: (estimateId: string) => IEstimate | null; fetchProjects: (workspaceSlug: string) => Promise; fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise; - fetchProjectLabels: (workspaceSlug: string, projectId: string) => Promise; fetchProjectEstimates: (workspaceSlug: string, projectId: string) => Promise; addProjectToFavorites: (workspaceSlug: string, projectId: string) => Promise; @@ -70,9 +62,6 @@ export class ProjectStore implements IProjectStore { project_details: { [projectId: string]: IProject; // projectId: project } = {}; - labels: { - [projectId: string]: IIssueLabels[]; // projectId: labels - } | null = {}; estimates: { [projectId: string]: IEstimate[]; // projectId: estimates } | null = {}; @@ -96,13 +85,13 @@ export class ProjectStore implements IProjectStore { projectId: observable.ref, projects: observable.ref, project_details: observable.ref, - labels: observable.ref, + estimates: observable.ref, // computed searchedProjects: computed, workspaceProjects: computed, - projectLabels: computed, + projectEstimates: computed, currentProjectDetails: computed, @@ -117,10 +106,8 @@ export class ProjectStore implements IProjectStore { fetchProjectDetails: action, getProjectById: action, - getProjectLabelById: action, getProjectEstimateById: action, - fetchProjectLabels: action, fetchProjectEstimates: action, addProjectToFavorites: action, @@ -177,11 +164,6 @@ export class ProjectStore implements IProjectStore { return this.projects?.[this.rootStore.workspace.workspaceSlug]?.filter((p) => p.is_favorite); } - get projectLabels() { - if (!this.projectId) return null; - return this.labels?.[this.projectId] || null; - } - get projectEstimates() { if (!this.projectId) return null; return this.estimates?.[this.projectId] || null; @@ -241,14 +223,6 @@ export class ProjectStore implements IProjectStore { return projectInfo; }; - getProjectLabelById = (labelId: string) => { - if (!this.projectId) return null; - const labels = this.projectLabels; - if (!labels) return null; - const labelInfo: IIssueLabels | null = labels.find((label) => label.id === labelId) || null; - return labelInfo; - }; - getProjectEstimateById = (estimateId: string) => { if (!this.projectId) return null; const estimates = this.projectEstimates; @@ -257,28 +231,6 @@ export class ProjectStore implements IProjectStore { return estimateInfo; }; - fetchProjectLabels = async (workspaceSlug: string, projectId: string) => { - try { - this.loader = true; - this.error = null; - - const labelResponse = await this.issueLabelService.getProjectIssueLabels(workspaceSlug, projectId); - - runInAction(() => { - this.labels = { - ...this.labels, - [projectId]: labelResponse, - }; - this.loader = false; - this.error = null; - }); - } catch (error) { - console.error(error); - this.loader = false; - this.error = error; - } - }; - fetchProjectEstimates = async (workspaceSlug: string, projectId: string) => { try { this.loader = true; diff --git a/web/store/workspace/workspace.store.ts b/web/store/workspace/workspace.store.ts index 1092ec33b..5fa071cd3 100644 --- a/web/store/workspace/workspace.store.ts +++ b/web/store/workspace/workspace.store.ts @@ -1,7 +1,7 @@ import { action, computed, observable, makeObservable, runInAction } from "mobx"; import { RootStore } from "../root"; // types -import { IIssueLabels, IProject, IWorkspace, IWorkspaceMember } from "types"; +import { IIssueLabel, IProject, IWorkspace, IWorkspaceMember } from "types"; // services import { WorkspaceService } from "services/workspace.service"; import { ProjectService } from "services/project"; @@ -15,12 +15,12 @@ export interface IWorkspaceStore { // observables workspaceSlug: string | null; workspaces: IWorkspace[] | undefined; - labels: { [workspaceSlug: string]: IIssueLabels[] }; // workspaceSlug: labels[] + labels: { [workspaceSlug: string]: IIssueLabel[] }; // workspaceSlug: labels[] // actions setWorkspaceSlug: (workspaceSlug: string) => void; getWorkspaceBySlug: (workspaceSlug: string) => IWorkspace | null; - getWorkspaceLabelById: (workspaceSlug: string, labelId: string) => IIssueLabels | null; + getWorkspaceLabelById: (workspaceSlug: string, labelId: string) => IIssueLabel | null; fetchWorkspaces: () => Promise; fetchWorkspaceLabels: (workspaceSlug: string) => Promise; @@ -32,7 +32,7 @@ export interface IWorkspaceStore { // computed currentWorkspace: IWorkspace | null; workspacesCreateByCurrentUser: IWorkspace[] | null; - workspaceLabels: IIssueLabels[] | null; + workspaceLabels: IIssueLabel[] | null; } export class WorkspaceStore implements IWorkspaceStore { @@ -44,7 +44,7 @@ export class WorkspaceStore implements IWorkspaceStore { workspaceSlug: string | null = null; workspaces: IWorkspace[] | undefined = []; projects: { [workspaceSlug: string]: IProject[] } = {}; // workspaceSlug: project[] - labels: { [workspaceSlug: string]: IIssueLabels[] } = {}; + labels: { [workspaceSlug: string]: IIssueLabel[] } = {}; members: { [workspaceSlug: string]: IWorkspaceMember[] } = {}; // services diff --git a/web/types/issues.d.ts b/web/types/issues.d.ts index 553a12ced..b04a7e5ef 100644 --- a/web/types/issues.d.ts +++ b/web/types/issues.d.ts @@ -159,7 +159,7 @@ export type IssuePriorities = { user: string; }; -export interface IIssueLabels { +export interface IIssueLabel { id: string; created_at: Date; updated_at: Date; @@ -173,6 +173,11 @@ export interface IIssueLabels { workspace: string; workspace_detail: IWorkspaceLite; parent: string | null; + sort_order: number; +} + +export interface IIssueLabelTree extends IIssueLabel { + children: IIssueLabel[] | undefined; } export interface IIssueActivity { diff --git a/web/types/pages.d.ts b/web/types/pages.d.ts index f7850d11d..f4cd52ffe 100644 --- a/web/types/pages.d.ts +++ b/web/types/pages.d.ts @@ -1,5 +1,5 @@ // types -import { IIssue, IIssueLabels, IWorkspaceLite, IProjectLite } from "types"; +import { IIssue, IIssueLabel, IWorkspaceLite, IProjectLite } from "types"; export interface IPage { access: number; @@ -12,7 +12,7 @@ export interface IPage { description_stripped: string | null; id: string; is_favorite: boolean; - label_details: IIssueLabels[]; + label_details: IIssueLabel[]; labels: string[]; name: string; owned_by: string; From 4c2074b6ff122d3ff56e21638fc98f4a8603f8b6 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Sun, 19 Nov 2023 01:48:05 +0530 Subject: [PATCH 08/10] dev: environment settings (#2794) * dev: update environment configuration * dev: update the takeoff script for instance registration --- apiserver/.env.example | 2 +- apiserver/bin/takeoff | 15 +++++++++++---- apiserver/plane/settings/common.py | 5 +---- deploy/selfhost/docker-compose.yml | 2 +- deploy/selfhost/variables.env | 2 +- 5 files changed, 15 insertions(+), 11 deletions(-) diff --git a/apiserver/.env.example b/apiserver/.env.example index 2078fc94a..88a9c17f5 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -2,10 +2,10 @@ # Debug value for api server use it as 0 for production use DEBUG=0 CORS_ALLOWED_ORIGINS="" -ENVIRONMENT="development" # Error logs SENTRY_DSN="" +SENTRY_ENVIRONMENT="development" # Database Settings PGUSER="plane" diff --git a/apiserver/bin/takeoff b/apiserver/bin/takeoff index 44f251155..13e557edc 100755 --- a/apiserver/bin/takeoff +++ b/apiserver/bin/takeoff @@ -3,10 +3,17 @@ set -e python manage.py wait_for_db python manage.py migrate -# Register instance -python manage.py register_instance -# Load the configuration variable -python manage.py configure_instance +# Set default value for ENABLE_REGISTRATION +ENABLE_REGISTRATION=${ENABLE_REGISTRATION:-1} + +# Check if ENABLE_REGISTRATION is not set to '0' +if [ "$ENABLE_REGISTRATION" != "0" ]; then + # Register instance + python manage.py register_instance + # Load the configuration variable + python manage.py configure_instance +fi + # Create the default bucket python bin/bucket_script.py diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 0ef96717f..f6359344d 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -27,9 +27,6 @@ DEBUG = False # Allowed Hosts ALLOWED_HOSTS = ["*"] -# Redirect if / is not present -APPEND_SLASH = True - # Application definition INSTALLED_APPS = [ "django.contrib.auth", @@ -301,7 +298,7 @@ if bool(os.environ.get("SENTRY_DSN", False)): ], traces_sample_rate=1, send_default_pii=True, - environment=os.environ.get("ENVIRONMENT", "development"), + environment=os.environ.get("SENTRY_ENVIRONMENT", "development"), profiles_sample_rate=1.0, ) diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 74377aef0..d324605ef 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -12,7 +12,7 @@ x-app-env : &app-env - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""} - DOCKERIZED=${DOCKERIZED:-1} # deprecated - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-""} - - ENVIRONMENT=${ENVIRONMENT:-"production"} + - SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"} # Gunicorn Workers - GUNICORN_WORKERS=${GUNICORN_WORKERS:-2} #DB SETTINGS diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env index f74c838e7..0a47395c3 100644 --- a/deploy/selfhost/variables.env +++ b/deploy/selfhost/variables.env @@ -14,7 +14,7 @@ SENTRY_DSN="" GITHUB_CLIENT_SECRET="" DOCKERIZED=1 # deprecated CORS_ALLOWED_ORIGINS="" -ENVIRONMENT="production" +SENTRY_ENVIRONMENT="production" #DB SETTINGS PGHOST=plane-db From e98e26edb119a2beeb6afe0e9c0c1a3df1b05241 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Sun, 19 Nov 2023 01:58:36 +0530 Subject: [PATCH 09/10] dev: update create bucket script --- apiserver/bin/takeoff | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apiserver/bin/takeoff b/apiserver/bin/takeoff index 13e557edc..637305457 100755 --- a/apiserver/bin/takeoff +++ b/apiserver/bin/takeoff @@ -15,6 +15,6 @@ if [ "$ENABLE_REGISTRATION" != "0" ]; then fi # Create the default bucket -python bin/bucket_script.py +python manage.py create_bucket exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --max-requests 1200 --max-requests-jitter 1000 --access-logfile - From 96212905d160e2592210acb31324fd3d1a0ee060 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Sun, 19 Nov 2023 02:05:03 +0530 Subject: [PATCH 10/10] dev: update patch endpoint for instance configuration --- apiserver/plane/license/api/views/instance.py | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py index 309b2b9da..e1b80e327 100644 --- a/apiserver/plane/license/api/views/instance.py +++ b/apiserver/plane/license/api/views/instance.py @@ -230,13 +230,18 @@ class InstanceConfigurationEndpoint(BaseAPIView): return Response(serializer.data, status=status.HTTP_200_OK) def patch(self, request): - key = request.data.get("key", False) - if not key: - return Response( - {"error": "Key is required"}, status=status.HTTP_400_BAD_REQUEST - ) - configuration = InstanceConfiguration.objects.get(key=key) - configuration.value = request.data.get("value") - configuration.save() - serializer = InstanceConfigurationSerializer(configuration) + configurations = InstanceConfiguration.objects.filter(key__in=request.data.keys()) + + bulk_configurations = [] + for configuration in configurations: + configuration.value = request.data.get(configuration.key, configuration.value) + bulk_configurations.append(configuration) + + InstanceConfiguration.objects.bulk_update( + bulk_configurations, + ["value"], + batch_size=100 + ) + + serializer = InstanceConfigurationSerializer(configurations, many=True) return Response(serializer.data, status=status.HTTP_200_OK)