commit
9bab108329
37 changed files with 251 additions and 195 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "admin",
|
||||
"version": "0.23.0",
|
||||
"version": "0.23.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run develop",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.23.0"
|
||||
"version": "0.23.1"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -413,9 +413,20 @@ class ProjectViewSet(BaseViewSet):
|
|||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.15
|
||||
Django==4.2.16
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "live",
|
||||
"version": "0.23.0",
|
||||
"version": "0.23.1",
|
||||
"description": "",
|
||||
"main": "./src/server.ts",
|
||||
"private": true,
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
}
|
||||
|
|
@ -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,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,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,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,6 +1,6 @@
|
|||
{
|
||||
"name": "@plane/constants",
|
||||
"version": "0.23.0",
|
||||
"version": "0.23.1",
|
||||
"private": true,
|
||||
"main": "./index.ts"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -201,8 +201,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;
|
||||
|
||||
|
|
@ -258,7 +260,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(
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export const SideMenuExtension = (props: Props) => {
|
|||
ai: aiEnabled,
|
||||
dragDrop: dragDropEnabled,
|
||||
},
|
||||
scrollThreshold: { up: 300, down: 100 },
|
||||
scrollThreshold: { up: 200, down: 100 },
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@plane/eslint-config",
|
||||
"private": true,
|
||||
"version": "0.23.0",
|
||||
"version": "0.23.1",
|
||||
"files": [
|
||||
"library.js",
|
||||
"next.js",
|
||||
|
|
|
|||
|
|
@ -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,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,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,6 +1,6 @@
|
|||
{
|
||||
"name": "@plane/typescript-config",
|
||||
"version": "0.23.0",
|
||||
"version": "0.23.1",
|
||||
"private": true,
|
||||
"files": [
|
||||
"base.json",
|
||||
|
|
|
|||
|
|
@ -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,6 +1,6 @@
|
|||
{
|
||||
"name": "space",
|
||||
"version": "0.23.0",
|
||||
"version": "0.23.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run develop",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,6 +1,6 @@
|
|||
{
|
||||
"name": "web",
|
||||
"version": "0.23.0",
|
||||
"version": "0.23.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run develop",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue