Compare commits

..
207 changed files with 5516 additions and 5453 deletions
-205
View File
@@ -1,205 +0,0 @@
name: Docker Branch Build
on:
workflow_dispatch:
inputs:
logLevel:
description: 'Log level'
required: true
default: 'warning'
tags:
description: 'Dev/QA Builds'
env:
gh_branch: ${{ github.ref_name }}
img_tag: latest
jobs:
branch_build_and_push:
name: Build-Push Web/Space/API/Proxy Docker Image
runs-on: ubuntu-20.04
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
- uses: ASzc/change-string-case-action@v2
id: gh_branch_upper_lower
with:
string: ${{ env.gh_branch }}
- uses: mad9000/actions-find-and-replace-string@2
id: gh_branch_replace_slash
with:
source: ${{ steps.gh_branch_upper_lower.outputs.lowercase }}
find: '/'
replace: '-'
- uses: mad9000/actions-find-and-replace-string@2
id: gh_branch_replace_dot
with:
source: ${{ steps.gh_branch_replace_slash.outputs.value }}
find: '.'
replace: ''
- uses: mad9000/actions-find-and-replace-string@2
id: gh_branch_clean
with:
source: ${{ steps.gh_branch_replace_dot.outputs.value }}
find: '_'
replace: ''
- name: Uploading Proxy Source
uses: actions/upload-artifact@v3
with:
name: proxy-src-code
path: ./nginx
- name: Uploading Backend Source
uses: actions/upload-artifact@v3
with:
name: backend-src-code
path: ./apiserver
- name: Uploading Web Source
uses: actions/upload-artifact@v3
with:
name: web-src-code
path: |
./
!./apiserver
!./nginx
!./deploy
!./space
- name: Uploading Space Source
uses: actions/upload-artifact@v3
with:
name: space-src-code
path: |
./
!./apiserver
!./nginx
!./deploy
!./web
outputs:
gh_branch_name: ${{ steps.gh_branch_clean.outputs.value }}
branch_build_push_frontend:
runs-on: ubuntu-20.04
needs: [ branch_build_and_push ]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Downloading Web Source Code
uses: actions/download-artifact@v3
with:
name: web-src-code
- name: Build and Push Frontend to Docker Container Registry
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./web/Dockerfile.web
platforms: linux/amd64
tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_space:
runs-on: ubuntu-20.04
needs: [ branch_build_and_push ]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Downloading Space Source Code
uses: actions/download-artifact@v3
with:
name: space-src-code
- name: Build and Push Space to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./space/Dockerfile.space
platforms: linux/amd64
tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_backend:
runs-on: ubuntu-20.04
needs: [ branch_build_and_push ]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Downloading Backend Source Code
uses: actions/download-artifact@v3
with:
name: backend-src-code
- name: Build and Push Backend to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile.api
platforms: linux/amd64
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_proxy:
runs-on: ubuntu-20.04
needs: [ branch_build_and_push ]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Downloading Proxy Source Code
uses: actions/download-artifact@v3
with:
name: proxy-src-code
- name: Build and Push Plane-Proxy to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
+1 -1
View File
@@ -75,7 +75,7 @@ pnpm-lock.yaml
pnpm-workspace.yaml
.npmrc
.secrets
tmp/
## packages
dist
+1 -1
View File
@@ -7,7 +7,7 @@
</p>
<h3 align="center"><b>Plane</b></h3>
<p align="center"><b>Flexible, extensible open-source project management</b></p>
<p align="center"><b>Open-source, self-hosted project planning tool</b></p>
<p align="center">
<a href="https://discord.com/invite/A92xrEGCge">
+1 -1
View File
@@ -1,7 +1,7 @@
from .analytic import urlpatterns as analytic_urls
from .asset import urlpatterns as asset_urls
from .authentication import urlpatterns as authentication_urls
from .config import urlpatterns as configuration_urls
from .configuration import urlpatterns as configuration_urls
from .cycle import urlpatterns as cycle_urls
from .estimate import urlpatterns as estimate_urls
from .gpt import urlpatterns as gpt_urls
-1
View File
@@ -30,5 +30,4 @@ class ConfigurationEndpoint(BaseAPIView):
data["email_password_login"] = (
os.environ.get("ENABLE_EMAIL_PASSWORD", "0") == "1"
)
data["slack"] = os.environ.get("SLACK_CLIENT_ID", None)
return Response(data, status=status.HTTP_200_OK)
+5 -14
View File
@@ -1,13 +1,13 @@
# Python improts
import uuid
import requests
# Django imports
from django.contrib.auth.hashers import make_password
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from sentry_sdk import capture_exception, capture_message
from sentry_sdk import capture_exception
# Module imports
from plane.api.views import BaseViewSet
@@ -25,7 +25,7 @@ from plane.utils.integrations.github import (
delete_github_installation,
)
from plane.api.permissions import WorkSpaceAdminPermission
from plane.utils.integrations.slack import slack_oauth
class IntegrationViewSet(BaseViewSet):
serializer_class = IntegrationSerializer
@@ -98,20 +98,12 @@ class WorkspaceIntegrationViewSet(BaseViewSet):
config = {"installation_id": installation_id}
if provider == "slack":
code = request.data.get("code", False)
if not code:
return Response({"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST)
slack_response = slack_oauth(code=code)
metadata = slack_response
metadata = request.data.get("metadata", {})
access_token = metadata.get("access_token", False)
team_id = metadata.get("team", {}).get("id", False)
if not metadata or not access_token or not team_id:
capture_message(slack_response)
return Response(
{"error": "Slack could not be installed. Please try again later"},
{"error": "Access token and team id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
config = {"team_id": team_id, "access_token": access_token}
@@ -134,7 +126,6 @@ class WorkspaceIntegrationViewSet(BaseViewSet):
user=bot_user,
user_type=1, # bot user
workspace=workspace,
description="",
)
workspace_integration = WorkspaceIntegration.objects.create(
+11 -33
View File
@@ -11,7 +11,6 @@ from plane.api.views import BaseViewSet, BaseAPIView
from plane.db.models import SlackProjectSync, WorkspaceIntegration, ProjectMember
from plane.api.serializers import SlackProjectSyncSerializer
from plane.api.permissions import ProjectBasePermission, ProjectEntityPermission
from plane.utils.integrations.slack import slack_oauth
class SlackProjectSyncViewSet(BaseViewSet):
@@ -33,46 +32,25 @@ class SlackProjectSyncViewSet(BaseViewSet):
)
def create(self, request, slug, project_id, workspace_integration_id):
try:
code = request.data.get("code", False)
serializer = SlackProjectSyncSerializer(data=request.data)
if not code:
return Response(
{"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST
)
workspace_integration = WorkspaceIntegration.objects.get(
workspace__slug=slug, pk=workspace_integration_id
)
slack_response = slack_oauth(code=code)
workspace_integration = WorkspaceIntegration.objects.get(
workspace__slug=slug, pk=workspace_integration_id
if serializer.is_valid():
serializer.save(
project_id=project_id,
workspace_integration_id=workspace_integration_id,
)
workspace_integration = WorkspaceIntegration.objects.get(
pk=workspace_integration_id, workspace__slug=slug
)
slack_project_sync = SlackProjectSync.objects.create(
access_token=slack_response.get("access_token"),
scopes=slack_response.get("scope"),
bot_user_id=slack_response.get("bot_user_id"),
webhook_url=slack_response.get("incoming_webhook", {}).get("url"),
data=slack_response,
team_id=slack_response.get("team", {}).get("id"),
team_name=slack_response.get("team", {}).get("name"),
workspace_integration=workspace_integration,
)
_ = ProjectMember.objects.get_or_create(
member=workspace_integration.actor, role=20, project_id=project_id
)
serializer = SlackProjectSyncSerializer(slack_project_sync)
return Response(serializer.data, status=status.HTTP_200_OK)
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"error": "Slack is already installed for the project"},
status=status.HTTP_410_GONE,
)
capture_exception(e)
return Response(
{"error": "Slack could not be installed. Please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -408,6 +408,7 @@ def analytic_export_task(email, data, slug):
distribution,
x_axis,
y_axis,
segment,
key,
assignee_details,
label_details,
+4 -4
View File
@@ -12,19 +12,19 @@ from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear, Conc
from plane.db.models import Issue
def annotate_with_monthly_dimension(queryset, field_name, attribute):
def annotate_with_monthly_dimension(queryset, field_name):
# Get the year and the months
year = ExtractYear(field_name)
month = ExtractMonth(field_name)
# Concat the year and month
dimension = Concat(year, Value("-"), month, output_field=CharField())
# Annotate the dimension
return queryset.annotate(**{attribute: dimension})
return queryset.annotate(dimension=dimension)
def extract_axis(queryset, x_axis):
# Format the dimension when the axis is in date
if x_axis in ["created_at", "start_date", "target_date", "completed_at"]:
queryset = annotate_with_monthly_dimension(queryset, x_axis, "dimension")
queryset = annotate_with_monthly_dimension(queryset, x_axis)
return queryset, "dimension"
else:
return queryset.annotate(dimension=F(x_axis)), "dimension"
@@ -47,7 +47,7 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
#
if segment in ["created_at", "start_date", "target_date", "completed_at"]:
queryset = annotate_with_monthly_dimension(queryset, segment, "segmented")
queryset = annotate_with_monthly_dimension(queryset, segment)
segment = "segmented"
queryset = queryset.values(x_axis)
@@ -1,20 +0,0 @@
import os
import requests
def slack_oauth(code):
SLACK_OAUTH_URL = os.environ.get("SLACK_OAUTH_URL", False)
SLACK_CLIENT_ID = os.environ.get("SLACK_CLIENT_ID", False)
SLACK_CLIENT_SECRET = os.environ.get("SLACK_CLIENT_SECRET", False)
# Oauth Slack
if SLACK_OAUTH_URL and SLACK_CLIENT_ID and SLACK_CLIENT_SECRET:
response = requests.get(
SLACK_OAUTH_URL,
params={
"code": code,
"client_id": SLACK_CLIENT_ID,
"client_secret": SLACK_CLIENT_SECRET,
},
)
return response.json()
return {}
-3
View File
@@ -2,7 +2,6 @@
"name": "@plane/editor-core",
"version": "0.0.1",
"description": "Core Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
@@ -42,8 +41,6 @@
"@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/react": "^2.1.7",
"@tiptap/starter-kit": "^2.1.10",
-3
View File
@@ -2,11 +2,8 @@
// import "./styles/tailwind.css";
// import "./styles/editor.css";
export { isCellSelection } from "./ui/extensions/table/table/utilities/is-cell-selection";
// utils
export * from "./lib/utils";
export * from "./ui/extensions/table/table";
export { startImageUpload } from "./ui/plugins/upload-image";
// components
@@ -1,6 +1,7 @@
import { Editor, EditorContent } from "@tiptap/react";
import { ReactNode } from "react";
import { ImageResizer } from "../extensions/image/image-resize";
import { TableMenu } from "../menus/table-menu";
interface EditorContentProps {
editor: Editor | null;
@@ -9,8 +10,10 @@ interface EditorContentProps {
}
export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = '', children }: EditorContentProps) => (
<div className={`contentEditor ${editorContentCustomClassNames}`}>
<div className={`${editorContentCustomClassNames}`}>
{/* @ts-ignore */}
<EditorContent editor={editor} />
{editor?.isEditable && <TableMenu editor={editor} />}
{(editor?.isActive("image") && editor?.isEditable) && <ImageResizer editor={editor} />}
{children}
</div>
@@ -8,10 +8,10 @@ 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 { CustomTableCell } from "./table/table-cell";
import { Table } from "./table";
import { TableHeader } from "./table/table-header";
import { TableRow } from "@tiptap/extension-table-row";
import ImageExtension from "./image";
@@ -95,7 +95,7 @@ export const CoreEditorExtensions = (
}),
Table,
TableHeader,
TableCell,
CustomTableCell,
TableRow,
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false),
];
@@ -0,0 +1,9 @@
import { Table as BaseTable } from "@tiptap/extension-table";
const Table = BaseTable.configure({
resizable: true,
cellMinWidth: 100,
allowTableNodeSelection: true,
});
export { Table };
@@ -0,0 +1,32 @@
import { TableCell } from "@tiptap/extension-table-cell";
export const CustomTableCell = TableCell.extend({
addAttributes() {
return {
...this.parent?.(),
isHeader: {
default: false,
parseHTML: (element) => {
isHeader: element.tagName === "TD";
},
renderHTML: (attributes) => {
tag: attributes.isHeader ? "th" : "td";
},
},
};
},
renderHTML({ HTMLAttributes }) {
if (HTMLAttributes.isHeader) {
return [
"th",
{
...HTMLAttributes,
class: `relative ${HTMLAttributes.class}`,
},
["span", { class: "absolute top-0 right-0" }],
0,
];
}
return ["td", HTMLAttributes, 0];
},
});
@@ -1 +0,0 @@
export { default as default } from "./table-cell"
@@ -1,58 +0,0 @@
import { mergeAttributes, Node } from "@tiptap/core"
export interface TableCellOptions {
HTMLAttributes: Record<string, any>
}
export default Node.create<TableCellOptions>({
name: "tableCell",
addOptions() {
return {
HTMLAttributes: {}
}
},
content: "paragraph+",
addAttributes() {
return {
colspan: {
default: 1
},
rowspan: {
default: 1
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute("colwidth")
const value = colwidth ? [parseInt(colwidth, 10)] : null
return value
}
},
background: {
default: "none"
}
}
},
tableRole: "cell",
isolating: true,
parseHTML() {
return [{ tag: "td" }]
},
renderHTML({ node, HTMLAttributes }) {
return [
"td",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
style: `background-color: ${node.attrs.background}`
}),
0
]
}
})
@@ -0,0 +1,7 @@
import { TableHeader as BaseTableHeader } from "@tiptap/extension-table-header";
const TableHeader = BaseTableHeader.extend({
content: "paragraph",
});
export { TableHeader };
@@ -1 +0,0 @@
export { default as default } from "./table-header"
@@ -1,57 +0,0 @@
import { mergeAttributes, Node } from "@tiptap/core"
export interface TableHeaderOptions {
HTMLAttributes: Record<string, any>
}
export default Node.create<TableHeaderOptions>({
name: "tableHeader",
addOptions() {
return {
HTMLAttributes: {}
}
},
content: "paragraph+",
addAttributes() {
return {
colspan: {
default: 1
},
rowspan: {
default: 1
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute("colwidth")
const value = colwidth ? [parseInt(colwidth, 10)] : null
return value
}
},
background: {
default: "rgb(var(--color-primary-100))"
}
}
},
tableRole: "header_cell",
isolating: true,
parseHTML() {
return [{ tag: "th" }]
},
renderHTML({ node, HTMLAttributes }) {
return [
"th",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
style: `background-color: ${node.attrs.background}`
}),
0
]
}
})
@@ -1 +0,0 @@
export { default as default } from "./table-row"
@@ -1,31 +0,0 @@
import { mergeAttributes, Node } from "@tiptap/core"
export interface TableRowOptions {
HTMLAttributes: Record<string, any>
}
export default Node.create<TableRowOptions>({
name: "tableRow",
addOptions() {
return {
HTMLAttributes: {}
}
},
content: "(tableCell | tableHeader)*",
tableRole: "row",
parseHTML() {
return [{ tag: "tr" }]
},
renderHTML({ HTMLAttributes }) {
return [
"tr",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0
]
}
})
@@ -1,55 +0,0 @@
const icons = {
colorPicker: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path fill="rgb(var(--color-text-300))" d="M20 14c-.092.064-2 2.083-2 3.5 0 1.494.949 2.448 2 2.5.906.044 2-.891 2-2.5 0-1.5-1.908-3.436-2-3.5zM9.586 20c.378.378.88.586 1.414.586s1.036-.208 1.414-.586l7-7-.707-.707L11 4.586 8.707 2.293 7.293 3.707 9.586 6 4 11.586c-.378.378-.586.88-.586 1.414s.208 1.036.586 1.414L9.586 20zM11 7.414 16.586 13H5.414L11 7.414z"></path></svg>`,
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M12 3c.552 0 1 .448 1 1v8c.835-.628 1.874-1 3-1 2.761 0 5 2.239 5 5s-2.239 5-5 5c-1.032 0-1.99-.313-2.787-.848L13 20c0 .552-.448 1-1 1H6c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zm-1 2H7v14h4V5zm8 10h-6v2h6v-2z"/></svg>`,
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M20 5c.552 0 1 .448 1 1v6c0 .552-.448 1-1 1 .628.835 1 1.874 1 3 0 2.761-2.239 5-5 5s-5-2.239-5-5c0-1.126.372-2.165 1-3H4c-.552 0-1-.448-1-1V6c0-.552.448-1 1-1h16zm-7 10v2h6v-2h-6zm6-8H5v4h14V7z"/></svg>`,
insertLeftTableIcon: `<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
>
<path
d="M224.617-140.001q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21H360q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H224.617Zm375.383 0q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21h135.383q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H600Zm147.691-607.69q0-4.616-3.846-8.463-3.846-3.846-8.462-3.846H600q-4.616 0-8.462 3.846-3.847 3.847-3.847 8.463v535.382q0 4.616 3.847 8.463Q595.384-200 600-200h135.383q4.616 0 8.462-3.846 3.846-3.847 3.846-8.463v-535.382ZM587.691-200h160-160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
`,
insertRightTableIcon: `<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
>
<path
d="M600-140.001q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21h135.383q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H600Zm-375.383 0q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21H360q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H224.617Zm-12.308-607.69v535.382q0 4.616 3.846 8.463 3.846 3.846 8.462 3.846H360q4.616 0 8.462-3.846 3.847-3.847 3.847-8.463v-535.382q0-4.616-3.847-8.463Q364.616-760 360-760H224.617q-4.616 0-8.462 3.846-3.846 3.847-3.846 8.463Zm160 547.691h-160 160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
`,
insertTopTableIcon: `<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
>
<path
d="M212.309-527.693q-30.308 0-51.308-21t-21-51.307v-135.383q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307V-600q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0 375.383q-30.308 0-51.308-21t-21-51.307V-360q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307v135.383q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0-59.999h535.382q4.616 0 8.463-3.846 3.846-3.846 3.846-8.462V-360q0-4.616-3.846-8.462-3.847-3.847-8.463-3.847H212.309q-4.616 0-8.463 3.847Q200-364.616 200-360v135.383q0 4.616 3.846 8.462 3.847 3.846 8.463 3.846Zm-12.309-160v160-160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
`,
insertBottomTableIcon:`<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
>
<path
d="M212.309-152.31q-30.308 0-51.308-21t-21-51.307V-360q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307v135.383q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0-375.383q-30.308 0-51.308-21t-21-51.307v-135.383q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307V-600q0 30.307-21 51.307-21 21-51.308 21H212.309Zm535.382-219.998H212.309q-4.616 0-8.463 3.846-3.846 3.846-3.846 8.462V-600q0 4.616 3.846 8.462 3.847 3.847 8.463 3.847h535.382q4.616 0 8.463-3.847Q760-595.384 760-600v-135.383q0-4.616-3.846-8.462-3.847-3.846-8.463-3.846ZM200-587.691v-160 160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
`,
};
export default icons;
@@ -1 +0,0 @@
export { default as default } from "./table"
@@ -1,117 +0,0 @@
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { findParentNode } from "@tiptap/core";
import { DecorationSet, Decoration } from "@tiptap/pm/view";
const key = new PluginKey("tableControls");
export function tableControls() {
return new Plugin({
key,
state: {
init() {
return new TableControlsState();
},
apply(tr, prev) {
return prev.apply(tr);
},
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
const pluginState = key.getState(view.state);
if (
!(event.target as HTMLElement).closest(".tableWrapper") &&
pluginState.values.hoveredTable
) {
return view.dispatch(
view.state.tr.setMeta(key, {
setHoveredTable: null,
setHoveredCell: null,
}),
);
}
const pos = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!pos) return;
const table = findParentNode((node) => node.type.name === "table")(
TextSelection.create(view.state.doc, pos.pos),
);
const cell = findParentNode(
(node) =>
node.type.name === "tableCell" ||
node.type.name === "tableHeader",
)(TextSelection.create(view.state.doc, pos.pos));
if (!table || !cell) return;
if (pluginState.values.hoveredCell?.pos !== cell.pos) {
return view.dispatch(
view.state.tr.setMeta(key, {
setHoveredTable: table,
setHoveredCell: cell,
}),
);
}
},
},
decorations: (state) => {
const pluginState = key.getState(state);
if (!pluginState) {
return null;
}
const { hoveredTable, hoveredCell } = pluginState.values;
const docSize = state.doc.content.size;
if (hoveredTable && hoveredCell && hoveredTable.pos < docSize && hoveredCell.pos < docSize) {
const decorations = [
Decoration.node(
hoveredTable.pos,
hoveredTable.pos + hoveredTable.node.nodeSize,
{},
{
hoveredTable,
hoveredCell,
},
),
];
return DecorationSet.create(state.doc, decorations);
}
return null;
},
},
});
}
class TableControlsState {
values;
constructor(props = {}) {
this.values = {
hoveredTable: null,
hoveredCell: null,
...props,
};
}
apply(tr: any) {
const actions = tr.getMeta(key);
if (actions?.setHoveredTable !== undefined) {
this.values.hoveredTable = actions.setHoveredTable;
}
if (actions?.setHoveredCell !== undefined) {
this.values.hoveredCell = actions.setHoveredCell;
}
return this;
}
}
@@ -1,530 +0,0 @@
import { h } from "jsx-dom-cjs";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { Decoration, NodeView } from "@tiptap/pm/view";
import tippy, { Instance, Props } from "tippy.js";
import { Editor } from "@tiptap/core";
import {
CellSelection,
TableMap,
updateColumnsOnResize,
} from "@tiptap/prosemirror-tables";
import icons from "./icons";
export function updateColumns(
node: ProseMirrorNode,
colgroup: HTMLElement,
table: HTMLElement,
cellMinWidth: number,
overrideCol?: number,
overrideValue?: any,
) {
let totalWidth = 0;
let fixedWidth = true;
let nextDOM = colgroup.firstChild as HTMLElement;
const row = node.firstChild;
if (!row) return;
for (let i = 0, col = 0; i < row.childCount; i += 1) {
const { colspan, colwidth } = row.child(i).attrs;
for (let j = 0; j < colspan; j += 1, col += 1) {
const hasWidth =
overrideCol === col ? overrideValue : colwidth && colwidth[j];
const cssWidth = hasWidth ? `${hasWidth}px` : "";
totalWidth += hasWidth || cellMinWidth;
if (!hasWidth) {
fixedWidth = false;
}
if (!nextDOM) {
colgroup.appendChild(document.createElement("col")).style.width =
cssWidth;
} else {
if (nextDOM.style.width !== cssWidth) {
nextDOM.style.width = cssWidth;
}
nextDOM = nextDOM.nextSibling as HTMLElement;
}
}
}
while (nextDOM) {
const after = nextDOM.nextSibling;
nextDOM.parentNode?.removeChild(nextDOM);
nextDOM = after as HTMLElement;
}
if (fixedWidth) {
table.style.width = `${totalWidth}px`;
table.style.minWidth = "";
} else {
table.style.width = "";
table.style.minWidth = `${totalWidth}px`;
}
}
const defaultTippyOptions: Partial<Props> = {
allowHTML: true,
arrow: false,
trigger: "click",
animation: "scale-subtle",
theme: "light-border no-padding",
interactive: true,
hideOnClick: true,
placement: "right",
};
function setCellsBackgroundColor(editor: Editor, backgroundColor) {
return editor
.chain()
.focus()
.updateAttributes("tableCell", {
background: backgroundColor,
})
.updateAttributes("tableHeader", {
background: backgroundColor,
})
.run();
}
const columnsToolboxItems = [
{
label: "Add Column Before",
icon: icons.insertLeftTableIcon,
action: ({ editor }: { editor: Editor }) =>
editor.chain().focus().addColumnBefore().run(),
},
{
label: "Add Column After",
icon: icons.insertRightTableIcon,
action: ({ editor }: { editor: Editor }) =>
editor.chain().focus().addColumnAfter().run(),
},
{
label: "Pick Column Color",
icon: icons.colorPicker,
action: ({
editor,
triggerButton,
controlsContainer,
}: {
editor: Editor;
triggerButton: HTMLElement;
controlsContainer;
}) => {
createColorPickerToolbox({
triggerButton,
tippyOptions: {
appendTo: controlsContainer,
},
onSelectColor: (color) => setCellsBackgroundColor(editor, color),
});
},
},
{
label: "Delete Column",
icon: icons.deleteColumn,
action: ({ editor }: { editor: Editor }) =>
editor.chain().focus().deleteColumn().run(),
},
];
const rowsToolboxItems = [
{
label: "Add Row Above",
icon: icons.insertTopTableIcon,
action: ({ editor }: { editor: Editor }) =>
editor.chain().focus().addRowBefore().run(),
},
{
label: "Add Row Below",
icon: icons.insertBottomTableIcon,
action: ({ editor }: { editor: Editor }) =>
editor.chain().focus().addRowAfter().run(),
},
{
label: "Pick Row Color",
icon: icons.colorPicker,
action: ({
editor,
triggerButton,
controlsContainer,
}: {
editor: Editor;
triggerButton: HTMLButtonElement;
controlsContainer:
| Element
| "parent"
| ((ref: Element) => Element)
| undefined;
}) => {
createColorPickerToolbox({
triggerButton,
tippyOptions: {
appendTo: controlsContainer,
},
onSelectColor: (color) => setCellsBackgroundColor(editor, color),
});
},
},
{
label: "Delete Row",
icon: icons.deleteRow,
action: ({ editor }: { editor: Editor }) =>
editor.chain().focus().deleteRow().run(),
},
];
function createToolbox({
triggerButton,
items,
tippyOptions,
onClickItem,
}: {
triggerButton: HTMLElement;
items: { icon: string; label: string }[];
tippyOptions: any;
onClickItem: any;
}): Instance<Props> {
const toolbox = tippy(triggerButton, {
content: h(
"div",
{ className: "tableToolbox" },
items.map((item) =>
h(
"div",
{
className: "toolboxItem",
onClick() {
onClickItem(item);
},
},
[
h("div", {
className: "iconContainer",
innerHTML: item.icon,
}),
h("div", { className: "label" }, item.label),
],
),
),
),
...tippyOptions,
});
return Array.isArray(toolbox) ? toolbox[0] : toolbox;
}
function createColorPickerToolbox({
triggerButton,
tippyOptions,
onSelectColor = () => {},
}: {
triggerButton: HTMLElement;
tippyOptions: Partial<Props>;
onSelectColor?: (color: string) => void;
}) {
const items = {
Default: "rgb(var(--color-primary-100))",
Orange: "#FFE5D1",
Grey: "#F1F1F1",
Yellow: "#FEF3C7",
Green: "#DCFCE7",
Red: "#FFDDDD",
Blue: "#D9E4FF",
Pink: "#FFE8FA",
Purple: "#E8DAFB",
};
const colorPicker = tippy(triggerButton, {
...defaultTippyOptions,
content: h(
"div",
{ className: "tableColorPickerToolbox" },
Object.entries(items).map(([key, value]) =>
h(
"div",
{
className: "toolboxItem",
onClick: () => {
onSelectColor(value);
colorPicker.hide();
},
},
[
h("div", {
className: "colorContainer",
style: {
backgroundColor: value,
},
}),
h(
"div",
{
className: "label",
},
key,
),
],
),
),
),
onHidden: (instance) => {
instance.destroy();
},
showOnCreate: true,
...tippyOptions,
});
return colorPicker;
}
export class TableView implements NodeView {
node: ProseMirrorNode;
cellMinWidth: number;
decorations: Decoration[];
editor: Editor;
getPos: () => number;
hoveredCell;
map: TableMap;
root: HTMLElement;
table: HTMLElement;
colgroup: HTMLElement;
tbody: HTMLElement;
rowsControl?: HTMLElement;
columnsControl?: HTMLElement;
columnsToolbox?: Instance<Props>;
rowsToolbox?: Instance<Props>;
controls?: HTMLElement;
get dom() {
return this.root;
}
get contentDOM() {
return this.tbody;
}
constructor(
node: ProseMirrorNode,
cellMinWidth: number,
decorations: Decoration[],
editor: Editor,
getPos: () => number,
) {
this.node = node;
this.cellMinWidth = cellMinWidth;
this.decorations = decorations;
this.editor = editor;
this.getPos = getPos;
this.hoveredCell = null;
this.map = TableMap.get(node);
if (editor.isEditable) {
this.rowsControl = h(
"div",
{ className: "rowsControl" },
h("button", {
onClick: () => this.selectRow(),
}),
);
this.columnsControl = h(
"div",
{ className: "columnsControl" },
h("button", {
onClick: () => this.selectColumn(),
}),
);
this.controls = h(
"div",
{ className: "tableControls", contentEditable: "false" },
this.rowsControl,
this.columnsControl,
);
this.columnsToolbox = createToolbox({
triggerButton: this.columnsControl.querySelector("button"),
items: columnsToolboxItems,
tippyOptions: {
...defaultTippyOptions,
appendTo: this.controls,
},
onClickItem: (item) => {
item.action({
editor: this.editor,
triggerButton: this.columnsControl?.firstElementChild,
controlsContainer: this.controls,
});
this.columnsToolbox?.hide();
},
});
this.rowsToolbox = createToolbox({
triggerButton: this.rowsControl.firstElementChild,
items: rowsToolboxItems,
tippyOptions: {
...defaultTippyOptions,
appendTo: this.controls,
},
onClickItem: (item) => {
item.action({
editor: this.editor,
triggerButton: this.rowsControl?.firstElementChild,
controlsContainer: this.controls,
});
this.rowsToolbox?.hide();
},
});
}
// Table
this.colgroup = h(
"colgroup",
null,
Array.from({ length: this.map.width }, () => 1).map(() => h("col")),
);
this.tbody = h("tbody");
this.table = h("table", null, this.colgroup, this.tbody);
this.root = h(
"div",
{
className: "tableWrapper controls--disabled",
},
this.controls,
this.table,
);
this.render();
}
update(node: ProseMirrorNode, decorations) {
if (node.type !== this.node.type) {
return false;
}
this.node = node;
this.decorations = decorations;
this.map = TableMap.get(this.node);
if (this.editor.isEditable) {
this.updateControls();
}
this.render();
return true;
}
render() {
if (this.colgroup.children.length !== this.map.width) {
const cols = Array.from({ length: this.map.width }, () => 1).map(() =>
h("col"),
);
this.colgroup.replaceChildren(...cols);
}
updateColumnsOnResize(
this.node,
this.colgroup,
this.table,
this.cellMinWidth,
);
}
ignoreMutation() {
return true;
}
updateControls() {
const { hoveredTable: table, hoveredCell: cell } = Object.values(
this.decorations,
).reduce(
(acc, curr) => {
if (curr.spec.hoveredCell !== undefined) {
acc["hoveredCell"] = curr.spec.hoveredCell;
}
if (curr.spec.hoveredTable !== undefined) {
acc["hoveredTable"] = curr.spec.hoveredTable;
}
return acc;
},
{} as Record<string, HTMLElement>,
) as any;
if (table === undefined || cell === undefined) {
return this.root.classList.add("controls--disabled");
}
this.root.classList.remove("controls--disabled");
this.hoveredCell = cell;
const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement;
const tableRect = this.table.getBoundingClientRect();
const cellRect = cellDom.getBoundingClientRect();
this.columnsControl.style.left = `${
cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft
}px`;
this.columnsControl.style.width = `${cellRect.width}px`;
this.rowsControl.style.top = `${cellRect.top - tableRect.top}px`;
this.rowsControl.style.height = `${cellRect.height}px`;
}
selectColumn() {
if (!this.hoveredCell) return;
const colIndex = this.map.colCount(
this.hoveredCell.pos - (this.getPos() + 1),
);
const anchorCellPos = this.hoveredCell.pos;
const headCellPos =
this.map.map[colIndex + this.map.width * (this.map.height - 1)] +
(this.getPos() + 1);
const cellSelection = CellSelection.create(
this.editor.view.state.doc,
anchorCellPos,
headCellPos,
);
this.editor.view.dispatch(
// @ts-ignore
this.editor.state.tr.setSelection(cellSelection),
);
}
selectRow() {
if (!this.hoveredCell) return;
const anchorCellPos = this.hoveredCell.pos;
const anchorCellIndex = this.map.map.indexOf(
anchorCellPos - (this.getPos() + 1),
);
const headCellPos =
this.map.map[anchorCellIndex + (this.map.width - 1)] +
(this.getPos() + 1);
const cellSelection = CellSelection.create(
this.editor.state.doc,
anchorCellPos,
headCellPos,
);
this.editor.view.dispatch(
// @ts-ignore
this.editor.view.state.tr.setSelection(cellSelection),
);
}
}
@@ -1,298 +0,0 @@
import { TextSelection } from "@tiptap/pm/state"
import { callOrReturn, getExtensionField, mergeAttributes, Node, ParentConfig } from "@tiptap/core"
import {
addColumnAfter,
addColumnBefore,
addRowAfter,
addRowBefore,
CellSelection,
columnResizing,
deleteColumn,
deleteRow,
deleteTable,
fixTables,
goToNextCell,
mergeCells,
setCellAttr,
splitCell,
tableEditing,
toggleHeader,
toggleHeaderCell
} from "@tiptap/prosemirror-tables"
import { tableControls } from "./table-controls"
import { TableView } from "./table-view"
import { createTable } from "./utilities/create-table"
import { deleteTableWhenAllCellsSelected } from "./utilities/delete-table-when-all-cells-selected"
export interface TableOptions {
HTMLAttributes: Record<string, any>
resizable: boolean
handleWidth: number
cellMinWidth: number
lastColumnResizable: boolean
allowTableNodeSelection: boolean
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
table: {
insertTable: (options?: {
rows?: number
cols?: number
withHeaderRow?: boolean
}) => ReturnType
addColumnBefore: () => ReturnType
addColumnAfter: () => ReturnType
deleteColumn: () => ReturnType
addRowBefore: () => ReturnType
addRowAfter: () => ReturnType
deleteRow: () => ReturnType
deleteTable: () => ReturnType
mergeCells: () => ReturnType
splitCell: () => ReturnType
toggleHeaderColumn: () => ReturnType
toggleHeaderRow: () => ReturnType
toggleHeaderCell: () => ReturnType
mergeOrSplit: () => ReturnType
setCellAttribute: (name: string, value: any) => ReturnType
goToNextCell: () => ReturnType
goToPreviousCell: () => ReturnType
fixTables: () => ReturnType
setCellSelection: (position: {
anchorCell: number
headCell?: number
}) => ReturnType
}
}
interface NodeConfig<Options, Storage> {
tableRole?:
| string
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options>>["tableRole"]
}) => string)
}
}
export default Node.create({
name: "table",
addOptions() {
return {
HTMLAttributes: {},
resizable: true,
handleWidth: 5,
cellMinWidth: 100,
lastColumnResizable: true,
allowTableNodeSelection: true
}
},
content: "tableRow+",
tableRole: "table",
isolating: true,
group: "block",
allowGapCursor: false,
parseHTML() {
return [{ tag: "table" }]
},
renderHTML({ HTMLAttributes }) {
return [
"table",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
["tbody", 0]
]
},
addCommands() {
return {
insertTable:
({ rows = 3, cols = 3, withHeaderRow = true} = {}) =>
({ tr, dispatch, editor }) => {
const node = createTable(
editor.schema,
rows,
cols,
withHeaderRow
)
if (dispatch) {
const offset = tr.selection.anchor + 1
tr.replaceSelectionWith(node)
.scrollIntoView()
.setSelection(
TextSelection.near(tr.doc.resolve(offset))
)
}
return true
},
addColumnBefore:
() =>
({ state, dispatch }) => addColumnBefore(state, dispatch),
addColumnAfter:
() =>
({ state, dispatch }) => addColumnAfter(state, dispatch),
deleteColumn:
() =>
({ state, dispatch }) => deleteColumn(state, dispatch),
addRowBefore:
() =>
({ state, dispatch }) => addRowBefore(state, dispatch),
addRowAfter:
() =>
({ state, dispatch }) => addRowAfter(state, dispatch),
deleteRow:
() =>
({ state, dispatch }) => deleteRow(state, dispatch),
deleteTable:
() =>
({ state, dispatch }) => deleteTable(state, dispatch),
mergeCells:
() =>
({ state, dispatch }) => mergeCells(state, dispatch),
splitCell:
() =>
({ state, dispatch }) => splitCell(state, dispatch),
toggleHeaderColumn:
() =>
({ state, dispatch }) => toggleHeader("column")(state, dispatch),
toggleHeaderRow:
() =>
({ state, dispatch }) => toggleHeader("row")(state, dispatch),
toggleHeaderCell:
() =>
({ state, dispatch }) => toggleHeaderCell(state, dispatch),
mergeOrSplit:
() =>
({ state, dispatch }) => {
if (mergeCells(state, dispatch)) {
return true
}
return splitCell(state, dispatch)
},
setCellAttribute:
(name, value) =>
({ state, dispatch }) => setCellAttr(name, value)(state, dispatch),
goToNextCell:
() =>
({ state, dispatch }) => goToNextCell(1)(state, dispatch),
goToPreviousCell:
() =>
({ state, dispatch }) => goToNextCell(-1)(state, dispatch),
fixTables:
() =>
({ state, dispatch }) => {
if (dispatch) {
fixTables(state)
}
return true
},
setCellSelection:
(position) =>
({ tr, dispatch }) => {
if (dispatch) {
const selection = CellSelection.create(
tr.doc,
position.anchorCell,
position.headCell
)
// @ts-ignore
tr.setSelection(selection)
}
return true
}
}
},
addKeyboardShortcuts() {
return {
Tab: () => {
if (this.editor.commands.goToNextCell()) {
return true
}
if (!this.editor.can().addRowAfter()) {
return false
}
return this.editor.chain().addRowAfter().goToNextCell().run()
},
"Shift-Tab": () => this.editor.commands.goToPreviousCell(),
Backspace: deleteTableWhenAllCellsSelected,
"Mod-Backspace": deleteTableWhenAllCellsSelected,
Delete: deleteTableWhenAllCellsSelected,
"Mod-Delete": deleteTableWhenAllCellsSelected
}
},
addNodeView() {
return ({ editor, getPos, node, decorations }) => {
const { cellMinWidth } = this.options
return new TableView(
node,
cellMinWidth,
decorations,
editor,
getPos as () => number
)
}
},
addProseMirrorPlugins() {
const isResizable = this.options.resizable && this.editor.isEditable
const plugins = [
tableEditing({
allowTableNodeSelection: this.options.allowTableNodeSelection
}),
tableControls()
]
if (isResizable) {
plugins.unshift(
columnResizing({
handleWidth: this.options.handleWidth,
cellMinWidth: this.options.cellMinWidth,
// View: TableView,
// @ts-ignore
lastColumnResizable: this.options.lastColumnResizable
})
)
}
return plugins
},
extendNodeSchema(extension) {
const context = {
name: extension.name,
options: extension.options,
storage: extension.storage
}
return {
tableRole: callOrReturn(
getExtensionField(extension, "tableRole", context)
)
}
}
})
@@ -1,12 +0,0 @@
import { Fragment, Node as ProsemirrorNode, NodeType } from "prosemirror-model"
export function createCell(
cellType: NodeType,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
): ProsemirrorNode | null | undefined {
if (cellContent) {
return cellType.createChecked(null, cellContent)
}
return cellType.createAndFill()
}
@@ -1,45 +0,0 @@
import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model"
import { createCell } from "./create-cell"
import { getTableNodeTypes } from "./get-table-node-types"
export function createTable(
schema: Schema,
rowsCount: number,
colsCount: number,
withHeaderRow: boolean,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
): ProsemirrorNode {
const types = getTableNodeTypes(schema)
const headerCells: ProsemirrorNode[] = []
const cells: ProsemirrorNode[] = []
for (let index = 0; index < colsCount; index += 1) {
const cell = createCell(types.cell, cellContent)
if (cell) {
cells.push(cell)
}
if (withHeaderRow) {
const headerCell = createCell(types.header_cell, cellContent)
if (headerCell) {
headerCells.push(headerCell)
}
}
}
const rows: ProsemirrorNode[] = []
for (let index = 0; index < rowsCount; index += 1) {
rows.push(
types.row.createChecked(
null,
withHeaderRow && index === 0 ? headerCells : cells
)
)
}
return types.table.createChecked(null, rows)
}
@@ -1,39 +0,0 @@
import { findParentNodeClosestToPos, KeyboardShortcutCommand } from "@tiptap/core"
import { isCellSelection } from "./is-cell-selection"
export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({
editor
}) => {
const { selection } = editor.state
if (!isCellSelection(selection)) {
return false
}
let cellCount = 0
const table = findParentNodeClosestToPos(
selection.ranges[0].$from,
(node) => node.type.name === "table"
)
table?.node.descendants((node) => {
if (node.type.name === "table") {
return false
}
if (["tableCell", "tableHeader"].includes(node.type.name)) {
cellCount += 1
}
})
const allCellsSelected = cellCount === selection.ranges.length
if (!allCellsSelected) {
return false
}
editor.commands.deleteTable()
return true
}
@@ -1,21 +0,0 @@
import { NodeType, Schema } from "prosemirror-model"
export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } {
if (schema.cached.tableNodeTypes) {
return schema.cached.tableNodeTypes
}
const roles: { [key: string]: NodeType } = {}
Object.keys(schema.nodes).forEach((type) => {
const nodeType = schema.nodes[type]
if (nodeType.spec.tableRole) {
roles[nodeType.spec.tableRole] = nodeType
}
})
schema.cached.tableNodeTypes = roles
return roles
}
@@ -1,5 +0,0 @@
import { CellSelection } from "@tiptap/prosemirror-tables"
export function isCellSelection(value: unknown): value is CellSelection {
return value instanceof CellSelection
}
@@ -27,6 +27,8 @@ const MentionList = forwardRef((props: MentionListProps, ref) => {
const selectItem = (index: number) => {
const item = props.items[index];
console.log(props.command);
if (item) {
props.command({
id: item.id,
@@ -69,7 +71,7 @@ const MentionList = forwardRef((props: MentionListProps, ref) => {
if (event.key === "Enter") {
enterHandler();
return true;
return false;
}
return false;
@@ -77,7 +79,7 @@ const MentionList = forwardRef((props: MentionListProps, ref) => {
}));
return props.items && props.items.length !== 0 ? (
<div className="mentions absolute max-h-40 bg-custom-background-100 rounded-md shadow-custom-shadow-sm text-custom-text-300 text-sm overflow-y-auto w-48 p-1 space-y-0.5">
<div className="absolute max-h-40 bg-custom-background-100 rounded-md shadow-custom-shadow-sm text-custom-text-300 text-sm overflow-y-auto w-48 p-1 space-y-0.5">
{props.items.length ? (
props.items.map((item, index) => (
<div
@@ -9,6 +9,7 @@ export interface CustomMentionOptions extends MentionOptions {
}
export const CustomMention = Mention.extend<CustomMentionOptions>({
addAttributes() {
return {
id: {
@@ -53,3 +54,6 @@ export const CustomMention = Mention.extend<CustomMentionOptions>({
return ['mention-component', mergeAttributes(HTMLAttributes)]
},
})
@@ -1,37 +1,7 @@
import {
BoldIcon,
Heading1,
CheckSquare,
Heading2,
Heading3,
QuoteIcon,
ImageIcon,
TableIcon,
ListIcon,
ListOrderedIcon,
ItalicIcon,
UnderlineIcon,
StrikethroughIcon,
CodeIcon,
} from "lucide-react";
import { BoldIcon, Heading1, CheckSquare, Heading2, Heading3, QuoteIcon, ImageIcon, TableIcon, ListIcon, ListOrderedIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react";
import { Editor } from "@tiptap/react";
import { UploadImage } from "../../../types/upload-image";
import {
insertImageCommand,
insertTableCommand,
toggleBlockquote,
toggleBold,
toggleBulletList,
toggleCode,
toggleHeadingOne,
toggleHeadingThree,
toggleHeadingTwo,
toggleItalic,
toggleOrderedList,
toggleStrike,
toggleTaskList,
toggleUnderline,
} from "../../../lib/editor-commands";
import { insertImageCommand, insertTableCommand, toggleBlockquote, toggleBold, toggleBulletList, toggleCode, toggleHeadingOne, toggleHeadingThree, toggleHeadingTwo, toggleItalic, toggleOrderedList, toggleStrike, toggleTaskList, toggleUnderline, } from "../../../lib/editor-commands";
export interface EditorMenuItem {
name: string;
@@ -45,101 +15,95 @@ export const HeadingOneItem = (editor: Editor): EditorMenuItem => ({
isActive: () => editor.isActive("heading", { level: 1 }),
command: () => toggleHeadingOne(editor),
icon: Heading1,
});
})
export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({
name: "H2",
isActive: () => editor.isActive("heading", { level: 2 }),
command: () => toggleHeadingTwo(editor),
icon: Heading2,
});
})
export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({
name: "H3",
isActive: () => editor.isActive("heading", { level: 3 }),
command: () => toggleHeadingThree(editor),
icon: Heading3,
});
})
export const BoldItem = (editor: Editor): EditorMenuItem => ({
name: "bold",
isActive: () => editor?.isActive("bold"),
command: () => toggleBold(editor),
icon: BoldIcon,
});
})
export const ItalicItem = (editor: Editor): EditorMenuItem => ({
name: "italic",
isActive: () => editor?.isActive("italic"),
command: () => toggleItalic(editor),
icon: ItalicIcon,
});
})
export const UnderLineItem = (editor: Editor): EditorMenuItem => ({
name: "underline",
isActive: () => editor?.isActive("underline"),
command: () => toggleUnderline(editor),
icon: UnderlineIcon,
});
})
export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({
name: "strike",
isActive: () => editor?.isActive("strike"),
command: () => toggleStrike(editor),
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"),
command: () => toggleBulletList(editor),
icon: ListIcon,
});
})
export const TodoListItem = (editor: Editor): EditorMenuItem => ({
name: "To-do List",
isActive: () => editor.isActive("taskItem"),
command: () => toggleTaskList(editor),
icon: CheckSquare,
});
})
export const NumberedListItem = (editor: Editor): EditorMenuItem => ({
name: "ordered-list",
isActive: () => editor?.isActive("orderedList"),
command: () => toggleOrderedList(editor),
icon: ListOrderedIcon,
});
icon: ListOrderedIcon
})
export const QuoteItem = (editor: Editor): EditorMenuItem => ({
name: "quote",
isActive: () => editor?.isActive("quote"),
command: () => toggleBlockquote(editor),
icon: QuoteIcon,
});
icon: QuoteIcon
})
export const TableItem = (editor: Editor): EditorMenuItem => ({
name: "table",
name: "quote",
isActive: () => editor?.isActive("table"),
command: () => insertTableCommand(editor),
icon: TableIcon,
});
icon: TableIcon
})
export const ImageItem = (
editor: Editor,
uploadFile: UploadImage,
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void,
): EditorMenuItem => ({
export const ImageItem = (editor: Editor, uploadFile: UploadImage, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void): EditorMenuItem => ({
name: "image",
isActive: () => editor?.isActive("image"),
command: () => insertImageCommand(editor, uploadFile, setIsSubmitting),
icon: ImageIcon,
});
})
@@ -0,0 +1,120 @@
import { useState, useEffect } from "react";
import { Rows, Columns, ToggleRight } from "lucide-react";
import InsertLeftTableIcon from "./InsertLeftTableIcon";
import InsertRightTableIcon from "./InsertRightTableIcon";
import InsertTopTableIcon from "./InsertTopTableIcon";
import InsertBottomTableIcon from "./InsertBottomTableIcon";
import { cn, findTableAncestor } from "../../../lib/utils";
import { Tooltip } from "./tooltip";
interface TableMenuItem {
command: () => void;
icon: any;
key: string;
name: string;
}
export const TableMenu = ({ editor }: { editor: any }) => {
const [tableLocation, setTableLocation] = useState({ bottom: 0, left: 0 });
const isOpen = editor?.isActive("table");
const items: TableMenuItem[] = [
{
command: () => editor.chain().focus().addColumnBefore().run(),
icon: InsertLeftTableIcon,
key: "insert-column-left",
name: "Insert 1 column left",
},
{
command: () => editor.chain().focus().addColumnAfter().run(),
icon: InsertRightTableIcon,
key: "insert-column-right",
name: "Insert 1 column right",
},
{
command: () => editor.chain().focus().addRowBefore().run(),
icon: InsertTopTableIcon,
key: "insert-row-above",
name: "Insert 1 row above",
},
{
command: () => editor.chain().focus().addRowAfter().run(),
icon: InsertBottomTableIcon,
key: "insert-row-below",
name: "Insert 1 row below",
},
{
command: () => editor.chain().focus().deleteColumn().run(),
icon: Columns,
key: "delete-column",
name: "Delete column",
},
{
command: () => editor.chain().focus().deleteRow().run(),
icon: Rows,
key: "delete-row",
name: "Delete row",
},
{
command: () => editor.chain().focus().toggleHeaderRow().run(),
icon: ToggleRight,
key: "toggle-header-row",
name: "Toggle header row",
},
];
useEffect(() => {
if (!window) return;
const handleWindowClick = () => {
const selection: any = window?.getSelection();
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
const tableNode = findTableAncestor(range.startContainer);
if (tableNode) {
const tableRect = tableNode.getBoundingClientRect();
const tableCenter = tableRect.left + tableRect.width / 2;
const menuWidth = 45;
const menuLeft = tableCenter - menuWidth / 2;
const tableBottom = tableRect.bottom;
setTableLocation({ bottom: tableBottom, left: menuLeft });
}
}
};
window.addEventListener("click", handleWindowClick);
return () => {
window.removeEventListener("click", handleWindowClick);
};
}, [tableLocation, editor]);
return (
<section
className={`absolute z-20 left-1/2 -translate-x-1/2 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-sm p-1 ${
isOpen ? "block" : "hidden"
}`}
>
{items.map((item, index) => (
<Tooltip key={index} tooltipContent={item.name}>
<button
onClick={item.command}
className="p-1.5 text-custom-text-200 hover:bg-text-custom-text-100 hover:bg-custom-background-80 active:bg-custom-background-80 rounded"
title={item.name}
>
<item.icon
className={cn("h-4 w-4 text-lg", {
"text-red-600": item.key.includes("delete"),
})}
/>
</button>
</Tooltip>
))}
</section>
);
};
@@ -8,10 +8,10 @@ import TaskList from "@tiptap/extension-task-list";
import { Markdown } from "tiptap-markdown";
import Gapcursor from "@tiptap/extension-gapcursor";
import TableHeader from "../extensions/table/table-header/table-header";
import Table from "../extensions/table/table";
import TableCell from "../extensions/table/table-cell/table-cell";
import TableRow from "../extensions/table/table-row/table-row";
import { CustomTableCell } from "../extensions/table/table-cell";
import { Table } from "../extensions/table";
import { TableHeader } from "../extensions/table/table-header";
import { TableRow } from "@tiptap/extension-table-row";
import ReadOnlyImageExtension from "../extensions/image/read-only-image";
import { isValidHttpUrl } from "../../lib/utils";
@@ -91,7 +91,7 @@ export const CoreReadOnlyEditorExtensions = (
}),
Table,
TableHeader,
TableCell,
CustomTableCell,
TableRow,
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, true),
];
@@ -2,7 +2,6 @@
"name": "@plane/lite-text-editor",
"version": "0.0.1",
"description": "Package that powers Plane's Comment Editor",
"private": true,
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
@@ -30,6 +29,9 @@
"dependencies": {
"@plane/editor-core": "*",
"@tiptap/extension-list-item": "^2.1.11",
"@types/node": "18.15.3",
"@types/react": "^18.2.5",
"@types/react-dom": "18.0.11",
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",
"eslint": "8.36.0",
@@ -44,9 +46,6 @@
"use-debounce": "^9.0.4"
},
"devDependencies": {
"@types/node": "18.15.3",
"@types/react": "^18.2.35",
"@types/react-dom": "^18.2.14",
"eslint": "^7.32.0",
"postcss": "^8.4.29",
"tailwind-config-custom": "*",
@@ -1,5 +1,5 @@
import { EnterKeyExtension } from "./enter-key-extension";
export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [
// EnterKeyExtension(onEnterKeyPress),
EnterKeyExtension(onEnterKeyPress),
];
@@ -1,3 +1,4 @@
"use client";
import * as React from "react";
import {
EditorContainer,
@@ -31,7 +32,7 @@ interface ILiteTextEditor {
editorContentCustomClassNames?: string;
onChange?: (json: any, html: string) => void;
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved"
isSubmitting: "submitting" | "submitted" | "saved",
) => void;
setShouldShowAlert?: (showAlert: boolean) => void;
forwardedRef?: any;
@@ -126,7 +127,7 @@ const LiteTextEditor = (props: LiteTextEditorProps) => {
};
const LiteTextEditorWithRef = React.forwardRef<EditorHandle, ILiteTextEditor>(
(props, ref) => <LiteTextEditor {...props} forwardedRef={ref} />
(props, ref) => <LiteTextEditor {...props} forwardedRef={ref} />,
);
LiteTextEditorWithRef.displayName = "LiteTextEditorWithRef";
@@ -72,10 +72,12 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
props.commentAccessSpecifier?.onAccessChange(accessKey);
};
console.log(complexItems);
return (
<div className="flex items-stretch gap-1.5 w-full h-9 overflow-x-scroll">
<div className="flex items-stretch gap-1.5 w-full h-9">
{props.commentAccessSpecifier && (
<div className="flex-shrink-0 flex items-stretch gap-0.5 border-[0.5px] border-custom-border-200 rounded p-1">
<div className="flex-shrink-0 flex items-stretch gap-0.5 border border-custom-border-200 rounded p-1">
{props?.commentAccessSpecifier.commentAccess?.map((access) => (
<Tooltip key={access.key} tooltipContent={access.label}>
<button
@@ -100,118 +102,102 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
))}
</div>
)}
<div className="flex items-stretch justify-between gap-2 w-full border-[0.5px] border-custom-border-200 bg-custom-background-90 rounded p-1">
<div className="flex items-stretch justify-between gap-2 w-full border border-custom-border-200 bg-custom-background-90 rounded p-1">
<div className="flex items-stretch">
<div className="flex items-stretch gap-0.5 pr-2.5 border-r border-custom-border-200">
{basicMarkItems.map((item, index) => (
<Tooltip
<button
key={index}
tooltipContent={<span className="capitalize">{item.name}</span>}
type="button"
onClick={item.command}
className={cn(
"p-1 aspect-square text-custom-text-400 hover:bg-custom-background-80 rounded-sm grid place-items-center",
{
"text-custom-text-100 bg-custom-background-80":
item.isActive(),
},
)}
>
<button
type="button"
onClick={item.command}
className={cn(
"p-1 aspect-square text-custom-text-400 hover:bg-custom-background-80 rounded-sm grid place-items-center",
{
"text-custom-text-100 bg-custom-background-80":
item.isActive(),
},
)}
>
<item.icon
className={cn("h-3.5 w-3.5", {
"text-custom-text-100": item.isActive(),
})}
strokeWidth={2.5}
/>
</button>
</Tooltip>
<item.icon
className={cn("h-3.5 w-3.5", {
"text-custom-text-100": item.isActive(),
})}
strokeWidth={2.5}
/>
</button>
))}
</div>
<div className="flex items-stretch gap-0.5 px-2.5 border-r border-custom-border-200">
{listItems.map((item, index) => (
<Tooltip
<button
key={index}
tooltipContent={<span className="capitalize">{item.name}</span>}
type="button"
onClick={item.command}
className={cn(
"p-1 aspect-square text-custom-text-400 hover:bg-custom-background-80 rounded-sm grid place-items-center",
{
"text-custom-text-100 bg-custom-background-80":
item.isActive(),
},
)}
>
<button
type="button"
onClick={item.command}
className={cn(
"p-1 aspect-square text-custom-text-400 hover:bg-custom-background-80 rounded-sm grid place-items-center",
{
"text-custom-text-100 bg-custom-background-80":
item.isActive(),
},
)}
>
<item.icon
className={cn("h-3.5 w-3.5", {
"text-custom-text-100": item.isActive(),
})}
strokeWidth={2.5}
/>
</button>
</Tooltip>
<item.icon
className={cn("h-3.5 w-3.5", {
"text-custom-text-100": item.isActive(),
})}
strokeWidth={2.5}
/>
</button>
))}
</div>
<div className="flex items-stretch gap-0.5 px-2.5 border-r border-custom-border-200">
{userActionItems.map((item, index) => (
<Tooltip
<button
key={index}
tooltipContent={<span className="capitalize">{item.name}</span>}
type="button"
onClick={item.command}
className={cn(
"p-1 aspect-square text-custom-text-400 hover:bg-custom-background-80 rounded-sm grid place-items-center",
{
"text-custom-text-100 bg-custom-background-80":
item.isActive(),
},
)}
>
<button
type="button"
onClick={item.command}
className={cn(
"p-1 aspect-square text-custom-text-400 hover:bg-custom-background-80 rounded-sm grid place-items-center",
{
"text-custom-text-100 bg-custom-background-80":
item.isActive(),
},
)}
>
<item.icon
className={cn("h-3.5 w-3.5", {
"text-custom-text-100": item.isActive(),
})}
strokeWidth={2.5}
/>
</button>
</Tooltip>
<item.icon
className={cn("h-3.5 w-3.5", {
"text-custom-text-100": item.isActive(),
})}
strokeWidth={2.5}
/>
</button>
))}
</div>
<div className="flex items-stretch gap-0.5 pl-2.5">
{complexItems.map((item, index) => (
<Tooltip
<button
key={index}
tooltipContent={<span className="capitalize">{item.name}</span>}
type="button"
onClick={item.command}
className={cn(
"p-1 aspect-square text-custom-text-400 hover:bg-custom-background-80 rounded-sm grid place-items-center",
{
"text-custom-text-100 bg-custom-background-80":
item.isActive(),
},
)}
>
<button
type="button"
onClick={item.command}
className={cn(
"p-1 aspect-square text-custom-text-400 hover:bg-custom-background-80 rounded-sm grid place-items-center",
{
"text-custom-text-100 bg-custom-background-80":
item.isActive(),
},
)}
>
<item.icon
className={cn("h-3.5 w-3.5", {
"text-custom-text-100": item.isActive(),
})}
strokeWidth={2.5}
/>
</button>
</Tooltip>
<item.icon
className={cn("h-3.5 w-3.5", {
"text-custom-text-100": item.isActive(),
})}
strokeWidth={2.5}
/>
</button>
))}
</div>
</div>
<div className="sticky right-1">{props.submitButton}</div>
{props.submitButton}
</div>
</div>
);
@@ -1,10 +1,6 @@
import * as React from "react";
import {
EditorContainer,
EditorContentWrapper,
getEditorClassNames,
useReadOnlyEditor,
} from "@plane/editor-core";
"use client"
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useReadOnlyEditor } from '@plane/editor-core';
import * as React from 'react';
interface ICoreReadOnlyEditor {
value: string;
@@ -12,7 +8,6 @@ interface ICoreReadOnlyEditor {
noBorder?: boolean;
borderOnFocus?: boolean;
customClassName?: string;
mentionHighlights: string[];
}
interface EditorCoreProps extends ICoreReadOnlyEditor {
@@ -31,39 +26,29 @@ const LiteReadOnlyEditor = ({
customClassName,
value,
forwardedRef,
mentionHighlights,
}: EditorCoreProps) => {
const editor = useReadOnlyEditor({
value,
forwardedRef,
mentionHighlights,
});
const editorClassNames = getEditorClassNames({
noBorder,
borderOnFocus,
customClassName,
});
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
if (!editor) return null;
return (
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<div className="flex flex-col">
<EditorContentWrapper
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
</div>
</EditorContainer>
</EditorContainer >
);
};
const LiteReadOnlyEditorWithRef = React.forwardRef<
EditorHandle,
ICoreReadOnlyEditor
>((props, ref) => <LiteReadOnlyEditor {...props} forwardedRef={ref} />);
const LiteReadOnlyEditorWithRef = React.forwardRef<EditorHandle, ICoreReadOnlyEditor>((props, ref) => (
<LiteReadOnlyEditor {...props} forwardedRef={ref} />
));
LiteReadOnlyEditorWithRef.displayName = "LiteReadOnlyEditorWithRef";
export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef };
export { LiteReadOnlyEditor , LiteReadOnlyEditorWithRef };
@@ -1,4 +1,5 @@
import * as React from "react";
import * as React from 'react';
// next-themes
import { useTheme } from "next-themes";
// tooltip2
@@ -68,16 +69,8 @@ export const Tooltip: React.FC<Props> = ({
</div>
}
position={position}
renderTarget={({
isOpen: isTooltipOpen,
ref: eleReference,
...tooltipProps
}) =>
React.cloneElement(children, {
ref: eleReference,
...tooltipProps,
...children.props,
})
renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) =>
React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props })
}
/>
);
@@ -2,7 +2,6 @@
"name": "@plane/rich-text-editor",
"version": "0.0.1",
"description": "Rich Text Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
@@ -22,19 +21,19 @@
"check-types": "tsc --noEmit"
},
"peerDependencies": {
"@tiptap/core": "^2.1.11",
"next": "12.3.2",
"next-themes": "^0.2.1",
"react": "^18.2.0",
"react-dom": "18.2.0"
"react-dom": "18.2.0",
"@tiptap/core": "^2.1.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",
"@tiptap/suggestion": "^2.1.7",
"clsx": "^1.2.1",
"highlight.js": "^11.8.0",
"lowlight": "^3.0.0",
@@ -42,8 +41,8 @@
},
"devDependencies": {
"@types/node": "18.15.3",
"@types/react": "^18.2.35",
"@types/react-dom": "^18.2.14",
"@types/react": "^18.2.5",
"@types/react-dom": "18.0.11",
"eslint": "^7.32.0",
"postcss": "^8.4.29",
"react": "^18.2.0",
@@ -1,11 +1,4 @@
import {
useState,
useEffect,
useCallback,
ReactNode,
useRef,
useLayoutEffect,
} from "react";
import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react";
import { Editor, Range, Extension } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
import { ReactRenderer } from "@tiptap/react";
@@ -25,18 +18,7 @@ import {
Table,
} from "lucide-react";
import { UploadImage } from "../";
import {
cn,
insertTableCommand,
toggleBlockquote,
toggleBulletList,
toggleOrderedList,
toggleTaskList,
insertImageCommand,
toggleHeadingOne,
toggleHeadingTwo,
toggleHeadingThree,
} from "@plane/editor-core";
import { cn, insertTableCommand, toggleBlockquote, toggleBulletList, toggleOrderedList, toggleTaskList, insertImageCommand, toggleHeadingOne, toggleHeadingTwo, toggleHeadingThree } from "@plane/editor-core";
interface CommandItemProps {
title: string;
@@ -55,15 +37,7 @@ const Command = Extension.create({
return {
suggestion: {
char: "/",
command: ({
editor,
range,
props,
}: {
editor: Editor;
range: Range;
props: any;
}) => {
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
props.command({ editor, range });
},
},
@@ -85,135 +59,127 @@ const Command = Extension.create({
const getSuggestionItems =
(
uploadFile: UploadImage,
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
) =>
({ query }: { query: string }) =>
[
{
title: "Text",
description: "Just start typing with plain text.",
searchTerms: ["p", "paragraph"],
icon: <Text size={18} />,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode("paragraph", "paragraph")
.run();
({ query }: { query: string }) =>
[
{
title: "Text",
description: "Just start typing with plain text.",
searchTerms: ["p", "paragraph"],
icon: <Text size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run();
},
},
},
{
title: "Heading 1",
description: "Big section heading.",
searchTerms: ["title", "big", "large"],
icon: <Heading1 size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingOne(editor, range);
{
title: "Heading 1",
description: "Big section heading.",
searchTerms: ["title", "big", "large"],
icon: <Heading1 size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingOne(editor, range);
},
},
},
{
title: "Heading 2",
description: "Medium section heading.",
searchTerms: ["subtitle", "medium"],
icon: <Heading2 size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingTwo(editor, range);
{
title: "Heading 2",
description: "Medium section heading.",
searchTerms: ["subtitle", "medium"],
icon: <Heading2 size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingTwo(editor, range);
},
},
},
{
title: "Heading 3",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading3 size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingThree(editor, range);
{
title: "Heading 3",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading3 size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingThree(editor, range);
},
},
},
{
title: "To-do List",
description: "Track tasks with a to-do list.",
searchTerms: ["todo", "task", "list", "check", "checkbox"],
icon: <CheckSquare size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleTaskList(editor, range);
{
title: "To-do List",
description: "Track tasks with a to-do list.",
searchTerms: ["todo", "task", "list", "check", "checkbox"],
icon: <CheckSquare size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleTaskList(editor, range)
},
},
},
{
title: "Bullet List",
description: "Create a simple bullet list.",
searchTerms: ["unordered", "point"],
icon: <List size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleBulletList(editor, range);
{
title: "Bullet List",
description: "Create a simple bullet list.",
searchTerms: ["unordered", "point"],
icon: <List size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleBulletList(editor, range);
},
},
},
{
title: "Divider",
description: "Visually divide blocks",
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
icon: <MinusSquare size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
{
title: "Divider",
description: "Visually divide blocks",
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
icon: <MinusSquare size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
},
{
title: "Table",
description: "Create a Table",
searchTerms: ["table", "cell", "db", "data", "tabular"],
icon: <Table size={18} />,
command: ({ editor, range }: CommandProps) => {
insertTableCommand(editor, range);
{
title: "Table",
description: "Create a Table",
searchTerms: ["table", "cell", "db", "data", "tabular"],
icon: <Table size={18} />,
command: ({ editor, range }: CommandProps) => {
insertTableCommand(editor, range);
},
},
},
{
title: "Numbered List",
description: "Create a list with numbering.",
searchTerms: ["ordered"],
icon: <ListOrdered size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleOrderedList(editor, range);
{
title: "Numbered List",
description: "Create a list with numbering.",
searchTerms: ["ordered"],
icon: <ListOrdered size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleOrderedList(editor, range)
},
},
},
{
title: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: <TextQuote size={18} />,
command: ({ editor, range }: CommandProps) =>
toggleBlockquote(editor, range),
},
{
title: "Code",
description: "Capture a code snippet.",
searchTerms: ["codeblock"],
icon: <Code size={18} />,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: "Image",
description: "Upload an image from your computer.",
searchTerms: ["photo", "picture", "media"],
icon: <ImageIcon size={18} />,
command: ({ editor, range }: CommandProps) => {
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
{
title: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: <TextQuote size={18} />,
command: ({ editor, range }: CommandProps) =>
toggleBlockquote(editor, range)
},
},
].filter((item) => {
if (typeof query === "string" && query.length > 0) {
const search = query.toLowerCase();
return (
item.title.toLowerCase().includes(search) ||
item.description.toLowerCase().includes(search) ||
(item.searchTerms &&
item.searchTerms.some((term: string) => term.includes(search)))
);
}
return true;
});
{
title: "Code",
description: "Capture a code snippet.",
searchTerms: ["codeblock"],
icon: <Code size={18} />,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: "Image",
description: "Upload an image from your computer.",
searchTerms: ["photo", "picture", "media"],
icon: <ImageIcon size={18} />,
command: ({ editor, range }: CommandProps) => {
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
},
},
].filter((item) => {
if (typeof query === "string" && query.length > 0) {
const search = query.toLowerCase();
return (
item.title.toLowerCase().includes(search) ||
item.description.toLowerCase().includes(search) ||
(item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)))
);
}
return true;
});
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
const containerHeight = container.offsetHeight;
@@ -247,7 +213,7 @@ const CommandList = ({
command(item);
}
},
[command, items],
[command, items]
);
useEffect(() => {
@@ -300,17 +266,11 @@ const CommandList = ({
<button
className={cn(
`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100`,
{
"bg-custom-primary-100/5 text-custom-text-100":
index === selectedIndex,
},
{ "bg-custom-primary-100/5 text-custom-text-100": index === selectedIndex }
)}
key={index}
onClick={() => selectItem(index)}
>
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-custom-border-300 bg-custom-background-100">
{item.icon}
</div>
<div>
<p className="font-medium">{item.title}</p>
<p className="text-xs text-custom-text-300">{item.description}</p>
@@ -371,9 +331,7 @@ const renderItems = () => {
export const SlashCommand = (
uploadFile: UploadImage,
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
) =>
Command.configure({
suggestion: {
@@ -1,18 +1,10 @@
import { BubbleMenu, BubbleMenuProps, isNodeSelection } from "@tiptap/react";
import { FC, useEffect, useState } from "react";
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react";
import { FC, useState } from "react";
import { BoldIcon } from "lucide-react";
import { NodeSelector } from "./node-selector";
import { LinkSelector } from "./link-selector";
import {
BoldItem,
cn,
CodeItem,
isCellSelection,
ItalicItem,
StrikeThroughItem,
UnderLineItem,
} from "@plane/editor-core";
import { BoldItem, cn, CodeItem, ItalicItem, StrikeThroughItem, UnderLineItem } from "@plane/editor-core";
export interface BubbleMenuItem {
name: string;
@@ -34,35 +26,14 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
const bubbleMenuProps: EditorBubbleMenuProps = {
...props,
shouldShow: ({ view, state, editor }) => {
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") ||
isNodeSelection(selection) ||
isCellSelection(selection) ||
isSelecting
) {
shouldShow: ({ editor }) => {
if (!editor.isEditable) {
return false;
}
return true;
if (editor.isActive("image")) {
return false;
}
return editor.view.state.selection.content().size > 0;
},
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
@@ -76,83 +47,50 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isSelecting, setIsSelecting] = useState(false);
useEffect(() => {
function handleMouseDown() {
function handleMouseMove() {
if (!props.editor.state.selection.empty) {
setIsSelecting(true);
document.removeEventListener("mousemove", handleMouseMove);
}
}
function handleMouseUp() {
setIsSelecting(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
}
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
document.addEventListener("mousedown", handleMouseDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
};
}, []);
return (
<BubbleMenu
{...bubbleMenuProps}
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
>
{isSelecting ? null : (
<>
{!props.editor.isActive("table") && (
<NodeSelector
editor={props.editor!}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsLinkSelectorOpen(false);
}}
/>
)}
<LinkSelector
editor={props.editor!!}
isOpen={isLinkSelectorOpen}
setIsOpen={() => {
setIsLinkSelectorOpen(!isLinkSelectorOpen);
setIsNodeSelectorOpen(false);
}}
/>
<div className="flex">
{items.map((item, index) => (
<button
key={index}
type="button"
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
{
"text-custom-text-100 bg-custom-primary-100/5":
item.isActive(),
},
)}
>
<item.icon
className={cn("h-4 w-4", {
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
</div>
</>
{!props.editor.isActive("table") && (
<NodeSelector
editor={props.editor!}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsLinkSelectorOpen(false);
}}
/>
)}
<LinkSelector
editor={props.editor!!}
isOpen={isLinkSelectorOpen}
setIsOpen={() => {
setIsLinkSelectorOpen(!isLinkSelectorOpen);
setIsNodeSelectorOpen(false);
}}
/>
<div className="flex">
{items.map((item, index) => (
<button
key={index}
type="button"
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
{
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
}
)}
>
<item.icon
className={cn("h-4 w-4", {
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
</div>
</BubbleMenu>
);
};
@@ -8,7 +8,6 @@ interface IRichTextReadOnlyEditor {
noBorder?: boolean;
borderOnFocus?: boolean;
customClassName?: string;
mentionHighlights?: string[];
}
interface RichTextReadOnlyEditorProps extends IRichTextReadOnlyEditor {
@@ -27,12 +26,10 @@ const RichReadOnlyEditor = ({
customClassName,
value,
forwardedRef,
mentionHighlights,
}: RichTextReadOnlyEditorProps) => {
const editor = useReadOnlyEditor({
value,
forwardedRef,
mentionHighlights,
});
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
@@ -1,6 +1,5 @@
{
"name": "eslint-config-custom",
"private": true,
"version": "0.13.2",
"main": "index.js",
"license": "MIT",
@@ -3,7 +3,6 @@
"version": "0.13.2",
"description": "common tailwind configuration across monorepo",
"main": "index.js",
"private": true,
"devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"autoprefixer": "^10.4.14",
-2
View File
@@ -1,7 +1,5 @@
{
"name": "@plane/ui",
"description": "UI components shared across multiple apps internally",
"private": true,
"version": "0.0.1",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
+4 -10
View File
@@ -35,11 +35,8 @@ export const AvatarGroup: React.FC<Props> = (props) => {
// calculate total length of avatars inside the group
const totalAvatars = React.Children.toArray(children).length;
// if avatars are equal to max + 1, then we need to show the last avatar as well, if avatars are more than max + 1, then we need to show the count of the remaining avatars
const maxAvatarsToRender = totalAvatars <= max + 1 ? max + 1 : max;
// slice the children to the maximum number of avatars
const avatars = React.Children.toArray(children).slice(0, maxAvatarsToRender);
const avatars = React.Children.toArray(children).slice(0, max);
// assign the necessary props from the AvatarGroup component to the Avatar components
const avatarsWithUpdatedProps = avatars.map((avatar) => {
@@ -57,14 +54,11 @@ export const AvatarGroup: React.FC<Props> = (props) => {
return (
<div className={`flex ${sizeInfo.spacing}`}>
{avatarsWithUpdatedProps.map((avatar, index) => (
<div
key={index}
className="ring-1 ring-custom-background-100 rounded-full"
>
<div key={index} className="ring-1 ring-custom-border-200 rounded-full">
{avatar}
</div>
))}
{maxAvatarsToRender < totalAvatars && (
{max < totalAvatars && (
<Tooltip
tooltipContent={`${totalAvatars} total`}
disabled={!showTooltip}
@@ -72,7 +66,7 @@ export const AvatarGroup: React.FC<Props> = (props) => {
<div
className={`${
!isAValidNumber(size) ? sizeInfo.avatarSize : ""
} ring-1 ring-custom-background-100 bg-custom-primary-10 text-custom-primary-100 rounded-full grid place-items-center text-[9px]`}
} ring-1 ring-custom-border-200 bg-custom-primary-500 text-white rounded-full grid place-items-center text-[9px]`}
style={
isAValidNumber(size)
? {
+1
View File
@@ -0,0 +1 @@
export * from "./state-group";
@@ -0,0 +1,23 @@
import React from "react";
// types
import type { Props } from "../types";
// constants
import { issueGroupColors } from "constants/data";
export const BacklogStateIcon: React.FC<Props> = ({
width = "14",
height = "14",
className,
color = issueGroupColors["backlog"],
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="10" cy="10" r="9" stroke={color} strokeLinecap="round" strokeDasharray="4 4" />
</svg>
);
@@ -0,0 +1,74 @@
import React from "react";
// types
import type { Props } from "../types";
// constants
import { issueGroupColors } from "constants/data";
export const CancelledStateIcon: React.FC<Props> = ({
width = "14",
height = "14",
className,
color = issueGroupColors["cancelled"],
}) => (
<svg width={width} height={height} className={className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84.36 84.36">
<g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M20.45,7.69a39.74,39.74,0,0,1,43.43.54"
/>
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M76.67,20.45a39.76,39.76,0,0,1-.53,43.43"
/>
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M63.92,76.67a39.78,39.78,0,0,1-43.44-.53"
/>
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M7.69,63.92a39.75,39.75,0,0,1,.54-43.44"
/>
<circle className="cls-2" fill={color} cx="42.18" cy="42.18" r="31.04" />
<path
className="cls-3"
fill="none"
strokeWidth={3}
stroke="#ffffff"
strokeLinecap="square"
strokeMiterlimit={10}
d="M32.64,32.44q9.54,9.75,19.09,19.48"
/>
<path
className="cls-3"
fill="none"
strokeWidth={3}
stroke="#ffffff"
strokeLinecap="square"
strokeMiterlimit={10}
d="M32.64,51.92,51.73,32.44"
/>
</g>
</g>
</svg>
);
@@ -0,0 +1,65 @@
import React from "react";
// types
import type { Props } from "../types";
// constants
import { issueGroupColors } from "constants/data";
export const CompletedStateIcon: React.FC<Props> = ({
width = "14",
height = "14",
className,
color = issueGroupColors["completed"],
}) => (
<svg width={width} height={height} className={className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84.36 84.36">
<g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M20.45,7.69a39.74,39.74,0,0,1,43.43.54"
/>
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M76.67,20.45a39.76,39.76,0,0,1-.53,43.43"
/>
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M63.92,76.67a39.78,39.78,0,0,1-43.44-.53"
/>
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M7.69,63.92a39.75,39.75,0,0,1,.54-43.44"
/>
<circle className="cls-2" fill={color} cx="42.18" cy="42.18" r="31.04" />
<path
className="cls-3"
fill="none"
strokeWidth={3}
stroke="#ffffff"
strokeLinecap="square"
strokeMiterlimit={10}
d="M30.45,43.75l6.61,6.61L53.92,34"
/>
</g>
</g>
</svg>
);
@@ -0,0 +1,6 @@
export * from "./backlog-state-icon";
export * from "./cancelled-state-icon";
export * from "./completed-state-icon";
export * from "./started-state-icon";
export * from "./state-group-icon";
export * from "./unstarted-state-icon";
@@ -0,0 +1,73 @@
import React from "react";
// types
import type { Props } from "../types";
// constants
import { issueGroupColors } from "constants/data";
export const StartedStateIcon: React.FC<Props> = ({
width = "14",
height = "14",
className,
color = issueGroupColors["started"],
}) => (
<svg width={width} height={height} className={className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 83.36 83.36">
<g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<path
className="cls-1"
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M20,7.19a39.74,39.74,0,0,1,43.43.54"
/>
<path
className="cls-1"
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M76.17,20a39.76,39.76,0,0,1-.53,43.43"
/>
<path
className="cls-1"
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M63.42,76.17A39.78,39.78,0,0,1,20,75.64"
/>
<path
className="cls-1"
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M7.19,63.42A39.75,39.75,0,0,1,7.73,20"
/>
<path
className="cls-2"
fill={color}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M42.32,41.21q9.57-14.45,19.13-28.9a35.8,35.8,0,0,0-39.09,0Z"
/>
<path
className="cls-2"
fill={color}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M42.32,41.7,61.45,70.6a35.75,35.75,0,0,1-39.09,0Z"
/>
</g>
</g>
</svg>
);
@@ -0,0 +1,29 @@
// icons
import {
BacklogStateIcon,
CancelledStateIcon,
CompletedStateIcon,
StartedStateIcon,
UnstartedStateIcon,
} from "components/icons";
import { TIssueGroupKey } from "types/issue";
type Props = {
stateGroup: TIssueGroupKey;
color: string;
className?: string;
height?: string;
width?: string;
};
export const StateGroupIcon: React.FC<Props> = ({ stateGroup, className, color, height = "12px", width = "12px" }) => {
if (stateGroup === "backlog")
return <BacklogStateIcon className={className} color={color} height={height} width={width} />;
else if (stateGroup === "cancelled")
return <CancelledStateIcon className={className} color={color} height={height} width={width} />;
else if (stateGroup === "completed")
return <CompletedStateIcon className={className} color={color} height={height} width={width} />;
else if (stateGroup === "started")
return <StartedStateIcon className={className} color={color} height={height} width={width} />;
else return <UnstartedStateIcon className={className} color={color} height={height} width={width} />;
};
@@ -0,0 +1,55 @@
import React from "react";
// types
import type { Props } from "../types";
// constants
import { issueGroupColors } from "constants/data";
export const UnstartedStateIcon: React.FC<Props> = ({
width = "14",
height = "14",
className,
color = issueGroupColors["unstarted"],
}) => (
<svg width={width} height={height} className={className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84.36 84.36">
<g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<path
className="cls-1"
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M20.45,7.69a39.74,39.74,0,0,1,43.43.54"
/>
<path
className="cls-1"
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M76.67,20.45a39.76,39.76,0,0,1-.53,43.43"
/>
<path
className="cls-1"
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M63.92,76.67a39.78,39.78,0,0,1-43.44-.53"
/>
<path
className="cls-1"
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M7.69,63.92a39.75,39.75,0,0,1,.54-43.44"
/>
</g>
</g>
</svg>
);
+6
View File
@@ -0,0 +1,6 @@
export type Props = {
width?: string | number;
height?: string | number;
color?: string;
className?: string;
};
@@ -1,5 +1,3 @@
// ui
import { StateGroupIcon } from "@plane/ui";
// constants
import { issueGroupFilter } from "constants/data";
@@ -10,7 +8,7 @@ export const IssueBlockState = ({ state }: any) => {
return (
<div className="flex items-center justify-between gap-1 w-full rounded shadow-sm border-[0.5px] border-custom-border-300 duration-300 focus:outline-none px-2.5 py-1 text-xs">
<div className="flex items-center w-full gap-1.5 text-custom-text-200">
<StateGroupIcon stateGroup={state.group} color={state.color} />
<stateGroup.icon />
<div className="text-xs">{state?.name}</div>
</div>
</div>
@@ -7,6 +7,7 @@ import { useMobxStore } from "lib/mobx/store-provider";
// components
import { IssueBlockPriority } from "components/issues/board-views/block-priority";
import { IssueBlockState } from "components/issues/board-views/block-state";
import { IssueBlockLabels } from "components/issues/board-views/block-labels";
import { IssueBlockDueDate } from "components/issues/board-views/block-due-date";
// interfaces
import { IIssue } from "types/issue";
@@ -36,7 +37,7 @@ export const IssueListBlock = observer(({ issue }: { issue: IIssue }) => {
};
return (
<div className="py-2 px-3 flex flex-col gap-1.5 bg-custom-background-100 rounded shadow-custom-shadow-2xs border-[0.5px] border-custom-border-200 space-y-2 text-sm">
<div className="py-3 px-4 h-[118px] flex flex-col gap-1.5 bg-custom-background-100 rounded shadow-custom-shadow-sm border-[0.5px] border-custom-border-200">
{/* id */}
<div className="text-xs text-custom-text-300 break-words">
{projectStore?.project?.identifier}-{issue?.sequence_id}
@@ -4,8 +4,8 @@ import { observer } from "mobx-react-lite";
import { IIssueState } from "types/issue";
// constants
import { issueGroupFilter } from "constants/data";
// ui
import { StateGroupIcon } from "@plane/ui";
// icons
import { StateGroupIcon } from "components/icons";
// mobx hook
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
@@ -18,11 +18,11 @@ export const IssueListHeader = observer(({ state }: { state: IIssueState }) => {
if (stateGroup === null) return <></>;
return (
<div className="pb-2 px-2 flex items-center gap-2">
<div className="w-3.5 h-3.5 flex justify-center items-center flex-shrink-0">
<StateGroupIcon stateGroup={state.group} color={state.color} height="14" width="14" />
<div className="pb-2 px-2 flex items-center">
<div className="w-4 h-4 flex justify-center items-center flex-shrink-0">
<StateGroupIcon stateGroup={state.group} color={state.color} />
</div>
<div className="font-semibold text-custom-text-200 capitalize mr-1 truncate">{state?.name}</div>
<div className="font-semibold text-custom-text-200 capitalize ml-2 mr-3 truncate">{state?.name}</div>
<span className="text-custom-text-300 rounded-full flex-shrink-0">
{store.issue.getCountOfIssuesByState(state.id)}
</span>
@@ -38,10 +38,10 @@ export const IssueListBlock: FC<{ issue: IIssue }> = observer((props) => {
};
return (
<div className="flex items-center p-3 relative gap-10 bg-custom-background-100">
<div className="relative flex items-center gap-3 w-full flex-grow overflow-hidden">
<div className="flex items-center px-6 py-3.5 relative gap-10 bg-custom-background-100">
<div className="relative flex items-center gap-5 w-full flex-grow overflow-hidden">
{/* id */}
<div className="flex-shrink-0 text-xs text-custom-text-300 font-medium">
<div className="flex-shrink-0 text-sm text-custom-text-300">
{projectStore?.project?.identifier}-{issue?.sequence_id}
</div>
{/* name */}
@@ -2,8 +2,8 @@
import { observer } from "mobx-react-lite";
// interfaces
import { IIssueState } from "types/issue";
// ui
import { StateGroupIcon } from "@plane/ui";
// icons
import { StateGroupIcon } from "components/icons";
// constants
import { issueGroupFilter } from "constants/data";
// mobx hook
@@ -18,12 +18,12 @@ export const IssueListHeader = observer(({ state }: { state: IIssueState }) => {
if (stateGroup === null) return <></>;
return (
<div className="p-3 flex items-center gap-2">
<div className="w-3.5 h-3.5 flex justify-center items-center">
<StateGroupIcon stateGroup={state.group} color={state.color} height="14" width="14" />
<div className="px-6 py-2 flex items-center">
<div className="w-4 h-4 flex justify-center items-center">
<StateGroupIcon stateGroup={state.group} color={state.color} />
</div>
<div className="font-medium capitalize mr-1">{state?.name}</div>
<div className="text-custom-text-200 text-sm font-medium">{store.issue.getCountOfIssuesByState(state.id)}</div>
<div className="font-semibold capitalize ml-2 mr-3">{state?.name}</div>
<div className="text-custom-text-200">{store.issue.getCountOfIssuesByState(state.id)}</div>
</div>
);
});
@@ -27,7 +27,9 @@ export const IssueListView = observer(() => {
))}
</div>
) : (
<div className="p-3 text-sm text-custom-text-400 bg-custom-background-100">No issues.</div>
<div className="px-6 py-3.5 text-sm text-custom-text-200 bg-custom-background-100">
No Issues are available.
</div>
)}
</div>
))}
@@ -7,7 +7,7 @@ import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button } from "@plane/ui";
import { SecondaryButton } from "components/ui";
// types
import { Comment } from "types/issue";
// components
@@ -29,6 +29,7 @@ export const AddComment: React.FC<Props> = observer((props) => {
const {
handleSubmit,
control,
setValue,
watch,
formState: { isSubmitting },
reset,
@@ -84,30 +85,27 @@ export const AddComment: React.FC<Props> = observer((props) => {
? watch("comment_html")
: value
}
customClassName="p-2"
editorContentCustomClassNames="min-h-[35px]"
customClassName="p-3 min-h-[50px] shadow-sm"
debouncedUpdatesEnabled={false}
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
}}
submitButton={
<Button
disabled={isSubmitting || disabled}
variant="primary"
type="submit"
className="!px-2.5 !py-1.5 !text-xs"
onClick={(e) => {
userStore.requiredLogin(() => {
handleSubmit(onSubmit)(e);
});
}}
>
{isSubmitting ? "Adding..." : "Comment"}
</Button>
}
/>
)}
/>
<SecondaryButton
onClick={(e) => {
userStore.requiredLogin(() => {
handleSubmit(onSubmit)(e);
});
}}
type="submit"
disabled={isSubmitting || disabled}
className="mt-2"
>
{isSubmitting ? "Adding..." : "Comment"}
</SecondaryButton>
</div>
</div>
);
@@ -15,7 +15,6 @@ import { timeAgo } from "helpers/date-time.helper";
import { Comment } from "types/issue";
// services
import fileService from "services/file.service";
import useEditorSuggestions from "hooks/use-editor-suggestions";
type Props = {
workspaceSlug: string;
@@ -29,8 +28,6 @@ export const CommentCard: React.FC<Props> = observer((props) => {
// states
const [isEditing, setIsEditing] = useState(false);
const mentionsConfig = useEditorSuggestions();
const editorRef = React.useRef<any>(null);
const showEditorRef = React.useRef<any>(null);
@@ -138,7 +135,6 @@ export const CommentCard: React.FC<Props> = observer((props) => {
ref={showEditorRef}
value={comment.comment_html}
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
mentionHighlights={mentionsConfig.mentionHighlights}
/>
<CommentReactions commentId={comment.id} projectId={comment.project} />
</div>
@@ -2,33 +2,27 @@ import { IssueReactions } from "components/issues/peek-overview";
import { RichReadOnlyEditor } from "@plane/rich-text-editor";
// types
import { IIssue } from "types/issue";
import useEditorSuggestions from "hooks/use-editor-suggestions";
type Props = {
issueDetails: IIssue;
};
export const PeekOverviewIssueDetails: React.FC<Props> = ({ issueDetails }) => {
const mentionConfig = useEditorSuggestions();
return (
<div className="space-y-2">
<h6 className="font-medium text-custom-text-200">
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
</h6>
<h4 className="break-words text-2xl font-semibold">{issueDetails.name}</h4>
{issueDetails.description_html !== "" && issueDetails.description_html !== "<p></p>" && (
<RichReadOnlyEditor
value={!issueDetails.description_html ||
issueDetails.description_html === "" ||
(typeof issueDetails.description_html === "object" &&
Object.keys(issueDetails.description_html).length === 0)
? "<p></p>"
: issueDetails.description_html}
customClassName="p-3 min-h-[50px] shadow-sm" mentionHighlights={mentionConfig.mentionHighlights} />
)}
<IssueReactions />
</div>
)
};
export const PeekOverviewIssueDetails: React.FC<Props> = ({ issueDetails }) => (
<div className="space-y-2">
<h6 className="font-medium text-custom-text-200">
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
</h6>
<h4 className="break-words text-2xl font-semibold">{issueDetails.name}</h4>
{issueDetails.description_html !== "" && issueDetails.description_html !== "<p></p>" && (
<RichReadOnlyEditor
value={!issueDetails.description_html ||
issueDetails.description_html === "" ||
(typeof issueDetails.description_html === "object" &&
Object.keys(issueDetails.description_html).length === 0)
? "<p></p>"
: issueDetails.description_html}
customClassName="p-3 min-h-[50px] shadow-sm" />
)}
<IssueReactions />
</div>
);
-13
View File
@@ -1,13 +0,0 @@
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
const useEditorSuggestions = () => {
const { mentionsStore }: RootStore = useMobxStore();
return {
// mentionSuggestions: mentionsStore.mentionSuggestions,
mentionHighlights: mentionsStore.mentionHighlights,
};
};
export default useEditorSuggestions;
-2
View File
@@ -4,8 +4,6 @@ import { ThemeProvider } from "next-themes";
// styles
import "styles/globals.css";
import "styles/editor.css";
import "styles/table.css";
// contexts
import { ToastContextProvider } from "contexts/toast.context";
// mobx store provider
-45
View File
@@ -1,45 +0,0 @@
import { IMentionHighlight } from "@plane/lite-text-editor";
import { RootStore } from "./root";
import { computed, makeObservable } from "mobx";
export interface IMentionsStore {
// mentionSuggestions: IMentionSuggestion[];
mentionHighlights: IMentionHighlight[];
}
export class MentionsStore implements IMentionsStore{
// root store
rootStore;
constructor(_rootStore: RootStore ){
// rootStore
this.rootStore = _rootStore;
makeObservable(this, {
mentionHighlights: computed,
// mentionSuggestions: computed
})
}
// get mentionSuggestions() {
// const projectMembers = this.rootStore.project.project.
// const suggestions = projectMembers === null ? [] : projectMembers.map((member) => ({
// id: member.member.id,
// type: "User",
// title: member.member.display_name,
// subtitle: member.member.email ?? "",
// avatar: member.member.avatar,
// redirect_uri: `/${member.workspace.slug}/profile/${member.member.id}`,
// }))
// return suggestions
// }
get mentionHighlights() {
const user = this.rootStore.user.currentUser;
return user ? [user.id] : []
}
}
-3
View File
@@ -5,7 +5,6 @@ import UserStore from "./user";
import IssueStore, { IIssueStore } from "./issue";
import ProjectStore, { IProjectStore } from "./project";
import IssueDetailStore, { IIssueDetailStore } from "./issue_details";
import { IMentionsStore, MentionsStore } from "./mentions.store";
enableStaticRendering(typeof window === "undefined");
@@ -14,13 +13,11 @@ export class RootStore {
issue: IIssueStore;
issueDetails: IIssueDetailStore;
project: IProjectStore;
mentionsStore: IMentionsStore;
constructor() {
this.user = new UserStore(this);
this.issue = new IssueStore(this);
this.project = new ProjectStore(this);
this.issueDetails = new IssueDetailStore(this);
this.mentionsStore = new MentionsStore(this);
}
}
+1
View File
@@ -2,6 +2,7 @@
import { observable, action, computed, makeObservable, runInAction } from "mobx";
// service
import UserService from "services/user.service";
import { ActorDetail } from "types/issue";
// types
import { IUser } from "types/user";
+3 -3
View File
@@ -199,9 +199,9 @@
--color-sidebar-text-400: var(--color-text-400); /* sidebar placeholder text */
--color-sidebar-border-100: var(--color-border-100); /* subtle sidebar border= 1 */
--color-sidebar-border-200: var(--color-border-200); /* subtle sidebar border- 2 */
--color-sidebar-border-300: var(--color-border-300); /* strong sidebar border- 1 */
--color-sidebar-border-400: var(--color-border-400); /* strong sidebar border- 2 */
--color-sidebar-border-200: var(--color-border-100); /* subtle sidebar border- 2 */
--color-sidebar-border-300: var(--color-border-100); /* strong sidebar border- 1 */
--color-sidebar-border-400: var(--color-border-100); /* strong sidebar border- 2 */
}
}
-194
View File
@@ -1,194 +0,0 @@
.tableWrapper {
overflow-x: auto;
padding: 2px;
width: fit-content;
max-width: 100%;
}
.tableWrapper table {
border-collapse: collapse;
table-layout: fixed;
margin: 0;
margin-bottom: 3rem;
border: 1px solid rgba(var(--color-border-200));
width: 100%;
}
.tableWrapper table td,
.tableWrapper table th {
min-width: 1em;
border: 1px solid rgba(var(--color-border-200));
padding: 10px 15px;
vertical-align: top;
box-sizing: border-box;
position: relative;
transition: background-color 0.3s ease;
> * {
margin-bottom: 0;
}
}
.tableWrapper table td > *,
.tableWrapper table th > * {
margin: 0 !important;
padding: 0.25rem 0 !important;
}
.tableWrapper table td.has-focus,
.tableWrapper table th.has-focus {
box-shadow: rgba(var(--color-primary-300), 0.1) 0px 0px 0px 2px inset !important;
}
.tableWrapper table th {
font-weight: bold;
text-align: left;
background-color: rgba(var(--color-primary-100));
}
.tableWrapper table th * {
font-weight: 600;
}
.tableWrapper table .selectedCell:after {
z-index: 2;
position: absolute;
content: "";
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(var(--color-primary-300), 0.1);
pointer-events: none;
}
.tableWrapper table .column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: -2px;
width: 4px;
z-index: 99;
background-color: rgba(var(--color-primary-400));
pointer-events: none;
}
.tableWrapper .tableControls {
position: absolute;
}
.tableWrapper .tableControls .columnsControl,
.tableWrapper .tableControls .rowsControl {
transition: opacity ease-in 100ms;
position: absolute;
z-index: 99;
display: flex;
justify-content: center;
align-items: center;
}
.tableWrapper .tableControls .columnsControl {
height: 20px;
transform: translateY(-50%);
}
.tableWrapper .tableControls .columnsControl > button {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
width: 30px;
height: 15px;
}
.tableWrapper .tableControls .rowsControl {
width: 20px;
transform: translateX(-50%);
}
.tableWrapper .tableControls .rowsControl > button {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M12 3c-.825 0-1.5.675-1.5 1.5S11.175 6 12 6s1.5-.675 1.5-1.5S12.825 3 12 3zm0 15c-.825 0-1.5.675-1.5 1.5S11.175 21 12 21s1.5-.675 1.5-1.5S12.825 18 12 18zm0-7.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
height: 30px;
width: 15px;
}
.tableWrapper .tableControls button {
background-color: rgba(var(--color-primary-100));
border: 1px solid rgba(var(--color-border-200));
border-radius: 2px;
background-size: 1.25rem;
background-repeat: no-repeat;
background-position: center;
transition: transform ease-out 100ms, background-color ease-out 100ms;
outline: none;
box-shadow: #000 0px 2px 4px;
cursor: pointer;
}
.tableWrapper .tableControls .tableToolbox,
.tableWrapper .tableControls .tableColorPickerToolbox {
border: 1px solid rgba(var(--color-border-300));
background-color: rgba(var(--color-background-100));
padding: 0.25rem;
display: flex;
flex-direction: column;
width: 200px;
gap: 0.25rem;
}
.tableWrapper .tableControls .tableToolbox .toolboxItem,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem {
background-color: rgba(var(--color-background-100));
display: flex;
align-items: center;
gap: 0.5rem;
border: none;
padding: 0.1rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.tableWrapper .tableControls .tableToolbox .toolboxItem:hover,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem:hover {
background-color: rgba(var(--color-background-100), 0.5);
}
.tableWrapper .tableControls .tableToolbox .toolboxItem .iconContainer,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer,
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer {
border: 1px solid rgba(var(--color-border-300));
border-radius: 3px;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
}
.tableWrapper .tableControls .tableToolbox .toolboxItem .iconContainer svg,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer svg,
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer svg,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer svg {
width: 2rem;
height: 2rem;
}
.tableToolbox {
background-color: rgba(var(--color-background-100));
}
.tableWrapper .tableControls .tableToolbox .toolboxItem .label,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .label {
font-size: 0.85rem;
color: rgba(var(--color-text-300));
}
.resize-cursor .tableWrapper .tableControls .rowsControl,
.tableWrapper.controls--disabled .tableControls .rowsControl,
.resize-cursor .tableWrapper .tableControls .columnsControl,
.tableWrapper.controls--disabled .tableControls .columnsControl {
opacity: 0;
pointer-events: none;
}
+1 -2
View File
@@ -22,8 +22,7 @@
"SLACK_CLIENT_SECRET",
"JITSU_TRACKER_ACCESS_KEY",
"JITSU_TRACKER_HOST",
"UNSPLASH_ACCESS_KEY",
"NEXT_PUBLIC_SLACK_CLIENT_ID"
"UNSPLASH_ACCESS_KEY"
],
"pipeline": {
"build": {
@@ -3,7 +3,7 @@ import { BarDatum } from "@nivo/bar";
// components
import { CustomTooltip } from "./custom-tooltip";
// ui
import { BarGraph, Tooltip } from "components/ui";
import { BarGraph } from "components/ui";
// helpers
import { findStringWithMostCharacters } from "helpers/array.helper";
import { generateBarColor, generateDisplayName } from "helpers/analytics.helper";
@@ -72,50 +72,42 @@ export const AnalyticsGraph: React.FC<Props> = ({ analytics, barGraphData, param
renderTick:
params.x_axis === "assignees__id"
? (datum) => {
const assignee = analytics.extras.assignee_details?.find((a) => a?.assignees__id === datum?.value);
const avatar = analytics.extras.assignee_details?.find(
(a) => a?.assignees__display_name === datum?.value
)?.assignees__avatar;
if (assignee?.assignees__avatar && assignee?.assignees__avatar !== "")
if (avatar && avatar !== "")
return (
<Tooltip tooltipContent={assignee?.assignees__display_name}>
<g transform={`translate(${datum.x},${datum.y})`}>
<image
x={-8}
y={10}
width={16}
height={16}
xlinkHref={assignee?.assignees__avatar}
style={{ clipPath: "circle(50%)" }}
/>
</g>
</Tooltip>
<g transform={`translate(${datum.x},${datum.y})`}>
<image
x={-8}
y={10}
width={16}
height={16}
xlinkHref={avatar}
style={{ clipPath: "circle(50%)" }}
/>
</g>
);
else
return (
<Tooltip tooltipContent={assignee?.assignees__display_name}>
<g transform={`translate(${datum.x},${datum.y})`}>
<circle cy={18} r={8} fill="#374151" />
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
{params.x_axis === "assignees__id"
? datum.value && datum.value !== "None"
? generateDisplayName(datum.value, analytics, params, "x_axis")[0].toUpperCase()
: "?"
: datum.value && datum.value !== "None"
? `${datum.value}`.toUpperCase()[0]
: "?"}
</text>
</g>
</Tooltip>
<g transform={`translate(${datum.x},${datum.y})`}>
<circle cy={18} r={8} fill="#374151" />
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
{params.x_axis === "assignees__id"
? datum.value && datum.value !== "None"
? generateDisplayName(datum.value, analytics, params, "x_axis")[0].toUpperCase()
: "?"
: datum.value && datum.value !== "None"
? `${datum.value}`.toUpperCase()[0]
: "?"}
</text>
</g>
);
}
: (datum) => (
<g transform={`translate(${datum.x},${datum.y + 10})`}>
<text
x={0}
y={datum.y}
textAnchor="end"
fontSize={10}
className={`${barGraphData.data.length > 7 ? "-rotate-45" : ""}`}
>
<g transform={`translate(${datum.x},${datum.y})`}>
<text x={0} y={21} textAnchor="middle" fontSize={10}>
{generateDisplayName(datum.value, analytics, params, "x_axis")}
</text>
</g>
@@ -66,7 +66,6 @@ export const CustomAnalyticsSelectBar: React.FC<Props> = observer((props) => {
onChange(val);
}}
params={params}
/>
)}
/>
@@ -3,19 +3,16 @@ import { useRouter } from "next/router";
// ui
import { CustomSelect } from "@plane/ui";
// types
import { IAnalyticsParams, TXAxisValues } from "types";
import { TXAxisValues } from "types";
// constants
import { ANALYTICS_X_AXIS_VALUES } from "constants/analytics";
type Props = {
value: TXAxisValues;
onChange: (val: string) => void;
params: IAnalyticsParams;
};
export const SelectXAxis: React.FC<Props> = (props) => {
const { value, onChange, params } = props;
export const SelectXAxis: React.FC<Props> = ({ value, onChange }) => {
const router = useRouter();
const { cycleId, moduleId } = router.query;
@@ -28,7 +25,6 @@ export const SelectXAxis: React.FC<Props> = (props) => {
maxHeight="lg"
>
{ANALYTICS_X_AXIS_VALUES.map((item) => {
if (params.segment === item.value) return null;
if (cycleId && item.value === "issue_cycle__cycle_id") return null;
if (moduleId && item.value === "issue_module__module_id") return null;
+1 -1
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react";
// react beautiful dnd
import { Droppable, DroppableProps } from "@hello-pangea/dnd";
import { Droppable, DroppableProps } from "react-beautiful-dnd";
const StrictModeDroppable = ({ children, ...props }: DroppableProps) => {
const [enabled, setEnabled] = useState(false);
+1 -1
View File
@@ -1,5 +1,5 @@
import { useRouter } from "next/router";
import { DragDropContext, Draggable, DropResult } from "@hello-pangea/dnd";
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { MoreVertical } from "lucide-react";
// hooks
@@ -1,5 +1,5 @@
import { useRouter } from "next/router";
import { DragDropContext, Draggable, DropResult } from "@hello-pangea/dnd";
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { MoreVertical } from "lucide-react";
// hooks
+1 -1
View File
@@ -1,5 +1,5 @@
import { useRouter } from "next/router";
import { DragDropContext, Draggable, DropResult } from "@hello-pangea/dnd";
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { MoreVertical } from "lucide-react";
// hooks
+34 -23
View File
@@ -1,6 +1,6 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Plus } from "lucide-react";
import { GanttChartSquare, LayoutGrid, List, Plus } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
@@ -8,9 +8,23 @@ import useLocalStorage from "hooks/use-local-storage";
// ui
import { Breadcrumbs, Button, Tooltip, DiceIcon } from "@plane/ui";
// helper
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
import { renderEmoji } from "helpers/emoji.helper";
// constants
import { MODULE_VIEW_LAYOUTS } from "constants/module";
const moduleViewOptions: { type: "list" | "grid" | "gantt_chart"; icon: any }[] = [
{
type: "list",
icon: List,
},
{
type: "grid",
icon: LayoutGrid,
},
{
type: "gantt_chart",
icon: GanttChartSquare,
},
];
export const ModulesListHeader: React.FC = observer(() => {
// router
@@ -54,26 +68,23 @@ export const ModulesListHeader: React.FC = observer(() => {
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 p-1 rounded bg-custom-background-80">
{MODULE_VIEW_LAYOUTS.map((layout) => (
<Tooltip key={layout.key} tooltipContent={layout.title}>
<button
type="button"
className={`w-7 h-[22px] rounded grid place-items-center transition-all hover:bg-custom-background-100 overflow-hidden group ${
modulesView == layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : ""
}`}
onClick={() => setModulesView(layout.key)}
>
<layout.icon
strokeWidth={2}
className={`h-3.5 w-3.5 ${
modulesView == layout.key ? "text-custom-text-100" : "text-custom-text-200"
}`}
/>
</button>
</Tooltip>
))}
</div>
{moduleViewOptions.map((option) => (
<Tooltip
key={option.type}
tooltipContent={<span className="capitalize">{replaceUnderscoreIfSnakeCase(option.type)} Layout</span>}
position="bottom"
>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-sidebar-background-80 ${
modulesView === option.type ? "bg-custom-sidebar-background-80" : "text-custom-sidebar-text-200"
}`}
onClick={() => setModulesView(option.type)}
>
<option.icon className="h-3.5 w-3.5" />
</button>
</Tooltip>
))}
<Button
variant="primary"
prependIcon={<Plus />}
@@ -3,82 +3,22 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// hooks
import { useMobxStore } from "lib/mobx/store-provider";
// constants
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
// helper
import { truncateText } from "helpers/string.helper";
// ui
import { Breadcrumbs, BreadcrumbItem, LayersIcon } from "@plane/ui";
// icons
import { ArrowLeft } from "lucide-react";
// components
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "components/issues";
// types
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "types";
import { Breadcrumbs, LayersIcon } from "@plane/ui";
// helper
import { renderEmoji } from "helpers/emoji.helper";
export const ProjectArchivedIssuesHeader: FC = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { workspaceSlug } = router.query;
const { project: projectStore, archivedIssueFilters: archivedIssueFiltersStore } = useMobxStore();
const { project: projectStore } = useMobxStore();
const { currentProjectDetails } = projectStore;
// for archived issues list layout is the only option
const activeLayout = "list";
const handleFiltersUpdate = (key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !projectId) return;
const newValues = archivedIssueFiltersStore.userFilters?.[key] ?? [];
if (Array.isArray(value)) {
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
});
} else {
if (archivedIssueFiltersStore.userFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
archivedIssueFiltersStore.updateUserFilters(workspaceSlug.toString(), projectId.toString(), {
filters: {
[key]: newValues,
},
});
};
const handleDisplayFiltersUpdate = (updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId) return;
archivedIssueFiltersStore.updateUserFilters(workspaceSlug.toString(), projectId.toString(), {
display_filters: {
...archivedIssueFiltersStore.userDisplayFilters,
...updatedDisplayFilter,
},
});
};
const handleDisplayPropertiesUpdate = (property: Partial<IIssueDisplayProperties>) => {
if (!workspaceSlug || !projectId) return;
archivedIssueFiltersStore.updateDisplayProperties(workspaceSlug.toString(), projectId.toString(), property);
};
return (
<div className="relative flex w-full flex-shrink-0 flex-row z-10 items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
<div className="block md:hidden">
<button
type="button"
className="grid h-8 w-8 place-items-center rounded border border-custom-border-200"
onClick={() => router.back()}
>
<ArrowLeft fontSize={14} strokeWidth={2} />
</button>
</div>
<div>
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
@@ -106,33 +46,6 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
</Breadcrumbs>
</div>
</div>
{/* filter options */}
<div className="flex items-center gap-2">
<FiltersDropdown title="Filters" placement="bottom-end">
<FilterSelection
filters={archivedIssueFiltersStore.userFilters}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.archived_issues[activeLayout] : undefined
}
labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? undefined}
members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)}
states={projectStore.states?.[projectId?.toString() ?? ""] ?? undefined}
/>
</FiltersDropdown>
<FiltersDropdown title="Display" placement="bottom-end">
<DisplayFiltersSelection
displayFilters={archivedIssueFiltersStore.userDisplayFilters}
displayProperties={archivedIssueFiltersStore.userDisplayProperties}
handleDisplayFiltersUpdate={handleDisplayFiltersUpdate}
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
/>
</FiltersDropdown>
</div>
</div>
);
});
@@ -24,15 +24,13 @@ export const WorkspaceDashboardHeader = () => {
Dashboard
</div>
<div className="flex items-center gap-3 px-3">
<a
href="https://plane.so/changelog"
target="_blank"
rel="noopener noreferrer"
<button
onClick={() => setIsProductUpdatesModalOpen(true)}
className="flex items-center gap-1.5 bg-custom-background-80 text-xs font-medium py-1.5 px-3 rounded"
>
<Zap size={14} strokeWidth={2} fill="rgb(var(--color-text-100))" />
{"What's New?"}
</a>
</button>
<a
className="flex items-center gap-1.5 bg-custom-background-80 text-xs font-medium py-1.5 px-3 rounded"
href="https://github.com/makeplane/plane"
+33 -42
View File
@@ -50,9 +50,9 @@ export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit, showAc
const editorRef = React.useRef<any>(null);
const router = useRouter();
const { workspaceSlug } = router.query;
const { workspaceSlug, projectId } = router.query;
const editorSuggestions = useEditorSuggestions();
const editorSuggestions = useEditorSuggestions(workspaceSlug as string | undefined, projectId as string | undefined)
const {
control,
@@ -74,46 +74,37 @@ export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit, showAc
<div>
<form onSubmit={handleSubmit(handleAddComment)}>
<div>
<Controller
name="access"
control={control}
render={({ field: { onChange: onAccessChange, value: accessValue } }) => (
<Controller
name="comment_html"
control={control}
render={({ field: { onChange: onCommentChange, value: commentValue } }) => (
<LiteTextEditorWithRef
onEnterKeyPress={handleSubmit(handleAddComment)}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
ref={editorRef}
value={!commentValue || commentValue === "" ? "<p></p>" : commentValue}
customClassName="p-2 h-full"
editorContentCustomClassNames="min-h-[35px]"
debouncedUpdatesEnabled={false}
onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)}
commentAccessSpecifier={
showAccessSpecifier
? { accessValue, onAccessChange, showAccessSpecifier, commentAccess }
: undefined
}
mentionSuggestions={editorSuggestions.mentionSuggestions}
mentionHighlights={editorSuggestions.mentionHighlights}
submitButton={
<Button
variant="primary"
type="submit"
className="!px-2.5 !py-1.5 !text-xs"
disabled={isSubmitting || disabled}
>
{isSubmitting ? "Adding..." : "Comment"}
</Button>
}
/>
)}
/>
)}
/>
<div className="relative">
<Controller
name="access"
control={control}
render={({ field: { onChange: onAccessChange, value: accessValue } }) => (
<Controller
name="comment_html"
control={control}
render={({ field: { onChange: onCommentChange, value: commentValue } }) => (
<LiteTextEditorWithRef
onEnterKeyPress={handleSubmit(handleAddComment)}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
ref={editorRef}
value={!commentValue || commentValue === "" ? "<p></p>" : commentValue}
customClassName="p-3 min-h-[100px] shadow-sm"
debouncedUpdatesEnabled={false}
onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)}
commentAccessSpecifier={{ accessValue, onAccessChange, showAccessSpecifier, commentAccess }}
mentionSuggestions={editorSuggestions.mentionSuggestions}
mentionHighlights={editorSuggestions.mentionHighlights}
/>
)}
/>
)}
/>
</div>
<Button variant="neutral-primary" type="submit" disabled={isSubmitting || disabled}>
{isSubmitting ? "Adding..." : "Comment"}
</Button>
</div>
</form>
</div>
@@ -40,7 +40,7 @@ export const CommentCard: React.FC<Props> = ({
const editorRef = React.useRef<any>(null);
const showEditorRef = React.useRef<any>(null);
const editorSuggestions = useEditorSuggestions();
const editorSuggestions = useEditorSuggestions(workspaceSlug, comment.project_detail.id)
const [isEditing, setIsEditing] = useState(false);
@@ -147,7 +147,6 @@ export const CommentCard: React.FC<Props> = ({
ref={showEditorRef}
value={comment.comment_html}
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
mentionHighlights={editorSuggestions.mentionHighlights}
/>
<CommentReaction projectId={comment.project} commentId={comment.id} />
</div>
@@ -1,131 +0,0 @@
import { useEffect, useState, Fragment } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Dialog, Transition } from "@headlessui/react";
import { AlertTriangle } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button } from "@plane/ui";
// types
import type { IIssue } from "types";
type Props = {
isOpen: boolean;
handleClose: () => void;
data: IIssue;
onSubmit?: () => Promise<void>;
};
export const DeleteArchivedIssueModal: React.FC<Props> = observer((props) => {
const { data, isOpen, handleClose, onSubmit } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const { archivedIssueDetail: archivedIssueDetailStore } = useMobxStore();
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
setIsDeleteLoading(false);
}, [isOpen]);
const onClose = () => {
setIsDeleteLoading(false);
handleClose();
};
const handleIssueDelete = async () => {
if (!workspaceSlug) return;
setIsDeleteLoading(true);
await archivedIssueDetailStore
.deleteArchivedIssue(workspaceSlug.toString(), data.project, data.id)
.then(() => {
if (onSubmit) onSubmit();
})
.catch((err) => {
const error = err?.detail;
const errorString = Array.isArray(error) ? error[0] : error;
setToastAlert({
title: "Error",
type: "error",
message: errorString || "Something went wrong.",
});
})
.finally(() => {
setIsDeleteLoading(false);
onClose();
});
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
<div className="flex flex-col gap-6 p-6">
<div className="flex w-full items-center justify-start gap-6">
<span className="place-items-center rounded-full bg-red-500/20 p-4">
<AlertTriangle className="h-6 w-6 text-red-600" aria-hidden="true" />
</span>
<span className="flex items-center justify-start">
<h3 className="text-xl font-medium 2xl:text-2xl">Delete Archived Issue</h3>
</span>
</div>
<span>
<p className="text-sm text-custom-text-200">
Are you sure you want to delete issue{" "}
<span className="break-words font-medium text-custom-text-100">
{data?.project_detail.identifier}-{data?.sequence_id}
</span>
{""}? All of the data related to the archived issue will be permanently removed. This action
cannot be undone.
</p>
</span>
<div className="flex justify-end gap-2">
<Button variant="neutral-primary" onClick={onClose}>
Cancel
</Button>
<Button variant="danger" onClick={handleIssueDelete} loading={isDeleteLoading}>
{isDeleteLoading ? "Deleting..." : "Delete Issue"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});
+3 -4
View File
@@ -38,7 +38,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
const { setShowAlert } = useReloadConfirmations();
const editorSuggestion = useEditorSuggestions();
const editorSuggestion = useEditorSuggestions(workspaceSlug, issue.project_id)
const {
handleSubmit,
@@ -164,9 +164,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
)}
/>
<div
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
}`}
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${isSubmitting === "saved" ? "fadeOut" : "fadeIn"
}`}
>
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
</div>
+1 -1
View File
@@ -122,7 +122,7 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
const { setToastAlert } = useToast();
const editorSuggestions = useEditorSuggestions();
const editorSuggestions = useEditorSuggestions(workspaceSlug as string | undefined, projectId)
const {
formState: { errors, isSubmitting },
+1 -1
View File
@@ -112,7 +112,7 @@ export const IssueForm: FC<IssueFormProps> = observer((props) => {
const user = userStore.currentUser;
const editorSuggestion = useEditorSuggestions();
const editorSuggestion = useEditorSuggestions(workspaceSlug as string | undefined, projectId);
const { setToastAlert } = useToast();
-3
View File
@@ -20,6 +20,3 @@ export * from "./confirm-issue-discard";
export * from "./draft-issue-form";
export * from "./draft-issue-modal";
export * from "./delete-draft-issue-modal";
// archived issue
export * from "./delete-archived-issue-modal";
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/router";
import { Transition } from "@headlessui/react";
import { useForm } from "react-hook-form";
// store
@@ -65,7 +66,7 @@ const Inputs = (props: any) => {
return (
<>
<h4 className="text-xs leading-5 text-custom-text-400">{projectDetails?.identifier ?? "..."}</h4>
<h4 className="text-sm font-medium leading-5 text-custom-text-400">{projectDetails?.identifier ?? "..."}</h4>
<input
type="text"
autoComplete="off"
@@ -73,7 +74,7 @@ const Inputs = (props: any) => {
{...register("name", {
required: "Issue title is required.",
})}
className="w-full pr-2 py-1.5 rounded-md bg-transparent text-xs font-medium leading-5 text-custom-text-200 outline-none"
className="w-full pr-2 py-1.5 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</>
);
@@ -180,7 +181,15 @@ export const CalendarInlineCreateIssueForm: React.FC<Props> = observer((props) =
return (
<>
{isOpen && (
<Transition
show={isOpen}
enter="transition ease-in-out duration-200 transform"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in-out duration-200 transform"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div
ref={ref}
className={`transition-all z-20 w-full ${
@@ -189,21 +198,21 @@ export const CalendarInlineCreateIssueForm: React.FC<Props> = observer((props) =
>
<form
onSubmit={handleSubmit(onSubmitHandler)}
className="flex w-full px-2 border-[0.5px] border-custom-border-200 rounded z-50 items-center gap-x-2 bg-custom-background-100 shadow-custom-shadow-2xs transition-opacity"
className="flex w-full px-1.5 border-[0.5px] border-custom-border-100 rounded z-50 items-center gap-x-2 bg-custom-background-100 shadow-custom-shadow-sm transition-opacity"
>
<Inputs register={register} setFocus={setFocus} projectDetails={projectDetails} />
</form>
</div>
)}
</Transition>
{!isOpen && (
<div className="hidden group-hover:block border-[0.5px] border-custom-border-200 rounded">
<button
type="button"
className="w-full flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1.5 rounded-md"
className="w-full flex items-center gap-x-[6px] text-custom-primary-100 px-1 py-1.5 rounded-md"
onClick={() => setIsOpen(true)}
>
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
<PlusIcon className="h-4 w-4" />
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
</button>
</div>
@@ -1,85 +1,38 @@
import { useState } from "react";
import { observer } from "mobx-react-lite";
import { PlusIcon } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
// components
import { EmptyState } from "components/common";
import { ExistingIssuesListModal } from "components/core";
// ui
import { Button } from "@plane/ui";
// assets
import emptyIssue from "public/empty-state/issue.svg";
// types
import { ISearchIssueResponse } from "types";
import { Button } from "@plane/ui";
type Props = {
workspaceSlug: string | undefined;
projectId: string | undefined;
cycleId: string | undefined;
openIssuesListModal: () => void;
};
export const CycleEmptyState: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId, cycleId } = props;
// states
const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false);
const { cycleIssue: cycleIssueStore } = useMobxStore();
const { setToastAlert } = useToast();
const handleAddIssuesToCycle = async (data: ISearchIssueResponse[]) => {
if (!workspaceSlug || !projectId || !cycleId) return;
const issueIds = data.map((i) => i.id);
await cycleIssueStore
.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds)
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Selected issues could not be added to the cycle. Please try again.",
});
});
};
return (
<>
<ExistingIssuesListModal
isOpen={cycleIssuesListModal}
handleClose={() => setCycleIssuesListModal(false)}
searchParams={{ cycle: true }}
handleOnSubmit={handleAddIssuesToCycle}
/>
<div className="h-full w-full grid place-items-center">
<EmptyState
title="Cycle issues will appear here"
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
image={emptyIssue}
primaryButton={{
text: "New issue",
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
},
}}
secondaryButton={
<Button
variant="neutral-primary"
prependIcon={<PlusIcon className="h-3 w-3" strokeWidth={2} />}
onClick={() => setCycleIssuesListModal(true)}
>
Add an existing issue
</Button>
}
/>
</div>
</>
);
});
export const CycleEmptyState: React.FC<Props> = ({ openIssuesListModal }) => (
<div className="h-full w-full grid place-items-center">
<EmptyState
title="Cycle issues will appear here"
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
image={emptyIssue}
primaryButton={{
text: "New issue",
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
},
}}
secondaryButton={
<Button
variant="neutral-primary"
prependIcon={<PlusIcon className="h-3 w-3" strokeWidth={2} onClick={openIssuesListModal} />}
>
Add an existing issue
</Button>
}
/>
</div>
);
@@ -4,78 +4,36 @@ import { EmptyState } from "components/common";
import { Button } from "@plane/ui";
// assets
import emptyIssue from "public/empty-state/issue.svg";
import { ExistingIssuesListModal } from "components/core";
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
import { ISearchIssueResponse } from "types";
import useToast from "hooks/use-toast";
import { useState } from "react";
type Props = {
workspaceSlug: string | undefined;
projectId: string | undefined;
moduleId: string | undefined;
openIssuesListModal: () => void;
};
export const ModuleEmptyState: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId, moduleId } = props;
// states
const [moduleIssuesListModal, setModuleIssuesListModal] = useState(false);
const { moduleIssue: moduleIssueStore } = useMobxStore();
const { setToastAlert } = useToast();
const handleAddIssuesToModule = async (data: ISearchIssueResponse[]) => {
if (!workspaceSlug || !projectId || !moduleId) return;
const issueIds = data.map((i) => i.id);
await moduleIssueStore
.addIssueToModule(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), issueIds)
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Selected issues could not be added to the module. Please try again.",
})
);
};
return (
<>
<ExistingIssuesListModal
isOpen={moduleIssuesListModal}
handleClose={() => setModuleIssuesListModal(false)}
searchParams={{ module: true }}
handleOnSubmit={handleAddIssuesToModule}
/>
<div className="h-full w-full grid place-items-center">
<EmptyState
title="Module issues will appear here"
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
image={emptyIssue}
primaryButton={{
text: "New issue",
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
},
}}
secondaryButton={
<Button
variant="neutral-primary"
prependIcon={<PlusIcon className="h-3 w-3" strokeWidth={2} />}
onClick={() => setModuleIssuesListModal(true)}
>
Add an existing issue
</Button>
}
/>
</div>
</>
);
});
export const ModuleEmptyState: React.FC<Props> = ({ openIssuesListModal }) => (
<div className="h-full w-full grid place-items-center">
<EmptyState
title="Module issues will appear here"
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
image={emptyIssue}
primaryButton={{
text: "New issue",
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
},
}}
secondaryButton={
<Button
variant="neutral-primary"
prependIcon={<PlusIcon className="h-3 w-3" strokeWidth={2} />}
onClick={openIssuesListModal}
>
Add an existing issue
</Button>
}
/>
</div>
);
@@ -1,81 +0,0 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { AppliedFiltersList } from "components/issues";
// types
import { IIssueFilterOptions } from "types";
export const ArchivedIssueAppliedFiltersRoot: React.FC = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { archivedIssueFilters: archivedIssueFiltersStore, project: projectStore } = useMobxStore();
const userFilters = archivedIssueFiltersStore.userFilters;
// filters whose value not null or empty array
const appliedFilters: IIssueFilterOptions = {};
Object.entries(userFilters).forEach(([key, value]) => {
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
appliedFilters[key as keyof IIssueFilterOptions] = value;
});
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
if (!workspaceSlug || !projectId) return;
// remove all values of the key if value is null
if (!value) {
archivedIssueFiltersStore.updateUserFilters(workspaceSlug.toString(), projectId.toString(), {
filters: {
[key]: null,
},
});
return;
}
// remove the passed value from the key
let newValues = archivedIssueFiltersStore.userFilters?.[key] ?? [];
newValues = newValues.filter((val) => val !== value);
archivedIssueFiltersStore.updateUserFilters(workspaceSlug.toString(), projectId.toString(), {
filters: {
[key]: newValues,
},
});
};
const handleClearAllFilters = () => {
if (!workspaceSlug || !projectId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(userFilters).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = null;
});
archivedIssueFiltersStore.updateUserFilters(workspaceSlug.toString(), projectId.toString(), {
filters: { ...newFilters },
});
};
// return if no filters are applied
if (Object.keys(appliedFilters).length === 0) return null;
return (
<div className="p-4">
<AppliedFiltersList
appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters}
handleRemoveFilter={handleRemoveFilter}
labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? []}
members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)}
states={projectStore.states?.[projectId?.toString() ?? ""]}
/>
</div>
);
});

Some files were not shown because too many files have changed in this diff Show More