Compare commits

..

15 Commits

Author SHA1 Message Date
gakshita 314f714277 fix: added tooltips 2025-01-06 12:41:54 +05:30
gakshita 5a1c521e62 fix: types 2025-01-03 19:59:13 +05:30
gakshita fb9c3645bd fix: types 2025-01-03 19:55:13 +05:30
gakshita ff29170ec7 fix: recents api integrations 2025-01-03 19:38:14 +05:30
sangeethailango 99d9702ef5 Return issue ID 2025-01-03 17:02:30 +05:30
sangeethailango bba84361b1 Only return user ID of project members 2025-01-03 16:51:27 +05:30
gakshita 0263021f2f Merge branch 'preview' of https://github.com/makeplane/plane into feat-home 2025-01-03 15:12:39 +05:30
gakshita 0d564e6a5d fix 2025-01-03 15:12:15 +05:30
gakshita a0317650fb fix: seperate route added 2025-01-03 14:57:52 +05:30
gakshita 2254d50491 fix 2025-01-03 14:45:26 +05:30
gakshita c2412bb3ea fix: preserved old component 2025-01-03 14:39:07 +05:30
gakshita 1c020f07fe Merge branch 'preview' of https://github.com/makeplane/plane into feat-home 2024-12-31 15:33:01 +05:30
gakshita 8b43497967 Merge branch 'preview' of https://github.com/makeplane/plane into feat-home 2024-12-31 14:31:24 +05:30
gakshita ae89bed07b chore: wip 2024-12-24 15:56:24 +05:30
gakshita 15ff3eea9b wip 2024-12-19 17:36:14 +05:30
461 changed files with 6272 additions and 11704 deletions
+4 -5
View File
@@ -6,7 +6,6 @@
</a>
</p>
<h1 align="center"><b>Plane</b></h1>
<p align="center"><b>Open-source project management that unlocks customer value</b></p>
<p align="center">
<a href="https://discord.com/invite/A92xrEGCge">
@@ -58,7 +57,7 @@ Prefer full control over your data and infrastructure? Install and run Plane on
| Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://developers.plane.so/self-hosting/methods/docker-compose) |
| Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://developers.plane.so/self-hosting/methods/kubernetes) |
`Instance admins` can configure instance settings with [God mode](https://developers.plane.so/self-hosting/govern/instance-admin).
`Instance admins` can manage and customize settings using [God mode](https://developers.plane.so/self-hosting/govern/instance-admin).
## 🌟 Features
@@ -118,9 +117,9 @@ Setting up your local environment is simple and straightforward. Follow these st
Thats it! Youre all set to begin coding. Remember to refresh your browser if changes dont auto-reload. Happy contributing! 🎉
## ⚙️ Built with
[![Next JS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/)
[![Django](https://img.shields.io/badge/Django-092E20?style=for-the-badge&logo=django&logoColor=green)](https://www.djangoproject.com/)
## Built with
[![Next JS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/)<br/>
[![Django](https://img.shields.io/badge/Django-092E20?style=for-the-badge&logo=django&logoColor=green)](https://www.djangoproject.com/)<br/>
[![Node JS](https://img.shields.io/badge/node.js-339933?style=for-the-badge&logo=Node.js&logoColor=white)](https://nodejs.org/en)
## 📸 Screenshots
+3
View File
@@ -2,4 +2,7 @@ module.exports = {
root: true,
extends: ["@plane/eslint-config/next.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
};
+3 -5
View File
@@ -1,9 +1,7 @@
FROM node:20-alpine as base
# *****************************************************************************
# STAGE 1: Build the project
# *****************************************************************************
FROM base AS builder
FROM node:18-alpine AS builder
RUN apk add --no-cache libc6-compat
WORKDIR /app
@@ -15,7 +13,7 @@ RUN turbo prune --scope=admin --docker
# *****************************************************************************
# STAGE 2: Install dependencies & build the project
# *****************************************************************************
FROM base AS installer
FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat
WORKDIR /app
@@ -54,7 +52,7 @@ RUN yarn turbo run build --filter=admin
# *****************************************************************************
# STAGE 3: Copy the project and start it
# *****************************************************************************
FROM base AS runner
FROM node:18-alpine AS runner
WORKDIR /app
COPY --from=installer /app/admin/next.config.js .
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:18-alpine
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
+2 -2
View File
@@ -1,9 +1,9 @@
import React, { FC, useEffect, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
// plane imports
import { InstanceService } from "@plane/services";
// ui
import { Button, Input } from "@plane/ui";
// services
import { InstanceService } from "@/services/instance.service";
type Props = {
isOpen: boolean;
+7 -5
View File
@@ -2,16 +2,18 @@ import { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
// plane imports
// constants
import { WEB_BASE_URL, ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants";
import { InstanceWorkspaceService } from "@plane/services";
// types
import { IWorkspace } from "@plane/types";
// components
import { Button, CustomSelect, getButtonStyling, Input, setToast, TOAST_TYPE } from "@plane/ui";
// hooks
import { useWorkspace } from "@/hooks/store";
// services
import { WorkspaceService } from "@/services/workspace.service";
const instanceWorkspaceService = new InstanceWorkspaceService();
const workspaceService = new WorkspaceService();
export const WorkspaceCreateForm = () => {
// router
@@ -38,8 +40,8 @@ export const WorkspaceCreateForm = () => {
const workspaceBaseURL = encodeURI(WEB_BASE_URL || window.location.origin + "/");
const handleCreateWorkspace = async (formData: IWorkspace) => {
await instanceWorkspaceService
.slugCheck(formData.slug)
await workspaceService
.workspaceSlugCheck(formData.slug)
.then(async (res) => {
if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) {
setSlugError(false);
@@ -7,11 +7,12 @@ import { LogOut, UserCog2, Palette } from "lucide-react";
import { Menu, Transition } from "@headlessui/react";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
import {AuthService } from "@plane/services";
import { Avatar } from "@plane/ui";
import { getFileURL, cn } from "@plane/utils";
// hooks
import { useTheme, useUser } from "@/hooks/store";
// services
import { AuthService } from "@/services/auth.service";
// service initialization
const authService = new AuthService();
@@ -6,11 +6,12 @@ import { useSearchParams } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
import { AuthService } from "@plane/services";
import { Button, Checkbox, Input, Spinner } from "@plane/ui";
import { getPasswordStrength } from "@plane/utils";
// components
import { Banner, PasswordStrengthMeter } from "@/components/common";
// services
import { AuthService } from "@/services/auth.service";
// service initialization
const authService = new AuthService();
+2 -1
View File
@@ -5,12 +5,13 @@ import { useSearchParams } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { API_BASE_URL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
import { AuthService } from "@plane/services";
import { Button, Input, Spinner } from "@plane/ui";
// components
import { Banner } from "@/components/common";
// helpers
import { authErrorHandler } from "@/lib/auth-helpers";
// services
import { AuthService } from "@/services/auth.service";
// local components
import { AuthBanner } from "../authentication";
+53
View File
@@ -0,0 +1,53 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
// store
// import { rootStore } from "@/lib/store-context";
export abstract class APIService {
protected baseURL: string;
private axiosInstance: AxiosInstance;
constructor(baseURL: string) {
this.baseURL = baseURL;
this.axiosInstance = axios.create({
baseURL,
withCredentials: true,
});
this.setupInterceptors();
}
private setupInterceptors() {
// this.axiosInstance.interceptors.response.use(
// (response) => response,
// (error) => {
// const store = rootStore;
// if (error.response && error.response.status === 401 && store.user.currentUser) store.user.reset();
// return Promise.reject(error);
// }
// );
}
get<ResponseType>(url: string, params = {}): Promise<AxiosResponse<ResponseType>> {
return this.axiosInstance.get(url, { params });
}
post<RequestType, ResponseType>(url: string, data: RequestType, config = {}): Promise<AxiosResponse<ResponseType>> {
return this.axiosInstance.post(url, data, config);
}
put<RequestType, ResponseType>(url: string, data: RequestType, config = {}): Promise<AxiosResponse<ResponseType>> {
return this.axiosInstance.put(url, data, config);
}
patch<RequestType, ResponseType>(url: string, data: RequestType, config = {}): Promise<AxiosResponse<ResponseType>> {
return this.axiosInstance.patch(url, data, config);
}
delete<RequestType>(url: string, data?: RequestType, config = {}) {
return this.axiosInstance.delete(url, { data, ...config });
}
request<T>(config: AxiosRequestConfig = {}): Promise<AxiosResponse<T>> {
return this.axiosInstance(config);
}
}
+21
View File
@@ -0,0 +1,21 @@
import { API_BASE_URL } from "@plane/constants";
// services
import { APIService } from "@/services/api.service";
type TCsrfTokenResponse = {
csrf_token: string;
};
export class AuthService extends APIService {
constructor() {
super(API_BASE_URL);
}
async requestCSRFToken(): Promise<TCsrfTokenResponse> {
return this.get<TCsrfTokenResponse>("/auth/get-csrf-token/")
.then((response) => response.data)
.catch((error) => {
throw error;
});
}
}
+72
View File
@@ -0,0 +1,72 @@
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
import type {
IFormattedInstanceConfiguration,
IInstance,
IInstanceAdmin,
IInstanceConfiguration,
IInstanceInfo,
} from "@plane/types";
// helpers
import { APIService } from "@/services/api.service";
export class InstanceService extends APIService {
constructor() {
super(API_BASE_URL);
}
async getInstanceInfo(): Promise<IInstanceInfo> {
return this.get<IInstanceInfo>("/api/instances/")
.then((response) => response.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getInstanceAdmins(): Promise<IInstanceAdmin[]> {
return this.get<IInstanceAdmin[]>("/api/instances/admins/")
.then((response) => response.data)
.catch((error) => {
throw error;
});
}
async updateInstanceInfo(data: Partial<IInstance>): Promise<IInstance> {
return this.patch<Partial<IInstance>, IInstance>("/api/instances/", data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getInstanceConfigurations() {
return this.get<IInstanceConfiguration[]>("/api/instances/configurations/")
.then((response) => response.data)
.catch((error) => {
throw error;
});
}
async updateInstanceConfigurations(
data: Partial<IFormattedInstanceConfiguration>
): Promise<IInstanceConfiguration[]> {
return this.patch<Partial<IFormattedInstanceConfiguration>, IInstanceConfiguration[]>(
"/api/instances/configurations/",
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async sendTestEmail(receiverEmail: string): Promise<undefined> {
return this.post<{ receiver_email: string }, undefined>("/api/instances/email-credentials-check/", {
receiver_email: receiverEmail,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
+29
View File
@@ -0,0 +1,29 @@
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
import type { IUser } from "@plane/types";
// services
import { APIService } from "@/services/api.service";
interface IUserSession extends IUser {
isAuthenticated: boolean;
}
export class UserService extends APIService {
constructor() {
super(API_BASE_URL);
}
async authCheck(): Promise<IUserSession> {
return this.get<any>("/api/instances/admins/me/")
.then((response) => ({ ...response?.data, isAuthenticated: true }))
.catch(() => ({ isAuthenticated: false }));
}
async currentUser(): Promise<IUser> {
return this.get<IUser>("/api/instances/admins/me/")
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
}
+52
View File
@@ -0,0 +1,52 @@
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
import type { IWorkspace, TWorkspacePaginationInfo } from "@plane/types";
// services
import { APIService } from "@/services/api.service";
export class WorkspaceService extends APIService {
constructor() {
super(API_BASE_URL);
}
/**
* @description Fetches all workspaces
* @returns Promise<TWorkspacePaginationInfo>
*/
async getWorkspaces(nextPageCursor?: string): Promise<TWorkspacePaginationInfo> {
return this.get<TWorkspacePaginationInfo>("/api/instances/workspaces/", {
cursor: nextPageCursor,
})
.then((response) => response.data)
.catch((error) => {
throw error?.response?.data;
});
}
/**
* @description Checks if a slug is available
* @param slug - string
* @returns Promise<any>
*/
async workspaceSlugCheck(slug: string): Promise<any> {
const params = new URLSearchParams({ slug });
return this.get(`/api/instances/workspace-slug-check/?${params.toString()}`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
/**
* @description Creates a new workspace
* @param data - IWorkspace
* @returns Promise<IWorkspace>
*/
async createWorkspace(data: IWorkspace): Promise<IWorkspace> {
return this.post<IWorkspace, IWorkspace>("/api/instances/workspaces/", data)
.then((response) => response.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
+7 -6
View File
@@ -2,7 +2,6 @@ import set from "lodash/set";
import { observable, action, computed, makeObservable, runInAction } from "mobx";
// plane internal packages
import { EInstanceStatus, TInstanceStatus } from "@plane/constants";
import {InstanceService} from "@plane/services";
import {
IInstance,
IInstanceAdmin,
@@ -11,6 +10,8 @@ import {
IInstanceInfo,
IInstanceConfig,
} from "@plane/types";
// services
import { InstanceService } from "@/services/instance.service";
// root store
import { CoreRootStore } from "@/store/root.store";
@@ -95,7 +96,7 @@ export class InstanceStore implements IInstanceStore {
try {
if (this.instance === undefined) this.isLoading = true;
this.error = undefined;
const instanceInfo = await this.instanceService.info();
const instanceInfo = await this.instanceService.getInstanceInfo();
// handling the new user popup toggle
if (this.instance === undefined && !instanceInfo?.instance?.workspaces_exist)
this.store.theme.toggleNewUserPopup();
@@ -124,7 +125,7 @@ export class InstanceStore implements IInstanceStore {
*/
updateInstanceInfo = async (data: Partial<IInstance>) => {
try {
const instanceResponse = await this.instanceService.update(data);
const instanceResponse = await this.instanceService.updateInstanceInfo(data);
if (instanceResponse) {
runInAction(() => {
if (this.instance) set(this.instance, "instance", instanceResponse);
@@ -143,7 +144,7 @@ export class InstanceStore implements IInstanceStore {
*/
fetchInstanceAdmins = async () => {
try {
const instanceAdmins = await this.instanceService.admins();
const instanceAdmins = await this.instanceService.getInstanceAdmins();
if (instanceAdmins) runInAction(() => (this.instanceAdmins = instanceAdmins));
return instanceAdmins;
} catch (error) {
@@ -158,7 +159,7 @@ export class InstanceStore implements IInstanceStore {
*/
fetchInstanceConfigurations = async () => {
try {
const instanceConfigurations = await this.instanceService.configurations();
const instanceConfigurations = await this.instanceService.getInstanceConfigurations();
if (instanceConfigurations) runInAction(() => (this.instanceConfigurations = instanceConfigurations));
return instanceConfigurations;
} catch (error) {
@@ -173,7 +174,7 @@ export class InstanceStore implements IInstanceStore {
*/
updateInstanceConfigurations = async (data: Partial<IFormattedInstanceConfiguration>) => {
try {
const response = await this.instanceService.updateConfigurations(data);
const response = await this.instanceService.updateInstanceConfigurations(data);
runInAction(() => {
this.instanceConfigurations = this.instanceConfigurations?.map((config) => {
const item = response.find((item) => item.key === config.key);
+4 -2
View File
@@ -1,8 +1,10 @@
import { action, observable, runInAction, makeObservable } from "mobx";
// plane internal packages
import { EUserStatus, TUserStatus } from "@plane/constants";
import { AuthService, UserService } from "@plane/services";
import { IUser } from "@plane/types";
// services
import { AuthService } from "@/services/auth.service";
import { UserService } from "@/services/user.service";
// root store
import { CoreRootStore } from "@/store/root.store";
@@ -56,7 +58,7 @@ export class UserStore implements IUserStore {
fetchCurrentUser = async () => {
try {
if (this.currentUser === undefined) this.isLoading = true;
const currentUser = await this.userService.adminDetails();
const currentUser = await this.userService.currentUser();
if (currentUser) {
await this.store.instance.fetchInstanceAdmins();
runInAction(() => {
+7 -7
View File
@@ -1,8 +1,8 @@
import set from "lodash/set";
import { action, observable, runInAction, makeObservable, computed } from "mobx";
// plane imports
import { InstanceWorkspaceService } from "@plane/services";
import { IWorkspace, TLoader, TPaginationInfo } from "@plane/types";
// services
import { WorkspaceService } from "@/services/workspace.service";
// root store
import { CoreRootStore } from "@/store/root.store";
@@ -29,7 +29,7 @@ export class WorkspaceStore implements IWorkspaceStore {
workspaces: Record<string, IWorkspace> = {};
paginationInfo: TPaginationInfo | undefined = undefined;
// services
instanceWorkspaceService;
workspaceService;
constructor(private store: CoreRootStore) {
makeObservable(this, {
@@ -48,7 +48,7 @@ export class WorkspaceStore implements IWorkspaceStore {
// curd actions
createWorkspace: action,
});
this.instanceWorkspaceService = new InstanceWorkspaceService();
this.workspaceService = new WorkspaceService();
}
// computed
@@ -84,7 +84,7 @@ export class WorkspaceStore implements IWorkspaceStore {
} else {
this.loader = "init-loader";
}
const paginatedWorkspaceData = await this.instanceWorkspaceService.list();
const paginatedWorkspaceData = await this.workspaceService.getWorkspaces();
runInAction(() => {
const { results, ...paginationInfo } = paginatedWorkspaceData;
results.forEach((workspace: IWorkspace) => {
@@ -109,7 +109,7 @@ export class WorkspaceStore implements IWorkspaceStore {
if (!this.paginationInfo || this.paginationInfo.next_page_results === false) return [];
try {
this.loader = "pagination";
const paginatedWorkspaceData = await this.instanceWorkspaceService.list(this.paginationInfo.next_cursor);
const paginatedWorkspaceData = await this.workspaceService.getWorkspaces(this.paginationInfo.next_cursor);
runInAction(() => {
const { results, ...paginationInfo } = paginatedWorkspaceData;
results.forEach((workspace: IWorkspace) => {
@@ -135,7 +135,7 @@ export class WorkspaceStore implements IWorkspaceStore {
createWorkspace = async (data: IWorkspace): Promise<IWorkspace> => {
try {
this.loader = "mutation";
const workspace = await this.instanceWorkspaceService.create(data);
const workspace = await this.workspaceService.createWorkspace(data);
runInAction(() => {
set(this.workspaces, [workspace.id], workspace);
});
+3 -3
View File
@@ -18,12 +18,11 @@
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
"@plane/services": "*",
"@sentry/nextjs": "^8.32.0",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
"axios": "^1.7.9",
"axios": "^1.7.4",
"lodash": "^4.17.21",
"lucide-react": "^0.356.0",
"mobx": "^6.12.0",
@@ -35,18 +34,19 @@
"react-dom": "^18.3.1",
"react-hook-form": "7.51.5",
"swr": "^2.2.4",
"tailwindcss": "3.3.2",
"uuid": "^9.0.1",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@plane/tailwind-config": "*",
"@plane/typescript-config": "*",
"@types/node": "18.16.1",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.8",
"@types/zxcvbn": "^4.4.4",
"tailwind-config-custom": "*",
"typescript": "5.3.3"
}
}
+1 -2
View File
@@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const sharedConfig = require("@plane/tailwind-config/tailwind.config.js");
const sharedConfig = require("tailwind-config-custom/tailwind.config.js");
module.exports = {
presets: [sharedConfig],
+2 -12
View File
@@ -6,7 +6,6 @@ from .base import BaseSerializer
from plane.db.models import Cycle, CycleIssue
from plane.utils.timezone_converter import convert_to_utc
class CycleSerializer(BaseSerializer):
total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True)
@@ -31,20 +30,11 @@ class CycleSerializer(BaseSerializer):
and data.get("end_date", None) is not None
):
project_id = self.initial_data.get("project_id") or self.instance.project_id
is_start_date_end_date_equal = (
True
if str(data.get("start_date")) == str(data.get("end_date"))
else False
)
data["start_date"] = convert_to_utc(
date=str(data.get("start_date").date()),
project_id=project_id,
is_start_date=True,
str(data.get("start_date").date()), project_id, is_start_date=True
)
data["end_date"] = convert_to_utc(
date=str(data.get("end_date", None).date()),
project_id=project_id,
is_start_date_end_date_equal=is_start_date_end_date_equal,
str(data.get("end_date", None).date()), project_id
)
return data
+10
View File
@@ -288,6 +288,16 @@ class ProjectAPIEndpoint(BaseAPIView):
is_default=True,
)
# Create the triage state in Backlog group
State.objects.get_or_create(
name="Triage",
group="triage",
description="Default state for managing all Intake Issues",
project_id=pk,
color="#ff7700",
is_triage=True,
)
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
model_activity.delay(
+1 -3
View File
@@ -20,9 +20,7 @@ from .workspace import (
WorkspaceMemberMeSerializer,
WorkspaceUserPropertiesSerializer,
WorkspaceUserLinkSerializer,
WorkspaceRecentVisitSerializer,
WorkspaceHomePreferenceSerializer,
StickySerializer,
WorkspaceRecentVisitSerializer
)
from .project import (
ProjectSerializer,
+3 -16
View File
@@ -20,25 +20,12 @@ class CycleWriteSerializer(BaseSerializer):
data.get("start_date", None) is not None
and data.get("end_date", None) is not None
):
project_id = (
self.initial_data.get("project_id", None)
or (self.instance and self.instance.project_id)
or self.context.get("project_id", None)
)
is_start_date_end_date_equal = (
True
if str(data.get("start_date")) == str(data.get("end_date"))
else False
)
project_id = self.initial_data.get("project_id") or self.instance.project_id
data["start_date"] = convert_to_utc(
date=str(data.get("start_date").date()),
project_id=project_id,
is_start_date=True,
str(data.get("start_date").date()), project_id, is_start_date=True
)
data["end_date"] = convert_to_utc(
date=str(data.get("end_date", None).date()),
project_id=project_id,
is_start_date_end_date_equal=is_start_date_end_date_equal,
str(data.get("end_date", None).date()), project_id
)
return data
+3 -3
View File
@@ -1,6 +1,6 @@
# Module imports
from .base import BaseSerializer
from plane.db.models import DeprecatedDashboard, DeprecatedWidget
from plane.db.models import Dashboard, Widget
# Third party frameworks
from rest_framework import serializers
@@ -8,7 +8,7 @@ from rest_framework import serializers
class DashboardSerializer(BaseSerializer):
class Meta:
model = DeprecatedDashboard
model = Dashboard
fields = "__all__"
@@ -17,5 +17,5 @@ class WidgetSerializer(BaseSerializer):
widget_filters = serializers.JSONField(read_only=True)
class Meta:
model = DeprecatedWidget
model = Widget
fields = ["id", "key", "is_visible", "widget_filters"]
+21 -62
View File
@@ -16,12 +16,10 @@ from plane.db.models import (
WorkspaceUserProperties,
WorkspaceUserLink,
UserRecentVisit,
Issue,
Page,
Issue,
Page,
Project,
ProjectMember,
WorkspaceHomePreference,
Sticky,
ProjectMember
)
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
@@ -29,7 +27,6 @@ from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
class WorkSpaceSerializer(DynamicBaseSerializer):
owner = UserLiteSerializer(read_only=True)
total_members = serializers.IntegerField(read_only=True)
@@ -122,7 +119,6 @@ class WorkspaceUserPropertiesSerializer(BaseSerializer):
fields = "__all__"
read_only_fields = ["workspace", "user"]
class WorkspaceUserLinkSerializer(BaseSerializer):
class Meta:
model = WorkspaceUserLink
@@ -133,7 +129,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
url = data.get("url", "")
if url and not url.startswith(("http://", "https://")):
data["url"] = "http://" + url
return super().to_internal_value(data)
def validate_url(self, value):
@@ -145,88 +141,66 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
return value
class IssueRecentVisitSerializer(serializers.ModelSerializer):
project_identifier = serializers.SerializerMethodField()
class Meta:
model = Issue
fields = [
"id",
"name",
"state",
"priority",
"assignees",
"type",
"sequence_id",
"project_id",
"project_identifier",
]
fields = ["id", "name", "state", "priority", "assignees", "type", "sequence_id", "project_id", "project_identifier", ]
def get_project_identifier(self, obj):
project = obj.project
return project.identifier if project else None
class ProjectRecentVisitSerializer(serializers.ModelSerializer):
project_members = serializers.SerializerMethodField()
project_members = serializers.SerializerMethodField()
class Meta:
model = Project
fields = ["id", "name", "logo_props", "project_members", "identifier"]
def get_project_members(self, obj):
members = ProjectMember.objects.filter(
project_id=obj.id, member__is_bot=False, is_active=True
).values_list("member", flat=True)
members = ProjectMember.objects.filter(project_id=obj.id).values_list("member", flat=True)
return members
class PageRecentVisitSerializer(serializers.ModelSerializer):
project_id = serializers.SerializerMethodField()
project_identifier = serializers.SerializerMethodField()
class Meta:
model = Page
fields = [
"id",
"name",
"logo_props",
"project_id",
"owned_by",
"project_identifier",
]
fields = ["id", "name", "logo_props", "project_id", "owned_by", "project_identifier"]
def get_project_id(self, obj):
return (
obj.project_id
if hasattr(obj, "project_id")
else obj.projects.values_list("id", flat=True).first()
)
return obj.project_id if hasattr(obj, 'project_id') else obj.projects.values_list('id', flat=True).first()
def get_project_identifier(self, obj):
project = obj.projects.first()
return project.identifier if project else None
def get_entity_model_and_serializer(entity_type):
entity_map = {
"issue": (Issue, IssueRecentVisitSerializer),
"page": (Page, PageRecentVisitSerializer),
"project": (Project, ProjectRecentVisitSerializer),
"project": (Project, ProjectRecentVisitSerializer)
}
return entity_map.get(entity_type, (None, None))
class WorkspaceRecentVisitSerializer(BaseSerializer):
entity_data = serializers.SerializerMethodField()
class Meta:
model = UserRecentVisit
fields = ["id", "entity_name", "entity_identifier", "entity_data", "visited_at"]
fields = [
"id",
"entity_name",
"entity_identifier",
"entity_data",
"visited_at"
]
read_only_fields = ["workspace", "owner", "created_by", "updated_by"]
def get_entity_data(self, obj):
@@ -243,18 +217,3 @@ class WorkspaceRecentVisitSerializer(BaseSerializer):
except entity_model.DoesNotExist:
return None
return None
class WorkspaceHomePreferenceSerializer(BaseSerializer):
class Meta:
model = WorkspaceHomePreference
fields = ["key", "is_enabled", "sort_order"]
read_only_fields = ["workspace", "created_by", "updated_by"]
class StickySerializer(BaseSerializer):
class Meta:
model = Sticky
fields = "__all__"
read_only_fields = ["workspace", "owner"]
extra_kwargs = {"name": {"required": False}}
+12 -34
View File
@@ -28,9 +28,7 @@ from plane.app.views import (
WorkspaceFavoriteGroupEndpoint,
WorkspaceDraftIssueViewSet,
QuickLinkViewSet,
UserRecentVisitViewSet,
WorkspaceHomePreferenceViewSet,
WorkspaceStickyViewSet,
UserRecentVisitViewSet
)
@@ -217,45 +215,25 @@ urlpatterns = [
WorkspaceDraftIssueViewSet.as_view({"post": "create_draft_to_issue"}),
name="workspace-drafts-issues",
),
# quick link
path(
"workspaces/<str:slug>/quick-links/",
QuickLinkViewSet.as_view({"get": "list", "post": "create"}),
name="workspace-quick-links",
name="workspace-quick-links"
),
path(
"workspaces/<str:slug>/quick-links/<uuid:pk>/",
QuickLinkViewSet.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
name="workspace-quick-links",
),
# Widgets
path(
"workspaces/<str:slug>/home-preferences/",
WorkspaceHomePreferenceViewSet.as_view(),
name="workspace-home-preference",
),
path(
"workspaces/<str:slug>/home-preferences/<str:key>/",
WorkspaceHomePreferenceViewSet.as_view(),
name="workspace-home-preference",
"workspaces/<str:slug>/quick-links/<uuid:pk>/",
QuickLinkViewSet.as_view({
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy"
}),
name="workspace-quick-links"
),
path(
"workspaces/<str:slug>/recent-visits/",
UserRecentVisitViewSet.as_view({"get": "list"}),
name="workspace-recent-visits",
),
path(
"workspaces/<str:slug>/stickies/",
WorkspaceStickyViewSet.as_view({"get": "list", "post": "create"}),
name="workspace-sticky",
),
path(
"workspaces/<str:slug>/stickies/<uuid:pk>/",
WorkspaceStickyViewSet.as_view(
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
),
name="workspace-sticky",
),
name="workspace-recent-visits"
)
]
-3
View File
@@ -41,8 +41,6 @@ from .workspace.base import (
from .workspace.draft import WorkspaceDraftIssueViewSet
from .workspace.home import WorkspaceHomePreferenceViewSet
from .workspace.favorite import (
WorkspaceFavoriteEndpoint,
WorkspaceFavoriteGroupEndpoint,
@@ -76,7 +74,6 @@ from .workspace.estimate import WorkspaceEstimatesEndpoint
from .workspace.module import WorkspaceModulesEndpoint
from .workspace.cycle import WorkspaceCyclesEndpoint
from .workspace.quick_link import QuickLinkViewSet
from .workspace.sticky import WorkspaceStickyViewSet
from .state.base import StateViewSet
from .view.base import (
+15 -49
View File
@@ -54,7 +54,11 @@ from plane.bgtasks.recent_visited_task import recent_visited_task
# Module imports
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.webhook_task import model_activity
from plane.utils.timezone_converter import convert_to_utc, user_timezone_converter
from plane.utils.timezone_converter import (
convert_utc_to_project_timezone,
convert_to_utc,
user_timezone_converter,
)
class CycleViewSet(BaseViewSet):
@@ -132,18 +136,6 @@ class CycleViewSet(BaseViewSet):
),
)
)
.annotate(
pending_issues=Count(
"issue_cycle__issue__id",
distinct=True,
filter=Q(
issue_cycle__issue__state__group__in=["backlog", "unstarted", "started"],
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
status=Case(
When(
@@ -151,7 +143,10 @@ class CycleViewSet(BaseViewSet):
& Q(end_date__gte=current_time_in_utc),
then=Value("CURRENT"),
),
When(start_date__gt=current_time_in_utc, then=Value("UPCOMING")),
When(
start_date__gt=current_time_in_utc,
then=Value("UPCOMING"),
),
When(end_date__lt=current_time_in_utc, then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
@@ -226,7 +221,6 @@ class CycleViewSet(BaseViewSet):
"is_favorite",
"total_issues",
"completed_issues",
"pending_issues",
"assignee_ids",
"status",
"version",
@@ -258,7 +252,6 @@ class CycleViewSet(BaseViewSet):
# meta fields
"is_favorite",
"total_issues",
"pending_issues",
"completed_issues",
"assignee_ids",
"status",
@@ -266,9 +259,7 @@ class CycleViewSet(BaseViewSet):
"created_by",
)
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(
data, datetime_fields, request.user.user_timezone
)
data = user_timezone_converter(data, datetime_fields, request.user.user_timezone)
return Response(data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@@ -280,9 +271,7 @@ class CycleViewSet(BaseViewSet):
request.data.get("start_date", None) is not None
and request.data.get("end_date", None) is not None
):
serializer = CycleWriteSerializer(
data=request.data, context={"project_id": project_id}
)
serializer = CycleWriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id, owned_by=request.user)
cycle = (
@@ -317,11 +306,6 @@ class CycleViewSet(BaseViewSet):
.first()
)
datetime_fields = ["start_date", "end_date"]
cycle = user_timezone_converter(
cycle, datetime_fields, request.user.user_timezone
)
# Send the model activity
model_activity.delay(
model_name="cycle",
@@ -374,9 +358,7 @@ class CycleViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CycleWriteSerializer(
cycle, data=request.data, partial=True, context={"project_id": project_id}
)
serializer = CycleWriteSerializer(cycle, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
cycle = queryset.values(
@@ -406,11 +388,6 @@ class CycleViewSet(BaseViewSet):
"created_by",
).first()
datetime_fields = ["start_date", "end_date"]
cycle = user_timezone_converter(
cycle, datetime_fields, request.user.user_timezone
)
# Send the model activity
model_activity.delay(
model_name="cycle",
@@ -480,9 +457,7 @@ class CycleViewSet(BaseViewSet):
queryset = queryset.first()
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(
data, datetime_fields, request.user.user_timezone
)
data = user_timezone_converter(data, datetime_fields, request.user.user_timezone)
recent_visited_task.delay(
slug=slug,
@@ -558,17 +533,8 @@ class CycleDateCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
is_start_date_end_date_equal = (
True if str(start_date) == str(end_date) else False
)
start_date = convert_to_utc(
date=str(start_date), project_id=project_id, is_start_date=True
)
end_date = convert_to_utc(
date=str(end_date),
project_id=project_id,
is_start_date_end_date_equal=is_start_date_end_date_equal,
)
start_date = convert_to_utc(str(start_date), project_id, is_start_date=True)
end_date = convert_to_utc(str(end_date), project_id)
# Check if any cycle intersects in the given interval
cycles = Cycle.objects.filter(
+16 -22
View File
@@ -32,15 +32,15 @@ from plane.app.serializers import (
WidgetSerializer,
)
from plane.db.models import (
DeprecatedDashboard,
DeprecatedDashboardWidget,
Dashboard,
DashboardWidget,
Issue,
IssueActivity,
FileAsset,
IssueLink,
IssueRelation,
Project,
DeprecatedWidget,
Widget,
WorkspaceMember,
CycleIssue,
)
@@ -53,10 +53,10 @@ from .. import BaseAPIView
def dashboard_overview_stats(self, request, slug):
assigned_issues = (
Issue.issue_objects.filter(
(Q(assignees__in=[request.user]) & Q(issue_assignee__deleted_at__isnull=True)),
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
workspace__slug=slug,
assignees__in=[request.user],
)
.filter(
Q(
@@ -133,13 +133,10 @@ def dashboard_overview_stats(self, request, slug):
completed_issues_count = (
Issue.issue_objects.filter(
(
Q(assignees__in=[request.user])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
assignees__in=[request.user],
state__group="completed",
)
.filter(
@@ -179,13 +176,10 @@ def dashboard_assigned_issues(self, request, slug):
# get all the assigned issues
assigned_issues = (
Issue.issue_objects.filter(
(
Q(assignees__in=[request.user])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
assignees__in=[request.user],
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
@@ -693,7 +687,7 @@ class DashboardEndpoint(BaseAPIView):
if not dashboard_id:
dashboard_type = request.GET.get("dashboard_type", None)
if dashboard_type == "home":
dashboard, created = DeprecatedDashboard.objects.get_or_create(
dashboard, created = Dashboard.objects.get_or_create(
type_identifier=dashboard_type,
owned_by=request.user,
is_default=True,
@@ -713,24 +707,24 @@ class DashboardEndpoint(BaseAPIView):
updated_dashboard_widgets = []
for widget_key in widgets_to_fetch:
widget = DeprecatedWidget.objects.filter(
key=widget_key
).values_list("id", flat=True)
widget = Widget.objects.filter(key=widget_key).values_list(
"id", flat=True
)
if widget:
updated_dashboard_widgets.append(
DeprecatedDashboardWidget(
DashboardWidget(
widget_id=widget, dashboard_id=dashboard.id
)
)
DeprecatedDashboardWidget.objects.bulk_create(
DashboardWidget.objects.bulk_create(
updated_dashboard_widgets, batch_size=100
)
widgets = (
DeprecatedWidget.objects.annotate(
Widget.objects.annotate(
is_visible=Exists(
DeprecatedDashboardWidget.objects.filter(
DashboardWidget.objects.filter(
widget_id=OuterRef("pk"),
dashboard_id=dashboard.id,
is_visible=True,
@@ -739,7 +733,7 @@ class DashboardEndpoint(BaseAPIView):
)
.annotate(
dashboard_filters=Subquery(
DeprecatedDashboardWidget.objects.filter(
DashboardWidget.objects.filter(
widget_id=OuterRef("pk"),
dashboard_id=dashboard.id,
filters__isnull=False,
@@ -798,7 +792,7 @@ class DashboardEndpoint(BaseAPIView):
class WidgetsEndpoint(BaseAPIView):
def patch(self, request, dashboard_id, widget_id):
dashboard_widget = DeprecatedDashboardWidget.objects.filter(
dashboard_widget = DashboardWidget.objects.filter(
widget_id=widget_id, dashboard_id=dashboard_id
).first()
dashboard_widget.is_visible = request.data.get(
@@ -120,12 +120,10 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
# Return the presigned URL
return Response(
{
-2
View File
@@ -573,8 +573,6 @@ class PageDuplicateEndpoint(BaseAPIView):
page.name = f"{page.name} (Copy)"
page.description_binary = None
page.owned_by = request.user
page.created_by = request.user
page.updated_by = request.user
page.save()
for project_id in project_ids:
+10
View File
@@ -416,6 +416,16 @@ class ProjectViewSet(BaseViewSet):
is_default=True,
)
# Create the triage state in Backlog group
State.objects.get_or_create(
name="Triage",
group="triage",
description="Default state for managing all Intake Issues",
project_id=pk,
color="#ff7700",
is_triage=True,
)
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
model_activity.delay(
+27 -34
View File
@@ -277,14 +277,28 @@ class SearchEndpoint(BaseAPIView):
for field in fields:
q |= Q(**{f"{field}__icontains": query})
users = (
ProjectMember.objects.filter(
q,
is_active=True,
workspace__slug=slug,
member__is_bot=False,
project_id=project_id,
base_filters = Q(
q,
is_active=True,
workspace__slug=slug,
member__is_bot=False,
project_id=project_id,
role__gt=10,
)
if issue_id:
issue_created_by = (
Issue.objects.filter(id=issue_id)
.values_list("created_by_id", flat=True)
.first()
)
# Add condition to include `issue_created_by` in the query
filters = Q(member_id=issue_created_by) | base_filters
else:
filters = base_filters
# Query to fetch users
users = (
ProjectMember.objects.filter(filters)
.annotate(
member__avatar_url=Case(
When(
@@ -304,35 +318,14 @@ class SearchEndpoint(BaseAPIView):
)
)
.order_by("-created_at")
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)[:count]
)
if issue_id:
issue_created_by = (
Issue.objects.filter(id=issue_id)
.values_list("created_by_id", flat=True)
.first()
)
users = (
users.filter(Q(role__gt=10) | Q(member_id=issue_created_by))
.distinct()
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)
)
else:
users = (
users.filter(Q(role__gt=10))
.distinct()
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)
)
response_data["user_mention"] = list(users[:count])
response_data["user_mention"] = list(users)
elif query_type == "project":
fields = ["name", "identifier"]
@@ -1,85 +0,0 @@
# Module imports
from ..base import BaseAPIView
from plane.db.models.workspace import WorkspaceHomePreference
from plane.app.permissions import allow_permission, ROLE
from plane.db.models import Workspace
from plane.app.serializers.workspace import WorkspaceHomePreferenceSerializer
# Third party imports
from rest_framework.response import Response
from rest_framework import status
class WorkspaceHomePreferenceViewSet(BaseAPIView):
model = WorkspaceHomePreference
def get_serializer_class(self):
return WorkspaceHomePreferenceSerializer
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def get(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
get_preference = WorkspaceHomePreference.objects.filter(
user=request.user, workspace_id=workspace.id
)
create_preference_keys = []
keys = [
key
for key, _ in WorkspaceHomePreference.HomeWidgetKeys.choices
if key not in ["quick_tutorial", "new_at_plane"]
]
sort_order_counter = 1
for preference in keys:
if preference not in get_preference.values_list("key", flat=True):
create_preference_keys.append(preference)
sort_order = 1000 - sort_order_counter
preference = WorkspaceHomePreference.objects.bulk_create(
[
WorkspaceHomePreference(
key=key,
user=request.user,
workspace=workspace,
sort_order=sort_order,
)
for key in create_preference_keys
],
batch_size=10,
ignore_conflicts=True,
)
sort_order_counter += 1
preference = WorkspaceHomePreference.objects.filter(
user=request.user, workspace_id=workspace.id
)
return Response(
preference.values("key", "is_enabled", "config", "sort_order"),
status=status.HTTP_200_OK,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def patch(self, request, slug, key):
preference = WorkspaceHomePreference.objects.filter(
key=key, workspace__slug=slug, user=request.user
).first()
if preference:
serializer = WorkspaceHomePreferenceSerializer(
preference, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"detail": "Preference not found"}, status=status.HTTP_400_BAD_REQUEST
)
@@ -8,8 +8,7 @@ from plane.app.serializers import WorkspaceUserLinkSerializer
from ..base import BaseViewSet
from plane.app.permissions import allow_permission, ROLE
class QuickLinkViewSet(BaseViewSet):
class QuickLinkViewSet(BaseViewSet):
model = WorkspaceUserLink
def get_serializer_class(self):
@@ -17,58 +16,52 @@ class QuickLinkViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def create(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
serializer = WorkspaceUserLinkSerializer(data=request.data)
workspace = Workspace.objects.get(slug=slug, owner=request.user)
serializer = WorkspaceUserLinkSerializer(data=request.data)
if serializer.is_valid():
serializer.save(workspace_id=workspace.id, owner=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def partial_update(self, request, slug, pk):
quick_link = WorkspaceUserLink.objects.filter(
pk=pk, workspace__slug=slug, owner=request.user
).first()
quick_link = WorkspaceUserLink.objects.filter(pk=pk, workspace__slug=slug, owner=request.user).first()
if quick_link:
serializer = WorkspaceUserLinkSerializer(
quick_link, data=request.data, partial=True
)
if quick_link:
serializer = WorkspaceUserLinkSerializer(quick_link, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"detail": "Quick link not found."}, status=status.HTTP_404_NOT_FOUND
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response({"detail": "Quick link not found."}, status=status.HTTP_404_NOT_FOUND)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def retrieve(self, request, slug, pk):
try:
quick_link = WorkspaceUserLink.objects.get(
pk=pk, workspace__slug=slug, owner=request.user
pk=pk,
workspace__slug=slug,
owner=request.user
)
serializer = WorkspaceUserLinkSerializer(quick_link)
return Response(serializer.data, status=status.HTTP_200_OK)
except WorkspaceUserLink.DoesNotExist:
return Response(
{"error": "Quick link not found."}, status=status.HTTP_404_NOT_FOUND
{"error": "Quick link not found."},
status=status.HTTP_404_NOT_FOUND
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def destroy(self, request, slug, pk):
quick_link = WorkspaceUserLink.objects.get(
pk=pk, workspace__slug=slug, owner=request.user
)
quick_link.delete()
quick_link = WorkspaceUserLink.objects.get(pk=pk, workspace__slug=slug, owner=request.user)
quick_link.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def list(self, request, slug):
quick_links = WorkspaceUserLink.objects.filter(
workspace__slug=slug, owner=request.user
)
quick_links = WorkspaceUserLink.objects.filter(workspace__slug=slug, owner=request.user)
serializer = WorkspaceUserLinkSerializer(quick_links, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -9,7 +9,6 @@ from plane.app.serializers import WorkspaceRecentVisitSerializer
from ..base import BaseViewSet
from plane.app.permissions import allow_permission, ROLE
class UserRecentVisitViewSet(BaseViewSet):
model = UserRecentVisit
@@ -18,18 +17,15 @@ class UserRecentVisitViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def list(self, request, slug):
user_recent_visits = UserRecentVisit.objects.filter(
workspace__slug=slug, user=request.user
)
user_recent_visits = UserRecentVisit.objects.filter(workspace__slug=slug)
entity_name = request.query_params.get("entity_name")
entity_name = request.query_params.get("entity_name")
if entity_name:
user_recent_visits = user_recent_visits.filter(entity_name=entity_name)
if entity_name:
user_recent_visits = user_recent_visits.filter(entity_name=entity_name)
user_recent_visits = user_recent_visits.filter(entity_name__in=["issue","page","project"])
serializer = WorkspaceRecentVisitSerializer(user_recent_visits[:20], many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
user_recent_visits = user_recent_visits.filter(
entity_name__in=["issue", "page", "project"]
)
serializer = WorkspaceRecentVisitSerializer(user_recent_visits[:20], many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -1,59 +0,0 @@
# Third party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from plane.app.views.base import BaseViewSet
from plane.app.permissions import ROLE, allow_permission
from plane.db.models import Sticky, Workspace
from plane.app.serializers import StickySerializer
class WorkspaceStickyViewSet(BaseViewSet):
serializer_class = StickySerializer
model = Sticky
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(owner_id=self.request.user.id)
.select_related("workspace", "owner")
.distinct()
)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def create(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
serializer = StickySerializer(data=request.data)
if serializer.is_valid():
serializer.save(workspace_id=workspace.id, owner_id=request.user.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def list(self, request, slug):
query = request.query_params.get("query", False)
stickies = self.get_queryset().order_by("-sort_order")
if query:
stickies = stickies.filter(description_stripped__icontains=query)
return self.paginate(
request=request,
queryset=(stickies),
on_results=lambda stickies: StickySerializer(stickies, many=True).data,
default_per_page=20,
)
@allow_permission(allowed_roles=[], creator=True, model=Sticky, level="WORKSPACE")
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
@allow_permission(allowed_roles=[], creator=True, model=Sticky, level="WORKSPACE")
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
+5 -20
View File
@@ -375,11 +375,8 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
state_distribution = (
Issue.issue_objects.filter(
(
Q(assignees__in=[user_id])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
@@ -394,11 +391,8 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
priority_distribution = (
Issue.issue_objects.filter(
(
Q(assignees__in=[user_id])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
@@ -432,11 +426,8 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
assigned_issues_count = (
Issue.issue_objects.filter(
(
Q(assignees__in=[user_id])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
@@ -447,11 +438,8 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
pending_issues_count = (
Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
(
Q(assignees__in=[user_id])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
@@ -461,11 +449,8 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
completed_issues_count = (
Issue.issue_objects.filter(
(
Q(assignees__in=[user_id])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
assignees__in=[user_id],
state__group="completed",
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
@@ -16,9 +16,9 @@ from plane.utils.exception_logger import log_exception
@shared_task
def workspace_invitation(email, workspace_id, token, current_site, inviter):
def workspace_invitation(email, workspace_id, token, current_site, invitor):
try:
user = User.objects.get(email=inviter)
user = User.objects.get(email=invitor)
workspace = Workspace.objects.get(pk=workspace_id)
workspace_member_invite = WorkspaceMemberInvite.objects.get(
@@ -26,7 +26,7 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter):
)
# Relative link
relative_link = f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" # noqa: E501
relative_link = f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}"
# The complete url including the domain
abs_url = str(current_site) + relative_link
@@ -42,7 +42,7 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter):
) = get_email_configuration()
# Subject of the email
subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane" # noqa: E501
subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane"
context = {
"email": email,
@@ -78,9 +78,11 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter):
)
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane").info("Email sent successfully")
logging.getLogger("plane").info("Email sent succesfully")
return
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist):
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e:
log_exception(e)
return
except Exception as e:
log_exception(e)
@@ -1,120 +0,0 @@
# Generated by Django 4.2.17 on 2025-01-02 07:47
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
("db", "0088_sticky_sort_order_workspaceuserlink"),
]
operations = [
migrations.CreateModel(
name="WorkspaceHomePreference",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"deleted_at",
models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("key", models.CharField(max_length=255)),
("is_enabled", models.BooleanField(default=True)),
("config", models.JSONField(default=dict)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_user_home_preferences",
to=settings.AUTH_USER_MODEL,
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_user_home_preferences",
to="db.workspace",
),
),
],
options={
"verbose_name": "Workspace Home Preference",
"verbose_name_plural": "Workspace Home Preferences",
"db_table": "workspace_home_preferences",
"ordering": ("-created_at",),
},
),
migrations.AddConstraint(
model_name="workspacehomepreference",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("workspace", "user", "key"),
name="workspace_user_home_preferences_unique_workspace_user_key_when_deleted_at_null",
),
),
migrations.AlterUniqueTogether(
name="workspacehomepreference",
unique_together={("workspace", "user", "key", "deleted_at")},
),
migrations.AlterField(
model_name="page",
name="name",
field=models.TextField(blank=True),
),
migrations.AlterField(
model_name="sticky",
name="name",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='workspacehomepreference',
name='sort_order',
field=models.PositiveIntegerField(default=65535),
),
]
@@ -1,87 +0,0 @@
# Generated by Django 4.2.17 on 2025-01-09 14:43
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0089_workspacehomepreference_and_more'),
]
operations = [
migrations.RenameModel(
old_name='Dashboard',
new_name='DeprecatedDashboard',
),
migrations.RenameModel(
old_name='DashboardWidget',
new_name='DeprecatedDashboardWidget',
),
migrations.RenameModel(
old_name='Widget',
new_name='DeprecatedWidget',
),
migrations.AlterModelOptions(
name='deprecateddashboard',
options={'ordering': ('-created_at',), 'verbose_name': 'DeprecatedDashboard', 'verbose_name_plural': 'DeprecatedDashboards'},
),
migrations.AlterModelOptions(
name='deprecateddashboardwidget',
options={'ordering': ('-created_at',), 'verbose_name': 'Deprecated Dashboard Widget', 'verbose_name_plural': 'Deprecated Dashboard Widgets'},
),
migrations.AlterModelOptions(
name='deprecatedwidget',
options={'ordering': ('-created_at',), 'verbose_name': 'DeprecatedWidget', 'verbose_name_plural': 'DeprecatedWidgets'},
),
migrations.AlterField(
model_name='workspacehomepreference',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.AlterModelTable(
name='deprecateddashboard',
table='deprecated_dashboards',
),
migrations.AlterModelTable(
name='deprecateddashboardwidget',
table='deprecated_dashboard_widgets',
),
migrations.AlterModelTable(
name='deprecatedwidget',
table='deprecated_widgets',
),
migrations.CreateModel(
name='WorkspaceUserPreference',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('key', models.CharField(max_length=255)),
('is_pinned', models.BooleanField(default=False)),
('sort_order', models.FloatField(default=65535)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_user_preferences', to=settings.AUTH_USER_MODEL)),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_user_preferences', to='db.workspace')),
],
options={
'verbose_name': 'Workspace User Preference',
'verbose_name_plural': 'Workspace User Preferences',
'db_table': 'workspace_user_preferences',
'ordering': ('-created_at',),
},
),
migrations.AddConstraint(
model_name='workspaceuserpreference',
constraint=models.UniqueConstraint(condition=models.Q(('deleted_at__isnull', True)), fields=('workspace', 'user', 'key'), name='workspace_user_preferences_unique_workspace_user_key_when_deleted_at_null'),
),
migrations.AlterUniqueTogether(
name='workspaceuserpreference',
unique_together={('workspace', 'user', 'key', 'deleted_at')},
),
]
+1 -2
View File
@@ -3,7 +3,7 @@ from .api import APIActivityLog, APIToken
from .asset import FileAsset
from .base import BaseModel
from .cycle import Cycle, CycleIssue, CycleUserProperties
from .dashboard import DeprecatedDashboard, DeprecatedDashboardWidget, DeprecatedWidget
from .dashboard import Dashboard, DashboardWidget, Widget
from .deploy_board import DeployBoard
from .draft import (
DraftIssue,
@@ -69,7 +69,6 @@ from .workspace import (
WorkspaceTheme,
WorkspaceUserProperties,
WorkspaceUserLink,
WorkspaceHomePreference
)
from .favorite import UserFavorite
+14 -14
View File
@@ -8,7 +8,7 @@ from ..mixins import TimeAuditModel
from .base import BaseModel
class DeprecatedDashboard(BaseModel):
class Dashboard(BaseModel):
DASHBOARD_CHOICES = (
("workspace", "Workspace"),
("project", "Project"),
@@ -36,13 +36,13 @@ class DeprecatedDashboard(BaseModel):
return f"{self.name}"
class Meta:
verbose_name = "DeprecatedDashboard"
verbose_name_plural = "DeprecatedDashboards"
db_table = "deprecated_dashboards"
verbose_name = "Dashboard"
verbose_name_plural = "Dashboards"
db_table = "dashboards"
ordering = ("-created_at",)
class DeprecatedWidget(TimeAuditModel):
class Widget(TimeAuditModel):
id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
)
@@ -55,18 +55,18 @@ class DeprecatedWidget(TimeAuditModel):
return f"{self.key}"
class Meta:
verbose_name = "DeprecatedWidget"
verbose_name_plural = "DeprecatedWidgets"
db_table = "deprecated_widgets"
verbose_name = "Widget"
verbose_name_plural = "Widgets"
db_table = "widgets"
ordering = ("-created_at",)
class DeprecatedDashboardWidget(BaseModel):
class DashboardWidget(BaseModel):
widget = models.ForeignKey(
DeprecatedWidget, on_delete=models.CASCADE, related_name="dashboard_widgets"
Widget, on_delete=models.CASCADE, related_name="dashboard_widgets"
)
dashboard = models.ForeignKey(
DeprecatedDashboard, on_delete=models.CASCADE, related_name="dashboard_widgets"
Dashboard, on_delete=models.CASCADE, related_name="dashboard_widgets"
)
is_visible = models.BooleanField(default=True)
sort_order = models.FloatField(default=65535)
@@ -86,7 +86,7 @@ class DeprecatedDashboardWidget(BaseModel):
name="dashboard_widget_unique_widget_dashboard_when_deleted_at_null",
)
]
verbose_name = "Deprecated Dashboard Widget"
verbose_name_plural = "Deprecated Dashboard Widgets"
db_table = "deprecated_dashboard_widgets"
verbose_name = "Dashboard Widget"
verbose_name_plural = "Dashboard Widgets"
db_table = "dashboard_widgets"
ordering = ("-created_at",)
+1 -1
View File
@@ -20,7 +20,7 @@ class Page(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="pages"
)
name = models.TextField(blank=True)
name = models.CharField(max_length=255, blank=True)
description = models.JSONField(default=dict, blank=True)
description_binary = models.BinaryField(null=True)
description_html = models.TextField(blank=True, default="<p></p>")
+1 -10
View File
@@ -5,12 +5,9 @@ from django.db import models
# Module imports
from .base import BaseModel
# Third party imports
from plane.utils.html_processor import strip_tags
class Sticky(BaseModel):
name = models.TextField(null=True, blank=True)
name = models.TextField()
description = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="<p></p>")
@@ -36,12 +33,6 @@ class Sticky(BaseModel):
ordering = ("-created_at",)
def save(self, *args, **kwargs):
# Strip the html tags using html parser
self.description_stripped = (
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
if self._state.adding:
# Get the maximum sequence value from the database
last_id = Sticky.objects.filter(workspace=self.workspace).aggregate(
+1 -84
View File
@@ -1,5 +1,4 @@
# Python imports
from django.db.models.functions import Ln
import pytz
# Django imports
@@ -342,86 +341,4 @@ class WorkspaceUserLink(WorkspaceBaseModel):
ordering = ("-created_at",)
def __str__(self):
return f"{self.workspace.id} {self.url}"
class WorkspaceHomePreference(BaseModel):
"""Preference for the home page of a workspace for a user"""
class HomeWidgetKeys(models.TextChoices):
QUICK_LINKS = "quick_links", "Quick Links"
RECENTS = "recents", "Recents"
MY_STICKIES = "my_stickies", "My Stickies"
NEW_AT_PLANE = "new_at_plane", "New at Plane"
QUICK_TUTORIAL = "quick_tutorial", "Quick Tutorial"
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_user_home_preferences",
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="workspace_user_home_preferences",
)
key = models.CharField(max_length=255)
is_enabled = models.BooleanField(default=True)
config = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
class Meta:
unique_together = ["workspace", "user", "key", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["workspace", "user", "key"],
condition=models.Q(deleted_at__isnull=True),
name="workspace_user_home_preferences_unique_workspace_user_key_when_deleted_at_null",
)
]
verbose_name = "Workspace Home Preference"
verbose_name_plural = "Workspace Home Preferences"
db_table = "workspace_home_preferences"
ordering = ("-created_at",)
def __str__(self):
return f"{self.workspace.name} {self.user.email} {self.key}"
class WorkspaceUserPreference(BaseModel):
"""Preference for the workspace for a user"""
class UserPreferenceKeys(models.TextChoices):
CYCLES = "cycles", "Cycles"
VIEWS = "views", "Views"
ANALYTICS = "analytics", "Analytics"
PROJECTS = "projects", "Projects"
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_user_preferences",
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="workspace_user_preferences",
)
key = models.CharField(max_length=255)
is_pinned = models.BooleanField(default=False)
sort_order = models.FloatField(default=65535)
class Meta:
unique_together = ["workspace", "user", "key", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["workspace", "user", "key"],
condition=models.Q(deleted_at__isnull=True),
name="workspace_user_preferences_unique_workspace_user_key_when_deleted_at_null",
)
]
verbose_name = "Workspace User Preference"
verbose_name_plural = "Workspace User Preferences"
db_table = "workspace_user_preferences"
ordering = ("-created_at",)
return f"{self.workspace.id} {self.url}"
+9 -9
View File
@@ -290,12 +290,11 @@ class InstanceAdminSignInEndpoint(View):
# Fetch the user
user = User.objects.filter(email=email).first()
# Error out if the user is not present
if not user:
# is_active
if not user.is_active:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DOES_NOT_EXIST"],
error_message="ADMIN_USER_DOES_NOT_EXIST",
payload={"email": email},
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"],
error_message="ADMIN_USER_DEACTIVATED",
)
url = urljoin(
base_host(request=request, is_admin=True),
@@ -303,11 +302,12 @@ class InstanceAdminSignInEndpoint(View):
)
return HttpResponseRedirect(url)
# is_active
if not user.is_active:
# Error out if the user is not present
if not user:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"],
error_message="ADMIN_USER_DEACTIVATED",
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DOES_NOT_EXIST"],
error_message="ADMIN_USER_DOES_NOT_EXIST",
payload={"email": email},
)
url = urljoin(
base_host(request=request, is_admin=True),
-12
View File
@@ -361,18 +361,6 @@ ATTACHMENT_MIME_TYPES = [
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
"application/rtf",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.oasis.opendocument.text",
"application/vnd.oasis.opendocument.presentation",
"application/vnd.oasis.opendocument.graphics",
# Microsoft Visio
"application/vnd.visio",
# Netpbm format
"image/x-portable-graymap",
"image/x-portable-bitmap",
"image/x-portable-pixmap",
# Open Office Bae
"application/vnd.oasis.opendocument.database",
# Audio
"audio/mpeg",
"audio/wav",
+10
View File
@@ -130,6 +130,15 @@ class IntakeIssuePublicViewSet(BaseViewSet):
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
)
# Create or get state
state, _ = State.objects.get_or_create(
name="Triage",
group="backlog",
description="Default state for managing all Intake Issues",
project_id=project_deploy_board.project_id,
color="#ff7700",
)
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
@@ -139,6 +148,7 @@ class IntakeIssuePublicViewSet(BaseViewSet):
),
priority=request.data.get("issue", {}).get("priority", "low"),
project_id=project_deploy_board.project_id,
state=state,
)
# Create an Issue Activity
+2 -10
View File
@@ -3,7 +3,6 @@ from plane.db.models import Project
from datetime import datetime, time
from datetime import timedelta
def user_timezone_converter(queryset, datetime_fields, user_timezone):
# Create a timezone object for the user's timezone
user_tz = pytz.timezone(user_timezone)
@@ -29,9 +28,7 @@ def user_timezone_converter(queryset, datetime_fields, user_timezone):
return queryset_values
def convert_to_utc(
date, project_id, is_start_date=False, is_start_date_end_date_equal=False
):
def convert_to_utc(date, project_id, is_start_date=False):
"""
Converts a start date string to the project's local timezone at 12:00 AM
and then converts it to UTC for storage.
@@ -63,12 +60,7 @@ def convert_to_utc(
# If it's an start date, add one minute
if is_start_date:
localized_datetime += timedelta(minutes=0, seconds=1)
# If it's start an end date are equal, add 23 hours, 59 minutes, and 59 seconds
# to make it the end of the day
if is_start_date_end_date_equal:
localized_datetime += timedelta(hours=23, minutes=59, seconds=59)
localized_datetime += timedelta(minutes=1)
# Convert the localized datetime to UTC
utc_datetime = localized_datetime.astimezone(pytz.utc)
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.18
Django==4.2.17
# rest framework
djangorestframework==3.15.2
# postgres
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:18-alpine
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine AS base
FROM node:18-alpine AS base
# The web Dockerfile is copy-pasted into our main docs at /docs/handbook/deploying-with-docker.
# Make sure you update this Dockerfile, the Dockerfile in the web workspace and copy that over to Dockerfile in the docs.
+9 -9
View File
@@ -16,18 +16,18 @@
"author": "",
"license": "ISC",
"dependencies": {
"@hocuspocus/extension-database": "^2.15.0",
"@hocuspocus/extension-logger": "^2.15.0",
"@hocuspocus/extension-redis": "^2.15.0",
"@hocuspocus/server": "^2.15.0",
"@hocuspocus/extension-database": "^2.11.3",
"@hocuspocus/extension-logger": "^2.11.3",
"@hocuspocus/extension-redis": "^2.13.5",
"@hocuspocus/server": "^2.11.3",
"@plane/constants": "*",
"@plane/editor": "*",
"@plane/types": "*",
"@sentry/node": "^8.28.0",
"@sentry/profiling-node": "^8.28.0",
"@tiptap/core": "2.10.4",
"@tiptap/html": "2.11.0",
"axios": "^1.7.9",
"@tiptap/core": "^2.4.0",
"@tiptap/html": "^2.3.0",
"axios": "^1.7.2",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
@@ -40,9 +40,9 @@
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"uuid": "^10.0.0",
"y-prosemirror": "^1.2.15",
"y-prosemirror": "^1.2.9",
"y-protocols": "^1.0.6",
"yjs": "^13.6.20"
"yjs": "^13.6.14"
},
"devDependencies": {
"@babel/cli": "^7.25.6",
-44
View File
@@ -1,44 +0,0 @@
// plane editor
import {
getAllDocumentFormatsFromDocumentEditorBinaryData,
getAllDocumentFormatsFromRichTextEditorBinaryData,
getBinaryDataFromDocumentEditorHTMLString,
getBinaryDataFromRichTextEditorHTMLString,
} from "@plane/editor";
// plane types
import { TDocumentPayload } from "@plane/types";
type TArgs = {
document_html: string;
variant: "rich" | "document";
};
export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => {
const { document_html, variant } = args;
let allFormats: TDocumentPayload;
if (variant === "rich") {
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else if (variant === "document") {
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else {
throw new Error(`Invalid variant provided: ${variant}`);
}
return allFormats;
};
-5
View File
@@ -6,8 +6,3 @@ export type TDocumentTypes = "project_page" | TAdditionalDocumentTypes;
export type HocusPocusServerContext = {
cookie: string;
};
export type TConvertDocumentRequestBody = {
description_html: string;
variant: "rich" | "document";
};
+14 -36
View File
@@ -1,19 +1,20 @@
import "@/core/config/sentry-config.js";
import express from "express";
import expressWs from "express-ws";
import * as Sentry from "@sentry/node";
import compression from "compression";
import cors from "cors";
import expressWs from "express-ws";
import express from "express";
import helmet from "helmet";
// config
import "@/core/config/sentry-config.js";
// hocuspocus server
// cors
import cors from "cors";
// core hocuspocus server
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
// helpers
import { convertHTMLDocumentToAllFormats } from "@/core/helpers/convert-document.js";
import { logger, manualLogger } from "@/core/helpers/logger.js";
import { errorHandler } from "@/core/helpers/error-handler.js";
// types
import { TConvertDocumentRequestBody } from "@/core/types/common.js";
const app = express();
expressWs(app);
@@ -28,7 +29,7 @@ app.use(
compression({
level: 6,
threshold: 5 * 1000,
})
}),
);
// Logging middleware
@@ -61,31 +62,6 @@ router.ws("/collaboration", (ws, req) => {
}
});
router.post("/convert-document", (req, res) => {
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
try {
if (description_html === undefined || variant === undefined) {
res.status(400).send({
message: "Missing required fields",
});
return;
}
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
res.status(200).json({
description,
description_binary,
});
} catch (error) {
manualLogger.error("Error in /convert-document endpoint:", error);
res.status(500).send({
message: `Internal server error. ${error}`,
});
}
});
app.use(process.env.LIVE_BASE_PATH || "/live", router);
app.use((_req, res) => {
@@ -106,7 +82,9 @@ const gracefulShutdown = async () => {
try {
// Close the HocusPocus server WebSocket connections
await HocusPocusServer.destroy();
manualLogger.info("HocusPocus server WebSocket connections closed gracefully.");
manualLogger.info(
"HocusPocus server WebSocket connections closed gracefully.",
);
// Close the Express server
liveServer.close(() => {
-78
View File
@@ -1,78 +0,0 @@
// types
import { TXAxisValues, TYAxisValues } from "@plane/types";
export const ANALYTICS_TABS = [
{ key: "scope_and_demand", title: "Scope and Demand" },
{ key: "custom", title: "Custom Analytics" },
];
export const ANALYTICS_X_AXIS_VALUES: { value: TXAxisValues; label: string }[] =
[
{
value: "state_id",
label: "State name",
},
{
value: "state__group",
label: "State group",
},
{
value: "priority",
label: "Priority",
},
{
value: "labels__id",
label: "Label",
},
{
value: "assignees__id",
label: "Assignee",
},
{
value: "estimate_point__value",
label: "Estimate point",
},
{
value: "issue_cycle__cycle_id",
label: "Cycle",
},
{
value: "issue_module__module_id",
label: "Module",
},
{
value: "completed_at",
label: "Completed date",
},
{
value: "target_date",
label: "Due date",
},
{
value: "start_date",
label: "Start date",
},
{
value: "created_at",
label: "Created date",
},
];
export const ANALYTICS_Y_AXIS_VALUES: { value: TYAxisValues; label: string }[] =
[
{
value: "issue_count",
label: "Issue Count",
},
{
value: "estimate",
label: "Estimate",
},
];
export const ANALYTICS_DATE_KEYS = [
"completed_at",
"target_date",
"start_date",
"created_at",
];
-10
View File
@@ -1,10 +0,0 @@
export const ISSUE_REACTION_EMOJI_CODES = [
"128077",
"128078",
"128516",
"128165",
"128533",
"129505",
"9992",
"128064",
];
-4
View File
@@ -23,7 +23,3 @@ export const WEBSITE_URL =
// support email
export const SUPPORT_EMAIL =
process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@plane.so";
// marketing links
export const MARKETING_PRICING_PAGE_LINK = "https://plane.so/pricing";
export const MARKETING_CONTACT_US_PAGE_LINK = "https://plane.so/contact";
export const MARKETING_PLANE_ONE_PAGE_LINK = "https://plane.so/one";
-5
View File
@@ -1,5 +0,0 @@
export enum E_ARCHIVE_ERROR_CODES {
"INVALID_ARCHIVE_STATE_GROUP" = 4091,
"INVALID_ISSUE_START_DATE" = 4101,
"INVALID_ISSUE_TARGET_DATE" = 4102,
}
-1
View File
@@ -1 +0,0 @@
export const SIDEBAR_CLICKED = "Sidenav clicked";
-4
View File
@@ -1,4 +0,0 @@
export enum E_SORT_ORDER {
ASC = "asc",
DESC = "desc",
}
-5
View File
@@ -1,11 +1,7 @@
export * from "./ai";
export * from "./analytics";
export * from "./auth";
export * from "./endpoints";
export * from "./event";
export * from "./file";
export * from "./filter";
export * from "./graph";
export * from "./instance";
export * from "./issue";
export * from "./metadata";
@@ -13,4 +9,3 @@ export * from "./state";
export * from "./swr";
export * from "./user";
export * from "./workspace";
export * from "./stickies";
-23
View File
@@ -43,26 +43,3 @@ export const ARCHIVABLE_STATE_GROUPS = [
STATE_GROUPS.completed.key,
STATE_GROUPS.cancelled.key,
];
export const PROGRESS_STATE_GROUPS_DETAILS = [
{
key: "completed_issues",
title: "Completed",
color: "#16A34A",
},
{
key: "started_issues",
title: "Started",
color: "#F59E0B",
},
{
key: "unstarted_issues",
title: "Unstarted",
color: "#3A3A3A",
},
{
key: "backlog_issues",
title: "Backlog",
color: "#A3A3A3",
},
];
-1
View File
@@ -1 +0,0 @@
export const STICKIES_PER_PAGE = 30;
-17
View File
@@ -19,20 +19,3 @@ export type TUserStatus = {
status: EUserStatus | undefined;
message?: string;
};
export enum EUserPermissionsLevel {
WORKSPACE = "WORKSPACE",
PROJECT = "PROJECT",
}
export enum EUserWorkspaceRoles {
ADMIN = 20,
MEMBER = 15,
GUEST = 5,
}
export enum EUserProjectRoles {
ADMIN = 20,
MEMBER = 15,
GUEST = 5,
}
+27 -25
View File
@@ -12,12 +12,14 @@
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
"import": "./dist/index.mjs",
"module": "./dist/index.mjs"
},
"./lib": {
"require": "./dist/lib.js",
"types": "./dist/lib.d.mts",
"import": "./dist/lib.mjs"
"import": "./dist/lib.mjs",
"module": "./dist/lib.mjs"
}
},
"scripts": {
@@ -34,28 +36,27 @@
},
"dependencies": {
"@floating-ui/react": "^0.26.4",
"@hocuspocus/provider": "^2.15.0",
"@hocuspocus/provider": "^2.13.5",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
"@tiptap/core": "2.10.4",
"@tiptap/extension-blockquote": "2.10.4",
"@tiptap/extension-character-count": "2.11.0",
"@tiptap/extension-collaboration": "2.11.0",
"@tiptap/extension-image": "2.11.0",
"@tiptap/extension-list-item": "2.11.0",
"@tiptap/extension-mention": "2.11.0",
"@tiptap/extension-placeholder": "2.11.0",
"@tiptap/extension-task-item": "2.11.0",
"@tiptap/extension-task-list": "2.11.0",
"@tiptap/extension-text-align": "2.11.0",
"@tiptap/extension-text-style": "2.11.0",
"@tiptap/extension-underline": "2.11.0",
"@tiptap/html": "2.11.0",
"@tiptap/pm": "2.11.0",
"@tiptap/react": "2.11.0",
"@tiptap/starter-kit": "2.11.0",
"@tiptap/suggestion": "2.11.0",
"@tiptap/core": "^2.1.13",
"@tiptap/extension-blockquote": "^2.1.13",
"@tiptap/extension-character-count": "^2.6.5",
"@tiptap/extension-collaboration": "^2.3.2",
"@tiptap/extension-image": "^2.1.13",
"@tiptap/extension-list-item": "^2.1.13",
"@tiptap/extension-mention": "^2.1.13",
"@tiptap/extension-placeholder": "^2.3.0",
"@tiptap/extension-task-item": "^2.1.13",
"@tiptap/extension-task-list": "^2.1.13",
"@tiptap/extension-text-align": "^2.8.0",
"@tiptap/extension-text-style": "^2.7.1",
"@tiptap/extension-underline": "^2.1.13",
"@tiptap/pm": "^2.1.13",
"@tiptap/react": "^2.1.13",
"@tiptap/starter-kit": "^2.1.13",
"@tiptap/suggestion": "^2.0.13",
"class-variance-authority": "^0.7.0",
"highlight.js": "^11.8.0",
"jsx-dom-cjs": "^8.0.3",
@@ -64,22 +65,23 @@
"lucide-react": "^0.378.0",
"prosemirror-codemark": "^0.4.2",
"prosemirror-utils": "^1.2.2",
"react-moveable": "^0.54.2",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.10",
"tiptap-markdown": "^0.8.9",
"uuid": "^10.0.0",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.15",
"y-prosemirror": "^1.2.5",
"y-protocols": "^1.0.6",
"yjs": "^13.6.20"
"yjs": "^13.6.15"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@plane/tailwind-config": "*",
"@plane/typescript-config": "*",
"@types/node": "18.15.3",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
"postcss": "^8.4.38",
"tailwind-config-custom": "*",
"tsup": "^7.2.0",
"typescript": "5.3.3"
},
@@ -1,6 +1,5 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { Extensions } from "@tiptap/core";
import { AnyExtension } from "@tiptap/core";
import { SlashCommands } from "@/extensions";
// plane editor types
import { TIssueEmbedConfig } from "@/plane-editor/types";
@@ -14,24 +13,15 @@ type Props = {
userDetails: TUserDetails;
};
type ExtensionConfig = {
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
getExtension: (props: Props) => AnyExtension;
};
const extensionRegistry: ExtensionConfig[] = [
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
getExtension: () => SlashCommands({}),
},
];
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
const { disabledExtensions = [] } = _props;
const { disabledExtensions } = _props;
const extensions: Extensions = disabledExtensions?.includes("slash-commands")
? []
: [
SlashCommands({
disabledExtensions,
}),
];
const documentExtensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions))
.map((config) => config.getExtension(_props));
return documentExtensions;
return extensions;
};
@@ -4,7 +4,7 @@ import { TSlashCommandAdditionalOption } from "@/extensions";
import { TExtensions } from "@/types";
type Props = {
disabledExtensions?: TExtensions[];
disabledExtensions: TExtensions[];
};
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
@@ -71,7 +71,7 @@ export const BubbleMenuColorSelector: FC<Props> = (props) => {
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => TextColorItem(editor).command({ color: undefined })}
onClick={() => TextColorItem(editor).command(undefined)}
>
<Ban className="size-4" />
</button>
@@ -94,7 +94,7 @@ export const BubbleMenuColorSelector: FC<Props> = (props) => {
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => BackgroundColorItem(editor).command({ color: undefined })}
onClick={() => BackgroundColorItem(editor).command(undefined)}
>
<Ban className="size-4" />
</button>
@@ -87,9 +87,12 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
}, []);
return (
<BubbleMenu {...bubbleMenuProps}>
<BubbleMenu
{...bubbleMenuProps}
className="flex py-2 divide-x divide-custom-border-200 rounded-lg border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg"
>
{!isSelecting && (
<div className="flex py-2 divide-x divide-custom-border-200 rounded-lg border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg">
<>
<div className="px-2">
{!props.editor.isActive("table") && (
<BubbleMenuNodeSelector
@@ -158,7 +161,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
editor.commands.setTextSelection(pos ?? 0);
}}
/>
</div>
</>
)}
</BubbleMenu>
);
@@ -202,7 +202,8 @@ export const ImageItem = (editor: Editor): EditorMenuItem<"image"> => ({
key: "image",
name: "Image",
isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
command: () => insertImage({ editor, event: "insert", pos: editor.state.selection.from }),
command: ({ savedSelection }) =>
insertImage({ editor, event: "insert", pos: savedSelection?.from ?? editor.state.selection.from }),
icon: ImageIcon,
});
@@ -51,7 +51,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex } = args;
return [
// @ts-expect-error tiptap types are incorrect
StarterKit.configure({
bulletList: {
HTMLAttributes: {
@@ -42,7 +42,6 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
const { disabledExtensions, fileHandler, mentionHandler } = props;
return [
// @ts-expect-error tiptap types are incorrect
StarterKit.configure({
bulletList: {
HTMLAttributes: {
@@ -39,11 +39,11 @@ import {
setText,
} from "@/helpers/editor-commands";
// types
import { CommandProps, ISlashCommandItem, TSlashCommandSectionKeys } from "@/types";
import { CommandProps, ISlashCommandItem, TExtensions, TSlashCommandSectionKeys } from "@/types";
// plane editor extensions
import { coreEditorAdditionalSlashCommandOptions } from "@/plane-editor/extensions";
// local types
import { TExtensionProps } from "./root";
import { TSlashCommandAdditionalOption } from "./root";
export type TSlashCommandSection = {
key: TSlashCommandSectionKeys;
@@ -51,8 +51,13 @@ export type TSlashCommandSection = {
items: ISlashCommandItem[];
};
type TArgs = {
additionalOptions?: TSlashCommandAdditionalOption[];
disabledExtensions: TExtensions[];
};
export const getSlashCommandFilteredSections =
(args: TExtensionProps) =>
(args: TArgs) =>
({ query }: { query: string }): TSlashCommandSection[] => {
const { additionalOptions, disabledExtensions } = args;
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
@@ -103,9 +103,9 @@ const renderItems = () => {
};
};
export type TExtensionProps = {
type TExtensionProps = {
additionalOptions?: TSlashCommandAdditionalOption[];
disabledExtensions?: TExtensions[];
disabledExtensions: TExtensions[];
};
export const SlashCommands = (props: TExtensionProps) =>
@@ -13,51 +13,41 @@ export const setText = (editor: Editor, range?: Range) => {
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleHeading({ level: 1 }).run();
};
export const toggleHeadingTwo = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleHeading({ level: 2 }).run();
};
export const toggleHeadingThree = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleHeading({ level: 3 }).run();
};
export const toggleHeadingFour = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 4 }).run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleHeading({ level: 4 }).run();
};
export const toggleHeadingFive = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 5 }).run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleHeading({ level: 5 }).run();
};
export const toggleHeadingSix = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 6 }).run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleHeading({ level: 6 }).run();
};
export const toggleBold = (editor: Editor, range?: Range) => {
// @ts-expect-error tiptap types are incorrect
if (range) editor.chain().focus().deleteRange(range).toggleBold().run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleBold().run();
};
export const toggleItalic = (editor: Editor, range?: Range) => {
// @ts-expect-error tiptap types are incorrect
if (range) editor.chain().focus().deleteRange(range).toggleItalic().run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleItalic().run();
};
@@ -96,16 +86,12 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => {
};
export const toggleOrderedList = (editor: Editor, range?: Range) => {
// @ts-expect-error tiptap types are incorrect
if (range) editor.chain().focus().deleteRange(range).toggleOrderedList().run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleOrderedList().run();
};
export const toggleBulletList = (editor: Editor, range?: Range) => {
// @ts-expect-error tiptap types are incorrect
if (range) editor.chain().focus().deleteRange(range).toggleBulletList().run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleBulletList().run();
};
@@ -115,9 +101,7 @@ export const toggleTaskList = (editor: Editor, range?: Range) => {
};
export const toggleStrike = (editor: Editor, range?: Range) => {
// @ts-expect-error tiptap types are incorrect
if (range) editor.chain().focus().deleteRange(range).toggleStrike().run();
// @ts-expect-error tiptap types are incorrect
else editor.chain().focus().toggleStrike().run();
};
@@ -1,21 +1,27 @@
import { MutableRefObject } from "react";
import { Selection } from "@tiptap/pm/state";
import { Editor } from "@tiptap/react";
export const insertContentAtSavedSelection = (editor: Editor, content: string) => {
if (!editor || editor.isDestroyed) {
export const insertContentAtSavedSelection = (
editorRef: MutableRefObject<Editor | null>,
content: string,
savedSelection: Selection
) => {
if (!editorRef.current || editorRef.current.isDestroyed) {
console.error("Editor reference is not available or has been destroyed.");
return;
}
if (!editor.state.selection) {
if (!savedSelection) {
console.error("Saved selection is invalid.");
return;
}
const docSize = editor.state.doc.content.size;
const safePosition = Math.max(0, Math.min(editor.state.selection.anchor, docSize));
const docSize = editorRef.current.state.doc.content.size;
const safePosition = Math.max(0, Math.min(savedSelection.anchor, docSize));
try {
editor.chain().focus().insertContentAt(safePosition, content).run();
editorRef.current.chain().focus().insertContentAt(safePosition, content).run();
} catch (error) {
console.error("An error occurred while inserting content at saved selection:", error);
}
@@ -1,142 +0,0 @@
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
import * as Y from "yjs";
// extensions
import {
CoreEditorExtensionsWithoutProps,
DocumentEditorExtensionsWithoutProps,
} from "@/extensions/core-without-props";
// editor extension configs
const RICH_TEXT_EDITOR_EXTENSIONS = CoreEditorExtensionsWithoutProps;
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
// editor schemas
// @ts-expect-error tiptap types are incorrect
const richTextEditorSchema = getSchema(RICH_TEXT_EDITOR_EXTENSIONS);
// @ts-expect-error tiptap types are incorrect
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
/**
* @description apply updates to a doc and return the updated doc in binary format
* @param {Uint8Array} document
* @param {Uint8Array} updates
* @returns {Uint8Array}
*/
export const applyUpdates = (document: Uint8Array, updates?: Uint8Array): Uint8Array => {
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, document);
if (updates) {
Y.applyUpdate(yDoc, updates);
}
const encodedDoc = Y.encodeStateAsUpdate(yDoc);
return encodedDoc;
};
/**
* @description this function encodes binary data to base64 string
* @param {Uint8Array} document
* @returns {string}
*/
export const convertBinaryDataToBase64String = (document: Uint8Array): string =>
Buffer.from(document).toString("base64");
/**
* @description this function decodes base64 string to binary data
* @param {string} document
* @returns {ArrayBuffer}
*/
export const convertBase64StringToBinaryData = (document: string): ArrayBuffer => Buffer.from(document, "base64");
/**
* @description this function generates the binary equivalent of html content for the rich text editor
* @param {string} descriptionHTML
* @returns {Uint8Array}
*/
export const getBinaryDataFromRichTextEditorHTMLString = (descriptionHTML: string): Uint8Array => {
// convert HTML to JSON
// @ts-expect-error tiptap types are incorrect
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", RICH_TEXT_EDITOR_EXTENSIONS);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(richTextEditorSchema, contentJSON, "default");
// convert Y.Doc to Uint8Array format
const encodedData = Y.encodeStateAsUpdate(transformedData);
return encodedData;
};
/**
* @description this function generates the binary equivalent of html content for the document editor
* @param {string} descriptionHTML
* @returns {Uint8Array}
*/
export const getBinaryDataFromDocumentEditorHTMLString = (descriptionHTML: string): Uint8Array => {
// convert HTML to JSON
// @ts-expect-error tiptap types are incorrect
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", DOCUMENT_EDITOR_EXTENSIONS);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default");
// convert Y.Doc to Uint8Array format
const encodedData = Y.encodeStateAsUpdate(transformedData);
return encodedData;
};
/**
* @description this function generates all document formats for the provided binary data for the rich text editor
* @param {Uint8Array} description
* @returns
*/
export const getAllDocumentFormatsFromRichTextEditorBinaryData = (
description: Uint8Array
): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
} => {
// encode binary description data
const base64Data = convertBinaryDataToBase64String(description);
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, description);
// convert to JSON
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, richTextEditorSchema).toJSON();
// convert to HTML
// @ts-expect-error tiptap types are incorrect
const contentHTML = generateHTML(contentJSON, RICH_TEXT_EDITOR_EXTENSIONS);
return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
};
};
/**
* @description this function generates all document formats for the provided binary data for the document editor
* @param {Uint8Array} description
* @returns
*/
export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
description: Uint8Array
): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
} => {
// encode binary description data
const base64Data = convertBinaryDataToBase64String(description);
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, description);
// convert to JSON
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, documentEditorSchema).toJSON();
// convert to HTML
// @ts-expect-error tiptap types are incorrect
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
};
};
+16
View File
@@ -0,0 +1,16 @@
import * as Y from "yjs";
/**
* @description apply updates to a doc and return the updated doc in base64(binary) format
* @param {Uint8Array} document
* @param {Uint8Array} updates
* @returns {string} base64(binary) form of the updated doc
*/
export const applyUpdates = (document: Uint8Array, updates: Uint8Array): Uint8Array => {
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, document);
Y.applyUpdate(yDoc, updates);
const encodedDoc = Y.encodeStateAsUpdate(yDoc);
return encodedDoc;
};
+76 -49
View File
@@ -1,11 +1,12 @@
import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { DOMSerializer } from "@tiptap/pm/model";
import { Selection } from "@tiptap/pm/state";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
import { useEditor as useTiptapEditor, Editor, Extensions } from "@tiptap/react";
import * as Y from "yjs";
// components
import { getEditorMenuItems } from "@/components/menus";
import { EditorMenuItem, getEditorMenuItems } from "@/components/menus";
// extensions
import { CoreEditorExtensions } from "@/extensions";
// helpers
@@ -70,12 +71,14 @@ export const useEditor = (props: CustomEditorProps) => {
provider,
autofocus = false,
} = props;
// states
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
// refs
const editorRef: MutableRefObject<Editor | null> = useRef(null);
const savedSelectionRef = useRef(savedSelection);
const editor = useTiptapEditor(
{
editable,
immediatelyRender: false,
shouldRerenderOnTransaction: false,
autofocus,
editorProps: {
...CoreEditorProps({
@@ -97,7 +100,8 @@ export const useEditor = (props: CustomEditorProps) => {
],
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
onCreate: () => handleEditorReady?.(true),
onTransaction: () => {
onTransaction: ({ editor }) => {
setSavedSelection(editor.state.selection);
onTransaction?.();
},
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
@@ -106,17 +110,23 @@ export const useEditor = (props: CustomEditorProps) => {
[editable]
);
// Update the ref whenever savedSelection changes
useEffect(() => {
savedSelectionRef.current = savedSelection;
}, [savedSelection]);
// Effect for syncing SWR data
useEffect(() => {
// value is null when intentionally passed where syncing is not yet
// supported and value is undefined when the data from swr is not populated
if (value == null) return;
if (value === null || value === undefined) return;
if (editor && !editor.isDestroyed && !editor.storage.imageComponent.uploadInProgress) {
try {
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
if (editor.state.selection) {
const currentSavedSelection = savedSelectionRef.current;
if (currentSavedSelection) {
const docLength = editor.state.doc.content.size;
const relativePosition = Math.min(editor.state.selection.from, docLength - 1);
const relativePosition = Math.min(currentSavedSelection.from, docLength - 1);
editor.commands.setTextSelection(relativePosition);
}
} catch (error) {
@@ -128,40 +138,46 @@ export const useEditor = (props: CustomEditorProps) => {
useImperativeHandle(
forwardedRef,
() => ({
blur: () => editor.commands.blur(),
blur: () => editorRef.current?.commands.blur(),
scrollToNodeViaDOMCoordinates(behavior?: ScrollBehavior, pos?: number) {
const resolvedPos = pos ?? editor.state.selection.from;
if (!editor || !resolvedPos) return;
scrollToNodeViaDOMCoordinates(editor, resolvedPos, behavior);
const resolvedPos = pos ?? savedSelection?.from;
if (!editorRef.current || !resolvedPos) return;
scrollToNodeViaDOMCoordinates(editorRef.current, resolvedPos, behavior);
},
getCurrentCursorPosition: () => editor.state.selection.from,
getCurrentCursorPosition: () => savedSelection?.from,
clearEditor: (emitUpdate = false) => {
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
},
setEditorValue: (content: string) => {
editor?.commands.setContent(content, false, { preserveWhitespace: "full" });
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
},
setEditorValueAtCursorPosition: (content: string) => {
if (editor.state.selection) {
insertContentAtSavedSelection(editor, content);
if (savedSelection) {
insertContentAtSavedSelection(editorRef, content, savedSelection);
}
},
executeMenuItemCommand: (props) => {
const { itemKey } = props;
const editorItems = getEditorMenuItems(editor);
const editorItems = getEditorMenuItems(editorRef.current);
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
const item = getEditorMenuItem(itemKey);
if (item) {
item.command(props);
if (item.key === "image") {
(item as EditorMenuItem<"image">).command({
savedSelection: savedSelectionRef.current,
});
} else {
item.command(props);
}
} else {
console.warn(`No command found for item: ${itemKey}`);
}
},
isMenuItemActive: (props) => {
const { itemKey } = props;
const editorItems = getEditorMenuItems(editor);
const editorItems = getEditorMenuItems(editorRef.current);
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
const item = getEditorMenuItem(itemKey);
@@ -171,20 +187,20 @@ export const useEditor = (props: CustomEditorProps) => {
},
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
// Subscribe to update event emitted from headers extension
editor?.on("update", () => {
callback(editor?.storage.headingList.headings);
editorRef.current?.on("update", () => {
callback(editorRef.current?.storage.headingList.headings);
});
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editor?.off("update");
editorRef.current?.off("update");
};
},
getHeadings: () => editor?.storage.headingList.headings,
getHeadings: () => editorRef?.current?.storage.headingList.headings,
onStateChange: (callback: () => void) => {
// Subscribe to editor state changes
editor?.on("transaction", () => {
editorRef.current?.on("transaction", () => {
callback();
});
@@ -192,17 +208,17 @@ export const useEditor = (props: CustomEditorProps) => {
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editor?.off("transaction");
editorRef.current?.off("transaction");
};
},
getMarkDown: (): string => {
const markdownOutput = editor?.storage.markdown.getMarkdown();
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
return markdownOutput;
},
getDocument: () => {
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
const documentHTML = editor?.getHTML() ?? "<p></p>";
const documentJSON = editor.getJSON() ?? null;
const documentHTML = editorRef.current?.getHTML() ?? "<p></p>";
const documentJSON = editorRef.current?.getJSON() ?? null;
return {
binary: documentBinary,
@@ -211,19 +227,19 @@ export const useEditor = (props: CustomEditorProps) => {
};
},
scrollSummary: (marking: IMarking): void => {
if (!editor) return;
scrollSummary(editor, marking);
if (!editorRef.current) return;
scrollSummary(editorRef.current, marking);
},
isEditorReadyToDiscard: () => editor?.storage.imageComponent.uploadInProgress === false,
isEditorReadyToDiscard: () => editorRef.current?.storage.imageComponent.uploadInProgress === false,
setFocusAtPosition: (position: number) => {
if (!editor || editor.isDestroyed) {
if (!editorRef.current || editorRef.current.isDestroyed) {
console.error("Editor reference is not available or has been destroyed.");
return;
}
try {
const docSize = editor.state.doc.content.size;
const docSize = editorRef.current.state.doc.content.size;
const safePosition = Math.max(0, Math.min(position, docSize));
editor
editorRef.current
.chain()
.insertContentAt(safePosition, [{ type: "paragraph" }])
.focus()
@@ -233,17 +249,17 @@ export const useEditor = (props: CustomEditorProps) => {
}
},
getSelectedText: () => {
if (!editor) return null;
if (!editorRef.current) return null;
const { state } = editor;
const { state } = editorRef.current;
const { from, to, empty } = state.selection;
if (empty) return null;
const nodesArray: string[] = [];
state.doc.nodesBetween(from, to, (node, _pos, parent) => {
if (parent === state.doc && editor) {
const serializer = DOMSerializer.fromSchema(editor.schema);
if (parent === state.doc && editorRef.current) {
const serializer = DOMSerializer.fromSchema(editorRef.current?.schema);
const dom = serializer.serializeNode(node);
const tempDiv = document.createElement("div");
tempDiv.appendChild(dom);
@@ -254,21 +270,28 @@ export const useEditor = (props: CustomEditorProps) => {
return selection;
},
insertText: (contentHTML, insertOnNextLine) => {
if (!editor) return;
const { from, to, empty } = editor.state.selection;
if (!editorRef.current) return;
// get selection
const { from, to, empty } = editorRef.current.state.selection;
if (empty) return;
if (insertOnNextLine) {
// move cursor to the end of the selection and insert a new line
editor.chain().focus().setTextSelection(to).insertContent("<br />").insertContent(contentHTML).run();
editorRef.current
.chain()
.focus()
.setTextSelection(to)
.insertContent("<br />")
.insertContent(contentHTML)
.run();
} else {
// replace selected text with the content provided
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
editorRef.current.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
}
},
getDocumentInfo: () => ({
characters: editor?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editor?.state),
words: editor?.storage?.characterCount?.words?.() ?? 0,
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef?.current?.state),
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
}),
setProviderDocument: (value) => {
const document = provider?.document;
@@ -278,12 +301,16 @@ export const useEditor = (props: CustomEditorProps) => {
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
}),
[editor]
[editorRef, savedSelection]
);
if (!editor) {
return null;
}
// the editorRef is used to access the editor instance from outside the hook
// and should only be used after editor is initialized
editorRef.current = editor;
return editor;
};
@@ -1,7 +1,7 @@
import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
import { useEditor as useCustomEditor, Editor, Extensions } from "@tiptap/react";
import * as Y from "yjs";
// extensions
import { CoreReadOnlyEditorExtensions } from "@/extensions";
@@ -11,7 +11,13 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
// props
import { CoreReadOnlyEditorProps } from "@/props";
// types
import type { EditorReadOnlyRefApi, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
import type {
EditorReadOnlyRefApi,
TExtensions,
TDocumentEventsServer,
TFileHandler,
TReadOnlyMentionHandler,
} from "@/types";
interface CustomReadOnlyEditorProps {
disabledExtensions: TExtensions[];
@@ -40,10 +46,8 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
provider,
} = props;
const editor = useTiptapEditor({
const editor = useCustomEditor({
editable: false,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
editorProps: {
...CoreReadOnlyEditorProps({
@@ -73,21 +77,23 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" });
}, [editor, initialValue]);
const editorRef: MutableRefObject<Editor | null> = useRef(null);
useImperativeHandle(forwardedRef, () => ({
clearEditor: (emitUpdate = false) => {
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
},
setEditorValue: (content: string) => {
editor?.commands.setContent(content, false, { preserveWhitespace: "full" });
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
},
getMarkDown: (): string => {
const markdownOutput = editor?.storage.markdown.getMarkdown();
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
return markdownOutput;
},
getDocument: () => {
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
const documentHTML = editor?.getHTML() ?? "<p></p>";
const documentJSON = editor?.getJSON() ?? null;
const documentHTML = editorRef.current?.getHTML() ?? "<p></p>";
const documentJSON = editorRef.current?.getJSON() ?? null;
return {
binary: documentBinary,
@@ -96,22 +102,35 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
};
},
scrollSummary: (marking: IMarking): void => {
if (!editor) return;
scrollSummary(editor, marking);
if (!editorRef.current) return;
scrollSummary(editorRef.current, marking);
},
getDocumentInfo: () => {
if (!editor) return;
return {
characters: editor.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editor.state),
words: editor.storage?.characterCount?.words?.() ?? 0,
getDocumentInfo: () => ({
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef?.current?.state),
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
}),
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
// Subscribe to update event emitted from headers extension
editorRef.current?.on("update", () => {
callback(editorRef.current?.storage.headingList.headings);
});
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editorRef.current?.off("update");
};
},
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
getHeadings: () => editorRef?.current?.storage.headingList.headings,
}));
if (!editor) {
return null;
}
editorRef.current = editor;
return editor;
};
+4 -4
View File
@@ -86,6 +86,10 @@ export type EditorReadOnlyRefApi = {
paragraphs: number;
words: number;
};
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
getHeadings: () => IMarking[];
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined;
};
export interface EditorRefApi extends EditorReadOnlyRefApi {
@@ -101,10 +105,6 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
getSelectedText: () => string | null;
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
setProviderDocument: (value: Uint8Array) => void;
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
getHeadings: () => IMarking[];
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined;
}
// editor props
+1 -1
View File
@@ -24,7 +24,7 @@ export * from "@/constants/common";
// helpers
export * from "@/helpers/common";
export * from "@/helpers/editor-commands";
export * from "@/helpers/yjs-utils";
export * from "@/helpers/yjs";
export * from "@/extensions/table/table";
// components
-1
View File
@@ -1,5 +1,4 @@
export * from "@/extensions/core-without-props";
export * from "@/constants/document-collaborative-events";
export * from "@/helpers/get-document-server-event";
export * from "@/helpers/yjs-utils";
export * from "@/types/document-collaborative-events";
+7 -7
View File
@@ -25,7 +25,7 @@
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--color-placeholder);
color: rgb(var(--color-text-400));
pointer-events: none;
height: 0;
}
@@ -34,7 +34,7 @@
.ProseMirror p.is-empty::before {
content: attr(data-placeholder);
float: left;
color: var(--color-placeholder);
color: rgb(var(--color-text-400));
pointer-events: none;
height: 0;
}
@@ -119,13 +119,13 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
pointer-events: none;
}
ul[data-type="taskList"] li > label input[type="checkbox"]:checked {
ul[data-type="taskList"] li > label input[type="checkbox"][checked] {
background-color: rgba(var(--color-primary-100)) !important;
border-color: rgba(var(--color-primary-100)) !important;
color: white !important;
}
ul[data-type="taskList"] li > label input[type="checkbox"]:checked:hover {
ul[data-type="taskList"] li > label input[type="checkbox"][checked]:hover {
background-color: rgba(var(--color-primary-300)) !important;
border-color: rgba(var(--color-primary-300)) !important;
}
@@ -145,7 +145,7 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
position: relative;
-webkit-appearance: none;
appearance: none;
background-color: transparent;
background-color: rgb(var(--color-background-100));
margin: 0;
cursor: pointer;
width: 0.8rem;
@@ -174,7 +174,7 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
&:checked::before {
&[checked]::before {
transform: scale(1) translate(-50%, -50%);
}
}
@@ -192,7 +192,7 @@ ul[data-type="taskList"] li > div {
ul[data-type="taskList"] li[data-checked="true"] {
& > div > p.editor-paragraph-block {
color: var(--color-placeholder);
color: rgb(var(--color-text-400));
}
[data-text-color] {
+38 -2
View File
@@ -1,6 +1,42 @@
.editor-container {
--color-placeholder: rgba(var(--color-text-100), 0.5);
:root {
/* text colors */
--editor-colors-gray-text: #5c5e63;
--editor-colors-peach-text: #ff5b59;
--editor-colors-pink-text: #f65385;
--editor-colors-orange-text: #fd9038;
--editor-colors-green-text: #0fc27b;
--editor-colors-light-blue-text: #17bee9;
--editor-colors-dark-blue-text: #266df0;
--editor-colors-purple-text: #9162f9;
/* end text colors */
}
/* text background colors */
[data-theme="light"],
[data-theme="light-contrast"] {
--editor-colors-gray-background: #d6d6d8;
--editor-colors-peach-background: #ffd5d7;
--editor-colors-pink-background: #fdd4e3;
--editor-colors-orange-background: #ffe3cd;
--editor-colors-green-background: #c3f0de;
--editor-colors-light-blue-background: #c5eff9;
--editor-colors-dark-blue-background: #c9dafb;
--editor-colors-purple-background: #e3d8fd;
}
[data-theme="dark"],
[data-theme="dark-contrast"] {
--editor-colors-gray-background: #404144;
--editor-colors-peach-background: #593032;
--editor-colors-pink-background: #562e3d;
--editor-colors-orange-background: #583e2a;
--editor-colors-green-background: #1d4a3b;
--editor-colors-light-blue-background: #1f495c;
--editor-colors-dark-blue-background: #223558;
--editor-colors-purple-background: #3d325a;
}
/* end text background colors */
.editor-container {
/* font sizes and line heights */
&.large-font {
--font-size-h1: 1.75rem;
+1 -1
View File
@@ -1,4 +1,4 @@
const sharedConfig = require("@plane/tailwind-config/tailwind.config.js");
const sharedConfig = require("tailwind-config-custom/tailwind.config.js");
module.exports = {
// prefix ui lib classes to avoid conflicting with the app
-1
View File
@@ -1,3 +1,2 @@
export * from "./use-local-storage";
export * from "./use-outside-click-detector";
export * from "./use-platform-os";
-34
View File
@@ -1,34 +0,0 @@
import { useState, useEffect } from "react";
export const usePlatformOS = () => {
const [platformData, setPlatformData] = useState({
isMobile: false,
platform: "",
});
useEffect(() => {
const detectPlatform = () => {
const userAgent = window.navigator.userAgent;
const isMobile = /iPhone|iPad|iPod|Android/i.test(userAgent);
let platform = "";
if (!isMobile) {
if (userAgent.indexOf("Win") !== -1) {
platform = "Windows";
} else if (userAgent.indexOf("Mac") !== -1) {
platform = "MacOS";
} else if (userAgent.indexOf("Linux") !== -1) {
platform = "Linux";
} else {
platform = "Unknown";
}
}
setPlatformData({ isMobile, platform });
};
detectPlatform();
}, []);
return platformData;
};
+1 -2
View File
@@ -10,8 +10,7 @@
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
"dependencies": {
"@plane/utils": "*",
"intl-messageformat": "^10.7.11"
"@plane/utils": "*"
},
"devDependencies": {
"@plane/eslint-config": "*",
+29
View File
@@ -0,0 +1,29 @@
import { observer } from "mobx-react";
import React, { createContext, useEffect } from "react";
import { Language, languages } from "../config";
import { TranslationStore } from "./store";
// Create the store instance
const translationStore = new TranslationStore();
// Create Context
export const TranslationContext = createContext<TranslationStore>(translationStore);
export const TranslationProvider = observer(({ children }: { children: React.ReactNode }) => {
// Handle storage events for cross-tab synchronization
useEffect(() => {
const handleStorageChange = (event: StorageEvent) => {
if (event.key === "userLanguage" && event.newValue) {
const newLang = event.newValue as Language;
if (languages.includes(newLang)) {
translationStore.setLanguage(newLang);
}
}
};
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, []);
return <TranslationContext.Provider value={translationStore}>{children}</TranslationContext.Provider>;
});
+42
View File
@@ -0,0 +1,42 @@
import { makeObservable, observable } from "mobx";
import { Language, fallbackLng, languages, translations } from "../config";
export class TranslationStore {
currentLocale: Language = fallbackLng;
constructor() {
makeObservable(this, {
currentLocale: observable.ref,
});
this.initializeLanguage();
}
get availableLanguages() {
return languages;
}
t(key: string) {
return translations[this.currentLocale]?.[key] || translations[fallbackLng][key] || key;
}
setLanguage(lng: Language) {
try {
localStorage.setItem("userLanguage", lng);
this.currentLocale = lng;
} catch (error) {
console.error(error);
}
}
initializeLanguage() {
if (typeof window === "undefined") return;
const savedLocale = localStorage.getItem("userLanguage") as Language;
if (savedLocale && languages.includes(savedLocale)) {
this.setLanguage(savedLocale);
} else {
const browserLang = navigator.language.split("-")[0] as Language;
const newLocale = languages.includes(browserLang as Language) ? (browserLang as Language) : fallbackLng;
this.setLanguage(newLocale);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
import en from "../locales/en/translations.json";
import fr from "../locales/fr/translations.json";
import es from "../locales/es/translations.json";
import ja from "../locales/ja/translations.json";
export type Language = (typeof languages)[number];
export type Translations = {
[key: string]: {
[key: string]: string;
};
};
export const fallbackLng = "en";
export const languages = ["en", "fr", "es", "ja"] as const;
export const translations: Translations = {
en,
fr,
es,
ja,
};
export const SUPPORTED_LANGUAGES = [
{
label: "English",
value: "en",
},
{
label: "French",
value: "fr",
},
{
label: "Spanish",
value: "es",
},
{
label: "Japanese",
value: "ja",
},
];
-1
View File
@@ -1 +0,0 @@
export * from "./language";
-13
View File
@@ -1,13 +0,0 @@
import { TLanguage, ILanguageOption } from "../types";
export const FALLBACK_LANGUAGE: TLanguage = "en";
export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
{ label: "English", value: "en" },
{ label: "Français", value: "fr" },
{ label: "Español", value: "es" },
{ label: "日本語", value: "ja" },
{ label: "中文", value: "zh-CN" },
];
export const STORAGE_KEY = "userLanguage";
-19
View File
@@ -1,19 +0,0 @@
import { observer } from "mobx-react";
import React, { createContext } from "react";
// store
import { TranslationStore } from "../store";
export const TranslationContext = createContext<TranslationStore | null>(null);
interface TranslationProviderProps {
children: React.ReactNode;
}
/**
* Provides the translation store to the application
*/
export const TranslationProvider: React.FC<TranslationProviderProps> = observer(({ children }) => {
const [store] = React.useState(() => new TranslationStore());
return <TranslationContext.Provider value={store}>{children}</TranslationContext.Provider>;
});
+7 -25
View File
@@ -1,35 +1,17 @@
import { useContext } from 'react';
// context
import { TranslationContext } from '../context';
// types
import { ILanguageOption, TLanguage } from '../types';
import { useContext } from "react";
import { TranslationContext } from "../components";
import { Language } from "../config";
export type TTranslationStore = {
t: (key: string, params?: Record<string, any>) => string;
currentLocale: TLanguage;
changeLanguage: (lng: TLanguage) => void;
languages: ILanguageOption[];
};
/**
* Provides the translation store to the application
* @returns {TTranslationStore}
* @returns {(key: string, params?: Record<string, any>) => string} t: method to translate the key with params
* @returns {TLanguage} currentLocale - current locale language
* @returns {(lng: TLanguage) => void} changeLanguage - method to change the language
* @returns {ILanguageOption[]} languages - available languages
* @throws {Error} if the TranslationProvider is not used
*/
export function useTranslation(): TTranslationStore {
export function useTranslation() {
const store = useContext(TranslationContext);
if (!store) {
throw new Error('useTranslation must be used within a TranslationProvider');
throw new Error("useTranslation must be used within a TranslationProvider");
}
return {
t: store.t.bind(store),
t: (key: string) => store.t(key),
currentLocale: store.currentLocale,
changeLanguage: (lng: TLanguage) => store.setLanguage(lng),
changeLanguage: (lng: Language) => store.setLanguage(lng),
languages: store.availableLanguages,
};
}
+2 -3
View File
@@ -1,4 +1,3 @@
export * from "./constants";
export * from "./context";
export * from "./config";
export * from "./components";
export * from "./hooks";
export * from "./types";

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