Compare commits

..

20 Commits

Author SHA1 Message Date
gakshita e9cd53826a fix: integrated dashboard apis 2025-01-06 19:46:28 +05:30
gakshita cbfb4ecee3 Merge branch 'preview' of https://github.com/makeplane/plane into feat-home-integrations 2025-01-06 19:04:47 +05:30
gakshita 92607d2459 fix: lint 2025-01-06 14:22:19 +05:30
gakshita 94a9b00008 fix: widgets fix 2025-01-06 13:53:56 +05:30
gakshita afa330e7ca chore: added apis 2025-01-06 13:27:34 +05:30
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
408 changed files with 5433 additions and 10497 deletions
+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(
@@ -22,7 +22,6 @@ from .workspace import (
WorkspaceUserLinkSerializer,
WorkspaceRecentVisitSerializer,
WorkspaceHomePreferenceSerializer,
StickySerializer,
)
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"]
+14 -15
View File
@@ -21,7 +21,6 @@ from plane.db.models import (
Project,
ProjectMember,
WorkspaceHomePreference,
Sticky,
)
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
@@ -152,7 +151,6 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer):
class Meta:
model = Issue
fields = [
"id",
"name",
"state",
"priority",
@@ -169,6 +167,14 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer):
return project.identifier if project else None
class ProjectMemberSerializer(BaseSerializer):
member = UserLiteSerializer(read_only=True)
class Meta:
model = ProjectMember
fields = ["member"]
class ProjectRecentVisitSerializer(serializers.ModelSerializer):
project_members = serializers.SerializerMethodField()
@@ -177,11 +183,12 @@ class ProjectRecentVisitSerializer(serializers.ModelSerializer):
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).select_related(
"member"
)
return members
serializer = ProjectMemberSerializer(members, many=True)
return serializer.data
class PageRecentVisitSerializer(serializers.ModelSerializer):
@@ -249,12 +256,4 @@ 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}}
read_only_fields = ["worspace", "created_by", "update_by"]
+3 -16
View File
@@ -29,8 +29,7 @@ from plane.app.views import (
WorkspaceDraftIssueViewSet,
QuickLinkViewSet,
UserRecentVisitViewSet,
WorkspaceHomePreferenceViewSet,
WorkspaceStickyViewSet,
WorkspacePreferenceViewSet,
)
@@ -233,12 +232,12 @@ urlpatterns = [
# Widgets
path(
"workspaces/<str:slug>/home-preferences/",
WorkspaceHomePreferenceViewSet.as_view(),
WorkspacePreferenceViewSet.as_view(),
name="workspace-home-preference",
),
path(
"workspaces/<str:slug>/home-preferences/<str:key>/",
WorkspaceHomePreferenceViewSet.as_view(),
WorkspacePreferenceViewSet.as_view(),
name="workspace-home-preference",
),
path(
@@ -246,16 +245,4 @@ urlpatterns = [
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",
),
]
+1 -3
View File
@@ -41,8 +41,7 @@ from .workspace.base import (
from .workspace.draft import WorkspaceDraftIssueViewSet
from .workspace.home import WorkspaceHomePreferenceViewSet
from .workspace.preference import WorkspacePreferenceViewSet
from .workspace.favorite import (
WorkspaceFavoriteEndpoint,
WorkspaceFavoriteGroupEndpoint,
@@ -76,7 +75,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(
@@ -10,7 +10,7 @@ from rest_framework.response import Response
from rest_framework import status
class WorkspaceHomePreferenceViewSet(BaseAPIView):
class WorkspacePreferenceViewSet(BaseAPIView):
model = WorkspaceHomePreference
def get_serializer_class(self):
@@ -26,35 +26,22 @@ class WorkspaceHomePreferenceViewSet(BaseAPIView):
create_preference_keys = []
keys = [
key
for key, _ in WorkspaceHomePreference.HomeWidgetKeys.choices
if key not in ["quick_tutorial", "new_at_plane"]
]
sort_order_counter = 1
keys = [key for key, _ in WorkspaceHomePreference.HomeWidgetKeys.choices]
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,
key=key, user=request.user, workspace=workspace
)
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
)
@@ -67,7 +54,7 @@ class WorkspaceHomePreferenceViewSet(BaseAPIView):
@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
key=key, workspace__slug=slug
).first()
if preference:
@@ -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,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 -1
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,
+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",)
-9
View File
@@ -5,9 +5,6 @@ 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)
@@ -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 -43
View File
@@ -1,5 +1,4 @@
# Python imports
from django.db.models.functions import Ln
import pytz
# Django imports
@@ -346,8 +345,6 @@ class WorkspaceUserLink(WorkspaceBaseModel):
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"
@@ -368,7 +365,7 @@ class WorkspaceHomePreference(BaseModel):
key = models.CharField(max_length=255)
is_enabled = models.BooleanField(default=True)
config = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
sort_order = models.PositiveIntegerField(default=65535)
class Meta:
unique_together = ["workspace", "user", "key", "deleted_at"]
@@ -386,42 +383,3 @@ class WorkspaceHomePreference(BaseModel):
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",)
+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,
}
-4
View File
@@ -1,4 +0,0 @@
export enum E_SORT_ORDER {
ASC = "asc",
DESC = "desc",
}
-4
View File
@@ -1,11 +1,8 @@
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 +10,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;
+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 -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";
@@ -283,7 +283,6 @@
"create_a_draft": "Create a draft",
"save_to_drafts": "Save to Drafts",
"save": "Save",
"update": "Update",
"updating": "Updating",
"create_new_issue": "Create new issue",
"editor_is_not_ready_to_discard_changes": "Editor is not ready to discard changes",
@@ -282,7 +282,6 @@
"create_a_draft": "Crear un borrador",
"save_to_drafts": "Guardar en borradores",
"save": "Guardar",
"update": "Actualizar",
"updating": "Actualizando",
"create_new_issue": "Crear nuevo problema",
"editor_is_not_ready_to_discard_changes": "El editor no está listo para descartar los cambios",
+19 -20
View File
@@ -19,12 +19,12 @@
"remove_members": "Supprimer des membres",
"add": "Ajouter",
"remove": "Supprimer",
"add_new": "Ajouter un nouveau",
"add_new": "Ajouter nouveau",
"remove_selected": "Supprimer la sélection",
"first_name": "Prénom",
"last_name": "Nom de famille",
"email": "Email",
"display_name": "Pseudonyme",
"display_name": "Nom d'affichage",
"role": "Rôle",
"timezone": "Fuseau horaire",
"avatar": "Avatar",
@@ -32,7 +32,7 @@
"password": "Mot de passe",
"change_cover": "Modifier la couverture",
"language": "Langue",
"saving": "Enregistrement en cours...",
"saving": "Enregistrement...",
"save_changes": "Enregistrer les modifications",
"deactivate_account": "Désactiver le compte",
"deactivate_account_description": "Lors de la désactivation d'un compte, toutes les données et ressources de ce compte seront définitivement supprimées et ne pourront pas être récupérées.",
@@ -43,14 +43,14 @@
"activity": "Activité",
"appearance": "Apparence",
"notifications": "Notifications",
"workspaces": "Espaces de travail",
"create_workspace": "Créer un espace de travail",
"workspaces": "Workspaces",
"create_workspace": "Créer un workspace",
"invitations": "Invitations",
"summary": "Résumé",
"assigned": "Assigné",
"created": "Créé",
"subscribed": "Souscrit",
"you_do_not_have_the_permission_to_access_this_page": "Vous n'avez pas les permissions d'accéder à cette page.",
"you_do_not_have_the_permission_to_access_this_page": "Vous n'avez pas les permissions pour accéder à cette page.",
"failed_to_sign_out_please_try_again": "Impossible de se déconnecter. Veuillez réessayer.",
"password_changed_successfully": "Mot de passe changé avec succès.",
"something_went_wrong_please_try_again": "Quelque chose s'est mal passé. Veuillez réessayer.",
@@ -62,7 +62,7 @@
"this_field_is_required": "Ce champ est requis",
"passwords_dont_match": "Les mots de passe ne correspondent pas",
"please_enter_your_password": "Veuillez entrer votre mot de passe.",
"password_length_should_me_more_than_8_characters": "La longueur du mot de passe doit faire au moins 8 caractères.",
"password_length_should_me_more_than_8_characters": "La longueur du mot de passe doit être supérieure à 8 caractères.",
"password_is_weak": "Le mot de passe est faible.",
"password_is_strong": "Le mot de passe est fort.",
"load_more": "Charger plus",
@@ -106,8 +106,8 @@
"comments_description": "Me notifier lorsqu'un utilisateur commente un problème",
"mentions": "Mention",
"mentions_description": "Me notifier uniquement lorsqu'un utilisateur mentionne un problème",
"create_your_workspace": "Créer votre espace de travail",
"only_your_instance_admin_can_create_workspaces": "Seuls les administrateurs de votre instance peuvent créer des espaces de travail",
"create_your_workspace": "Créer votre workspace",
"only_your_instance_admin_can_create_workspaces": "Seuls les administrateurs de votre instance peuvent créer des workspaces",
"only_your_instance_admin_can_create_workspaces_description": "Si vous connaissez l'adresse email de votre administrateur d'instance, cliquez sur le bouton ci-dessous pour les contacter.",
"go_back": "Retour",
"request_instance_admin": "Demander à l'administrateur de l'instance",
@@ -135,7 +135,7 @@
"old_password": "Mot de passe actuel",
"general_settings": "Paramètres généraux",
"sign_out": "Déconnexion",
"signing_out": "Déconnexion en cours",
"signing_out": "Déconnexion",
"active_cycles": "Cycles actifs",
"active_cycles_description": "Surveillez les cycles dans les projets, suivez les issues de haute priorité et zoomez sur les cycles qui nécessitent attention.",
"on_demand_snapshots_of_all_your_cycles": "Captures instantanées sur demande de tous vos cycles",
@@ -190,17 +190,17 @@
"project_created_successfully_description": "Projet créé avec succès. Vous pouvez maintenant ajouter des issues à ce projet.",
"project_cover_image_alt": "Image de couverture du projet",
"name_is_required": "Le nom est requis",
"title_should_be_less_than_255_characters": "Le titre ne doit pas dépasser 255 caractères",
"title_should_be_less_than_255_characters": "Le titre doit être inférieur à 255 caractères",
"project_name": "Nom du projet",
"project_id_must_be_at_least_1_character": "L'ID du projet doit être au moins de 1 caractère",
"project_id_must_be_at_most_5_characters": "L'ID du projet doit être au plus de 5 caractères",
"project_id_must_be_at_least_1_character": "Le projet ID doit être au moins de 1 caractère",
"project_id_must_be_at_most_5_characters": "Le projet ID doit être au plus de 5 caractères",
"project_id": "ID du projet",
"project_id_tooltip_content": "Aide à identifier les issues du projet de manière unique. Max 5 caractères.",
"description_placeholder": "Description...",
"only_alphanumeric_non_latin_characters_allowed": "Seuls les caractères alphanumériques et non latins sont autorisés.",
"project_id_is_required": "L'ID du projet est requis",
"project_id_is_required": "Le projet ID est requis",
"select_network": "Sélectionner le réseau",
"lead": "Responsable",
"lead": "Lead",
"private": "Privé",
"public": "Public",
"accessible_only_by_invite": "Accessible uniquement par invitation",
@@ -245,11 +245,11 @@
"contact_sales": "Contacter les ventes",
"hyper_mode": "Mode hyper",
"keyboard_shortcuts": "Raccourcis clavier",
"whats_new": "Nouveautés ?",
"whats_new": "Nouveautés?",
"version": "Version",
"we_are_having_trouble_fetching_the_updates": "Nous avons des difficultés à récupérer les mises à jour.",
"our_changelogs": "Notre journal de modifications",
"for_the_latest_updates": "Pour les dernières mises à jour.",
"our_changelogs": "nos changelogs",
"for_the_latest_updates": "pour les dernières mises à jour.",
"please_visit": "Veuillez visiter",
"docs": "Documentation",
"full_changelog": "Journal complet",
@@ -282,7 +282,6 @@
"create_a_draft": "Créer un brouillon",
"save_to_drafts": "Enregistrer dans les brouillons",
"save": "Enregistrer",
"update": "Mettre à jour",
"updating": "Mise à jour",
"create_new_issue": "Créer un nouveau problème",
"editor_is_not_ready_to_discard_changes": "L'éditeur n'est pas prêt à annuler les modifications",
@@ -316,5 +315,5 @@
"remove_parent_issue": "Supprimer le problème parent",
"add_parent": "Ajouter un parent",
"loading_members": "Chargement des membres...",
"inbox": "Boîte de réception"
"inbox": "boîte de réception"
}
@@ -282,7 +282,6 @@
"create_a_draft": "下書きを作成",
"save_to_drafts": "下書きに保存",
"save": "保存",
"update": "更新",
"updating": "更新中",
"create_new_issue": "新しい問題を作成",
"editor_is_not_ready_to_discard_changes": "エディターは変更を破棄する準備ができていません",
@@ -1,319 +0,0 @@
{
"submit": "提交",
"cancel": "取消",
"loading": "加载中",
"error": "错误",
"success": "成功",
"warning": "警告",
"info": "信息",
"close": "关闭",
"yes": "是",
"no": "否",
"ok": "确定",
"name": "名称",
"description": "描述",
"search": "搜索",
"add_member": "添加成员",
"remove_member": "移除成员",
"add_members": "添加成员",
"remove_members": "移除成员",
"add": "添加",
"remove": "移除",
"add_new": "新增",
"remove_selected": "移除选中项",
"first_name": "名字",
"last_name": "姓氏",
"email": "电子邮件",
"display_name": "显示名称",
"role": "角色",
"timezone": "时区",
"avatar": "头像",
"cover_image": "封面图片",
"password": "密码",
"change_cover": "更换封面",
"language": "语言",
"saving": "保存中...",
"save_changes": "保存更改",
"deactivate_account": "停用账户",
"deactivate_account_description": "停用账户后,该账户内的所有数据和资源将被永久删除,且无法恢复。",
"profile_settings": "个人资料设置",
"your_account": "账户",
"profile": "个人资料",
"security": "安全",
"activity": "活动",
"appearance": "外观",
"notifications": "通知",
"workspaces": "工作区",
"create_workspace": "创建工作区",
"invitations": "邀请",
"summary": "摘要",
"assigned": "已分配",
"created": "已创建",
"subscribed": "已订阅",
"you_do_not_have_the_permission_to_access_this_page": "您没有权限访问此页面。",
"failed_to_sign_out_please_try_again": "登出失败,请重试。",
"password_changed_successfully": "密码已成功更改。",
"something_went_wrong_please_try_again": "出错了,请重试。",
"change_password": "更改密码",
"passwords_dont_match": "密码不匹配",
"current_password": "当前密码",
"new_password": "新密码",
"confirm_password": "确认密码",
"this_field_is_required": "此字段为必填项",
"changing_password": "正在更改密码",
"please_enter_your_password": "请输入您的密码。",
"password_length_should_me_more_than_8_characters": "密码长度应大于8个字符。",
"password_is_weak": "密码强度较弱。",
"password_is_strong": "密码强度较强。",
"load_more": "加载更多",
"select_or_customize_your_interface_color_scheme": "选择或自定义界面配色方案。",
"theme": "主题",
"system_preference": "系统偏好",
"light": "浅色",
"dark": "深色",
"light_contrast": "浅色高对比",
"dark_contrast": "深色高对比",
"custom": "自定义主题",
"select_your_theme": "选择主题",
"customize_your_theme": "自定义主题",
"background_color": "背景颜色",
"text_color": "文字颜色",
"primary_color": "主色调",
"sidebar_background_color": "侧边栏背景颜色",
"sidebar_text_color": "侧边栏文字颜色",
"set_theme": "设置主题",
"enter_a_valid_hex_code_of_6_characters": "请输入6位有效的十六进制代码",
"background_color_is_required": "背景颜色为必填项",
"text_color_is_required": "文字颜色为必填项",
"primary_color_is_required": "主色调为必填项",
"sidebar_background_color_is_required": "侧边栏背景颜色为必填项",
"sidebar_text_color_is_required": "侧边栏文字颜色为必填项",
"updating_theme": "正在更新主题",
"theme_updated_successfully": "主题已成功更新",
"failed_to_update_the_theme": "主题更新失败",
"email_notifications": "电子邮件通知",
"stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "启用此功能以接收您订阅问题的通知。",
"email_notification_setting_updated_successfully": "电子邮件通知设置已成功更新",
"failed_to_update_email_notification_setting": "电子邮件通知设置更新失败",
"notify_me_when": "在以下情况下通知我",
"property_changes": "属性更改",
"property_changes_description": "当负责人、优先级、估算等属性更改时通知。",
"state_change": "状态更改",
"state_change_description": "当问题状态更改时通知",
"issue_completed": "问题完成",
"issue_completed_description": "仅在问题完成时通知",
"comments": "评论",
"comments_description": "当有人在问题中添加评论时通知",
"mentions": "提及",
"mentions_description": "仅在评论或描述中提及您时通知",
"create_your_workspace": "创建工作区",
"only_your_instance_admin_can_create_workspaces": "只有实例管理员可以创建工作区",
"only_your_instance_admin_can_create_workspaces_description": "如果您知道实例管理员的电子邮件地址,请点击以下按钮联系他们。",
"go_back": "返回",
"request_instance_admin": "请求实例管理员",
"plane_logo": "Plane 徽标",
"workspace_creation_disabled": "工作区创建已禁用",
"workspace_request_subject": "新工作区请求",
"workspace_request_body": "实例管理员:\n\n请在 URL [/workspace-name] 处创建一个新的工作区。[工作区创建目的]\n\n谢谢。\n{{firstName}} {{lastName}}\n{{email}}",
"creating_workspace": "正在创建工作区",
"workspace_created_successfully": "工作区已成功创建",
"create_workspace_page": "创建工作区页面",
"workspace_could_not_be_created_please_try_again": "无法创建工作区,请重试。",
"workspace_could_not_be_created_please_try_again_description": "创建工作区时出错,请重试。",
"this_is_a_required_field": "此字段为必填项。",
"name_your_workspace": "为工作区命名",
"workspaces_names_can_contain_only_space_dash_and_alphanumeric_characters": "工作区名称只能包含空格、破折号和字母数字字符。",
"limit_your_name_to_80_characters": "名称长度限制为80个字符。",
"set_your_workspace_url": "设置工作区URL",
"limit_your_url_to_48_characters": "URL长度限制为48个字符。",
"how_many_people_will_use_this_workspace": "有多少人将使用此工作区?",
"how_many_people_will_use_this_workspace_description": "这将帮助确定购买的席位数量。",
"select_a_range": "选择一个范围",
"urls_can_contain_only_dash_and_alphanumeric_characters": "URL只能包含破折号和字母数字字符。",
"something_familiar_and_recognizable_is_always_best": "熟悉且易于识别的名称总是最佳选择。",
"workspace_url_is_already_taken": "工作区URL已被占用!",
"old_password": "旧密码",
"general_settings": "常规设置",
"sign_out": "登出",
"signing_out": "正在登出",
"active_cycles": "活跃周期",
"active_cycles_description": "监控所有项目的周期,跟踪高优先级问题,并关注需要关注的周期。",
"on_demand_snapshots_of_all_your_cycles": "所有周期的按需快照",
"upgrade": "升级",
"10000_feet_view": "所有活跃周期的概览",
"10000_feet_view_description": "放大查看所有项目的周期,而不是单独查看每个项目的周期。",
"get_snapshot_of_each_active_cycle": "获取每个活跃周期的快照",
"get_snapshot_of_each_active_cycle_description": "跟踪所有活跃周期的高级指标,查看进度并了解范围与截止日期的关系。",
"compare_burndowns": "比较燃尽图",
"compare_burndowns_description": "监控每个团队的表现,查看每个周期的燃尽报告。",
"quickly_see_make_or_break_issues": "快速查看关键问题",
"quickly_see_make_or_break_issues_description": "预览每个周期中与截止日期相关的高优先级问题。一键查看所有周期的详细信息。",
"zoom_into_cycles_that_need_attention": "关注需要关注的周期",
"zoom_into_cycles_that_need_attention_description": "一键调查未达预期的周期状态。",
"stay_ahead_of_blockers": "提前解决阻碍",
"stay_ahead_of_blockers_description": "发现项目间的挑战,查看其他视图中不明显的周期依赖关系。",
"analytics": "分析",
"workspace_invites": "工作区邀请",
"workspace_settings": "工作区设置",
"enter_god_mode": "进入上帝模式",
"workspace_logo": "工作区徽标",
"new_issue": "新建问题",
"home": "首页",
"your_work": "您的工作",
"drafts": "草稿",
"projects": "项目",
"views": "视图",
"workspace": "工作区",
"archives": "归档",
"settings": "设置",
"failed_to_move_favorite": "移动收藏失败",
"your_favorites": "您的收藏",
"no_favorites_yet": "尚无收藏",
"create_folder": "创建文件夹",
"new_folder": "新建文件夹",
"favorite_updated_successfully": "收藏已成功更新",
"favorite_created_successfully": "收藏已成功创建",
"folder_already_exists": "文件夹已存在",
"folder_name_cannot_be_empty": "文件夹名称不能为空",
"something_went_wrong": "出错了",
"failed_to_reorder_favorite": "重新排序收藏失败",
"favorite_removed_successfully": "收藏已成功移除",
"failed_to_create_favorite": "创建收藏失败",
"failed_to_rename_favorite": "重命名收藏失败",
"project_link_copied_to_clipboard": "项目链接已复制到剪贴板",
"link_copied": "链接已复制",
"your_projects": "您的项目",
"add_project": "添加项目",
"create_project": "创建项目",
"failed_to_remove_project_from_favorites": "无法从收藏中移除项目,请重试。",
"project_created_successfully": "项目已成功创建",
"project_created_successfully_description": "项目已成功创建。您现在可以开始添加问题。",
"project_cover_image_alt": "项目封面图片",
"name_is_required": "名称为必填项",
"title_should_be_less_than_255_characters": "标题应少于255个字符",
"project_name": "项目名称",
"project_id_must_be_at_least_1_character": "项目ID至少为1个字符",
"project_id_must_be_at_most_5_characters": "项目ID最多为5个字符",
"project_id": "项目ID",
"project_id_tooltip_content": "用于唯一标识项目中的问题。最多5个字符。",
"description_placeholder": "描述...",
"only_alphanumeric_non_latin_characters_allowed": "仅允许字母数字和非拉丁字符。",
"project_id_is_required": "项目ID为必填项",
"select_network": "选择网络",
"lead": "负责人",
"private": "私有",
"public": "公开",
"accessible_only_by_invite": "仅限邀请访问",
"anyone_in_the_workspace_except_guests_can_join": "除访客外,工作区中的任何人都可以加入",
"creating": "创建中",
"creating_project": "正在创建项目",
"adding_project_to_favorites": "正在将项目添加到收藏",
"project_added_to_favorites": "项目已添加到收藏",
"couldnt_add_the_project_to_favorites": "无法将项目添加到收藏,请重试。",
"removing_project_from_favorites": "正在从收藏中移除项目",
"project_removed_from_favorites": "项目已从收藏中移除",
"couldnt_remove_the_project_from_favorites": "无法从收藏中移除项目,请重试。",
"add_to_favorites": "添加到收藏",
"remove_from_favorites": "从收藏中移除",
"publish_settings": "发布设置",
"publish": "发布",
"copy_link": "复制链接",
"leave_project": "离开项目",
"join_the_project_to_rearrange": "加入项目以重新排序",
"drag_to_rearrange": "拖动以重新排序",
"congrats": "恭喜!",
"project": "项目",
"open_project": "打开项目",
"issues": "问题",
"cycles": "周期",
"modules": "模块",
"pages": "页面",
"intake": "接收",
"time_tracking": "时间跟踪",
"work_management": "工作管理",
"projects_and_issues": "项目与问题",
"projects_and_issues_description": "在此项目上启用或禁用。",
"cycles_description": "按时间段对项目工作进行时间盒管理,并调整频率。",
"modules_description": "将工作分组为类似子项目的设置,拥有自己的负责人和分配人。",
"views_description": "保存排序、过滤和显示选项以供以后使用或共享。",
"pages_description": "写下一些内容。",
"intake_description": "启用此功能以接收您订阅问题的通知。",
"time_tracking_description": "跟踪问题和项目所花费的时间。",
"work_management_description": "轻松管理工作和项目。",
"documentation": "文档",
"message_support": "联系支持",
"contact_sales": "联系销售",
"hyper_mode": "超速模式",
"keyboard_shortcuts": "键盘快捷键",
"whats_new": "新功能",
"version": "版本",
"we_are_having_trouble_fetching_the_updates": "获取更新时出现问题。",
"our_changelogs": "我们的更新日志",
"for_the_latest_updates": "获取最新更新,请访问",
"please_visit": "请访问",
"docs": "文档",
"full_changelog": "完整更新日志",
"support": "支持",
"discord": "Discord",
"powered_by_plane_pages": "由 Plane Pages 提供支持",
"please_select_at_least_one_invitation": "请至少选择一个邀请。",
"please_select_at_least_one_invitation_description": "请至少选择一个邀请以加入工作区。",
"we_see_that_someone_has_invited_you_to_join_a_workspace": "我们发现有人邀请您加入一个工作区",
"join_a_workspace": "加入工作区",
"we_see_that_someone_has_invited_you_to_join_a_workspace_description": "我们发现有人邀请您加入一个工作区",
"join_a_workspace_description": "加入工作区",
"accept_and_join": "接受并加入",
"go_home": "返回首页",
"no_pending_invites": "没有待处理的邀请",
"you_can_see_here_if_someone_invites_you_to_a_workspace": "如果有人邀请您加入工作区,您可以在此处查看",
"back_to_home": "返回首页",
"workspace_name": "工作区名称",
"deactivate_your_account": "停用您的账户",
"deactivate_your_account_description": "停用后,您将无法再分配问题,工作区的计费也将停止。要重新激活账户,您需要使用此电子邮件地址收到工作区邀请。",
"deactivating": "正在停用",
"confirm": "确认",
"draft_created": "草稿已创建",
"issue_created_successfully": "问题已成功创建",
"draft_creation_failed": "草稿创建失败",
"issue_creation_failed": "问题创建失败",
"draft_issue": "草稿问题",
"issue_updated_successfully": "问题已成功更新",
"issue_could_not_be_updated": "无法更新问题",
"create_a_draft": "创建草稿",
"save_to_drafts": "保存为草稿",
"save": "保存",
"updating": "正在更新",
"create_new_issue": "创建新问题",
"editor_is_not_ready_to_discard_changes": "编辑器尚未准备好丢弃更改",
"failed_to_move_issue_to_project": "无法将问题移动到项目",
"create_more": "创建更多",
"add_to_project": "添加到项目",
"discard": "丢弃",
"duplicate_issue_found": "发现重复问题",
"duplicate_issues_found": "发现重复问题",
"no_matching_results": "没有匹配的结果",
"title_is_required": "标题为必填项",
"title": "标题",
"state": "状态",
"priority": "优先级",
"none": "无",
"urgent": "紧急",
"high": "高",
"medium": "中",
"low": "低",
"members": "成员",
"assignee": "分配人",
"assignees": "分配人",
"you": "您",
"labels": "标签",
"create_new_label": "创建新标签",
"start_date": "开始日期",
"due_date": "截止日期",
"cycle": "周期",
"estimate": "估算",
"change_parent_issue": "更改父问题",
"remove_parent_issue": "移除父问题",
"add_parent": "添加父问题",
"loading_members": "正在加载成员...",
"inbox": "收件箱"
}
-202
View File
@@ -1,202 +0,0 @@
import IntlMessageFormat from "intl-messageformat";
import get from "lodash/get";
import { makeAutoObservable } from "mobx";
// constants
import { FALLBACK_LANGUAGE, SUPPORTED_LANGUAGES, STORAGE_KEY } from "../constants";
// types
import { TLanguage, ILanguageOption, ITranslations } from "../types";
/**
* Mobx store class for handling translations and language changes in the application
* Provides methods to translate keys with params and change the language
* Uses IntlMessageFormat to format the translations
*/
export class TranslationStore {
// List of translations for each language
private translations: ITranslations = {};
// Cache for IntlMessageFormat instances
private messageCache: Map<string, IntlMessageFormat> = new Map();
// Current language
currentLocale: TLanguage = FALLBACK_LANGUAGE;
/**
* Constructor for the TranslationStore class
*/
constructor() {
makeAutoObservable(this);
this.initializeLanguage();
this.loadTranslations();
}
/**
* Loads translations from JSON files and initializes the message cache
*/
private async loadTranslations() {
try {
// dynamic import of translations
const translations = {
en: (await import("../locales/en/translations.json")).default,
fr: (await import("../locales/fr/translations.json")).default,
es: (await import("../locales/es/translations.json")).default,
ja: (await import("../locales/ja/translations.json")).default,
"zh-CN": (await import("../locales/zh-CN/translations.json")).default,
};
this.translations = translations;
this.messageCache.clear(); // Clear cache when translations change
} catch (error) {
console.error("Failed to load translations:", error);
}
}
/** Initializes the language based on the local storage or browser language */
private initializeLanguage() {
if (typeof window === "undefined") return;
const savedLocale = localStorage.getItem(STORAGE_KEY) as TLanguage;
if (this.isValidLanguage(savedLocale)) {
this.setLanguage(savedLocale);
return;
}
const browserLang = this.getBrowserLanguage();
this.setLanguage(browserLang);
}
/** Checks if the language is valid based on the supported languages */
private isValidLanguage(lang: string | null): lang is TLanguage {
return lang !== null && this.availableLanguages.some((l) => l.value === lang);
}
/** Checks if a language code is similar to any supported language */
private findSimilarLanguage(lang: string): TLanguage | null {
// Convert to lowercase for case-insensitive comparison
const normalizedLang = lang.toLowerCase();
// Find a supported language that includes or is included in the browser language
const similarLang = this.availableLanguages.find(
(l) => normalizedLang.includes(l.value.toLowerCase()) || l.value.toLowerCase().includes(normalizedLang)
);
return similarLang ? similarLang.value : null;
}
/** Gets the browser language based on the navigator.language */
private getBrowserLanguage(): TLanguage {
const browserLang = navigator.language;
// Check exact match first
if (this.isValidLanguage(browserLang)) {
return browserLang;
}
// Check base language without region code
const baseLang = browserLang.split("-")[0];
if (this.isValidLanguage(baseLang)) {
return baseLang as TLanguage;
}
// Try to find a similar language
const similarLang = this.findSimilarLanguage(browserLang) || this.findSimilarLanguage(baseLang);
return similarLang || FALLBACK_LANGUAGE;
}
/**
* Gets the cache key for the given key and locale
* @param key - the key to get the cache key for
* @param locale - the locale to get the cache key for
* @returns the cache key for the given key and locale
*/
private getCacheKey(key: string, locale: TLanguage): string {
return `${locale}:${key}`;
}
/**
* Gets the IntlMessageFormat instance for the given key and locale
* Returns cached instance if available
* Throws an error if the key is not found in the translations
*/
private getMessageInstance(key: string, locale: TLanguage): IntlMessageFormat | null {
const cacheKey = this.getCacheKey(key, locale);
// Check if the cache already has the key
if (this.messageCache.has(cacheKey)) {
return this.messageCache.get(cacheKey) || null;
}
// Get the message from the translations
const message = get(this.translations[locale], key);
if (!message) return null;
try {
const formatter = new IntlMessageFormat(message as any, locale);
this.messageCache.set(cacheKey, formatter);
return formatter;
} catch (error) {
console.error(`Failed to create message formatter for key "${key}":`, error);
return null;
}
}
/**
* Translates a key with params using the current locale
* Falls back to the default language if the translation is not found
* Returns the key itself if the translation is not found
* @param key - The key to translate
* @param params - The params to format the translation with
* @returns The translated string
*/
t(key: string, params?: Record<string, any>): string {
try {
// Try current locale
let formatter = this.getMessageInstance(key, this.currentLocale);
// Fallback to default language if necessary
if (!formatter && this.currentLocale !== FALLBACK_LANGUAGE) {
formatter = this.getMessageInstance(key, FALLBACK_LANGUAGE);
}
// If we have a formatter, use it
if (formatter) {
return formatter.format(params || {}) as string;
}
// Last resort: return the key itself
return key;
} catch (error) {
console.error(`Translation error for key "${key}":`, error);
return key;
}
}
/**
* Sets the current language and updates the translations
* @param lng - The new language
*/
setLanguage(lng: TLanguage): void {
try {
if (!this.isValidLanguage(lng)) {
throw new Error(`Invalid language: ${lng}`);
}
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEY, lng);
}
this.currentLocale = lng;
this.messageCache.clear(); // Clear cache when language changes
if (typeof window !== "undefined") {
document.documentElement.lang = lng;
}
} catch (error) {
console.error("Failed to set language:", error);
}
}
/**
* Gets the available language options for the dropdown
* @returns An array of language options
*/
get availableLanguages(): ILanguageOption[] {
return SUPPORTED_LANGUAGES;
}
}
-2
View File
@@ -1,2 +0,0 @@
export * from "./language";
export * from "./translation";
-6
View File
@@ -1,6 +0,0 @@
export type TLanguage = "en" | "fr" | "es" | "ja" | "zh-CN";
export interface ILanguageOption {
label: string;
value: TLanguage;
}

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