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/apiserver/.env.example b/apiserver/.env.example index 83f2d8e76..88a9c17f5 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -5,6 +5,7 @@ CORS_ALLOWED_ORIGINS="" # Error logs SENTRY_DSN="" +SENTRY_ENVIRONMENT="development" # Database Settings PGUSER="plane" diff --git a/apiserver/bin/takeoff b/apiserver/bin/takeoff index 44f251155..637305457 100755 --- a/apiserver/bin/takeoff +++ b/apiserver/bin/takeoff @@ -3,11 +3,18 @@ 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 +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 - 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/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) 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 348d23425..d324605ef 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -12,6 +12,7 @@ x-app-env : &app-env - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""} - DOCKERIZED=${DOCKERIZED:-1} # deprecated - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-""} + - SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"} # Gunicorn Workers - GUNICORN_WORKERS=${GUNICORN_WORKERS:-2} #DB SETTINGS 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 diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env index d1ab35d37..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="" - +SENTRY_ENVIRONMENT="production" #DB SETTINGS PGHOST=plane-db diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index ab6c77724..04100a729 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/core/package.json @@ -30,9 +30,11 @@ "dependencies": { "@blueprintjs/popover2": "^2.0.10", "@tiptap/core": "^2.1.7", + "@tiptap/extension-code-block-lowlight": "^2.1.12", "@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", @@ -42,9 +44,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 +57,11 @@ "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", "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/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/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/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..2c6d51ad9 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -6,12 +6,12 @@ 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 HorizontalRule from "./horizontal-rule"; import ImageExtension from "./image"; @@ -20,6 +20,10 @@ import { isValidHttpUrl } from "../../lib/utils"; import { IMentionSuggestion } from "../../types/mention-suggestion"; import { Mentions } from "../mentions"; +import { CustomKeymap } from "./keymap"; +import { CustomCodeBlock } from "./code"; +import { ListKeymap } from "./custom-list-keymap"; + export const CoreEditorExtensions = ( mentionConfig: { mentionSuggestions: IMentionSuggestion[]; @@ -49,22 +53,16 @@ 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, + ListKeymap, TiptapLink.configure({ protocols: ["http", "https"], validate: (url) => isValidHttpUrl(url), @@ -86,6 +84,7 @@ export const CoreEditorExtensions = ( class: "not-prose pl-2", }, }), + CustomCodeBlock, TaskItem.configure({ HTMLAttributes: { class: "flex items-start my-4", @@ -95,7 +94,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/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: ` ({ 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/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/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..60153daa9 --- /dev/null +++ b/packages/editor/rich-text-editor/src/ui/extensions/drag-drop.tsx @@ -0,0 +1,235 @@ +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; +} + +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 = createDragHandleElement(); + 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 + 3}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..81bbdb597 100644 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/index.tsx @@ -25,6 +25,7 @@ export type IMentionHighlight = string; interface IRichTextEditor { value: string; + dragDropEnabled?: boolean; uploadFile: UploadImage; deleteFile: DeleteImage; noBorder?: boolean; @@ -54,6 +55,7 @@ interface EditorHandle { const RichTextEditor = ({ onChange, + dragDropEnabled, debouncedUpdatesEnabled, setIsSubmitting, setShouldShowAlert, @@ -79,7 +81,11 @@ const RichTextEditor = ({ cancelUploadImage, 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..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 @@ -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") || 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/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/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/instance/general-form.tsx b/web/components/instance/general-form.tsx index 10e654cd7..87a268fd2 100644 --- a/web/components/instance/general-form.tsx +++ b/web/components/instance/general-form.tsx @@ -1,44 +1,125 @@ import { FC } from "react"; -import { useForm } from "react-hook-form"; +import { Controller, useForm } from "react-hook-form"; // ui -import { Input } from "@plane/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 { - data: IInstance; + instance: IInstance; } export interface GeneralFormValues { instance_name: string; - namespace: string | null; is_telemetry_enabled: boolean; } export const InstanceGeneralForm: FC = (props) => { - const { data } = props; - - const {} = useForm({ + const { instance } = props; + // store + const { instance: instanceStore } = useMobxStore(); + // toast + const { setToastAlert } = useToast(); + // form data + const { + handleSubmit, + control, + formState: { errors, isSubmitting }, + } = useForm({ defaultValues: { - instance_name: data.instance_name, - namespace: data.namespace, - is_telemetry_enabled: data.is_telemetry_enabled, + 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/index.ts b/web/components/instance/index.ts index 52f879062..c4840736a 100644 --- a/web/components/instance/index.ts +++ b/web/components/instance/index.ts @@ -1,3 +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 index 0eeaa7676..dbb697efb 100644 --- a/web/components/instance/sidebar-menu.tsx +++ b/web/components/instance/sidebar-menu.tsx @@ -37,7 +37,7 @@ export const InstanceAdminSidebarMenu = () => { const router = useRouter(); return ( -
+
{INSTANCE_ADMIN_LINKS.map((item, index) => { const isActive = item.name === "Settings" ? router.asPath.includes(item.href) : router.asPath === item.href; 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-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 &&
{errors.name ? errors.name.message : null} = observer((props) => { const { workspaceSlug } = router.query; const { - project: { labels, fetchProjectLabels }, + projectLabel: { labels, fetchProjectLabels }, } = useMobxStore(); const [referenceElement, setReferenceElement] = useState(null); diff --git a/web/components/issues/sidebar-select/label.tsx b/web/components/issues/sidebar-select/label.tsx index e997b6f49..52a50562c 100644 --- a/web/components/issues/sidebar-select/label.tsx +++ b/web/components/issues/sidebar-select/label.tsx @@ -15,7 +15,7 @@ import { IssueLabelSelect } from "../select"; // icons import { Plus, X } from "lucide-react"; // types -import { IIssue, IIssueLabels } from "types"; +import { IIssue, IIssueLabel } from "types"; // fetch-keys import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys"; import useToast from "hooks/use-toast"; @@ -28,7 +28,7 @@ type Props = { uneditable: boolean; }; -const defaultValues: Partial = { +const defaultValues: Partial = { name: "", color: "#ff0000", }; @@ -57,20 +57,20 @@ export const SidebarLabelSelect: React.FC = ({ watch, control, setFocus, - } = useForm>({ + } = useForm>({ defaultValues, }); const { user } = useUser(); - const { data: issueLabels, mutate: issueLabelMutate } = useSWR( + const { data: issueLabels, mutate: issueLabelMutate } = useSWR( workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null, workspaceSlug && projectId ? () => issueLabelService.getProjectIssueLabels(workspaceSlug as string, projectId as string) : null ); - const handleNewLabel = async (formData: Partial) => { + const handleNewLabel = async (formData: Partial) => { if (!workspaceSlug || !projectId || isSubmitting) return; await issueLabelService diff --git a/web/components/labels/create-label-modal.tsx b/web/components/labels/create-label-modal.tsx index e148f8231..a8e7e0e2e 100644 --- a/web/components/labels/create-label-modal.tsx +++ b/web/components/labels/create-label-modal.tsx @@ -12,7 +12,7 @@ import { Button, Input } from "@plane/ui"; // icons import { ChevronDown } from "lucide-react"; // types -import type { IIssueLabels, IState } from "types"; +import type { IIssueLabel, IState } from "types"; // constants import { LABEL_COLOR_OPTIONS, getRandomLabelColor } from "constants/label"; import useToast from "hooks/use-toast"; @@ -22,7 +22,7 @@ type Props = { isOpen: boolean; projectId: string; handleClose: () => void; - onSuccess?: (response: IIssueLabels) => void; + onSuccess?: (response: IIssueLabel) => void; }; const defaultValues: Partial = { @@ -47,7 +47,7 @@ export const CreateLabelModal: React.FC = observer((props) => { reset, setValue, setFocus, - } = useForm({ + } = useForm({ defaultValues, }); @@ -69,7 +69,7 @@ export const CreateLabelModal: React.FC = observer((props) => { const { setToastAlert } = useToast(); - const onSubmit = async (formData: IIssueLabels) => { + const onSubmit = async (formData: IIssueLabel) => { if (!workspaceSlug) return; await projectLabelStore diff --git a/web/components/labels/create-update-label-inline.tsx b/web/components/labels/create-update-label-inline.tsx index f27bd517e..91a65f94b 100644 --- a/web/components/labels/create-update-label-inline.tsx +++ b/web/components/labels/create-update-label-inline.tsx @@ -11,7 +11,7 @@ import { Popover, Transition } from "@headlessui/react"; // ui import { Button, Input } from "@plane/ui"; // types -import { IIssueLabels } from "types"; +import { IIssueLabel } from "types"; // fetch-keys import { getRandomLabelColor, LABEL_COLOR_OPTIONS } from "constants/label"; import useToast from "hooks/use-toast"; @@ -20,11 +20,11 @@ type Props = { labelForm: boolean; setLabelForm: React.Dispatch>; isUpdating: boolean; - labelToUpdate: IIssueLabels | null; + labelToUpdate?: IIssueLabel; onClose?: () => void; }; -const defaultValues: Partial = { +const defaultValues: Partial = { name: "", color: "rgb(var(--color-text-200))", }; @@ -50,7 +50,7 @@ export const CreateUpdateLabelInline = observer( watch, setValue, setFocus, - } = useForm({ + } = useForm({ defaultValues, }); @@ -60,7 +60,7 @@ export const CreateUpdateLabelInline = observer( if (onClose) onClose(); }; - const handleLabelCreate: SubmitHandler = async (formData) => { + const handleLabelCreate: SubmitHandler = async (formData) => { if (!workspaceSlug || !projectId || isSubmitting) return; await projectLabelStore @@ -79,7 +79,7 @@ export const CreateUpdateLabelInline = observer( }); }; - const handleLabelUpdate: SubmitHandler = async (formData) => { + const handleLabelUpdate: SubmitHandler = async (formData) => { if (!workspaceSlug || !projectId || isSubmitting) return; await projectLabelStore @@ -128,9 +128,7 @@ export const CreateUpdateLabelInline = observer( e.preventDefault(); handleSubmit(isUpdating ? handleLabelUpdate : handleLabelCreate)(); }} - className={`flex scroll-m-8 items-center gap-2 rounded border border-custom-border-200 bg-custom-background-100 px-3.5 py-2 ${ - labelForm ? "" : "hidden" - }`} + className={`flex scroll-m-8 items-center gap-2 bg-custom-background-100 w-full ${labelForm ? "" : "hidden"}`} >
@@ -198,10 +196,10 @@ export const CreateUpdateLabelInline = observer( )} />
- - diff --git a/web/components/labels/delete-label-modal.tsx b/web/components/labels/delete-label-modal.tsx index f8faeef33..33d4fdb09 100644 --- a/web/components/labels/delete-label-modal.tsx +++ b/web/components/labels/delete-label-modal.tsx @@ -12,12 +12,12 @@ import useToast from "hooks/use-toast"; // ui import { Button } from "@plane/ui"; // types -import type { IIssueLabels } from "types"; +import type { IIssueLabel } from "types"; type Props = { isOpen: boolean; onClose: () => void; - data: IIssueLabels | null; + data: IIssueLabel | null; }; export const DeleteLabelModal: React.FC = observer((props) => { diff --git a/web/components/labels/index.ts b/web/components/labels/index.ts index c7316b037..c9ef583bf 100644 --- a/web/components/labels/index.ts +++ b/web/components/labels/index.ts @@ -4,5 +4,5 @@ export * from "./delete-label-modal"; export * from "./label-select"; export * from "./labels-list-modal"; export * from "./project-setting-label-group"; -export * from "./project-setting-label-list-item"; +export * from "./project-setting-label-item"; export * from "./project-setting-label-list"; diff --git a/web/components/labels/label-block/drag-handle.tsx b/web/components/labels/label-block/drag-handle.tsx new file mode 100644 index 000000000..28c3b0115 --- /dev/null +++ b/web/components/labels/label-block/drag-handle.tsx @@ -0,0 +1,24 @@ +import { DraggableProvidedDragHandleProps } from "@hello-pangea/dnd"; +import { MoreVertical } from "lucide-react"; + +interface IDragHandle { + isDragging: boolean; + dragHandleProps: DraggableProvidedDragHandleProps; +} + +export const DragHandle = (props: IDragHandle) => { + const { isDragging, dragHandleProps } = props; + + return ( + + ); +}; diff --git a/web/components/labels/label-block/label-item-block.tsx b/web/components/labels/label-block/label-item-block.tsx new file mode 100644 index 000000000..a421aa0e9 --- /dev/null +++ b/web/components/labels/label-block/label-item-block.tsx @@ -0,0 +1,80 @@ +import { useRef, useState } from "react"; +import { LucideIcon, X } from "lucide-react"; +import { DraggableProvidedDragHandleProps } from "@hello-pangea/dnd"; +//ui +import { CustomMenu } from "@plane/ui"; +//types +import { IIssueLabel } from "types"; +//hooks +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +//components +import { DragHandle } from "./drag-handle"; +import { LabelName } from "./label-name"; + +//types +export interface ICustomMenuItem { + CustomIcon: LucideIcon; + onClick: (label: IIssueLabel) => void; + isVisible: boolean; + text: string; +} + +interface ILabelItemBlock { + label: IIssueLabel; + isDragging: boolean; + customMenuItems: ICustomMenuItem[]; + dragHandleProps: DraggableProvidedDragHandleProps; + handleLabelDelete: (label: IIssueLabel) => void; + isLabelGroup?: boolean; +} + +export const LabelItemBlock = (props: ILabelItemBlock) => { + const { label, isDragging, customMenuItems, dragHandleProps, handleLabelDelete, isLabelGroup } = props; + + //state + const [isMenuActive, setIsMenuActive] = useState(false); + + //refs + const actionSectionRef = useRef(null); + + useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false)); + + return ( +
+
+ + +
+ +
+ + {customMenuItems.map( + ({ isVisible, onClick, CustomIcon, text }) => + isVisible && ( + onClick(label)}> + + + {text} + + + ) + )} + + {!isLabelGroup && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/web/components/labels/label-block/label-name.tsx b/web/components/labels/label-block/label-name.tsx new file mode 100644 index 000000000..9c84959cc --- /dev/null +++ b/web/components/labels/label-block/label-name.tsx @@ -0,0 +1,27 @@ +import { Component } from "lucide-react"; + +interface ILabelName { + name: string; + color: string; + isGroup: boolean; +} + +export const LabelName = (props: ILabelName) => { + const { name, color, isGroup } = props; + + return ( +
+ {isGroup ? ( + + ) : ( + + )} +
{name}
+
+ ); +}; diff --git a/web/components/labels/label-select.tsx b/web/components/labels/label-select.tsx index 162c78b96..5c9feb2ed 100644 --- a/web/components/labels/label-select.tsx +++ b/web/components/labels/label-select.tsx @@ -6,12 +6,12 @@ import { Check, ChevronDown, Search } from "lucide-react"; // ui import { Tooltip } from "@plane/ui"; // types -import { IIssueLabels } from "types"; +import { IIssueLabel } from "types"; type Props = { value: string[]; onChange: (data: string[]) => void; - labels: IIssueLabels[] | undefined; + labels: IIssueLabel[] | undefined; className?: string; buttonClassName?: string; optionsClassName?: string; diff --git a/web/components/labels/labels-list-modal.tsx b/web/components/labels/labels-list-modal.tsx index e3176d8a0..650712314 100644 --- a/web/components/labels/labels-list-modal.tsx +++ b/web/components/labels/labels-list-modal.tsx @@ -11,12 +11,12 @@ import { useMobxStore } from "lib/mobx/store-provider"; import { LayerStackIcon } from "@plane/ui"; import { Search } from "lucide-react"; // types -import { IIssueLabels } from "types"; +import { IIssueLabel } from "types"; type Props = { isOpen: boolean; handleClose: () => void; - parent: IIssueLabels | undefined; + parent: IIssueLabel | undefined; }; export const LabelsListModal: React.FC = observer((props) => { @@ -27,7 +27,9 @@ export const LabelsListModal: React.FC = observer((props) => { const { workspaceSlug, projectId } = router.query; // store - const { projectLabel: projectLabelStore, project: projectStore } = useMobxStore(); + const { + projectLabel: { projectLabels, fetchProjectLabels, updateLabel }, + } = useMobxStore(); // states const [query, setQuery] = useState(""); @@ -35,28 +37,24 @@ export const LabelsListModal: React.FC = observer((props) => { // 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 filteredLabels: IIssueLabels[] = + const filteredLabels: IIssueLabel[] = query === "" - ? issueLabels ?? [] - : issueLabels?.filter((l) => l.name.toLowerCase().includes(query.toLowerCase())) ?? []; + ? projectLabels ?? [] + : projectLabels?.filter((l) => l.name.toLowerCase().includes(query.toLowerCase())) ?? []; const handleModalClose = () => { handleClose(); setQuery(""); }; - const addChildLabel = async (label: IIssueLabels) => { + const addChildLabel = async (label: IIssueLabel) => { if (!workspaceSlug || !projectId) return; - await projectLabelStore.updateLabel(workspaceSlug.toString(), projectId.toString(), label.id, { + await updateLabel(workspaceSlug.toString(), projectId.toString(), label.id, { parent: parent?.id!, }); }; @@ -108,7 +106,7 @@ export const LabelsListModal: React.FC = observer((props) => { )}
    {filteredLabels.map((label) => { - const children = issueLabels?.filter((l) => l.parent === label.id); + const children = projectLabels?.filter((l) => l.parent === label.id); if ( (label.parent === "" || label.parent === null) && // issue does not have any other parent @@ -128,7 +126,6 @@ export const LabelsListModal: React.FC = observer((props) => { } onClick={() => { addChildLabel(label); - handleClose(); }} > void; - editLabel: (label: IIssueLabels) => void; - addLabelToGroup: (parentLabel: IIssueLabels) => void; + label: IIssueLabel; + labelChildren: IIssueLabel[]; + handleLabelDelete: (label: IIssueLabel) => void; + dragHandleProps: DraggableProvidedDragHandleProps; + draggableSnapshot: DraggableStateSnapshot; + isUpdating: boolean; + setIsUpdating: Dispatch>; + isDropDisabled: boolean; }; export const ProjectSettingLabelGroup: React.FC = observer((props) => { - const { label, labelChildren, addLabelToGroup, editLabel, handleLabelDelete } = props; + const { + label, + labelChildren, + handleLabelDelete, + draggableSnapshot: groupDragSnapshot, + dragHandleProps, + isUpdating, + setIsUpdating, + isDropDisabled, + } = props; - // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const [isEditLabelForm, setEditLabelForm] = useState(false); - // store - const { projectLabel: projectLabelStore } = useMobxStore(); + const renderDraggable = useDraggableInPortal(); - const removeFromGroup = (label: IIssueLabels) => { - if (!workspaceSlug || !projectId) return; - - projectLabelStore.updateLabel(workspaceSlug.toString(), projectId.toString(), label.id, { - parent: null, - }); - }; + const customMenuItems: ICustomMenuItem[] = [ + { + CustomIcon: Pencil, + onClick: () => { + setEditLabelForm(true); + setIsUpdating(true); + }, + isVisible: true, + text: "Edit label", + }, + { + CustomIcon: Trash2, + onClick: handleLabelDelete, + isVisible: true, + text: "Delete label", + }, + ]; return ( {({ open }) => ( <> -
    -
    - -
    {label.name}
    -
    -
    - - addLabelToGroup(label)}> - - - Add more labels - - - editLabel(label)}> - - - Edit label - - - - - - Delete label - - - - - - - - -
    -
    - - -
    - {labelChildren.map((child) => ( -
    -
    - ( +
    + <> +
    + {isEditLabelForm ? ( + { + setEditLabelForm(false); + setIsUpdating(false); }} /> - {child.name} -
    -
    -
    - - -
    - } - > - removeFromGroup(child)}> - - - Remove from group - - - editLabel(child)}> - - - Edit label - - - -
    + ) : ( + + )} -
    - -
    -
    + + + + +
    - ))} + + +
    + {labelChildren.map((child, index) => ( +
    + + {renderDraggable((provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( +
    + handleLabelDelete(child)} + draggableSnapshot={snapshot} + dragHandleProps={provided.dragHandleProps!} + setIsUpdating={setIsUpdating} + isChild + /> +
    + ))} +
    +
    + ))} +
    +
    +
    + {droppableProvided.placeholder} +
- - + )} + )} 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/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/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/admin-layout/header.tsx b/web/layouts/admin-layout/header.tsx index f2a3e2266..a111222f3 100644 --- a/web/layouts/admin-layout/header.tsx +++ b/web/layouts/admin-layout/header.tsx @@ -1,21 +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 { Settings } from "lucide-react"; +import { ArrowLeftToLine, Settings } from "lucide-react"; -export const InstanceAdminHeader: FC = () => ( -
-
-
- - } - label="General" - /> - +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/sidebar.tsx b/web/layouts/admin-layout/sidebar.tsx index c067354fc..d3a9ecfa1 100644 --- a/web/layouts/admin-layout/sidebar.tsx +++ b/web/layouts/admin-layout/sidebar.tsx @@ -1,7 +1,7 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; // components -import { InstanceAdminSidebarMenu, InstanceHelpSection } from "components/instance"; +import { InstanceAdminSidebarMenu, InstanceHelpSection, InstanceSidebarDropdown } from "components/instance"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; @@ -19,6 +19,7 @@ export const InstanceAdminSidebar: FC = observer(() => { } ${themStore?.sidebarCollapsed ? "left-0" : "-left-full md:left-0"}`} >
+
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/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/[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/pages/admin/index.tsx b/web/pages/admin/index.tsx index 96d1091ef..70ffd0cc1 100644 --- a/web/pages/admin/index.tsx +++ b/web/pages/admin/index.tsx @@ -18,7 +18,7 @@ const InstanceAdminPage: NextPageWithLayout = observer(() => { useSWR("INSTANCE_INFO", () => fetchInstanceInfo()); - return
{instance && }
; + return
{instance && }
; }); InstanceAdminPage.getLayout = function getLayout(page: ReactElement) { diff --git a/web/services/file.service.ts b/web/services/file.service.ts index 84907161e..d92c55e18 100644 --- a/web/services/file.service.ts +++ b/web/services/file.service.ts @@ -52,6 +52,7 @@ export class FileService extends APIService { if (axios.isCancel(error)) { console.log(error.message); } else { + console.log(error); throw error?.response?.data; } }); diff --git a/web/services/instance.service.ts b/web/services/instance.service.ts index 281069bf4..74c32aa5f 100644 --- a/web/services/instance.service.ts +++ b/web/services/instance.service.ts @@ -17,6 +17,16 @@ export class InstanceService extends APIService { }); } + 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) 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/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/store/instance/instance.store.ts b/web/store/instance/instance.store.ts index 78740c11e..bd37110a1 100644 --- a/web/store/instance/instance.store.ts +++ b/web/store/instance/instance.store.ts @@ -15,6 +15,7 @@ export interface IInstanceStore { // computed // action fetchInstanceInfo: () => Promise; + updateInstanceInfo: (data: Partial) => Promise; fetchInstanceConfigurations: () => Promise; } @@ -38,6 +39,7 @@ export class InstanceStore implements IInstanceStore { // getIssueType: computed, // actions fetchInstanceInfo: action, + updateInstanceInfo: action, fetchInstanceConfigurations: action, }); @@ -45,6 +47,9 @@ export class InstanceStore implements IInstanceStore { this.instanceService = new InstanceService(); } + /** + * fetch instace info from API + */ fetchInstanceInfo = async () => { try { const instance = await this.instanceService.getInstanceInfo(); @@ -58,6 +63,39 @@ export class InstanceStore implements IInstanceStore { } }; + /** + * 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(); 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/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/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/styles/editor.css b/web/styles/editor.css index 85d881eeb..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; @@ -229,3 +236,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/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; 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; 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"