Compare commits

...

35 Commits

Author SHA1 Message Date
M. Palanikannan c68658d877 [PE-56] fix: image aspect ratio (#5794)
* regression: image aspect ratio fix

* fix: name of variables changed for clarity
2024-10-10 20:53:20 +05:30
Bavisetti Narayan 5573d85d80 chore: only admin's can delete a project (#5790) 2024-10-10 17:24:18 +05:30
sriram veeraghanta 9bab108329 Merge pull request #5788 from makeplane/preview
release: v0.23.1
2024-10-10 15:11:04 +05:30
sriram veeraghanta 5f4875cc60 fix: version bump 2024-10-10 15:05:03 +05:30
sriram veeraghanta 0c1c6dee99 fix: adding scheduled tracing 2024-10-10 14:57:42 +05:30
Bavisetti Narayan 8a866e440c chore: only admin can changed the project settings (#5766) 2024-10-10 14:06:14 +05:30
Prateek Shourya 7495a7d0cb [WEB-2605] fix: update URL regex pattern to allow complex links. (#5767) 2024-10-10 14:06:14 +05:30
M. Palanikannan 2b1da96c3f fix: drag handle scrolling fixed (#5619)
* fix: drag handle scrolling fixed

* fix: closest scrollable parent found and scrolled

* fix: removed overflow auto from framerenderer

* fix: make dragging dynamic and smoother
2024-10-10 14:06:14 +05:30
Aaryan Khandelwal daa06f1831 [WEB-2532] fix: custom theme mutation logic (#5685)
* fix: custom theme mutation logic

* chore: update querySelector element
2024-10-10 14:06:14 +05:30
M. Palanikannan b97fcfb46d fix: show the full screen toolbar in read only instances as well (#5746) 2024-10-10 14:06:14 +05:30
M. Palanikannan 852fc9bac1 [WEB-2603] fix: remove validation of roles from the live server (#5761)
* fix: remove validation of roles from the live server

* chore: remove the service

* fix: remove all validation of authorization

* fix: props updated
2024-10-10 14:06:14 +05:30
Akshita Goyal 55f44e0245 fix: spreadsheet flicker issue (#5769) 2024-10-10 14:06:14 +05:30
Prateek Shourya 8981e52dcc [WEB-2601] improvement: add click to copy issue identifier on peek-overview and issue detail page. (#5760) 2024-10-10 14:06:14 +05:30
Akshita Goyal d92dbaea72 [WEB-2589] Chore: inbox issue permissions (#5763)
* chore: changed permission in inbox issue

* chore: fixed permissions for intake

* fix: refactoring

* fix: lint

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-10-10 14:06:14 +05:30
dependabot[bot] 58f3d0a68c chore(deps): bump django in /apiserver/requirements (#5781)
Bumps [django](https://github.com/django/django) from 4.2.15 to 4.2.16.
- [Commits](https://github.com/django/django/compare/4.2.15...4.2.16)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-10 14:06:14 +05:30
sriram veeraghanta d97ca68229 Merge pull request #5764 from makeplane/preview
release: v0.23.0
2024-10-07 18:54:49 +05:30
Bavisetti Narayan c92fe6191e [WEB-2600] fix: estimate point deletion (#5762)
* chore: only delete the cascade fields

* chore: logged the issue activity
2024-10-07 17:23:37 +05:30
pablohashescobar 7bb04003ea fix: instance trace 2024-10-07 15:56:27 +05:30
sriram veeraghanta 19dab1fad0 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-10-07 13:20:07 +05:30
M. Palanikannan 5f7b6ecf7f fix: image deletion on submit fixed in comments (#5748)
* fix: image deletion on submit fixed in comments

* fix: cleareditor added to read only editor

* fix: image component double drop fixed

* feat: multiple image selection and uploading

* fix: click event on read only instance

* fix: made things async

* fix: prevented default behaviour

* fix: removed extra dep and cleaned up logic
2024-10-07 13:12:16 +05:30
guru_sainath dfd3af13cf fix: handled favorite entity data null (#5756) 2024-10-07 12:57:15 +05:30
sriram veeraghanta 707570ca7a Merge pull request #5041 from makeplane/preview
release: v0.22-dev
2024-07-05 13:28:45 +05:30
sriram veeraghanta c76af7d7d6 Merge pull request #4688 from makeplane/preview
release: v0.21-dev
2024-06-03 18:54:06 +05:30
sriram veeraghanta 1dcea9bcc8 Merge pull request #4569 from makeplane/preview
release: v0.20-dev
2024-05-23 19:55:06 +05:30
sriram veeraghanta da957e06b6 Merge pull request #4349 from makeplane/preview
release: v0.19-dev
2024-05-03 20:36:07 +05:30
sriram veeraghanta a0b9596cb4 Merge pull request #4239 from makeplane/preview
chore:version update
2024-04-19 12:01:15 +05:30
sriram veeraghanta f71e8a3a0f Merge pull request #4238 from makeplane/preview
release: v0.18-dev
2024-04-19 11:56:03 +05:30
sriram veeraghanta 002fb4547b Merge pull request #4107 from makeplane/preview
release: v0.17-dev
2024-04-02 20:07:48 +05:30
sriram veeraghanta c1b1ba35c1 Merge pull request #3878 from makeplane/preview
release: v0.16-dev
2024-03-05 20:04:08 +05:30
sriram veeraghanta 4566d6e80c Merge pull request #3697 from makeplane/preview
release: 0.15.4-dev
2024-02-19 19:30:06 +05:30
sriram veeraghanta e8d359e625 Merge pull request #3674 from makeplane/preview
fix: build branch docker images push on release
2024-02-15 14:35:32 +05:30
sriram veeraghanta 351eba8d61 Merge pull request #3671 from makeplane/preview
release: peek overview issue description initial load bug (#3670)
2024-02-15 03:25:30 +05:30
sriram veeraghanta 1e27e37b51 Merge pull request #3666 from makeplane/preview
release: v0.15.2-dev
2024-02-14 19:41:55 +05:30
sriram veeraghanta 7df2e9cf11 Merge pull request #3632 from makeplane/preview
release: v0.15.1-dev
2024-02-12 20:59:56 +05:30
sriram veeraghanta c6e3f1b932 Merge pull request #3535 from makeplane/preview
release: 0.15-dev
2024-02-01 15:01:49 +05:30
49 changed files with 497 additions and 307 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "admin",
"version": "0.23.0",
"version": "0.23.1",
"private": true,
"scripts": {
"dev": "turbo run develop",
+1 -1
View File
@@ -1,4 +1,4 @@
{
"name": "plane-api",
"version": "0.23.0"
"version": "0.23.1"
}
+1 -1
View File
@@ -285,7 +285,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
)
# Only project admins and members can edit inbox issue attributes
if project_member.role > 5:
if project_member.role > 15:
serializer = InboxIssueSerializer(
inbox_issue, data=request.data, partial=True
)
+62 -2
View File
@@ -1,5 +1,9 @@
import random
import string
import json
# Django imports
from django.utils import timezone
# Third party imports
from rest_framework.response import Response
@@ -19,6 +23,7 @@ from plane.app.serializers import (
EstimateReadSerializer,
)
from plane.utils.cache import invalidate_cache
from plane.bgtasks.issue_activities_task import issue_activity
def generate_random_name(length=10):
@@ -249,11 +254,66 @@ class EstimatePointEndpoint(BaseViewSet):
)
# update all the issues with the new estimate
if new_estimate_id:
_ = Issue.objects.filter(
issues = Issue.objects.filter(
project_id=project_id,
workspace__slug=slug,
estimate_point_id=estimate_point_id,
).update(estimate_point_id=new_estimate_id)
)
for issue in issues:
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps(
{
"estimate_point": (
str(new_estimate_id)
if new_estimate_id
else None
),
}
),
actor_id=str(request.user.id),
issue_id=issue.id,
project_id=str(project_id),
current_instance=json.dumps(
{
"estimate_point": (
str(issue.estimate_point_id)
if issue.estimate_point_id
else None
),
}
),
epoch=int(timezone.now().timestamp()),
)
issues.update(estimate_point_id=new_estimate_id)
else:
issues = Issue.objects.filter(
project_id=project_id,
workspace__slug=slug,
estimate_point_id=estimate_point_id,
)
for issue in issues:
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps(
{
"estimate_point": None,
}
),
actor_id=str(request.user.id),
issue_id=issue.id,
project_id=str(project_id),
current_instance=json.dumps(
{
"estimate_point": (
str(issue.estimate_point_id)
if issue.estimate_point_id
else None
),
}
),
epoch=int(timezone.now().timestamp()),
)
# delete the estimate point
old_estimate_point = EstimatePoint.objects.filter(
+2 -2
View File
@@ -323,7 +323,7 @@ class InboxIssueViewSet(BaseViewSet):
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=Issue)
def partial_update(self, request, slug, project_id, pk):
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
@@ -418,7 +418,7 @@ class InboxIssueViewSet(BaseViewSet):
)
# Only project admins and members can edit inbox issue attributes
if project_member.role > 5:
if project_member.role > 15:
serializer = InboxIssueSerializer(
inbox_issue, data=request.data, partial=True
)
+50 -1
View File
@@ -413,9 +413,20 @@ class ProjectViewSet(BaseViewSet):
status=status.HTTP_410_GONE,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def partial_update(self, request, slug, pk=None):
try:
if not ProjectMember.objects.filter(
member=request.user,
workspace__slug=slug,
project_id=pk,
role=20,
is_active=True,
).exists():
return Response(
{"error": "You don't have the required permissions."},
status=status.HTTP_403_FORBIDDEN,
)
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk)
@@ -497,6 +508,44 @@ class ProjectViewSet(BaseViewSet):
status=status.HTTP_410_GONE,
)
def destroy(self, request, slug, pk):
if (
WorkspaceMember.objects.filter(
member=request.user,
workspace__slug=slug,
is_active=True,
role=20,
).exists()
or ProjectMember.objects.filter(
member=request.user,
workspace__slug=slug,
project_id=pk,
role=20,
is_active=True,
).exists()
):
project = Project.objects.get(pk=pk)
project.delete()
# Delete the project members
DeployBoard.objects.filter(
project_id=pk,
workspace__slug=slug,
).delete()
# Delete the user favorite
UserFavorite.objects.filter(
project_id=pk,
workspace__slug=slug,
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
else:
return Response(
{"error": "You don't have the required permissions."},
status=status.HTTP_403_FORBIDDEN,
)
class ProjectArchiveUnarchiveEndpoint(BaseAPIView):
+21 -13
View File
@@ -2,6 +2,7 @@
from django.utils import timezone
from django.apps import apps
from django.conf import settings
from django.db import models
from django.core.exceptions import ObjectDoesNotExist
# Third party imports
@@ -18,17 +19,25 @@ def soft_delete_related_objects(
for field in related_fields:
if field.one_to_many or field.one_to_one:
try:
if field.one_to_many:
related_objects = getattr(instance, field.name).all()
elif field.one_to_one:
related_object = getattr(instance, field.name)
related_objects = (
[related_object] if related_object is not None else []
)
for obj in related_objects:
if obj:
obj.deleted_at = timezone.now()
obj.save(using=using)
# Check if the field has CASCADE on delete
if (
hasattr(field.remote_field, "on_delete")
and field.remote_field.on_delete == models.CASCADE
):
if field.one_to_many:
related_objects = getattr(instance, field.name).all()
elif field.one_to_one:
related_object = getattr(instance, field.name)
related_objects = (
[related_object]
if related_object is not None
else []
)
for obj in related_objects:
if obj:
obj.deleted_at = timezone.now()
obj.save(using=using)
except ObjectDoesNotExist:
pass
@@ -154,8 +163,7 @@ def hard_delete():
if hasattr(model, "deleted_at"):
# Get all instances where 'deleted_at' is greater than 30 days ago
_ = model.all_objects.filter(
deleted_at__lt=timezone.now()
- timezone.timedelta(days=days)
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
).delete()
return
@@ -465,7 +465,7 @@ def track_estimate_points(
IssueActivity(
issue_id=issue_id,
actor_id=actor_id,
verb="updated",
verb="removed" if new_estimate is None else "updated",
old_identifier=(
current_instance.get("estimate_point")
if current_instance.get("estimate_point") is not None
@@ -1700,16 +1700,12 @@ def issue_activity(
event=(
"issue_comment"
if activity.field == "comment"
else "inbox_issue"
if inbox
else "issue"
else "inbox_issue" if inbox else "issue"
),
event_id=(
activity.issue_comment_id
if activity.field == "comment"
else inbox
if inbox
else activity.issue_id
else inbox if inbox else activity.issue_id
),
verb=activity.verb,
field=(
+4
View File
@@ -40,6 +40,10 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.deletion_task.hard_delete",
"schedule": crontab(hour=0, minute=0),
},
"run-every-6-hours-for-instance-trace": {
"task": "plane.license.bgtasks.tracer.instance_traces",
"schedule": crontab(hour="*/6"),
},
}
# Load task modules from all registered Django app configs.
@@ -82,6 +82,7 @@ def instance_traces():
# Set span attributes
with tracer.start_as_current_span("workspace_details") as span:
span.set_attribute("instance_id", instance.instance_id)
span.set_attribute("workspace_id", str(workspace.id))
span.set_attribute("workspace_slug", workspace.slug)
span.set_attribute("project_count", project_count)
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.15
Django==4.2.16
# rest framework
djangorestframework==3.15.2
# postgres
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "0.23.0",
"version": "0.23.1",
"description": "",
"main": "./src/server.ts",
"private": true,
-15
View File
@@ -1,15 +0,0 @@
import { ConnectionConfiguration } from "@hocuspocus/server";
// types
import { TDocumentTypes } from "@/core/types/common.js";
type TArgs = {
connection: ConnectionConfiguration
cookie: string;
documentType: TDocumentTypes | undefined;
params: URLSearchParams;
}
export const authenticateUser = async (args: TArgs): Promise<void> => {
const { documentType } = args;
throw Error(`Authentication failed: Invalid document type ${documentType} provided.`);
}
+1 -7
View File
@@ -12,15 +12,11 @@ export const getHocusPocusServer = async () => {
name: serverName,
onAuthenticate: async ({
requestHeaders,
requestParameters,
connection,
// user id used as token for authentication
token,
}) => {
// request headers
const cookie = requestHeaders.cookie?.toString();
// params
const params = requestParameters;
if (!cookie) {
throw Error("Credentials not provided");
@@ -28,9 +24,7 @@ export const getHocusPocusServer = async () => {
try {
await handleAuthentication({
connection,
cookie,
params,
token,
});
} catch (error) {
@@ -38,6 +32,6 @@ export const getHocusPocusServer = async () => {
}
},
extensions,
debounce: 10000
debounce: 10000,
});
};
+1 -46
View File
@@ -1,28 +1,17 @@
import { ConnectionConfiguration } from "@hocuspocus/server";
// services
import { UserService } from "@/core/services/user.service.js";
// types
import { TDocumentTypes } from "@/core/types/common.js";
// plane live lib
import { authenticateUser } from "@/plane-live/lib/authentication.js";
// core helpers
import { manualLogger } from "@/core/helpers/logger.js";
const userService = new UserService();
type Props = {
connection: ConnectionConfiguration;
cookie: string;
params: URLSearchParams;
token: string;
};
export const handleAuthentication = async (props: Props) => {
const { connection, cookie, params, token } = props;
// params
const documentType = params.get("documentType")?.toString() as
| TDocumentTypes
| undefined;
const { cookie, token } = props;
// fetch current user info
let response;
try {
@@ -35,40 +24,6 @@ export const handleAuthentication = async (props: Props) => {
throw Error("Authentication failed: Token doesn't match the current user.");
}
if (documentType === "project_page") {
// params
const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId) {
throw Error(
"Authentication failed: Incomplete query params. Either workspaceSlug or projectId is missing."
);
}
// fetch current user's project membership info
try {
const projectMembershipInfo = await userService.getUserProjectMembership(
workspaceSlug,
projectId,
cookie
);
const projectRole = projectMembershipInfo.role;
// make the connection read only for roles lower than a member
if (projectRole < 15) {
connection.readOnly = true;
}
} catch (error) {
manualLogger.error("Failed to fetch project membership info:", error);
throw error;
}
} else {
await authenticateUser({
connection,
cookie,
documentType,
params,
});
}
return {
user: {
id: response.id,
+1 -34
View File
@@ -1,5 +1,5 @@
// types
import type { IProjectMember, IUser } from "@plane/types";
import type { IUser } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
@@ -25,37 +25,4 @@ export class UserService extends APIService {
throw error;
});
}
async getUserWorkspaceMembership(
workspaceSlug: string,
cookie: string
): Promise<IProjectMember> {
return this.get(`/api/workspaces/${workspaceSlug}/workspace-members/me/`,
{
headers: {
Cookie: cookie,
},
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async getUserProjectMembership(
workspaceSlug: string,
projectId: string,
cookie: string
): Promise<IProjectMember> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/project-members/me/`,
{
headers: {
Cookie: cookie,
},
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"repository": "https://github.com/makeplane/plane.git",
"version": "0.23.0",
"version": "0.23.1",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "0.23.0",
"version": "0.23.1",
"private": true,
"main": "./index.ts"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "0.23.0",
"version": "0.23.1",
"description": "Core Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
@@ -57,12 +57,12 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
editorContainer,
setEditorContainer,
} = props;
const { src: remoteImageSrc, width, height, aspectRatio } = node.attrs;
const { src: remoteImageSrc, width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio } = node.attrs;
// states
const [size, setSize] = useState<Size>({
width: ensurePixelString(width, "35%"),
height: ensurePixelString(height, "auto"),
aspectRatio: aspectRatio || 1,
width: ensurePixelString(nodeWidth, "35%"),
height: ensurePixelString(nodeHeight, "auto"),
aspectRatio: nodeAspectRatio || null,
});
const [isResizing, setIsResizing] = useState(false);
const [initialResizeComplete, setInitialResizeComplete] = useState(false);
@@ -71,6 +71,17 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const containerRect = useRef<DOMRect | null>(null);
const imageRef = useRef<HTMLImageElement>(null);
const updateAttributesSafely = useCallback(
(attributes: Partial<ImageAttributes>, errorMessage: string) => {
try {
updateAttributes(attributes);
} catch (error) {
console.error(`${errorMessage}:`, error);
}
},
[updateAttributes]
);
const handleImageLoad = useCallback(() => {
const img = imageRef.current;
if (!img) return;
@@ -91,40 +102,49 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
}
setEditorContainer(closestEditorContainer);
const aspectRatio = img.naturalWidth / img.naturalHeight;
const aspectRatioCalculated = img.naturalWidth / img.naturalHeight;
if (width === "35%") {
if (nodeWidth === "35%") {
const editorWidth = closestEditorContainer.clientWidth;
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
const initialHeight = initialWidth / aspectRatio;
const initialHeight = initialWidth / aspectRatioCalculated;
const initialComputedSize = {
width: `${Math.round(initialWidth)}px` satisfies Pixel,
height: `${Math.round(initialHeight)}px` satisfies Pixel,
aspectRatio: aspectRatio,
aspectRatio: aspectRatioCalculated,
};
setSize(initialComputedSize);
updateAttributes(initialComputedSize);
updateAttributesSafely(
initialComputedSize,
"Failed to update attributes while initializing an image for the first time:"
);
} else {
// as the aspect ratio in not stored for old images, we need to update the attrs
setSize((prevSize) => {
const newSize = { ...prevSize, aspectRatio };
updateAttributes(newSize);
return newSize;
});
// or if aspectRatioCalculated from the image's width and height doesn't match stored aspectRatio then also we'll update the attrs
if (!nodeAspectRatio || nodeAspectRatio !== aspectRatioCalculated) {
setSize((prevSize) => {
const newSize = { ...prevSize, aspectRatio: aspectRatioCalculated };
updateAttributesSafely(
newSize,
"Failed to update attributes while initializing images with width but no aspect ratio:"
);
return newSize;
});
}
}
setInitialResizeComplete(true);
}, [width, updateAttributes, editorContainer]);
}, [nodeWidth, updateAttributes, editorContainer, nodeAspectRatio]);
// for real time resizing
useLayoutEffect(() => {
setSize((prevSize) => ({
...prevSize,
width: ensurePixelString(width),
height: ensurePixelString(height),
width: ensurePixelString(nodeWidth),
height: ensurePixelString(nodeHeight),
}));
}, [width, height]);
}, [nodeWidth, nodeHeight]);
const handleResize = useCallback(
(e: MouseEvent | TouchEvent) => {
@@ -142,7 +162,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const handleResizeEnd = useCallback(() => {
setIsResizing(false);
updateAttributes(size);
updateAttributesSafely(size, "Failed to update attributes at the end of resizing:");
}, [size, updateAttributes]);
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
@@ -182,8 +202,10 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
// show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or)
// if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete
const showImageLoader = !(remoteImageSrc || imageFromFileSystem) || !initialResizeComplete;
// show the image utils only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
const showImageUtils = editor.isEditable && remoteImageSrc && initialResizeComplete;
// show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
const showImageUtils = remoteImageSrc && initialResizeComplete;
// show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
const showImageResizer = editor.isEditable && remoteImageSrc && initialResizeComplete;
// show the preview image from the file system if the remote image's src is not set
const displayedImageSrc = remoteImageSrc ?? imageFromFileSystem;
@@ -194,7 +216,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
onMouseDown={handleImageMouseDown}
style={{
width: size.width,
aspectRatio: size.aspectRatio,
...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
}}
>
{showImageLoader && (
@@ -220,7 +242,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
})}
style={{
width: size.width,
aspectRatio: size.aspectRatio,
...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
}}
/>
{showImageUtils && (
@@ -239,7 +261,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
{selected && displayedImageSrc === remoteImageSrc && (
<div className="absolute inset-0 size-full bg-custom-primary-500/30" />
)}
{showImageUtils && (
{showImageResizer && (
<>
<div
className={cn(
@@ -5,9 +5,7 @@ import { ImageIcon } from "lucide-react";
// helpers
import { cn } from "@/helpers/common";
// hooks
import { useUploader, useDropZone } from "@/hooks/use-file-upload";
// plugins
import { isFileValid } from "@/plugins/image";
import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload";
// extensions
import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image";
@@ -74,7 +72,11 @@ export const CustomImageUploader = (props: {
);
// hooks
const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ onUpload, editor, loadImageFromFileSystem });
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ uploader: uploadFile });
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
uploader: uploadFile,
editor,
pos: getPos(),
});
// the meta data of the image component
const meta = useMemo(
@@ -82,9 +84,6 @@ export const CustomImageUploader = (props: {
[imageComponentImageFileMap, imageEntityId]
);
// if the image component is dropped, we check if it has an existing file
const existingFile = useMemo(() => (meta && meta.event === "drop" ? meta.file : undefined), [meta]);
// after the image component is mounted we start the upload process based on
// it's uploaded
useEffect(() => {
@@ -100,27 +99,20 @@ export const CustomImageUploader = (props: {
}
}, [meta, uploadFile, imageComponentImageFileMap]);
// check if the image is dropped and set the local image as the existing file
useEffect(() => {
if (existingFile) {
uploadFile(existingFile);
}
}, [existingFile, uploadFile]);
const onFileChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (isFileValid(file)) {
uploadFile(file);
}
async (e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
const fileList = e.target.files;
if (!fileList) {
return;
}
await uploadFirstImageAndInsertRemaining(editor, fileList, getPos(), uploadFile);
},
[uploadFile]
[uploadFile, editor, getPos]
);
const getDisplayMessage = useCallback(() => {
const isUploading = isImageBeingUploaded || existingFile;
const isUploading = isImageBeingUploaded;
if (failedToLoadImage) {
return "Error loading image";
}
@@ -134,13 +126,14 @@ export const CustomImageUploader = (props: {
}
return "Add an image";
}, [draggedInside, failedToLoadImage, existingFile, isImageBeingUploaded]);
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
return (
<div
className={cn(
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 cursor-pointer transition-all duration-200 ease-in-out",
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 transition-all duration-200 ease-in-out cursor-default",
{
"hover:text-custom-text-200 cursor-pointer": editor.isEditable,
"bg-custom-background-80 text-custom-text-200": draggedInside,
"text-custom-primary-200 bg-custom-primary-100/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200 border-custom-primary-200/10":
selected,
@@ -153,7 +146,7 @@ export const CustomImageUploader = (props: {
onDragLeave={onDragLeave}
contentEditable={false}
onClick={() => {
if (!failedToLoadImage) {
if (!failedToLoadImage && editor.isEditable) {
fileInputRef.current?.click();
}
}}
@@ -167,6 +160,7 @@ export const CustomImageUploader = (props: {
type="file"
accept=".jpg,.jpeg,.png,.webp"
onChange={onFileChange}
multiple
/>
</div>
);
+3 -10
View File
@@ -21,7 +21,7 @@ export const DropHandlerExtension = () =>
if (imageFiles.length > 0) {
const pos = view.state.selection.from;
insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" });
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
@@ -41,7 +41,7 @@ export const DropHandlerExtension = () =>
if (coordinates) {
const pos = coordinates.pos;
insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" });
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
@@ -54,7 +54,7 @@ export const DropHandlerExtension = () =>
},
});
const insertImages = async ({
export const insertImagesSafely = async ({
editor,
files,
initialPos,
@@ -72,13 +72,6 @@ const insertImages = async ({
const docSize = editor.state.doc.content.size;
pos = Math.min(pos, docSize);
// Check if the position has a non-empty node
const nodeAtPos = editor.state.doc.nodeAt(pos);
if (nodeAtPos && nodeAtPos.content.size > 0) {
// Move to the end of the current node
pos += nodeAtPos.nodeSize;
}
try {
// Insert the image at the current position
editor.commands.insertImageComponent({ file, pos, event });
@@ -42,7 +42,7 @@ export const SideMenuExtension = (props: Props) => {
ai: aiEnabled,
dragDrop: dragDropEnabled,
},
scrollThreshold: { up: 300, down: 100 },
scrollThreshold: { up: 200, down: 100 },
}),
];
},
+1 -1
View File
@@ -126,7 +126,7 @@ export const useEditor = (props: CustomEditorProps) => {
forwardedRef,
() => ({
clearEditor: (emitUpdate = false) => {
editorRef.current?.commands.clearContent(emitUpdate);
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
@@ -1,6 +1,7 @@
import { DragEvent, useCallback, useEffect, useState } from "react";
import { Editor } from "@tiptap/core";
import { isFileValid } from "@/plugins/image";
import { insertImagesSafely } from "@/extensions/drop";
export const useUploader = ({
onUpload,
@@ -63,7 +64,15 @@ export const useUploader = ({
return { uploading, uploadFile };
};
export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) => {
export const useDropZone = ({
uploader,
editor,
pos,
}: {
uploader: (file: File) => Promise<void>;
editor: Editor;
pos: number;
}) => {
const [isDragging, setIsDragging] = useState<boolean>(false);
const [draggedInside, setDraggedInside] = useState<boolean>(false);
@@ -86,40 +95,16 @@ export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) =>
}, []);
const onDrop = useCallback(
(e: DragEvent<HTMLDivElement>) => {
async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDraggedInside(false);
if (e.dataTransfer.files.length === 0) {
return;
}
const fileList = e.dataTransfer.files;
const files: File[] = [];
for (let i = 0; i < fileList.length; i += 1) {
const item = fileList.item(i);
if (item) {
files.push(item);
}
}
if (files.some((file) => file.type.indexOf("image") === -1)) {
return;
}
e.preventDefault();
const filteredFiles = files.filter((f) => f.type.indexOf("image") !== -1);
const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined;
if (file) {
uploader(file);
} else {
console.error("No file found");
}
await uploadFirstImageAndInsertRemaining(editor, fileList, pos, uploader);
},
[uploader]
[uploader, editor, pos]
);
const onDragEnter = () => {
@@ -143,3 +128,40 @@ function trimFileName(fileName: string, maxLength = 100) {
return fileName;
}
// Upload the first image and insert the remaining images for uploading multiple image
// post insertion of image-component
export async function uploadFirstImageAndInsertRemaining(
editor: Editor,
fileList: FileList,
pos: number,
uploaderFn: (file: File) => Promise<void>
) {
const filteredFiles: File[] = [];
for (let i = 0; i < fileList.length; i += 1) {
const item = fileList.item(i);
if (item && item.type.indexOf("image") !== -1 && isFileValid(item)) {
filteredFiles.push(item);
}
}
if (filteredFiles.length !== fileList.length) {
console.warn("Some files were not images and have been ignored.");
}
if (filteredFiles.length === 0) {
console.error("No image files found to upload");
return;
}
// Upload the first image
const firstFile = filteredFiles[0];
uploaderFn(firstFile);
// Insert the remaining images
const remainingFiles = filteredFiles.slice(1);
if (remainingFiles.length > 0) {
const docSize = editor.state.doc.content.size;
const posOfNextImageToBeInserted = Math.min(pos + 1, docSize);
insertImagesSafely({ editor, files: remainingFiles, initialPos: posOfNextImageToBeInserted, event: "drop" });
}
}
@@ -70,8 +70,8 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
const editorRef: MutableRefObject<Editor | null> = useRef(null);
useImperativeHandle(forwardedRef, () => ({
clearEditor: () => {
editorRef.current?.commands.clearContent();
clearEditor: (emitUpdate = false) => {
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
@@ -233,14 +233,46 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
dragHandleElement.addEventListener("click", (e) => handleClick(e, view));
dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view));
const isScrollable = (node: HTMLElement | SVGElement) => {
if (!(node instanceof HTMLElement || node instanceof SVGElement)) {
return false;
}
const style = getComputedStyle(node);
return ["overflow", "overflow-y"].some((propertyName) => {
const value = style.getPropertyValue(propertyName);
return value === "auto" || value === "scroll";
});
};
const getScrollParent = (node: HTMLElement | SVGElement) => {
let currentParent = node.parentElement;
while (currentParent) {
if (isScrollable(currentParent)) {
return currentParent;
}
currentParent = currentParent.parentElement;
}
return document.scrollingElement || document.documentElement;
};
const maxScrollSpeed = 100;
dragHandleElement.addEventListener("drag", (e) => {
hideDragHandle();
const frameRenderer = document.querySelector(".frame-renderer");
if (!frameRenderer) return;
if (e.clientY < options.scrollThreshold.up) {
frameRenderer.scrollBy({ top: -70, behavior: "smooth" });
} else if (window.innerHeight - e.clientY < options.scrollThreshold.down) {
frameRenderer.scrollBy({ top: 70, behavior: "smooth" });
const scrollableParent = getScrollParent(dragHandleElement);
if (!scrollableParent) return;
const scrollThreshold = options.scrollThreshold;
if (e.clientY < scrollThreshold.up) {
const overflow = scrollThreshold.up - e.clientY;
const ratio = Math.min(overflow / scrollThreshold.up, 1);
const scrollAmount = -maxScrollSpeed * ratio;
scrollableParent.scrollBy({ top: scrollAmount });
} else if (window.innerHeight - e.clientY < scrollThreshold.down) {
const overflow = e.clientY - (window.innerHeight - scrollThreshold.down);
const ratio = Math.min(overflow / scrollThreshold.down, 1);
const scrollAmount = maxScrollSpeed * ratio;
scrollableParent.scrollBy({ top: scrollAmount });
}
});
@@ -17,6 +17,8 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
});
transactions.forEach((transaction) => {
// if the transaction has meta of skipImageDeletion get to true, then return (like while clearing the editor content programatically)
if (transaction.getMeta("skipImageDeletion")) return;
// transaction could be a selection
if (!transaction.docChanged) return;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@plane/eslint-config",
"private": true,
"version": "0.23.0",
"version": "0.23.1",
"files": [
"library.js",
"next.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/helpers",
"version": "0.23.0",
"version": "0.23.1",
"description": "Helper functions shared across multiple apps internally",
"private": true,
"main": "./dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tailwind-config-custom",
"version": "0.23.0",
"version": "0.23.1",
"description": "common tailwind configuration across monorepo",
"main": "index.js",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/types",
"version": "0.23.0",
"version": "0.23.1",
"private": true,
"types": "./src/index.d.ts",
"main": "./src/index.d.ts"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/typescript-config",
"version": "0.23.0",
"version": "0.23.1",
"private": true,
"files": [
"base.json",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@plane/ui",
"description": "UI components shared across multiple apps internally",
"private": true,
"version": "0.23.0",
"version": "0.23.1",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "0.23.0",
"version": "0.23.1",
"private": true,
"scripts": {
"dev": "turbo run develop",
+2 -7
View File
@@ -52,13 +52,8 @@ const ProfileAppearancePage = observer(() => {
const applyThemeChange = (theme: Partial<IUserTheme>) => {
setTheme(theme?.theme || "system");
const customThemeElement = window.document?.querySelector<HTMLElement>("[data-theme='custom']");
if (theme?.theme === "custom" && theme?.palette && customThemeElement) {
applyTheme(
theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5",
false,
customThemeElement
);
if (theme?.theme === "custom" && theme?.palette) {
applyTheme(theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", false);
} else unsetCustomCssVariables();
};
@@ -1,6 +1,8 @@
import { observer } from "mobx-react";
// types
import { IIssueDisplayProperties } from "@plane/types";
// ui
import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
@@ -11,6 +13,7 @@ type TIssueIdentifierBaseProps = {
size?: "xs" | "sm" | "md" | "lg";
textContainerClassName?: string;
displayProperties?: IIssueDisplayProperties | undefined;
enableClickToCopyIdentifier?: boolean;
};
type TIssueIdentifierFromStore = TIssueIdentifierBaseProps & {
@@ -23,9 +26,48 @@ type TIssueIdentifierWithDetails = TIssueIdentifierBaseProps & {
issueSequenceId: string | number;
};
type TIssueIdentifierProps = TIssueIdentifierFromStore | TIssueIdentifierWithDetails;
export type TIssueIdentifierProps = TIssueIdentifierFromStore | TIssueIdentifierWithDetails;
type TIdentifierTextProps = {
identifier: string;
enableClickToCopyIdentifier?: boolean;
textContainerClassName?: string;
};
export const IdentifierText: React.FC<TIdentifierTextProps> = (props) => {
const { identifier, enableClickToCopyIdentifier = false, textContainerClassName } = props;
// handlers
const handleCopyIssueIdentifier = () => {
if (enableClickToCopyIdentifier) {
navigator.clipboard.writeText(identifier).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Issue ID copied to clipboard",
});
});
}
};
return (
<Tooltip tooltipContent="Click to copy" disabled={!enableClickToCopyIdentifier} position="top">
<span
className={cn(
"text-base font-medium text-custom-text-300",
{
"cursor-pointer": enableClickToCopyIdentifier,
},
textContainerClassName
)}
onClick={handleCopyIssueIdentifier}
>
{identifier}
</span>
</Tooltip>
);
};
export const IssueIdentifier: React.FC<TIssueIdentifierProps> = observer((props) => {
const { projectId, textContainerClassName, displayProperties } = props;
const { projectId, textContainerClassName, displayProperties, enableClickToCopyIdentifier = false } = props;
// store hooks
const { getProjectIdentifierById } = useProject();
const {
@@ -43,9 +85,11 @@ export const IssueIdentifier: React.FC<TIssueIdentifierProps> = observer((props)
return (
<div className="flex items-center space-x-2">
<span className={cn("text-base font-medium text-custom-text-300", textContainerClassName)}>
{projectIdentifier}-{issueSequenceId}
</span>
<IdentifierText
identifier={`${projectIdentifier}-${issueSequenceId}`}
enableClickToCopyIdentifier={enableClickToCopyIdentifier}
textContainerClassName={textContainerClassName}
/>
</div>
);
});
@@ -20,5 +20,5 @@ export const IssueTypeSwitcher: React.FC<TIssueTypeSwitcherProps> = observer((pr
if (!issue || !issue.project_id) return <></>;
return <IssueIdentifier issueId={issueId} projectId={issue.project_id} size="md" />;
return <IssueIdentifier issueId={issueId} projectId={issue.project_id} size="md" enableClickToCopyIdentifier />;
});
@@ -89,6 +89,12 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
const canDelete =
allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId) ||
issue?.created_by === currentUser?.id;
const isProjectAdmin = allowPermissions(
[EUserPermissions.ADMIN],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
projectId
);
const isAcceptedOrDeclined = inboxIssue?.status ? [-1, 1, 2].includes(inboxIssue.status) : undefined;
// days left for snooze
const numberOfDaysLeft = findHowManyDaysLeft(inboxIssue?.snoozed_till);
@@ -199,6 +205,17 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
[handleInboxIssueNavigation]
);
const handleActionWithPermission = (isAdmin: boolean, action: () => void, errorMessage: string) => {
if (isAdmin) action();
else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Permission denied",
message: errorMessage,
});
}
};
useEffect(() => {
if (!isNotificationEmbed) document.addEventListener("keydown", onKeyDown);
return () => {
@@ -293,7 +310,13 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
size="sm"
prependIcon={<CircleCheck className="w-3 h-3" />}
className="text-green-500 border-0.5 border-green-500 bg-green-500/20 focus:bg-green-500/20 focus:text-green-500 hover:bg-green-500/40 bg-opacity-20"
onClick={() => setAcceptIssueModal(true)}
onClick={() =>
handleActionWithPermission(
isProjectAdmin,
() => setAcceptIssueModal(true),
"Only project admins can accept issues"
)
}
>
Accept
</Button>
@@ -307,7 +330,13 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
size="sm"
prependIcon={<CircleX className="w-3 h-3" />}
className="text-red-500 border-0.5 border-red-500 bg-red-500/20 focus:bg-red-500/20 focus:text-red-500 hover:bg-red-500/40 bg-opacity-20"
onClick={() => setDeclineIssueModal(true)}
onClick={() =>
handleActionWithPermission(
isProjectAdmin,
() => setDeclineIssueModal(true),
"Only project admins can deny issues"
)
}
>
Decline
</Button>
@@ -341,7 +370,15 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
{isAllowed && (
<CustomMenu verticalEllipsis placement="bottom-start">
{canMarkAsAccepted && (
<CustomMenu.MenuItem onClick={handleIssueSnoozeAction}>
<CustomMenu.MenuItem
onClick={() =>
handleActionWithPermission(
isProjectAdmin,
handleIssueSnoozeAction,
"Only project admins can snooze/Un-snooze issues"
)
}
>
<div className="flex items-center gap-2">
<Clock size={14} strokeWidth={2} />
{inboxIssue?.snoozed_till && numberOfDaysLeft && numberOfDaysLeft > 0
@@ -351,7 +388,15 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
</CustomMenu.MenuItem>
)}
{canMarkAsDuplicate && (
<CustomMenu.MenuItem onClick={() => setSelectDuplicateIssue(true)}>
<CustomMenu.MenuItem
onClick={() =>
handleActionWithPermission(
isProjectAdmin,
() => setSelectDuplicateIssue(true),
"Only project admins can mark issues as duplicate"
)
}
>
<div className="flex items-center gap-2">
<FileStack size={14} strokeWidth={2} />
Mark as duplicate
@@ -401,6 +446,8 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
setIsMobileSidebar={setIsMobileSidebar}
isNotificationEmbed={isNotificationEmbed}
embedRemoveCurrentNotification={embedRemoveCurrentNotification}
isProjectAdmin={isProjectAdmin}
handleActionWithPermission={handleActionWithPermission}
/>
</div>
</>
@@ -47,6 +47,8 @@ type Props = {
setIsMobileSidebar: (value: boolean) => void;
isNotificationEmbed: boolean;
embedRemoveCurrentNotification?: () => void;
isProjectAdmin: boolean;
handleActionWithPermission: (isAdmin: boolean, action: () => void, errorMessage: string) => void;
};
export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) => {
@@ -70,6 +72,8 @@ export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) =
setIsMobileSidebar,
isNotificationEmbed,
embedRemoveCurrentNotification,
isProjectAdmin,
handleActionWithPermission,
} = props;
const router = useAppRouter();
const issue = inboxIssue?.issue;
@@ -139,7 +143,15 @@ export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) =
</CustomMenu.MenuItem>
)}
{canMarkAsAccepted && !isAcceptedOrDeclined && (
<CustomMenu.MenuItem onClick={handleIssueSnoozeAction}>
<CustomMenu.MenuItem
onClick={() =>
handleActionWithPermission(
isProjectAdmin,
handleIssueSnoozeAction,
"Only project admins can snooze/Un-snooze issues"
)
}
>
<div className="flex items-center gap-2">
<Clock size={14} strokeWidth={2} />
{inboxIssue?.snoozed_till && numberOfDaysLeft && numberOfDaysLeft > 0 ? "Un-snooze" : "Snooze"}
@@ -147,7 +159,15 @@ export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) =
</CustomMenu.MenuItem>
)}
{canMarkAsDuplicate && !isAcceptedOrDeclined && (
<CustomMenu.MenuItem onClick={() => setSelectDuplicateIssue(true)}>
<CustomMenu.MenuItem
onClick={() =>
handleActionWithPermission(
isProjectAdmin,
() => setSelectDuplicateIssue(true),
"Only project admins can mark issues as duplicate"
)
}
>
<div className="flex items-center gap-2">
<FileStack size={14} strokeWidth={2} />
Mark as duplicate
@@ -155,7 +175,15 @@ export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) =
</CustomMenu.MenuItem>
)}
{canMarkAsAccepted && (
<CustomMenu.MenuItem onClick={() => setAcceptIssueModal(true)}>
<CustomMenu.MenuItem
onClick={() =>
handleActionWithPermission(
isProjectAdmin,
() => setAcceptIssueModal(true),
"Only project admins can accept issues"
)
}
>
<div className="flex items-center gap-2 text-green-500">
<CircleCheck size={14} strokeWidth={2} />
Accept
@@ -163,7 +191,15 @@ export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) =
</CustomMenu.MenuItem>
)}
{canMarkAsDeclined && (
<CustomMenu.MenuItem onClick={() => setDeclineIssueModal(true)}>
<CustomMenu.MenuItem
onClick={() =>
handleActionWithPermission(
isProjectAdmin,
() => setDeclineIssueModal(true),
"Only project admins can deny issues"
)
}
>
<div className="flex items-center gap-2 text-red-500">
<CircleX size={14} strokeWidth={2} />
Decline
+4 -4
View File
@@ -62,10 +62,10 @@ export const InboxContentRoot: FC<TInboxContentRoot> = observer((props) => {
}
);
const isEditable = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
EUserPermissionsLevel.PROJECT
);
const isEditable =
allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT) ||
inboxIssue.created_by === currentUser?.id;
const isGuest = projectPermissionsByWorkspaceSlugAndProjectId(workspaceSlug, projectId) === EUserPermissions.GUEST;
const isOwner = inboxIssue?.issue.created_by === currentUser?.id;
const readOnly = !isOwner && isGuest;
@@ -18,7 +18,7 @@ import { SPREADSHEET_SELECT_GROUP } from "@/constants/spreadsheet";
// helper
import { cn } from "@/helpers/common.helper";
// hooks
import { useIssueDetail, useProject } from "@/hooks/store";
import { useIssueDetail, useIssues, useProject } from "@/hooks/store";
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
import { TSelectionHelper } from "@/hooks/use-multiple-select";
import { usePlatformOS } from "@/hooks/use-platform-os";
@@ -26,6 +26,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
import { IssueIdentifier } from "@/plane-web/components/issues";
// local components
import { TRenderQuickActions } from "../list/list-view-types";
import { isIssueNew } from "../utils";
import { IssueColumn } from "./issue-column";
interface Props {
@@ -42,6 +43,7 @@ interface Props {
spreadsheetColumnsList: (keyof IIssueDisplayProperties)[];
spacingLeft?: number;
selectionHelpers: TSelectionHelper;
shouldRenderByDefault?: boolean;
}
export const SpreadsheetIssueRow = observer((props: Props) => {
@@ -59,11 +61,14 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
spreadsheetColumnsList,
spacingLeft = 6,
selectionHelpers,
shouldRenderByDefault,
} = props;
// states
const [isExpanded, setExpanded] = useState<boolean>(false);
// store hooks
const { subIssues: subIssuesStore } = useIssueDetail();
const { issueMap } = useIssues();
// derived values
const subIssues = subIssuesStore.subIssuesByIssueId(issueId);
const isIssueSelected = selectionHelpers.getIsEntitySelected(issueId);
@@ -88,6 +93,7 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
})}
verticalOffset={100}
shouldRecordHeights={false}
defaultValue={shouldRenderByDefault || isIssueNew(issueMap[issueId])}
>
<IssueRowDetails
issueId={issueId}
@@ -124,6 +130,7 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
containerRef={containerRef}
spreadsheetColumnsList={spreadsheetColumnsList}
selectionHelpers={selectionHelpers}
shouldRenderByDefault={isExpanded}
/>
))}
</>
@@ -182,7 +182,7 @@ export const SidebarFavoritesMenu = observer(() => {
.map((fav, index) => (
<Tooltip
key={fav.id}
tooltipContent={fav.entity_data ? fav.entity_data.name : fav.name}
tooltipContent={fav?.entity_data ? fav.entity_data?.name : fav?.name}
position="right"
className="ml-2"
disabled={!sidebarCollapsed}
+1 -1
View File
@@ -13,7 +13,7 @@ import { useProject, usePage, useProjectView, useCycle, useModule } from "@/hook
export const useFavoriteItemDetails = (workspaceSlug: string, favorite: IFavorite) => {
const favoriteItemId = favorite?.entity_data?.id;
const favoriteItemLogoProps = favorite?.entity_data?.logo_props;
const favoriteItemName = favorite?.entity_data.name || favorite?.name;
const favoriteItemName = favorite?.entity_data?.name || favorite?.name;
const favoriteItemEntityType = favorite?.entity_type;
// store hooks
+3 -27
View File
@@ -21,8 +21,6 @@ const StoreWrapper: FC<TStoreWrapper> = observer((props) => {
const { setQuery } = useRouterParams();
const { sidebarCollapsed, toggleSidebar } = useAppTheme();
const { data: userProfile } = useUserProfile();
// states
const [dom, setDom] = useState<HTMLElement | null>(null);
/**
* Sidebar collapsed fetching from local storage
@@ -44,36 +42,14 @@ const StoreWrapper: FC<TStoreWrapper> = observer((props) => {
const currentThemePalette = userProfile?.theme?.palette;
if (currentTheme) {
setTheme(currentTheme);
if (currentTheme === "custom" && currentThemePalette && dom) {
if (currentTheme === "custom" && currentThemePalette) {
applyTheme(
currentThemePalette !== ",,,," ? currentThemePalette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5",
false,
dom
false
);
} else unsetCustomCssVariables();
}
}, [userProfile?.theme?.theme, userProfile?.theme?.palette, setTheme, dom]);
useEffect(() => {
if (dom) return;
const observer = new MutationObserver((mutationsList, observer) => {
for (const mutation of mutationsList) {
if (mutation.type === "childList") {
const customThemeElement = window.document?.querySelector<HTMLElement>("[data-theme='custom']");
if (customThemeElement) {
setDom(customThemeElement);
observer.disconnect();
break;
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}, [dom]);
}, [userProfile?.theme?.theme, userProfile?.theme?.palette, setTheme]);
useEffect(() => {
if (!params) return;
+1 -1
View File
@@ -423,7 +423,7 @@ export class ProjectInboxStore implements IProjectInboxStore {
if (inboxIssue && issueId) {
runInAction(() => {
set(this.inboxIssues, [issueId], new InboxIssueStore(workspaceSlug, projectId, inboxIssue, this.store));
this.createOrUpdateInboxIssue([inboxIssue], workspaceSlug, projectId);
set(this, "loader", undefined);
});
await Promise.all([
+1 -1
View File
@@ -270,7 +270,7 @@ export const isCommentEmpty = (comment: string | undefined): boolean => {
export const checkURLValidity = (url: string): boolean => {
if (!url) return false;
// regex to match valid URLs (with or without http/https)
const urlPattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z]{2,6})(\/[\w.-]*)*\/?(\?[=&\w.-]*)?$/i;
const urlPattern = /^(https?:\/\/)?([\w.-]+\.[a-z]{2,6})(\/[\w\-.~:/?#[\]@!$&'()*+,;=%]*)?$/i;
// test if the URL matches the pattern
return urlPattern.test(url);
};
+10 -9
View File
@@ -59,8 +59,9 @@ const calculateShades = (hexValue: string): TShades => {
return shades as TShades;
};
export const applyTheme = (palette: string, isDarkPalette: boolean, dom: HTMLElement | null) => {
export const applyTheme = (palette: string, isDarkPalette: boolean) => {
if (!palette) return;
const themeElement = document?.querySelector("html");
// palette: [bg, text, primary, sidebarBg, sidebarText]
const values: string[] = palette.split(",");
values.push(isDarkPalette ? "dark" : "light");
@@ -80,27 +81,27 @@ export const applyTheme = (palette: string, isDarkPalette: boolean, dom: HTMLEle
const sidebarBackgroundRgbValues = `${sidebarBackgroundShades[shade].r}, ${sidebarBackgroundShades[shade].g}, ${sidebarBackgroundShades[shade].b}`;
const sidebarTextRgbValues = `${sidebarTextShades[shade].r}, ${sidebarTextShades[shade].g}, ${sidebarTextShades[shade].b}`;
dom?.style.setProperty(`--color-background-${shade}`, bgRgbValues);
dom?.style.setProperty(`--color-text-${shade}`, textRgbValues);
dom?.style.setProperty(`--color-primary-${shade}`, primaryRgbValues);
dom?.style.setProperty(`--color-sidebar-background-${shade}`, sidebarBackgroundRgbValues);
dom?.style.setProperty(`--color-sidebar-text-${shade}`, sidebarTextRgbValues);
themeElement?.style.setProperty(`--color-background-${shade}`, bgRgbValues);
themeElement?.style.setProperty(`--color-text-${shade}`, textRgbValues);
themeElement?.style.setProperty(`--color-primary-${shade}`, primaryRgbValues);
themeElement?.style.setProperty(`--color-sidebar-background-${shade}`, sidebarBackgroundRgbValues);
themeElement?.style.setProperty(`--color-sidebar-text-${shade}`, sidebarTextRgbValues);
if (i >= 100 && i <= 400) {
const borderShade = i === 100 ? 70 : i === 200 ? 80 : i === 300 ? 90 : 100;
dom?.style.setProperty(
themeElement?.style.setProperty(
`--color-border-${shade}`,
`${bgShades[borderShade].r}, ${bgShades[borderShade].g}, ${bgShades[borderShade].b}`
);
dom?.style.setProperty(
themeElement?.style.setProperty(
`--color-sidebar-border-${shade}`,
`${sidebarBackgroundShades[borderShade].r}, ${sidebarBackgroundShades[borderShade].g}, ${sidebarBackgroundShades[borderShade].b}`
);
}
}
dom?.style.setProperty("--color-scheme", values[5]);
themeElement?.style.setProperty("--color-scheme", values[5]);
};
export const unsetCustomCssVariables = () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "0.23.0",
"version": "0.23.1",
"private": true,
"scripts": {
"dev": "turbo run develop",