diff --git a/.github/actions/build-push-ce/action.yml b/.github/actions/build-push-ce/action.yml deleted file mode 100644 index acf06982d31..00000000000 --- a/.github/actions/build-push-ce/action.yml +++ /dev/null @@ -1,126 +0,0 @@ -name: "Build and Push Docker Image" -description: "Reusable action for building and pushing Docker images" -inputs: - docker-username: - description: "The Dockerhub username" - required: true - docker-token: - description: "The Dockerhub Token" - required: true - - # Docker Image Options - docker-image-owner: - description: "The owner of the Docker image" - required: true - docker-image-name: - description: "The name of the Docker image" - required: true - build-context: - description: "The build context" - required: true - default: "." - dockerfile-path: - description: "The path to the Dockerfile" - required: true - build-args: - description: "The build arguments" - required: false - default: "" - - # Buildx Options - buildx-driver: - description: "Buildx driver" - required: true - default: "docker-container" - buildx-version: - description: "Buildx version" - required: true - default: "latest" - buildx-platforms: - description: "Buildx platforms" - required: true - default: "linux/amd64" - buildx-endpoint: - description: "Buildx endpoint" - required: true - default: "default" - - # Release Build Options - build-release: - description: "Flag to publish release" - required: false - default: "false" - build-prerelease: - description: "Flag to publish prerelease" - required: false - default: "false" - release-version: - description: "The release version" - required: false - default: "latest" - -runs: - using: "composite" - steps: - - name: Set Docker Tag - shell: bash - env: - IMG_OWNER: ${{ inputs.docker-image-owner }} - IMG_NAME: ${{ inputs.docker-image-name }} - BUILD_RELEASE: ${{ inputs.build-release }} - IS_PRERELEASE: ${{ inputs.build-prerelease }} - REL_VERSION: ${{ inputs.release-version }} - run: | - FLAT_BRANCH_VERSION=$(echo "${{ github.ref_name }}" | sed 's/[^a-zA-Z0-9.-]//g') - - if [ "${{ env.BUILD_RELEASE }}" == "true" ]; then - semver_regex="^v([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)?$" - if [[ ! ${{ env.REL_VERSION }} =~ $semver_regex ]]; then - echo "Invalid Release Version Format : ${{ env.REL_VERSION }}" - echo "Please provide a valid SemVer version" - echo "e.g. v1.2.3 or v1.2.3-alpha-1" - echo "Exiting the build process" - exit 1 # Exit with status 1 to fail the step - fi - - TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:${{ env.REL_VERSION }} - - if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then - TAG=${TAG},${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:stable - fi - elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:latest - else - TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:${FLAT_BRANCH_VERSION} - fi - - echo "DOCKER_TAGS=${TAG}" >> $GITHUB_ENV - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ inputs.docker-username }} - password: ${{ inputs.docker-token}} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - with: - driver: ${{ inputs.buildx-driver }} - version: ${{ inputs.buildx-version }} - endpoint: ${{ inputs.buildx-endpoint }} - - - name: Check out the repo - uses: actions/checkout@v4 - - - name: Build and Push Docker Image - uses: docker/build-push-action@v5.1.0 - with: - context: ${{ inputs.build-context }} - file: ${{ inputs.dockerfile-path }} - platforms: ${{ inputs.buildx-platforms }} - tags: ${{ env.DOCKER_TAGS }} - push: true - build-args: ${{ inputs.build-args }} - env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ inputs.docker-username }} - DOCKER_PASSWORD: ${{ inputs.docker-token }} \ No newline at end of file diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index 627c782f998..7d32b73c4e1 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -36,7 +36,7 @@ env: jobs: branch_build_setup: name: Build Setup - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 outputs: gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }} @@ -160,20 +160,17 @@ jobs: branch_build_push_admin: if: ${{ needs.branch_build_setup.outputs.build_admin == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push Admin Docker Image - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: [branch_build_setup] steps: - - id: checkout_files - name: Checkout Files - uses: actions/checkout@v4 - name: Admin Build and Push - uses: ./.github/actions/build-push-ce + uses: makeplane/actions/build-push@v1.0.0 with: build-release: ${{ needs.branch_build_setup.outputs.build_release }} build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} release-version: ${{ needs.branch_build_setup.outputs.release_version }} - docker-username: ${{ secrets.DOCKERHUB_USERNAME }} - docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} docker-image-owner: makeplane docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_admin }} build-context: . @@ -186,20 +183,17 @@ jobs: branch_build_push_web: if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push Web Docker Image - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: [branch_build_setup] steps: - - id: checkout_files - name: Checkout Files - uses: actions/checkout@v4 - name: Web Build and Push - uses: ./.github/actions/build-push-ce + uses: makeplane/actions/build-push@v1.0.0 with: build-release: ${{ needs.branch_build_setup.outputs.build_release }} build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} release-version: ${{ needs.branch_build_setup.outputs.release_version }} - docker-username: ${{ secrets.DOCKERHUB_USERNAME }} - docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} docker-image-owner: makeplane docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_web }} build-context: . @@ -212,20 +206,17 @@ jobs: branch_build_push_space: if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push Space Docker Image - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: [branch_build_setup] steps: - - id: checkout_files - name: Checkout Files - uses: actions/checkout@v4 - name: Space Build and Push - uses: ./.github/actions/build-push-ce + uses: makeplane/actions/build-push@v1.0.0 with: build-release: ${{ needs.branch_build_setup.outputs.build_release }} build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} release-version: ${{ needs.branch_build_setup.outputs.release_version }} - docker-username: ${{ secrets.DOCKERHUB_USERNAME }} - docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} docker-image-owner: makeplane docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_space }} build-context: . @@ -238,20 +229,17 @@ jobs: branch_build_push_live: if: ${{ needs.branch_build_setup.outputs.build_live == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push Live Collaboration Docker Image - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: [branch_build_setup] steps: - - id: checkout_files - name: Checkout Files - uses: actions/checkout@v4 - name: Live Build and Push - uses: ./.github/actions/build-push-ce + uses: makeplane/actions/build-push@v1.0.0 with: build-release: ${{ needs.branch_build_setup.outputs.build_release }} build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} release-version: ${{ needs.branch_build_setup.outputs.release_version }} - docker-username: ${{ secrets.DOCKERHUB_USERNAME }} - docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} docker-image-owner: makeplane docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_live }} build-context: . @@ -264,20 +252,17 @@ jobs: branch_build_push_apiserver: if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push API Server Docker Image - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: [branch_build_setup] steps: - - id: checkout_files - name: Checkout Files - uses: actions/checkout@v4 - name: Backend Build and Push - uses: ./.github/actions/build-push-ce + uses: makeplane/actions/build-push@v1.0.0 with: build-release: ${{ needs.branch_build_setup.outputs.build_release }} build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} release-version: ${{ needs.branch_build_setup.outputs.release_version }} - docker-username: ${{ secrets.DOCKERHUB_USERNAME }} - docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} docker-image-owner: makeplane docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_backend }} build-context: ./apiserver @@ -290,20 +275,17 @@ jobs: branch_build_push_proxy: if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push Proxy Docker Image - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: [branch_build_setup] steps: - - id: checkout_files - name: Checkout Files - uses: actions/checkout@v4 - name: Proxy Build and Push - uses: ./.github/actions/build-push-ce + uses: makeplane/actions/build-push@v1.0.0 with: build-release: ${{ needs.branch_build_setup.outputs.build_release }} build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} release-version: ${{ needs.branch_build_setup.outputs.release_version }} - docker-username: ${{ secrets.DOCKERHUB_USERNAME }} - docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} docker-image-owner: makeplane docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_proxy }} build-context: ./nginx @@ -316,7 +298,7 @@ jobs: attach_assets_to_build: if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }} name: Attach Assets to Release - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: [branch_build_setup] steps: - name: Checkout @@ -341,7 +323,7 @@ jobs: publish_release: if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }} name: Build Release - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: [ branch_build_setup, diff --git a/README.md b/README.md index 9c4ea9da614..3588f20e2d0 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@

Plane

+

Open-source project management that unlocks customer value

@@ -57,7 +58,7 @@ Prefer full control over your data and infrastructure? Install and run Plane on | Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://developers.plane.so/self-hosting/methods/docker-compose) | | Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://developers.plane.so/self-hosting/methods/kubernetes) | -`Instance admins` can manage and customize settings using [God mode](https://developers.plane.so/self-hosting/govern/instance-admin). +`Instance admins` can configure instance settings with [God mode](https://developers.plane.so/self-hosting/govern/instance-admin). ## 🌟 Features @@ -117,9 +118,9 @@ Setting up your local environment is simple and straightforward. Follow these st That’s it! You’re all set to begin coding. Remember to refresh your browser if changes don’t auto-reload. Happy contributing! 🎉 -## Built with -[![Next JS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/)
-[![Django](https://img.shields.io/badge/Django-092E20?style=for-the-badge&logo=django&logoColor=green)](https://www.djangoproject.com/)
+## ⚙️ Built with +[![Next JS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/) +[![Django](https://img.shields.io/badge/Django-092E20?style=for-the-badge&logo=django&logoColor=green)](https://www.djangoproject.com/) [![Node JS](https://img.shields.io/badge/node.js-339933?style=for-the-badge&logo=Node.js&logoColor=white)](https://nodejs.org/en) ## 📸 Screenshots diff --git a/admin/Dockerfile.admin b/admin/Dockerfile.admin index ad9469110e7..8046bf32943 100644 --- a/admin/Dockerfile.admin +++ b/admin/Dockerfile.admin @@ -1,7 +1,9 @@ +FROM node:20-alpine as base + # ***************************************************************************** # STAGE 1: Build the project # ***************************************************************************** -FROM node:18-alpine AS builder +FROM base AS builder RUN apk add --no-cache libc6-compat WORKDIR /app @@ -13,7 +15,7 @@ RUN turbo prune --scope=admin --docker # ***************************************************************************** # STAGE 2: Install dependencies & build the project # ***************************************************************************** -FROM node:18-alpine AS installer +FROM base AS installer RUN apk add --no-cache libc6-compat WORKDIR /app @@ -52,7 +54,7 @@ RUN yarn turbo run build --filter=admin # ***************************************************************************** # STAGE 3: Copy the project and start it # ***************************************************************************** -FROM node:18-alpine AS runner +FROM base AS runner WORKDIR /app COPY --from=installer /app/admin/next.config.js . diff --git a/admin/Dockerfile.dev b/admin/Dockerfile.dev index 1ed84e78efa..3bdc71c16d6 100644 --- a/admin/Dockerfile.dev +++ b/admin/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM node:18-alpine +FROM node:20-alpine RUN apk add --no-cache libc6-compat # Set working directory WORKDIR /app diff --git a/admin/app/email/test-email-modal.tsx b/admin/app/email/test-email-modal.tsx index 6d5cb8032d0..676f3b6853c 100644 --- a/admin/app/email/test-email-modal.tsx +++ b/admin/app/email/test-email-modal.tsx @@ -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; diff --git a/admin/app/workspace/create/form.tsx b/admin/app/workspace/create/form.tsx index 2a7eda207ef..d086777fcf0 100644 --- a/admin/app/workspace/create/form.tsx +++ b/admin/app/workspace/create/form.tsx @@ -2,18 +2,16 @@ import { useState, useEffect } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Controller, useForm } from "react-hook-form"; -// constants +// plane imports import { WEB_BASE_URL, ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants"; -// types +import { InstanceWorkspaceService } from "@plane/services"; 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 workspaceService = new WorkspaceService(); +const instanceWorkspaceService = new InstanceWorkspaceService(); export const WorkspaceCreateForm = () => { // router @@ -40,8 +38,8 @@ export const WorkspaceCreateForm = () => { const workspaceBaseURL = encodeURI(WEB_BASE_URL || window.location.origin + "/"); const handleCreateWorkspace = async (formData: IWorkspace) => { - await workspaceService - .workspaceSlugCheck(formData.slug) + await instanceWorkspaceService + .slugCheck(formData.slug) .then(async (res) => { if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) { setSlugError(false); diff --git a/admin/core/components/admin-sidebar/sidebar-dropdown.tsx b/admin/core/components/admin-sidebar/sidebar-dropdown.tsx index f34372413fe..501d501d8ba 100644 --- a/admin/core/components/admin-sidebar/sidebar-dropdown.tsx +++ b/admin/core/components/admin-sidebar/sidebar-dropdown.tsx @@ -7,12 +7,11 @@ 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(); diff --git a/admin/core/components/instance/setup-form.tsx b/admin/core/components/instance/setup-form.tsx index f033ce42e4a..ccbc557ba9c 100644 --- a/admin/core/components/instance/setup-form.tsx +++ b/admin/core/components/instance/setup-form.tsx @@ -6,12 +6,11 @@ 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(); diff --git a/admin/core/components/login/sign-in-form.tsx b/admin/core/components/login/sign-in-form.tsx index af1ba077e6f..986e5cebea5 100644 --- a/admin/core/components/login/sign-in-form.tsx +++ b/admin/core/components/login/sign-in-form.tsx @@ -5,13 +5,12 @@ 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"; diff --git a/admin/core/services/api.service.ts b/admin/core/services/api.service.ts deleted file mode 100644 index fa45c10b7a2..00000000000 --- a/admin/core/services/api.service.ts +++ /dev/null @@ -1,53 +0,0 @@ -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(url: string, params = {}): Promise> { - return this.axiosInstance.get(url, { params }); - } - - post(url: string, data: RequestType, config = {}): Promise> { - return this.axiosInstance.post(url, data, config); - } - - put(url: string, data: RequestType, config = {}): Promise> { - return this.axiosInstance.put(url, data, config); - } - - patch(url: string, data: RequestType, config = {}): Promise> { - return this.axiosInstance.patch(url, data, config); - } - - delete(url: string, data?: RequestType, config = {}) { - return this.axiosInstance.delete(url, { data, ...config }); - } - - request(config: AxiosRequestConfig = {}): Promise> { - return this.axiosInstance(config); - } -} diff --git a/admin/core/services/auth.service.ts b/admin/core/services/auth.service.ts deleted file mode 100644 index a47fd839628..00000000000 --- a/admin/core/services/auth.service.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 { - return this.get("/auth/get-csrf-token/") - .then((response) => response.data) - .catch((error) => { - throw error; - }); - } -} diff --git a/admin/core/services/instance.service.ts b/admin/core/services/instance.service.ts deleted file mode 100644 index 510a780d792..00000000000 --- a/admin/core/services/instance.service.ts +++ /dev/null @@ -1,72 +0,0 @@ -// 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 { - return this.get("/api/instances/") - .then((response) => response.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async getInstanceAdmins(): Promise { - return this.get("/api/instances/admins/") - .then((response) => response.data) - .catch((error) => { - throw error; - }); - } - - async updateInstanceInfo(data: Partial): Promise { - return this.patch, IInstance>("/api/instances/", data) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async getInstanceConfigurations() { - return this.get("/api/instances/configurations/") - .then((response) => response.data) - .catch((error) => { - throw error; - }); - } - - async updateInstanceConfigurations( - data: Partial - ): Promise { - return this.patch, IInstanceConfiguration[]>( - "/api/instances/configurations/", - data - ) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async sendTestEmail(receiverEmail: string): Promise { - 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; - }); - } -} diff --git a/admin/core/services/user.service.ts b/admin/core/services/user.service.ts deleted file mode 100644 index 74ef2a81bfa..00000000000 --- a/admin/core/services/user.service.ts +++ /dev/null @@ -1,29 +0,0 @@ -// 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 { - return this.get("/api/instances/admins/me/") - .then((response) => ({ ...response?.data, isAuthenticated: true })) - .catch(() => ({ isAuthenticated: false })); - } - - async currentUser(): Promise { - return this.get("/api/instances/admins/me/") - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } -} diff --git a/admin/core/services/workspace.service.ts b/admin/core/services/workspace.service.ts deleted file mode 100644 index 787ad426983..00000000000 --- a/admin/core/services/workspace.service.ts +++ /dev/null @@ -1,52 +0,0 @@ -// 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 - */ - async getWorkspaces(nextPageCursor?: string): Promise { - return this.get("/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 - */ - async workspaceSlugCheck(slug: string): Promise { - 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 - */ - async createWorkspace(data: IWorkspace): Promise { - return this.post("/api/instances/workspaces/", data) - .then((response) => response.data) - .catch((error) => { - throw error?.response?.data; - }); - } -} diff --git a/admin/core/store/instance.store.ts b/admin/core/store/instance.store.ts index 0daadb1fcda..9b25a246901 100644 --- a/admin/core/store/instance.store.ts +++ b/admin/core/store/instance.store.ts @@ -2,6 +2,7 @@ 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, @@ -10,8 +11,6 @@ import { IInstanceInfo, IInstanceConfig, } from "@plane/types"; -// services -import { InstanceService } from "@/services/instance.service"; // root store import { CoreRootStore } from "@/store/root.store"; @@ -96,7 +95,7 @@ export class InstanceStore implements IInstanceStore { try { if (this.instance === undefined) this.isLoading = true; this.error = undefined; - const instanceInfo = await this.instanceService.getInstanceInfo(); + const instanceInfo = await this.instanceService.info(); // handling the new user popup toggle if (this.instance === undefined && !instanceInfo?.instance?.workspaces_exist) this.store.theme.toggleNewUserPopup(); @@ -125,7 +124,7 @@ export class InstanceStore implements IInstanceStore { */ updateInstanceInfo = async (data: Partial) => { try { - const instanceResponse = await this.instanceService.updateInstanceInfo(data); + const instanceResponse = await this.instanceService.update(data); if (instanceResponse) { runInAction(() => { if (this.instance) set(this.instance, "instance", instanceResponse); @@ -144,7 +143,7 @@ export class InstanceStore implements IInstanceStore { */ fetchInstanceAdmins = async () => { try { - const instanceAdmins = await this.instanceService.getInstanceAdmins(); + const instanceAdmins = await this.instanceService.admins(); if (instanceAdmins) runInAction(() => (this.instanceAdmins = instanceAdmins)); return instanceAdmins; } catch (error) { @@ -159,7 +158,7 @@ export class InstanceStore implements IInstanceStore { */ fetchInstanceConfigurations = async () => { try { - const instanceConfigurations = await this.instanceService.getInstanceConfigurations(); + const instanceConfigurations = await this.instanceService.configurations(); if (instanceConfigurations) runInAction(() => (this.instanceConfigurations = instanceConfigurations)); return instanceConfigurations; } catch (error) { @@ -174,7 +173,7 @@ export class InstanceStore implements IInstanceStore { */ updateInstanceConfigurations = async (data: Partial) => { try { - const response = await this.instanceService.updateInstanceConfigurations(data); + const response = await this.instanceService.updateConfigurations(data); runInAction(() => { this.instanceConfigurations = this.instanceConfigurations?.map((config) => { const item = response.find((item) => item.key === config.key); diff --git a/admin/core/store/user.store.ts b/admin/core/store/user.store.ts index 7f56c0523ef..85c56495b2d 100644 --- a/admin/core/store/user.store.ts +++ b/admin/core/store/user.store.ts @@ -1,10 +1,8 @@ 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"; @@ -58,7 +56,7 @@ export class UserStore implements IUserStore { fetchCurrentUser = async () => { try { if (this.currentUser === undefined) this.isLoading = true; - const currentUser = await this.userService.currentUser(); + const currentUser = await this.userService.adminDetails(); if (currentUser) { await this.store.instance.fetchInstanceAdmins(); runInAction(() => { diff --git a/admin/core/store/workspace.store.ts b/admin/core/store/workspace.store.ts index f892e14f0ca..64f7501d3ab 100644 --- a/admin/core/store/workspace.store.ts +++ b/admin/core/store/workspace.store.ts @@ -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 = {}; paginationInfo: TPaginationInfo | undefined = undefined; // services - workspaceService; + instanceWorkspaceService; constructor(private store: CoreRootStore) { makeObservable(this, { @@ -48,7 +48,7 @@ export class WorkspaceStore implements IWorkspaceStore { // curd actions createWorkspace: action, }); - this.workspaceService = new WorkspaceService(); + this.instanceWorkspaceService = new InstanceWorkspaceService(); } // computed @@ -84,7 +84,7 @@ export class WorkspaceStore implements IWorkspaceStore { } else { this.loader = "init-loader"; } - const paginatedWorkspaceData = await this.workspaceService.getWorkspaces(); + const paginatedWorkspaceData = await this.instanceWorkspaceService.list(); 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.workspaceService.getWorkspaces(this.paginationInfo.next_cursor); + const paginatedWorkspaceData = await this.instanceWorkspaceService.list(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 => { try { this.loader = "mutation"; - const workspace = await this.workspaceService.createWorkspace(data); + const workspace = await this.instanceWorkspaceService.create(data); runInAction(() => { set(this.workspaces, [workspace.id], workspace); }); diff --git a/admin/package.json b/admin/package.json index e2fe4cf331d..0f91f8d011c 100644 --- a/admin/package.json +++ b/admin/package.json @@ -18,11 +18,12 @@ "@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.4", + "axios": "^1.7.9", "lodash": "^4.17.21", "lucide-react": "^0.356.0", "mobx": "^6.12.0", diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index d394dc9bd6d..ea3c4eb3d6f 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -6,6 +6,7 @@ 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) @@ -30,11 +31,20 @@ def validate(self, data): 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( - str(data.get("start_date").date()), project_id, is_start_date=True + date=str(data.get("start_date").date()), + project_id=project_id, + is_start_date=True, ) data["end_date"] = convert_to_utc( - str(data.get("end_date", None).date()), project_id + date=str(data.get("end_date", None).date()), + project_id=project_id, + is_start_date_end_date_equal=is_start_date_end_date_equal, ) return data diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index df7b9aec21e..9f9b189aef3 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -1,9 +1,10 @@ # Python imports import json - -from django.core.serializers.json import DjangoJSONEncoder +import uuid # Django imports +from django.core.serializers.json import DjangoJSONEncoder +from django.http import HttpResponseRedirect from django.db import IntegrityError from django.db.models import ( Case, @@ -19,11 +20,11 @@ Subquery, ) from django.utils import timezone +from django.conf import settings # Third party imports from rest_framework import status from rest_framework.response import Response -from rest_framework.parsers import MultiPartParser, FormParser # Module imports from plane.api.serializers import ( @@ -50,8 +51,10 @@ Project, ProjectMember, CycleIssue, + Workspace, ) - +from plane.settings.storage import S3Storage +from plane.bgtasks.storage_metadata_task import get_asset_object_metadata from .base import BaseAPIView @@ -940,72 +943,180 @@ class IssueAttachmentEndpoint(BaseAPIView): serializer_class = IssueAttachmentSerializer permission_classes = [ProjectEntityPermission] model = FileAsset - parser_classes = (MultiPartParser, FormParser) def post(self, request, slug, project_id, issue_id): - serializer = IssueAttachmentSerializer(data=request.data) + name = request.data.get("name") + type = request.data.get("type", False) + size = request.data.get("size") + external_id = request.data.get("external_id") + external_source = request.data.get("external_source") + + # Check if the request is valid + if not name or not size: + return Response( + {"error": "Invalid request.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + size_limit = min(size, settings.FILE_SIZE_LIMIT) + + if not type or type not in settings.ATTACHMENT_MIME_TYPES: + return Response( + {"error": "Invalid file type.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the workspace + workspace = Workspace.objects.get(slug=slug) + + # asset key + asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" + if ( request.data.get("external_id") and request.data.get("external_source") and FileAsset.objects.filter( project_id=project_id, workspace__slug=slug, - issue_id=issue_id, external_source=request.data.get("external_source"), external_id=request.data.get("external_id"), + issue_id=issue_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ).exists() ): - issue_attachment = FileAsset.objects.filter( - workspace__slug=slug, + asset = FileAsset.objects.filter( project_id=project_id, - external_id=request.data.get("external_id"), + workspace__slug=slug, external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + issue_id=issue_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ).first() return Response( { - "error": "Issue attachment with the same external id and external source already exists", - "id": str(issue_attachment.id), + "error": "Issue with the same external id and external source already exists", + "id": str(asset.id), }, status=status.HTTP_409_CONFLICT, ) - if serializer.is_valid(): - serializer.save(project_id=project_id, issue_id=issue_id) - issue_activity.delay( - type="attachment.activity.created", - requested_data=None, - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder), - epoch=int(timezone.now().timestamp()), - notification=True, - origin=request.META.get("HTTP_ORIGIN"), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + # Create a File Asset + asset = FileAsset.objects.create( + attributes={"name": name, "type": type, "size": size_limit}, + asset=asset_key, + size=size_limit, + workspace_id=workspace.id, + created_by=request.user, + issue_id=issue_id, + project_id=project_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + external_id=external_id, + external_source=external_source, + ) + + # 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( + { + "upload_data": presigned_url, + "asset_id": str(asset.id), + "attachment": IssueAttachmentSerializer(asset).data, + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) def delete(self, request, slug, project_id, issue_id, pk): - issue_attachment = FileAsset.objects.get(pk=pk) - issue_attachment.asset.delete(save=False) - issue_attachment.delete() + issue_attachment = FileAsset.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) + issue_attachment.is_deleted = True + issue_attachment.deleted_at = timezone.now() + issue_attachment.save() + issue_activity.delay( type="attachment.activity.deleted", requested_data=None, actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(self.kwargs.get("project_id", None)), + issue_id=str(issue_id), + project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), notification=True, origin=request.META.get("HTTP_ORIGIN"), ) + # Get the storage metadata + if not issue_attachment.storage_metadata: + get_asset_object_metadata.delay(str(issue_attachment.id)) + issue_attachment.save() return Response(status=status.HTTP_204_NO_CONTENT) - def get(self, request, slug, project_id, issue_id): + def get(self, request, slug, project_id, issue_id, pk=None): + if pk: + # Get the asset + asset = FileAsset.objects.get( + id=pk, workspace__slug=slug, project_id=project_id + ) + + # Check if the asset is uploaded + if not asset.is_uploaded: + return Response( + {"error": "The asset is not uploaded.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + storage = S3Storage(request=request) + presigned_url = storage.generate_presigned_url( + object_name=asset.asset.name, + disposition="attachment", + filename=asset.attributes.get("name"), + ) + return HttpResponseRedirect(presigned_url) + + # Get all the attachments issue_attachments = FileAsset.objects.filter( - issue_id=issue_id, workspace__slug=slug, project_id=project_id + issue_id=issue_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + workspace__slug=slug, + project_id=project_id, + is_uploaded=True, ) + # Serialize the attachments serializer = IssueAttachmentSerializer(issue_attachments, many=True) return Response(serializer.data, status=status.HTTP_200_OK) + + def patch(self, request, slug, project_id, issue_id, pk): + issue_attachment = FileAsset.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) + serializer = IssueAttachmentSerializer(issue_attachment) + + # Send this activity only if the attachment is not uploaded before + if not issue_attachment.is_uploaded: + issue_activity.delay( + type="attachment.activity.created", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + + # Update the attachment + issue_attachment.is_uploaded = True + issue_attachment.created_by = request.user + + # Get the storage metadata + if not issue_attachment.storage_metadata: + get_asset_object_metadata.delay(str(issue_attachment.id)) + issue_attachment.save() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 527f43edb54..824ca6b5e31 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -288,16 +288,6 @@ def patch(self, request, slug, pk): 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( diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index cd9adb939ee..3568e2b3828 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -19,6 +19,10 @@ WorkspaceMemberAdminSerializer, WorkspaceMemberMeSerializer, WorkspaceUserPropertiesSerializer, + WorkspaceUserLinkSerializer, + WorkspaceRecentVisitSerializer, + WorkspaceHomePreferenceSerializer, + StickySerializer, ) from .project import ( ProjectSerializer, diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py index 28ec62134cf..b56b0835058 100644 --- a/apiserver/plane/app/serializers/cycle.py +++ b/apiserver/plane/app/serializers/cycle.py @@ -20,12 +20,25 @@ def validate(self, data): 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") or self.instance.project_id + 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 + ) data["start_date"] = convert_to_utc( - str(data.get("start_date").date()), project_id, is_start_date=True + date=str(data.get("start_date").date()), + project_id=project_id, + is_start_date=True, ) data["end_date"] = convert_to_utc( - str(data.get("end_date", None).date()), project_id + date=str(data.get("end_date", None).date()), + project_id=project_id, + is_start_date_end_date_equal=is_start_date_end_date_equal, ) return data diff --git a/apiserver/plane/app/serializers/favorite.py b/apiserver/plane/app/serializers/favorite.py index 940b8ee8284..18f92f3ea2a 100644 --- a/apiserver/plane/app/serializers/favorite.py +++ b/apiserver/plane/app/serializers/favorite.py @@ -53,7 +53,6 @@ def get_entity_model_and_serializer(entity_type): } return entity_map.get(entity_type, (None, None)) - class UserFavoriteSerializer(serializers.ModelSerializer): entity_data = serializers.SerializerMethodField() diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py index b69221081f4..1fd2f4d3c84 100644 --- a/apiserver/plane/app/serializers/page.py +++ b/apiserver/plane/app/serializers/page.py @@ -54,6 +54,8 @@ def create(self, validated_data): labels = validated_data.pop("labels", None) project_id = self.context["project_id"] owned_by_id = self.context["owned_by_id"] + description = self.context["description"] + description_binary = self.context["description_binary"] description_html = self.context["description_html"] # Get the workspace id from the project @@ -62,6 +64,8 @@ def create(self, validated_data): # Create the page page = Page.objects.create( **validated_data, + description=description, + description_binary=description_binary, description_html=description_html, owned_by_id=owned_by_id, workspace_id=project.workspace_id, diff --git a/apiserver/plane/app/serializers/webhook.py b/apiserver/plane/app/serializers/webhook.py index fa4019f7ae0..1036b700c69 100644 --- a/apiserver/plane/app/serializers/webhook.py +++ b/apiserver/plane/app/serializers/webhook.py @@ -116,7 +116,7 @@ def update(self, instance, validated_data): class Meta: model = Webhook fields = "__all__" - read_only_fields = ["workspace", "secret_key"] + read_only_fields = ["workspace", "secret_key", "deleted_at"] class WebhookLogSerializer(DynamicBaseSerializer): diff --git a/apiserver/plane/app/serializers/workspace.py b/apiserver/plane/app/serializers/workspace.py index 49cd55bf7f7..862637ff59a 100644 --- a/apiserver/plane/app/serializers/workspace.py +++ b/apiserver/plane/app/serializers/workspace.py @@ -1,19 +1,34 @@ # Third party imports from rest_framework import serializers +from rest_framework import status +from rest_framework.response import Response # Module imports from .base import BaseSerializer, DynamicBaseSerializer from .user import UserLiteSerializer, UserAdminLiteSerializer + from plane.db.models import ( Workspace, WorkspaceMember, WorkspaceMemberInvite, WorkspaceTheme, WorkspaceUserProperties, + WorkspaceUserLink, + UserRecentVisit, + Issue, + Page, + Project, + ProjectMember, + WorkspaceHomePreference, + Sticky, ) from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS +# Django imports +from django.core.validators import URLValidator +from django.core.exceptions import ValidationError + class WorkSpaceSerializer(DynamicBaseSerializer): owner = UserLiteSerializer(read_only=True) @@ -106,3 +121,139 @@ class Meta: model = WorkspaceUserProperties fields = "__all__" read_only_fields = ["workspace", "user"] + + +class WorkspaceUserLinkSerializer(BaseSerializer): + class Meta: + model = WorkspaceUserLink + fields = "__all__" + read_only_fields = ["workspace", "owner"] + + def to_internal_value(self, data): + url = data.get("url", "") + if url and not url.startswith(("http://", "https://")): + data["url"] = "http://" + url + + return super().to_internal_value(data) + + def validate_url(self, value): + url_validator = URLValidator() + try: + url_validator(value) + except ValidationError: + raise serializers.ValidationError({"error": "Invalid URL format."}) + + return value + + +class IssueRecentVisitSerializer(serializers.ModelSerializer): + project_identifier = serializers.SerializerMethodField() + + class Meta: + model = Issue + fields = [ + "id", + "name", + "state", + "priority", + "assignees", + "type", + "sequence_id", + "project_id", + "project_identifier", + ] + + def get_project_identifier(self, obj): + project = obj.project + + return project.identifier if project else None + + +class ProjectRecentVisitSerializer(serializers.ModelSerializer): + project_members = serializers.SerializerMethodField() + + class Meta: + model = Project + fields = ["id", "name", "logo_props", "project_members", "identifier"] + + def get_project_members(self, obj): + members = ProjectMember.objects.filter(project_id=obj.id).values_list( + "member", flat=True + ) + return members + + +class PageRecentVisitSerializer(serializers.ModelSerializer): + project_id = serializers.SerializerMethodField() + project_identifier = serializers.SerializerMethodField() + + class Meta: + model = Page + fields = [ + "id", + "name", + "logo_props", + "project_id", + "owned_by", + "project_identifier", + ] + + def get_project_id(self, obj): + return ( + obj.project_id + if hasattr(obj, "project_id") + else obj.projects.values_list("id", flat=True).first() + ) + + def get_project_identifier(self, obj): + project = obj.projects.first() + + return project.identifier if project else None + + +def get_entity_model_and_serializer(entity_type): + entity_map = { + "issue": (Issue, IssueRecentVisitSerializer), + "page": (Page, PageRecentVisitSerializer), + "project": (Project, ProjectRecentVisitSerializer), + } + return entity_map.get(entity_type, (None, None)) + + +class WorkspaceRecentVisitSerializer(BaseSerializer): + entity_data = serializers.SerializerMethodField() + + class Meta: + model = UserRecentVisit + fields = ["id", "entity_name", "entity_identifier", "entity_data", "visited_at"] + read_only_fields = ["workspace", "owner", "created_by", "updated_by"] + + def get_entity_data(self, obj): + entity_name = obj.entity_name + entity_identifier = obj.entity_identifier + + entity_model, entity_serializer = get_entity_model_and_serializer(entity_name) + + if entity_model and entity_serializer: + try: + entity = entity_model.objects.get(pk=entity_identifier) + + return entity_serializer(entity).data + except entity_model.DoesNotExist: + return None + return None + + +class WorkspaceHomePreferenceSerializer(BaseSerializer): + class Meta: + model = WorkspaceHomePreference + fields = ["key", "is_enabled", "sort_order"] + read_only_fields = ["worspace", "created_by", "update_by"] + + +class StickySerializer(BaseSerializer): + class Meta: + model = Sticky + fields = "__all__" + read_only_fields = ["workspace", "owner"] + extra_kwargs = {"name": {"required": False}} diff --git a/apiserver/plane/app/urls/page.py b/apiserver/plane/app/urls/page.py index b49f1d4a28d..f7eb7e42429 100644 --- a/apiserver/plane/app/urls/page.py +++ b/apiserver/plane/app/urls/page.py @@ -8,6 +8,7 @@ SubPagesEndpoint, PagesDescriptionViewSet, PageVersionEndpoint, + PageDuplicateEndpoint, ) @@ -78,4 +79,9 @@ PageVersionEndpoint.as_view(), name="page-versions", ), + path( + "workspaces//projects//pages//duplicate/", + PageDuplicateEndpoint.as_view(), + name="page-duplicate", + ), ] diff --git a/apiserver/plane/app/urls/search.py b/apiserver/plane/app/urls/search.py index de4f1e7b2c0..0bbbd9cf7f4 100644 --- a/apiserver/plane/app/urls/search.py +++ b/apiserver/plane/app/urls/search.py @@ -16,7 +16,7 @@ name="project-issue-search", ), path( - "workspaces//projects//entity-search/", + "workspaces//entity-search/", SearchEndpoint.as_view(), name="entity-search", ), diff --git a/apiserver/plane/app/urls/workspace.py b/apiserver/plane/app/urls/workspace.py index d91fdb60bca..cb0a026d19a 100644 --- a/apiserver/plane/app/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -27,6 +27,10 @@ WorkspaceFavoriteEndpoint, WorkspaceFavoriteGroupEndpoint, WorkspaceDraftIssueViewSet, + QuickLinkViewSet, + UserRecentVisitViewSet, + WorkspacePreferenceViewSet, + WorkspaceStickyViewSet, ) @@ -213,4 +217,45 @@ WorkspaceDraftIssueViewSet.as_view({"post": "create_draft_to_issue"}), name="workspace-drafts-issues", ), + # quick link + path( + "workspaces//quick-links/", + QuickLinkViewSet.as_view({"get": "list", "post": "create"}), + name="workspace-quick-links", + ), + path( + "workspaces//quick-links//", + QuickLinkViewSet.as_view( + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} + ), + name="workspace-quick-links", + ), + # Widgets + path( + "workspaces//home-preferences/", + WorkspacePreferenceViewSet.as_view(), + name="workspace-home-preference", + ), + path( + "workspaces//home-preferences//", + WorkspacePreferenceViewSet.as_view(), + name="workspace-home-preference", + ), + path( + "workspaces//recent-visits/", + UserRecentVisitViewSet.as_view({"get": "list"}), + name="workspace-recent-visits", + ), + path( + "workspaces//stickies/", + WorkspaceStickyViewSet.as_view({"get": "list", "post": "create"}), + name="workspace-sticky", + ), + path( + "workspaces//stickies//", + WorkspaceStickyViewSet.as_view( + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} + ), + name="workspace-sticky", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 56ea78b4130..01dba11a127 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -41,10 +41,12 @@ from .workspace.draft import WorkspaceDraftIssueViewSet +from .workspace.preference import WorkspacePreferenceViewSet from .workspace.favorite import ( WorkspaceFavoriteEndpoint, WorkspaceFavoriteGroupEndpoint, ) +from .workspace.recent_visit import UserRecentVisitViewSet from .workspace.member import ( WorkSpaceMemberViewSet, @@ -72,6 +74,8 @@ 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 ( @@ -155,6 +159,7 @@ PageLogEndpoint, SubPagesEndpoint, PagesDescriptionViewSet, + PageDuplicateEndpoint, ) from .page.version import PageVersionEndpoint diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 9bf498886b3..f30f498265f 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -54,11 +54,7 @@ # Module imports from .. import BaseAPIView, BaseViewSet from plane.bgtasks.webhook_task import model_activity -from plane.utils.timezone_converter import ( - convert_utc_to_project_timezone, - convert_to_utc, - user_timezone_converter, -) +from plane.utils.timezone_converter import convert_to_utc, user_timezone_converter class CycleViewSet(BaseViewSet): @@ -143,10 +139,7 @@ def get_queryset(self): & 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), @@ -259,7 +252,9 @@ def list(self, request, slug, project_id): "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]) @@ -271,7 +266,9 @@ def create(self, request, slug, project_id): request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None ): - serializer = CycleWriteSerializer(data=request.data) + serializer = CycleWriteSerializer( + data=request.data, context={"project_id": project_id} + ) if serializer.is_valid(): serializer.save(project_id=project_id, owned_by=request.user) cycle = ( @@ -306,6 +303,11 @@ def create(self, request, slug, project_id): .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", @@ -358,7 +360,9 @@ def partial_update(self, request, slug, project_id, pk): status=status.HTTP_400_BAD_REQUEST, ) - serializer = CycleWriteSerializer(cycle, data=request.data, partial=True) + serializer = CycleWriteSerializer( + cycle, data=request.data, partial=True, context={"project_id": project_id} + ) if serializer.is_valid(): serializer.save() cycle = queryset.values( @@ -388,6 +392,11 @@ def partial_update(self, request, slug, project_id, pk): "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", @@ -457,7 +466,9 @@ def retrieve(self, request, slug, project_id, pk): 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, @@ -533,8 +544,17 @@ def post(self, request, slug, project_id): status=status.HTTP_400_BAD_REQUEST, ) - start_date = convert_to_utc(str(start_date), project_id, is_start_date=True) - end_date = convert_to_utc(str(end_date), project_id) + 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, + ) # Check if any cycle intersects in the given interval cycles = Cycle.objects.filter( diff --git a/apiserver/plane/app/views/external/base.py b/apiserver/plane/app/views/external/base.py index 1dfbc421a00..ae5c47f1455 100644 --- a/apiserver/plane/app/views/external/base.py +++ b/apiserver/plane/app/views/external/base.py @@ -1,71 +1,169 @@ -# Python imports -import requests +# Python import import os +from typing import List, Dict, Tuple + +# Third party import +import litellm +import requests -# Third party imports -from openai import OpenAI -from rest_framework.response import Response from rest_framework import status +from rest_framework.response import Response -# Django imports +# Module import +from plane.app.permissions import ROLE, allow_permission +from plane.app.serializers import (ProjectLiteSerializer, + WorkspaceLiteSerializer) +from plane.db.models import Project, Workspace +from plane.license.utils.instance_value import get_configuration_value +from plane.utils.exception_logger import log_exception -# Module imports from ..base import BaseAPIView -from plane.app.permissions import allow_permission, ROLE -from plane.db.models import Workspace, Project -from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer -from plane.license.utils.instance_value import get_configuration_value +class LLMProvider: + """Base class for LLM provider configurations""" + name: str = "" + models: List[str] = [] + default_model: str = "" + + @classmethod + def get_config(cls) -> Dict[str, str | List[str]]: + return { + "name": cls.name, + "models": cls.models, + "default_model": cls.default_model, + } + +class OpenAIProvider(LLMProvider): + name = "OpenAI" + models = ["gpt-3.5-turbo", "gpt-4o-mini", "gpt-4o", "o1-mini", "o1-preview"] + default_model = "gpt-4o-mini" + +class AnthropicProvider(LLMProvider): + name = "Anthropic" + models = [ + "claude-3-5-sonnet-20240620", + "claude-3-haiku-20240307", + "claude-3-opus-20240229", + "claude-3-sonnet-20240229", + "claude-2.1", + "claude-2", + "claude-instant-1.2", + "claude-instant-1" + ] + default_model = "claude-3-sonnet-20240229" + +class GeminiProvider(LLMProvider): + name = "Gemini" + models = ["gemini-pro", "gemini-1.5-pro-latest", "gemini-pro-vision"] + default_model = "gemini-pro" + +SUPPORTED_PROVIDERS = { + "openai": OpenAIProvider, + "anthropic": AnthropicProvider, + "gemini": GeminiProvider, +} + +def get_llm_config() -> Tuple[str | None, str | None, str | None]: + """ + Helper to get LLM configuration values, returns: + - api_key, model, provider + """ + api_key, provider_key, model = get_configuration_value([ + { + "key": "LLM_API_KEY", + "default": os.environ.get("LLM_API_KEY", None), + }, + { + "key": "LLM_PROVIDER", + "default": os.environ.get("LLM_PROVIDER", "openai"), + }, + { + "key": "LLM_MODEL", + "default": os.environ.get("LLM_MODEL", None), + }, + ]) + + provider = SUPPORTED_PROVIDERS.get(provider_key.lower()) + if not provider: + log_exception(ValueError(f"Unsupported provider: {provider_key}")) + return None, None, None + + if not api_key: + log_exception(ValueError(f"Missing API key for provider: {provider.name}")) + return None, None, None + + # If no model specified, use provider's default + if not model: + model = provider.default_model + + # Validate model is supported by provider + if model not in provider.models: + log_exception(ValueError( + f"Model {model} not supported by {provider.name}. " + f"Supported models: {', '.join(provider.models)}" + )) + return None, None, None + + return api_key, model, provider_key + + +def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> Tuple[str | None, str | None]: + """Helper to get LLM completion response""" + final_text = task + "\n" + prompt + try: + # For Gemini, prepend provider name to model + if provider.lower() == "gemini": + model = f"gemini/{model}" + + response = litellm.completion( + model=model, + messages=[{"role": "user", "content": final_text}], + api_key=api_key, + ) + text = response.choices[0].message.content.strip() + return text, None + except Exception as e: + log_exception(e) + error_type = e.__class__.__name__ + if error_type == "AuthenticationError": + return None, f"Invalid API key for {provider}" + elif error_type == "RateLimitError": + return None, f"Rate limit exceeded for {provider}" + else: + return None, f"Error occurred while generating response from {provider}" + class GPTIntegrationEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def post(self, request, slug, project_id): - OPENAI_API_KEY, GPT_ENGINE = get_configuration_value( - [ - { - "key": "OPENAI_API_KEY", - "default": os.environ.get("OPENAI_API_KEY", None), - }, - { - "key": "GPT_ENGINE", - "default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), - }, - ] - ) + api_key, model, provider = get_llm_config() - # Get the configuration value - # Check the keys - if not OPENAI_API_KEY or not GPT_ENGINE: + if not api_key or not model or not provider: return Response( - {"error": "OpenAI API key and engine is required"}, + {"error": "LLM provider API key and model are required"}, status=status.HTTP_400_BAD_REQUEST, ) - prompt = request.data.get("prompt", False) task = request.data.get("task", False) - if not task: return Response( {"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST ) - final_text = task + "\n" + prompt - - client = OpenAI(api_key=OPENAI_API_KEY) - - response = client.chat.completions.create( - model=GPT_ENGINE, messages=[{"role": "user", "content": final_text}] - ) + text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider) + if not text and error: + return Response( + {"error": "An internal error has occurred."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) workspace = Workspace.objects.get(slug=slug) project = Project.objects.get(pk=project_id) - text = response.choices[0].message.content.strip() - text_html = text.replace("\n", "
") return Response( { "response": text, - "response_html": text_html, + "response_html": text.replace("\n", "
"), "project_detail": ProjectLiteSerializer(project).data, "workspace_detail": WorkspaceLiteSerializer(workspace).data, }, @@ -76,47 +174,33 @@ def post(self, request, slug, project_id): class WorkspaceGPTIntegrationEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def post(self, request, slug): - OPENAI_API_KEY, GPT_ENGINE = get_configuration_value( - [ - { - "key": "OPENAI_API_KEY", - "default": os.environ.get("OPENAI_API_KEY", None), - }, - { - "key": "GPT_ENGINE", - "default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), - }, - ] - ) - - # Get the configuration value - # Check the keys - if not OPENAI_API_KEY or not GPT_ENGINE: + api_key, model, provider = get_llm_config() + + if not api_key or not model or not provider: return Response( - {"error": "OpenAI API key and engine is required"}, + {"error": "LLM provider API key and model are required"}, status=status.HTTP_400_BAD_REQUEST, ) - prompt = request.data.get("prompt", False) task = request.data.get("task", False) - if not task: return Response( {"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST ) - final_text = task + "\n" + prompt - - client = OpenAI(api_key=OPENAI_API_KEY) - - response = client.chat.completions.create( - model=GPT_ENGINE, messages=[{"role": "user", "content": final_text}] - ) + text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider) + if not text and error: + return Response( + {"error": "An internal error has occurred."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) - text = response.choices[0].message.content.strip() - text_html = text.replace("\n", "
") return Response( - {"response": text, "response_html": text_html}, status=status.HTTP_200_OK + { + "response": text, + "response_html": text.replace("\n", "
"), + }, + status=status.HTTP_200_OK, ) diff --git a/apiserver/plane/app/views/issue/attachment.py b/apiserver/plane/app/views/issue/attachment.py index 4427227f12a..d519a52695d 100644 --- a/apiserver/plane/app/views/issue/attachment.py +++ b/apiserver/plane/app/views/issue/attachment.py @@ -120,10 +120,12 @@ def post(self, request, slug, project_id, issue_id): # 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( { diff --git a/apiserver/plane/app/views/issue/relation.py b/apiserver/plane/app/views/issue/relation.py index 35d88a54b14..529f31041c9 100644 --- a/apiserver/plane/app/views/issue/relation.py +++ b/apiserver/plane/app/views/issue/relation.py @@ -268,27 +268,20 @@ def create(self, request, slug, project_id, issue_id): ) def remove_relation(self, request, slug, project_id, issue_id): - relation_type = request.data.get("relation_type", None) related_issue = request.data.get("related_issue", None) - if relation_type in ["blocking", "start_after", "finish_after"]: - issue_relation = IssueRelation.objects.get( - workspace__slug=slug, - project_id=project_id, - issue_id=related_issue, - related_issue_id=issue_id, - ) - else: - issue_relation = IssueRelation.objects.get( - workspace__slug=slug, - project_id=project_id, - issue_id=issue_id, - related_issue_id=related_issue, - ) + issue_relations = IssueRelation.objects.filter( + workspace__slug=slug, + project_id=project_id, + ).filter( + Q(issue_id=related_issue, related_issue_id=issue_id) | + Q(issue_id=issue_id, related_issue_id=related_issue) + ) + issue_relations = issue_relations.first() current_instance = json.dumps( - IssueRelationSerializer(issue_relation).data, cls=DjangoJSONEncoder + IssueRelationSerializer(issue_relations).data, cls=DjangoJSONEncoder ) - issue_relation.delete() + issue_relations.delete() issue_activity.delay( type="issue_relation.activity.deleted", requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index 46ce81ce179..6f98d79523a 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -121,6 +121,8 @@ def create(self, request, slug, project_id): context={ "project_id": project_id, "owned_by_id": request.user.id, + "description": request.data.get("description", {}), + "description_binary": request.data.get("description_binary", None), "description_html": request.data.get("description_html", "

"), }, ) @@ -553,3 +555,49 @@ def partial_update(self, request, slug, project_id, pk): return Response({"message": "Updated successfully"}) else: return Response({"error": "No binary data provided"}) + + +class PageDuplicateEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def post(self, request, slug, project_id, page_id): + page = Page.objects.filter( + pk=page_id, workspace__slug=slug, projects__id=project_id + ).first() + + # get all the project ids where page is present + project_ids = ProjectPage.objects.filter(page_id=page_id).values_list( + "project_id", flat=True + ) + + page.pk = None + page.name = f"{page.name} (Copy)" + page.description_binary = None + page.owned_by = request.user + page.save() + + for project_id in project_ids: + ProjectPage.objects.create( + workspace_id=page.workspace_id, + project_id=project_id, + page_id=page.id, + created_by_id=page.created_by_id, + updated_by_id=page.updated_by_id, + ) + + page_transaction.delay( + {"description_html": page.description_html}, None, page.id + ) + page = ( + Page.objects.filter(pk=page.id) + .annotate( + project_ids=Coalesce( + ArrayAgg( + "projects__id", distinct=True, filter=~Q(projects__id=True) + ), + Value([], output_field=ArrayField(UUIDField())), + ) + ) + .first() + ) + serializer = PageDetailSerializer(page) + return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 16b2a6e775f..9123e6d87fb 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -416,16 +416,6 @@ def partial_update(self, request, slug, pk=None): 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( diff --git a/apiserver/plane/app/views/search/base.py b/apiserver/plane/app/views/search/base.py index 3736d8f81af..b98e2855f44 100644 --- a/apiserver/plane/app/views/search/base.py +++ b/apiserver/plane/app/views/search/base.py @@ -34,6 +34,7 @@ IssueView, ProjectMember, ProjectPage, + WorkspaceMember, ) @@ -252,214 +253,463 @@ def get(self, request, slug): class SearchEndpoint(BaseAPIView): - def get(self, request, slug, project_id): + def get(self, request, slug): query = request.query_params.get("query", False) query_types = request.query_params.get("query_type", "user_mention").split(",") query_types = [qt.strip() for qt in query_types] count = int(request.query_params.get("count", 5)) + project_id = request.query_params.get("project_id", None) + issue_id = request.query_params.get("issue_id", None) response_data = {} - for query_type in query_types: - if query_type == "user_mention": - fields = [ - "member__first_name", - "member__last_name", - "member__display_name", - ] - q = Q() - - if query: - for field in fields: - q |= Q(**{f"{field}__icontains": query}) - users = ( - ProjectMember.objects.filter( - q, is_active=True, project_id=project_id, workspace__slug=slug, member__is_bot=False + if project_id: + for query_type in query_types: + if query_type == "user_mention": + fields = [ + "member__first_name", + "member__last_name", + "member__display_name", + ] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + users = ( + ProjectMember.objects.filter( + q, + is_active=True, + workspace__slug=slug, + member__is_bot=False, + project_id=project_id, + ) + .annotate( + member__avatar_url=Case( + When( + member__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "member__avatar_asset", + Value("/"), + ), + ), + When( + member__avatar_asset__isnull=True, + then="member__avatar", + ), + default=Value(None), + output_field=CharField(), + ) + ) + .order_by("-created_at") + ) + + if issue_id: + issue_created_by = ( + Issue.objects.filter(id=issue_id) + .values_list("created_by_id", flat=True) + .first() + ) + users = ( + users.filter(Q(role__gt=10) | Q(member_id=issue_created_by)) + .distinct() + .values( + "member__avatar_url", + "member__display_name", + "member__id", + ) + ) + else: + users = ( + users.filter(Q(role__gt=10)) + .distinct() + .values( + "member__avatar_url", + "member__display_name", + "member__id", + ) + ) + + response_data["user_mention"] = list(users[:count]) + + elif query_type == "project": + fields = ["name", "identifier"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + projects = ( + Project.objects.filter( + q, + Q(project_projectmember__member=self.request.user) + | Q(network=2), + workspace__slug=slug, + ) + .order_by("-created_at") + .distinct() + .values( + "name", "id", "identifier", "logo_props", "workspace__slug" + )[:count] ) - .annotate( - member__avatar_url=Case( - When( - member__avatar_asset__isnull=False, - then=Concat( - Value("/api/assets/v2/static/"), - "member__avatar_asset", - Value("/"), + response_data["project"] = list(projects) + + elif query_type == "issue": + fields = ["name", "sequence_id", "project__identifier"] + q = Q() + + if query: + for field in fields: + if field == "sequence_id": + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) + + issues = ( + Issue.issue_objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + project_id=project_id, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "sequence_id", + "project__identifier", + "project_id", + "priority", + "state_id", + "type_id", + )[:count] + ) + response_data["issue"] = list(issues) + + elif query_type == "cycle": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + cycles = ( + Cycle.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + project_id=project_id, + ) + .annotate( + status=Case( + When( + Q(start_date__lte=timezone.now()) + & Q(end_date__gte=timezone.now()), + then=Value("CURRENT"), + ), + When( + start_date__gt=timezone.now(), + then=Value("UPCOMING"), + ), + When( + end_date__lt=timezone.now(), then=Value("COMPLETED") ), - ), - When( - member__avatar_asset__isnull=True, then="member__avatar" - ), - default=Value(None), - output_field=models.CharField(), + When( + Q(start_date__isnull=True) + & Q(end_date__isnull=True), + then=Value("DRAFT"), + ), + default=Value("DRAFT"), + output_field=CharField(), + ) ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "status", + "workspace__slug", + )[:count] ) - .order_by("-created_at") - .values("member__avatar_url", "member__display_name", "member__id")[ - :count - ] - ) - response_data["user_mention"] = list(users) - - elif query_type == "project": - fields = ["name", "identifier"] - q = Q() - - if query: - for field in fields: - q |= Q(**{f"{field}__icontains": query}) - projects = ( - Project.objects.filter( - q, - Q(project_projectmember__member=self.request.user) - | Q(network=2), - workspace__slug=slug, + response_data["cycle"] = list(cycles) + + elif query_type == "module": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + modules = ( + Module.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + project_id=project_id, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "status", + "workspace__slug", + )[:count] ) - .order_by("-created_at") - .distinct() - .values( - "name", "id", "identifier", "logo_props", "workspace__slug" - )[:count] - ) - response_data["project"] = list(projects) - - elif query_type == "issue": - fields = ["name", "sequence_id", "project__identifier"] - q = Q() - - if query: - for field in fields: - if field == "sequence_id": - sequences = re.findall(r"\b\d+\b", query) - for sequence_id in sequences: - q |= Q(**{"sequence_id": sequence_id}) - else: + response_data["module"] = list(modules) + + elif query_type == "page": + fields = ["name"] + q = Q() + + if query: + for field in fields: q |= Q(**{f"{field}__icontains": query}) - issues = ( - Issue.issue_objects.filter( - q, - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - workspace__slug=slug, - project_id=project_id, + pages = ( + Page.objects.filter( + q, + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + projects__id=project_id, + workspace__slug=slug, + access=0, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "logo_props", + "projects__id", + "workspace__slug", + )[:count] ) - .order_by("-created_at") - .distinct() - .values( - "name", - "id", - "sequence_id", - "project__identifier", - "project_id", - "priority", - "state_id", - "type_id", - )[:count] - ) - response_data["issue"] = list(issues) - - elif query_type == "cycle": - fields = ["name"] - q = Q() - - if query: - for field in fields: - q |= Q(**{f"{field}__icontains": query}) - - cycles = ( - Cycle.objects.filter( - q, - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - workspace__slug=slug, + response_data["page"] = list(pages) + return Response(response_data, status=status.HTTP_200_OK) + + else: + for query_type in query_types: + if query_type == "user_mention": + fields = [ + "member__first_name", + "member__last_name", + "member__display_name", + ] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + users = ( + WorkspaceMember.objects.filter( + q, + is_active=True, + workspace__slug=slug, + member__is_bot=False, + ) + .annotate( + member__avatar_url=Case( + When( + member__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "member__avatar_asset", + Value("/"), + ), + ), + When( + member__avatar_asset__isnull=True, + then="member__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) + .order_by("-created_at") + .values( + "member__avatar_url", "member__display_name", "member__id" + )[:count] ) - .annotate( - status=Case( - When( - Q(start_date__lte=timezone.now()) - & Q(end_date__gte=timezone.now()), - then=Value("CURRENT"), - ), - When(start_date__gt=timezone.now(), then=Value("UPCOMING")), - When(end_date__lt=timezone.now(), then=Value("COMPLETED")), - When( - Q(start_date__isnull=True) & Q(end_date__isnull=True), - then=Value("DRAFT"), - ), - default=Value("DRAFT"), - output_field=CharField(), + response_data["user_mention"] = list(users) + + elif query_type == "project": + fields = ["name", "identifier"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + projects = ( + Project.objects.filter( + q, + Q(project_projectmember__member=self.request.user) + | Q(network=2), + workspace__slug=slug, ) + .order_by("-created_at") + .distinct() + .values( + "name", "id", "identifier", "logo_props", "workspace__slug" + )[:count] ) - .order_by("-created_at") - .distinct() - .values( - "name", - "id", - "project_id", - "project__identifier", - "status", - "workspace__slug", - )[:count] - ) - response_data["cycle"] = list(cycles) - - elif query_type == "module": - fields = ["name"] - q = Q() - - if query: - for field in fields: - q |= Q(**{f"{field}__icontains": query}) - - modules = ( - Module.objects.filter( - q, - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - workspace__slug=slug, + response_data["project"] = list(projects) + + elif query_type == "issue": + fields = ["name", "sequence_id", "project__identifier"] + q = Q() + + if query: + for field in fields: + if field == "sequence_id": + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) + + issues = ( + Issue.issue_objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "sequence_id", + "project__identifier", + "project_id", + "priority", + "state_id", + "type_id", + )[:count] ) - .order_by("-created_at") - .distinct() - .values( - "name", - "id", - "project_id", - "project__identifier", - "status", - "workspace__slug", - )[:count] - ) - response_data["module"] = list(modules) - - elif query_type == "page": - fields = ["name"] - q = Q() - - if query: - for field in fields: - q |= Q(**{f"{field}__icontains": query}) - - pages = ( - Page.objects.filter( - q, - projects__project_projectmember__member=self.request.user, - projects__project_projectmember__is_active=True, - projects__id=project_id, - workspace__slug=slug, - access=0, + response_data["issue"] = list(issues) + + elif query_type == "cycle": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + cycles = ( + Cycle.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + ) + .annotate( + status=Case( + When( + Q(start_date__lte=timezone.now()) + & Q(end_date__gte=timezone.now()), + then=Value("CURRENT"), + ), + When( + start_date__gt=timezone.now(), + then=Value("UPCOMING"), + ), + When( + end_date__lt=timezone.now(), then=Value("COMPLETED") + ), + When( + Q(start_date__isnull=True) + & Q(end_date__isnull=True), + then=Value("DRAFT"), + ), + default=Value("DRAFT"), + output_field=CharField(), + ) + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "status", + "workspace__slug", + )[:count] ) - .order_by("-created_at") - .distinct() - .values( - "name", "id", "logo_props", "projects__id", "workspace__slug" - )[:count] - ) - response_data["page"] = list(pages) + response_data["cycle"] = list(cycles) - else: - return Response( - {"error": f"Invalid query type: {query_type}"}, - status=status.HTTP_400_BAD_REQUEST, - ) + elif query_type == "module": + fields = ["name"] + q = Q() - return Response(response_data, status=status.HTTP_200_OK) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + modules = ( + Module.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "status", + "workspace__slug", + )[:count] + ) + response_data["module"] = list(modules) + + elif query_type == "page": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + pages = ( + Page.objects.filter( + q, + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + workspace__slug=slug, + access=0, + is_global=True, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "logo_props", + "projects__id", + "workspace__slug", + )[:count] + ) + response_data["page"] = list(pages) + return Response(response_data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/search/issue.py b/apiserver/plane/app/views/search/issue.py index 13fdc4effa0..3db9e1cba1e 100644 --- a/apiserver/plane/app/views/search/issue.py +++ b/apiserver/plane/app/views/search/issue.py @@ -1,5 +1,3 @@ -# Python imports - # Django imports from django.db.models import Q @@ -9,7 +7,7 @@ # Module imports from .base import BaseAPIView -from plane.db.models import Issue, ProjectMember +from plane.db.models import Issue, ProjectMember, IssueRelation from plane.utils.issue_search import search_issues @@ -47,17 +45,18 @@ def get(self, request, slug, project_id): ) if issue_relation == "true" and issue_id: issue = Issue.issue_objects.filter(pk=issue_id).first() + related_issue_ids = IssueRelation.objects.filter( + Q(related_issue=issue) | Q(issue=issue) + ).values_list( + "issue_id", "related_issue_id" + ).distinct() + + related_issue_ids = [item for sublist in related_issue_ids for item in sublist] + if issue: issues = issues.filter( ~Q(pk=issue_id), - ~( - Q(issue_related__issue=issue) - & Q(issue_related__deleted_at__isnull=True) - ), - ~( - Q(issue_relation__related_issue=issue) - & Q(issue_relation__deleted_at__isnull=True) - ), + ~Q(pk__in=related_issue_ids), ) if sub_issue == "true" and issue_id: issue = Issue.issue_objects.filter(pk=issue_id).first() diff --git a/apiserver/plane/app/views/state/base.py b/apiserver/plane/app/views/state/base.py index 00f4813e681..4c7a73c369e 100644 --- a/apiserver/plane/app/views/state/base.py +++ b/apiserver/plane/app/views/state/base.py @@ -1,6 +1,9 @@ # Python imports from itertools import groupby +# Django imports +from django.db.utils import IntegrityError + # Third party imports from rest_framework.response import Response from rest_framework import status @@ -37,11 +40,19 @@ def get_queryset(self): @invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False) @allow_permission([ROLE.ADMIN]) def create(self, request, slug, project_id): - serializer = StateSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(project_id=project_id) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + try: + serializer = StateSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"name": "The state name is already taken"}, + status=status.HTTP_400_BAD_REQUEST, + ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def list(self, request, slug, project_id): diff --git a/apiserver/plane/app/views/workspace/preference.py b/apiserver/plane/app/views/workspace/preference.py new file mode 100644 index 00000000000..e3da9184da2 --- /dev/null +++ b/apiserver/plane/app/views/workspace/preference.py @@ -0,0 +1,90 @@ +# Module imports +from ..base import BaseAPIView +from plane.db.models.workspace import WorkspaceHomePreference +from plane.app.permissions import allow_permission, ROLE +from plane.db.models import Workspace +from plane.app.serializers.workspace import WorkspaceHomePreferenceSerializer + +# Django imports + +from django.db.models import Count + + +# Third party imports +from rest_framework.response import Response +from rest_framework import status + + +class WorkspacePreferenceViewSet(BaseAPIView): + model = WorkspaceHomePreference + + def get_serializer_class(self): + return WorkspaceHomePreferenceSerializer + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def get(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + + get_preference = WorkspaceHomePreference.objects.filter( + user=request.user, workspace_id=workspace.id + ) + + create_preference_keys = [] + + keys = [ + key + for key, _ in WorkspaceHomePreference.HomeWidgetKeys.choices + if key not in ["quick_tutorial", "new_at_plane"] + ] + + sort_order_counter = 1 + + for preference in keys: + if preference not in get_preference.values_list("key", flat=True): + create_preference_keys.append(preference) + + sort_order = 1000 - sort_order_counter + + preference = WorkspaceHomePreference.objects.bulk_create( + [ + WorkspaceHomePreference( + key=key, + user=request.user, + workspace=workspace, + sort_order=sort_order, + ) + for key in create_preference_keys + ], + batch_size=10, + ignore_conflicts=True, + ) + sort_order_counter += 1 + + preference = WorkspaceHomePreference.objects.filter( + user=request.user, workspace_id=workspace.id + ) + + return Response( + preference.values("key", "is_enabled", "config", "sort_order"), + status=status.HTTP_200_OK, + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def patch(self, request, slug, key): + preference = WorkspaceHomePreference.objects.filter( + key=key, workspace__slug=slug + ).first() + + if preference: + serializer = WorkspaceHomePreferenceSerializer( + preference, data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + return Response( + {"detail": "Preference not found"}, status=status.HTTP_400_BAD_REQUEST + ) diff --git a/apiserver/plane/app/views/workspace/quick_link.py b/apiserver/plane/app/views/workspace/quick_link.py new file mode 100644 index 00000000000..beb3d8c761e --- /dev/null +++ b/apiserver/plane/app/views/workspace/quick_link.py @@ -0,0 +1,74 @@ +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.db.models import WorkspaceUserLink, Workspace +from plane.app.serializers import WorkspaceUserLinkSerializer +from ..base import BaseViewSet +from plane.app.permissions import allow_permission, ROLE + + +class QuickLinkViewSet(BaseViewSet): + model = WorkspaceUserLink + + def get_serializer_class(self): + return WorkspaceUserLinkSerializer + + @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) + + 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() + + 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 + ) + + @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 + ) + 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 + ) + + @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() + 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 + ) + + serializer = WorkspaceUserLinkSerializer(quick_links, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/workspace/recent_visit.py b/apiserver/plane/app/views/workspace/recent_visit.py new file mode 100644 index 00000000000..4fe15b51312 --- /dev/null +++ b/apiserver/plane/app/views/workspace/recent_visit.py @@ -0,0 +1,35 @@ +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +from plane.db.models import UserRecentVisit +from plane.app.serializers import WorkspaceRecentVisitSerializer + +# Modules imports +from ..base import BaseViewSet +from plane.app.permissions import allow_permission, ROLE + + +class UserRecentVisitViewSet(BaseViewSet): + model = UserRecentVisit + + def get_serializer_class(self): + return WorkspaceRecentVisitSerializer + + @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 + ) + + entity_name = request.query_params.get("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) diff --git a/apiserver/plane/app/views/workspace/sticky.py b/apiserver/plane/app/views/workspace/sticky.py new file mode 100644 index 00000000000..98844fb4917 --- /dev/null +++ b/apiserver/plane/app/views/workspace/sticky.py @@ -0,0 +1,59 @@ +# 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() + if query: + stickies = stickies.filter(name__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) diff --git a/apiserver/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py b/apiserver/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py new file mode 100644 index 00000000000..1b312215778 --- /dev/null +++ b/apiserver/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py @@ -0,0 +1,124 @@ +# Generated by Django 4.2.15 on 2024-12-24 14:57 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0087_remove_issueversion_description_and_more'), + ] + + operations = [ + migrations.AddField( + model_name="sticky", + name="sort_order", + field=models.FloatField(default=65535), + ), + migrations.CreateModel( + name="WorkspaceUserLink", + 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, + ), + ), + ("title", models.CharField(blank=True, max_length=255, null=True)), + ("url", models.TextField()), + ("metadata", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="owner_workspace_user_link", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "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", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Workspace User Link", + "verbose_name_plural": "Workspace User Links", + "db_table": "workspace_user_links", + "ordering": ("-created_at",), + }, + ), + migrations.AlterField( + model_name="pagelog", + name="entity_name", + field=models.CharField(max_length=30, verbose_name="Transaction Type"), + ), + migrations.AlterUniqueTogether( + name="webhook", + unique_together={("workspace", "url", "deleted_at")}, + ), + migrations.AddConstraint( + model_name="webhook", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("workspace", "url"), + name="webhook_url_unique_url_when_deleted_at_null", + ), + ), + ] diff --git a/apiserver/plane/db/migrations/0089_workspacehomepreference_and_more.py b/apiserver/plane/db/migrations/0089_workspacehomepreference_and_more.py new file mode 100644 index 00000000000..b13f650701f --- /dev/null +++ b/apiserver/plane/db/migrations/0089_workspacehomepreference_and_more.py @@ -0,0 +1,120 @@ +# Generated by Django 4.2.17 on 2025-01-02 07:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0088_sticky_sort_order_workspaceuserlink"), + ] + + operations = [ + migrations.CreateModel( + name="WorkspaceHomePreference", + fields=[ + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("key", models.CharField(max_length=255)), + ("is_enabled", models.BooleanField(default=True)), + ("config", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_user_home_preferences", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_user_home_preferences", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Workspace Home Preference", + "verbose_name_plural": "Workspace Home Preferences", + "db_table": "workspace_home_preferences", + "ordering": ("-created_at",), + }, + ), + migrations.AddConstraint( + model_name="workspacehomepreference", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("workspace", "user", "key"), + name="workspace_user_home_preferences_unique_workspace_user_key_when_deleted_at_null", + ), + ), + migrations.AlterUniqueTogether( + name="workspacehomepreference", + unique_together={("workspace", "user", "key", "deleted_at")}, + ), + migrations.AlterField( + model_name="page", + name="name", + field=models.TextField(blank=True), + ), + migrations.AlterField( + model_name="sticky", + name="name", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='workspacehomepreference', + name='sort_order', + field=models.PositiveIntegerField(default=65535), + ), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 1cbd6276161..09b372fddcf 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -68,6 +68,8 @@ WorkspaceMemberInvite, WorkspaceTheme, WorkspaceUserProperties, + WorkspaceUserLink, + WorkspaceHomePreference ) from .favorite import UserFavorite diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index 81e2b15a0fc..91ffcf02371 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -20,7 +20,7 @@ class Page(BaseModel): workspace = models.ForeignKey( "db.Workspace", on_delete=models.CASCADE, related_name="pages" ) - name = models.CharField(max_length=255, blank=True) + name = models.TextField(blank=True) description = models.JSONField(default=dict, blank=True) description_binary = models.BinaryField(null=True) description_html = models.TextField(blank=True, default="

") @@ -90,7 +90,7 @@ class PageLog(BaseModel): page = models.ForeignKey(Page, related_name="page_log", on_delete=models.CASCADE) entity_identifier = models.UUIDField(null=True) entity_name = models.CharField( - max_length=30, choices=TYPE_CHOICES, verbose_name="Transaction Type" + max_length=30, verbose_name="Transaction Type" ) workspace = models.ForeignKey( "db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log" diff --git a/apiserver/plane/db/models/sticky.py b/apiserver/plane/db/models/sticky.py index 5f1c62660ba..a0590306f69 100644 --- a/apiserver/plane/db/models/sticky.py +++ b/apiserver/plane/db/models/sticky.py @@ -7,7 +7,7 @@ class Sticky(BaseModel): - name = models.TextField() + name = models.TextField(null=True, blank=True) description = models.JSONField(blank=True, default=dict) description_html = models.TextField(blank=True, default="

") @@ -24,9 +24,25 @@ class Sticky(BaseModel): owner = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="stickies" ) + sort_order = models.FloatField(default=65535) class Meta: verbose_name = "Sticky" verbose_name_plural = "Stickies" db_table = "stickies" ordering = ("-created_at",) + + def save(self, *args, **kwargs): + if self._state.adding: + # Get the maximum sequence value from the database + last_id = Sticky.objects.filter(workspace=self.workspace).aggregate( + largest=models.Max("sort_order") + )["largest"] + # if last_id is not None + if last_id is not None: + self.sort_order = last_id + 10000 + + super(Sticky, self).save(*args, **kwargs) + + def __str__(self): + return str(self.name) diff --git a/apiserver/plane/db/models/webhook.py b/apiserver/plane/db/models/webhook.py index ec8fcda3afc..dc04e041998 100644 --- a/apiserver/plane/db/models/webhook.py +++ b/apiserver/plane/db/models/webhook.py @@ -47,11 +47,18 @@ def __str__(self): return f"{self.workspace.slug} {self.url}" class Meta: - unique_together = ["workspace", "url"] + unique_together = ["workspace", "url", "deleted_at"] verbose_name = "Webhook" verbose_name_plural = "Webhooks" db_table = "webhooks" ordering = ("-created_at",) + constraints = [ + models.UniqueConstraint( + fields=["workspace", "url"], + condition=models.Q(deleted_at__isnull=True), + name="webhook_url_unique_url_when_deleted_at_null", + ) + ] class WebhookLog(BaseModel): diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index f8082e492b3..723a2628cd6 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -322,3 +322,64 @@ class Meta: def __str__(self): return f"{self.workspace.name} {self.user.email}" + + +class WorkspaceUserLink(WorkspaceBaseModel): + title = models.CharField(max_length=255, null=True, blank=True) + url = models.TextField() + metadata = models.JSONField(default=dict) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="owner_workspace_user_link", + ) + + class Meta: + verbose_name = "Workspace User Link" + verbose_name_plural = "Workspace User Links" + db_table = "workspace_user_links" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.workspace.id} {self.url}" + + +class WorkspaceHomePreference(BaseModel): + class HomeWidgetKeys(models.TextChoices): + QUICK_LINKS = "quick_links", "Quick Links" + RECENTS = "recents", "Recents" + MY_STICKIES = "my_stickies", "My Stickies" + NEW_AT_PLANE = "new_at_plane", "New at Plane" + QUICK_TUTORIAL = "quick_tutorial", "Quick Tutorial" + + workspace = models.ForeignKey( + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_user_home_preferences", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="workspace_user_home_preferences", + ) + key = models.CharField(max_length=255) + is_enabled = models.BooleanField(default=True) + config = models.JSONField(default=dict) + sort_order = models.PositiveIntegerField(default=65535) + + class Meta: + unique_together = ["workspace", "user", "key", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["workspace", "user", "key"], + condition=models.Q(deleted_at__isnull=True), + name="workspace_user_home_preferences_unique_workspace_user_key_when_deleted_at_null", + ) + ] + verbose_name = "Workspace Home Preference" + verbose_name_plural = "Workspace Home Preferences" + db_table = "workspace_home_preferences" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.workspace.name} {self.user.email} {self.key}" diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py index 548c9c77ed0..8458df5df6d 100644 --- a/apiserver/plane/license/management/commands/configure_instance.py +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -132,20 +132,33 @@ def handle(self, *args, **options): "is_encrypted": False, }, { - "key": "OPENAI_API_KEY", - "value": os.environ.get("OPENAI_API_KEY"), - "category": "OPENAI", + "key": "LLM_API_KEY", + "value": os.environ.get("LLM_API_KEY"), + "category": "AI", "is_encrypted": True, }, { - "key": "GPT_ENGINE", + "key": "LLM_PROVIDER", + "value": os.environ.get("LLM_PROVIDER", "openai"), + "category": "AI", + "is_encrypted": False, + }, + { + "key": "LLM_MODEL", + "value": os.environ.get("LLM_MODEL", "gpt-4o-mini"), + "category": "AI", + "is_encrypted": False, + }, + # Deprecated, use LLM_MODEL + { + "key": "GPT_ENGINE", "value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), "category": "SMTP", "is_encrypted": False, }, { "key": "UNSPLASH_ACCESS_KEY", - "value": os.environ.get("UNSPLASH_ACESS_KEY", ""), + "value": os.environ.get("UNSPLASH_ACCESS_KEY", ""), "category": "UNSPLASH", "is_encrypted": True, }, diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index db9a244537f..4de98b5501c 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -361,6 +361,18 @@ "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", diff --git a/apiserver/plane/space/views/intake.py b/apiserver/plane/space/views/intake.py index 0d39dd27690..bfce3a8bbe0 100644 --- a/apiserver/plane/space/views/intake.py +++ b/apiserver/plane/space/views/intake.py @@ -130,15 +130,6 @@ def create(self, request, anchor, intake_id): {"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"), @@ -148,7 +139,6 @@ def create(self, request, anchor, intake_id): ), priority=request.data.get("issue", {}).get("priority", "low"), project_id=project_deploy_board.project_id, - state=state, ) # Create an Issue Activity diff --git a/apiserver/plane/utils/timezone_converter.py b/apiserver/plane/utils/timezone_converter.py index 46a864b62dd..3e14d0bf60b 100644 --- a/apiserver/plane/utils/timezone_converter.py +++ b/apiserver/plane/utils/timezone_converter.py @@ -3,6 +3,7 @@ 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) @@ -28,7 +29,9 @@ def user_timezone_converter(queryset, datetime_fields, user_timezone): return queryset_values -def convert_to_utc(date, project_id, is_start_date=False): +def convert_to_utc( + date, project_id, is_start_date=False, is_start_date_end_date_equal=False +): """ Converts a start date string to the project's local timezone at 12:00 AM and then converts it to UTC for storage. @@ -60,7 +63,12 @@ def convert_to_utc(date, project_id, is_start_date=False): # If it's an start date, add one minute if is_start_date: - localized_datetime += timedelta(minutes=1) + 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) # Convert the localized datetime to UTC utc_datetime = localized_datetime.astimezone(pytz.utc) diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 40e90aedfc4..f7eb46a4a4f 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -37,7 +37,7 @@ uvicorn==0.29.0 # sockets channels==4.1.0 # ai -openai==1.25.0 +litellm==1.51.0 # slack slack-sdk==3.27.1 # apm diff --git a/live/.prettierignore b/live/.prettierignore new file mode 100644 index 00000000000..8f6f9062d21 --- /dev/null +++ b/live/.prettierignore @@ -0,0 +1,6 @@ +.next +.turbo +out/ +dist/ +build/ +node_modules/ \ No newline at end of file diff --git a/live/.prettierrc b/live/.prettierrc new file mode 100644 index 00000000000..87d988f1b26 --- /dev/null +++ b/live/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/live/Dockerfile.dev b/live/Dockerfile.dev index 92dee3e269d..d893194ca96 100644 --- a/live/Dockerfile.dev +++ b/live/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM node:18-alpine +FROM node:20-alpine RUN apk add --no-cache libc6-compat # Set working directory WORKDIR /app diff --git a/live/Dockerfile.live b/live/Dockerfile.live index 6664fee97f6..ae9eba9d14e 100644 --- a/live/Dockerfile.live +++ b/live/Dockerfile.live @@ -1,4 +1,4 @@ -FROM node:18-alpine AS base +FROM node:20-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. diff --git a/live/package.json b/live/package.json index 480da4726c2..572f0a3e776 100644 --- a/live/package.json +++ b/live/package.json @@ -25,9 +25,9 @@ "@plane/types": "*", "@sentry/node": "^8.28.0", "@sentry/profiling-node": "^8.28.0", - "@tiptap/core": "^2.4.0", - "@tiptap/html": "^2.3.0", - "axios": "^1.7.2", + "@tiptap/core": "2.10.4", + "@tiptap/html": "2.11.0", + "axios": "^1.7.9", "compression": "^1.7.4", "cors": "^2.8.5", "dotenv": "^16.4.5", diff --git a/live/src/core/helpers/convert-document.ts b/live/src/core/helpers/convert-document.ts new file mode 100644 index 00000000000..12398919014 --- /dev/null +++ b/live/src/core/helpers/convert-document.ts @@ -0,0 +1,44 @@ +// 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; +}; diff --git a/live/src/core/types/common.d.ts b/live/src/core/types/common.d.ts index 3156060efb3..90fd335ae45 100644 --- a/live/src/core/types/common.d.ts +++ b/live/src/core/types/common.d.ts @@ -6,3 +6,8 @@ export type TDocumentTypes = "project_page" | TAdditionalDocumentTypes; export type HocusPocusServerContext = { cookie: string; }; + +export type TConvertDocumentRequestBody = { + description_html: string; + variant: "rich" | "document"; +}; diff --git a/live/src/server.ts b/live/src/server.ts index 1868b86c198..93f56bdb572 100644 --- a/live/src/server.ts +++ b/live/src/server.ts @@ -1,20 +1,19 @@ -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 helmet from "helmet"; - -// cors import cors from "cors"; - -// core hocuspocus server +import expressWs from "express-ws"; +import express from "express"; +import helmet from "helmet"; +// config +import "@/core/config/sentry-config.js"; +// 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); @@ -29,7 +28,7 @@ app.use( compression({ level: 6, threshold: 5 * 1000, - }), + }) ); // Logging middleware @@ -62,6 +61,31 @@ 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) => { @@ -82,9 +106,7 @@ 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(() => { diff --git a/packages/constants/src/auth.ts b/packages/constants/src/auth.ts index 884a8dd1c89..bcdda31b4d4 100644 --- a/packages/constants/src/auth.ts +++ b/packages/constants/src/auth.ts @@ -7,7 +7,7 @@ export enum E_PASSWORD_STRENGTH { export const PASSWORD_MIN_LENGTH = 8; -export const PASSWORD_CRITERIA = [ +export const SPACE_PASSWORD_CRITERIA = [ { key: "min_8_char", label: "Min 8 characters", diff --git a/packages/constants/src/event.ts b/packages/constants/src/event.ts new file mode 100644 index 00000000000..5e7e22004d5 --- /dev/null +++ b/packages/constants/src/event.ts @@ -0,0 +1 @@ +export const SIDEBAR_CLICKED = "Sidenav clicked"; diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 95a4f978435..eeab193bb39 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -1,6 +1,7 @@ export * from "./ai"; export * from "./auth"; export * from "./endpoints"; +export * from "./event"; export * from "./file"; export * from "./instance"; export * from "./issue"; diff --git a/packages/constants/src/user.ts b/packages/constants/src/user.ts index 5c6a89a17f6..f10801807c5 100644 --- a/packages/constants/src/user.ts +++ b/packages/constants/src/user.ts @@ -19,3 +19,20 @@ export type TUserStatus = { status: EUserStatus | undefined; message?: string; }; + +export enum EUserPermissionsLevel { + WORKSPACE = "WORKSPACE", + PROJECT = "PROJECT", +} + +export enum EUserWorkspaceRoles { + ADMIN = 20, + MEMBER = 15, + GUEST = 5, +} + +export enum EUserProjectRoles { + ADMIN = 20, + MEMBER = 15, + GUEST = 5, +} diff --git a/packages/constants/src/workspace.ts b/packages/constants/src/workspace.ts index c17b5432ee8..d37decf3f2f 100644 --- a/packages/constants/src/workspace.ts +++ b/packages/constants/src/workspace.ts @@ -1,5 +1,5 @@ export const ORGANIZATION_SIZE = [ - "Just myself", + "Just myself", // TODO: translate "2-10", "11-50", "51-200", diff --git a/packages/editor/package.json b/packages/editor/package.json index 3cc4269fb49..ee6191d5845 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -37,25 +37,24 @@ "@hocuspocus/provider": "^2.15.0", "@plane/ui": "*", "@plane/utils": "*", - "@tiptap/core": "^2.10.3", - "@tiptap/extension-blockquote": "^2.10.3", - "@tiptap/extension-character-count": "^2.10.3", - "@tiptap/extension-collaboration": "^2.10.3", - "@tiptap/extension-image": "^2.10.3", - "@tiptap/extension-list-item": "^2.10.3", - "@tiptap/extension-mention": "^2.10.3", - "@tiptap/extension-placeholder": "^2.10.3", - "@tiptap/extension-task-item": "^2.10.3", - "@tiptap/extension-task-list": "^2.10.3", - "@tiptap/extension-text-align": "^2.10.3", - "@tiptap/extension-text-style": "^2.10.3", - "@tiptap/extension-underline": "^2.10.3", - "@tiptap/pm": "^2.10.3", - "@tiptap/react": "^2.10.3", - "@tiptap/starter-kit": "^2.10.3", - "@tiptap/suggestion": "^2.10.3", - "clsx": "^2.0.0", - "@plane/types": "*", + "@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", "class-variance-authority": "^0.7.0", "highlight.js": "^11.8.0", "jsx-dom-cjs": "^8.0.3", @@ -64,8 +63,6 @@ "lucide-react": "^0.378.0", "prosemirror-codemark": "^0.4.2", "prosemirror-utils": "^1.2.2", - "tailwind-merge": "^2.0.0", - "react-moveable": "^0.54.2", "tippy.js": "^6.3.7", "tiptap-markdown": "^0.8.10", "uuid": "^10.0.0", diff --git a/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx index ba9278b675f..fe996a71323 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx @@ -71,7 +71,7 @@ export const BubbleMenuColorSelector: FC = (props) => { @@ -94,7 +94,7 @@ export const BubbleMenuColorSelector: FC = (props) => { diff --git a/packages/editor/src/core/components/menus/bubble-menu/root.tsx b/packages/editor/src/core/components/menus/bubble-menu/root.tsx index 4ed3709f408..3f4e97ca706 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/root.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/root.tsx @@ -87,12 +87,9 @@ export const EditorBubbleMenu: FC = (props: any) => { }, []); return ( - + {!isSelecting && ( - <> +
{!props.editor.isActive("table") && ( = (props: any) => { editor.commands.setTextSelection(pos ?? 0); }} /> - +
)} ); diff --git a/packages/editor/src/core/constants/document-collaborative-events.ts b/packages/editor/src/core/constants/document-collaborative-events.ts index 5e79efc7a71..72e8b1dbded 100644 --- a/packages/editor/src/core/constants/document-collaborative-events.ts +++ b/packages/editor/src/core/constants/document-collaborative-events.ts @@ -3,4 +3,6 @@ export const DocumentCollaborativeEvents = { unlock: { client: "unlocked", server: "unlock" }, archive: { client: "archived", server: "archive" }, unarchive: { client: "unarchived", server: "unarchive" }, + "make-public": { client: "made-public", server: "make-public" }, + "make-private": { client: "made-private", server: "make-private" }, } as const; diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index 9fc98bf5877..0b772baf9db 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -51,6 +51,7 @@ 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: { diff --git a/packages/editor/src/core/extensions/read-only-extensions.tsx b/packages/editor/src/core/extensions/read-only-extensions.tsx index 38c7f996632..e39973f9c90 100644 --- a/packages/editor/src/core/extensions/read-only-extensions.tsx +++ b/packages/editor/src/core/extensions/read-only-extensions.tsx @@ -42,6 +42,7 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { const { disabledExtensions, fileHandler, mentionHandler } = props; return [ + // @ts-expect-error tiptap types are incorrect StarterKit.configure({ bulletList: { HTMLAttributes: { diff --git a/packages/editor/src/core/helpers/editor-commands.ts b/packages/editor/src/core/helpers/editor-commands.ts index ec593d53676..9e47e9a01f2 100644 --- a/packages/editor/src/core/helpers/editor-commands.ts +++ b/packages/editor/src/core/helpers/editor-commands.ts @@ -13,41 +13,51 @@ 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(); }; @@ -86,12 +96,16 @@ 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(); }; @@ -101,7 +115,9 @@ 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(); }; diff --git a/packages/editor/src/core/helpers/yjs-utils.ts b/packages/editor/src/core/helpers/yjs-utils.ts new file mode 100644 index 00000000000..dce75fd1f58 --- /dev/null +++ b/packages/editor/src/core/helpers/yjs-utils.ts @@ -0,0 +1,142 @@ +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 ?? "

", 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 ?? "

", 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, + }; +}; diff --git a/packages/editor/src/core/helpers/yjs.ts b/packages/editor/src/core/helpers/yjs.ts deleted file mode 100644 index ffd9367107d..00000000000 --- a/packages/editor/src/core/helpers/yjs.ts +++ /dev/null @@ -1,16 +0,0 @@ -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; -}; diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index 3a54e26b167..086713cf6f6 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -1,5 +1,9 @@ import { Extensions, JSONContent } from "@tiptap/core"; import { Selection } from "@tiptap/pm/state"; +// plane types +import { TWebhookConnectionQueryParams } from "@plane/types"; +// extension types +import { TTextAlign } from "@/extensions"; // helpers import { IMarking } from "@/helpers/scroll-to-node"; // types @@ -15,7 +19,6 @@ import { TReadOnlyMentionHandler, TServerHandler, } from "@/types"; -import { TTextAlign } from "@/extensions"; export type TEditorCommands = | "text" @@ -181,7 +184,5 @@ export type TUserDetails = { export type TRealtimeConfig = { url: string; - queryParams: { - [key: string]: string; - }; + queryParams: TWebhookConnectionQueryParams; }; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 9dd0db267f2..a2a9afaf92a 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -24,7 +24,7 @@ export * from "@/constants/common"; // helpers export * from "@/helpers/common"; export * from "@/helpers/editor-commands"; -export * from "@/helpers/yjs"; +export * from "@/helpers/yjs-utils"; export * from "@/extensions/table/table"; // components diff --git a/packages/editor/src/lib.ts b/packages/editor/src/lib.ts index e32fa078508..44388a00eae 100644 --- a/packages/editor/src/lib.ts +++ b/packages/editor/src/lib.ts @@ -1,4 +1,5 @@ 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"; diff --git a/packages/editor/src/styles/editor.css b/packages/editor/src/styles/editor.css index e234f87cf86..e263431f6f2 100644 --- a/packages/editor/src/styles/editor.css +++ b/packages/editor/src/styles/editor.css @@ -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; } @@ -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%); } } diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index c07642907fe..11c33cfa48d 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -1,2 +1,3 @@ export * from "./use-local-storage"; export * from "./use-outside-click-detector"; +export * from "./use-platform-os"; diff --git a/packages/hooks/src/use-platform-os.tsx b/packages/hooks/src/use-platform-os.tsx new file mode 100644 index 00000000000..3f62e1499bd --- /dev/null +++ b/packages/hooks/src/use-platform-os.tsx @@ -0,0 +1,34 @@ +import { useState, useEffect } from "react"; + +export const usePlatformOS = () => { + const [platformData, setPlatformData] = useState({ + isMobile: false, + platform: "", + }); + + useEffect(() => { + const detectPlatform = () => { + const userAgent = window.navigator.userAgent; + const isMobile = /iPhone|iPad|iPod|Android/i.test(userAgent); + let platform = ""; + + if (!isMobile) { + if (userAgent.indexOf("Win") !== -1) { + platform = "Windows"; + } else if (userAgent.indexOf("Mac") !== -1) { + platform = "MacOS"; + } else if (userAgent.indexOf("Linux") !== -1) { + platform = "Linux"; + } else { + platform = "Unknown"; + } + } + + setPlatformData({ isMobile, platform }); + }; + + detectPlatform(); + }, []); + + return platformData; +}; diff --git a/packages/i18n/.eslintignore b/packages/i18n/.eslintignore new file mode 100644 index 00000000000..6019047c3e5 --- /dev/null +++ b/packages/i18n/.eslintignore @@ -0,0 +1,3 @@ +build/* +dist/* +out/* \ No newline at end of file diff --git a/packages/i18n/.eslintrc.js b/packages/i18n/.eslintrc.js new file mode 100644 index 00000000000..558b8f76ed4 --- /dev/null +++ b/packages/i18n/.eslintrc.js @@ -0,0 +1,9 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ["@plane/eslint-config/library.js"], + parser: "@typescript-eslint/parser", + parserOptions: { + project: true, + }, +}; diff --git a/packages/i18n/.prettierignore b/packages/i18n/.prettierignore new file mode 100644 index 00000000000..d5be669c5e0 --- /dev/null +++ b/packages/i18n/.prettierignore @@ -0,0 +1,4 @@ +.turbo +out/ +dist/ +build/ \ No newline at end of file diff --git a/packages/i18n/.prettierrc b/packages/i18n/.prettierrc new file mode 100644 index 00000000000..87d988f1b26 --- /dev/null +++ b/packages/i18n/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/packages/i18n/package.json b/packages/i18n/package.json new file mode 100644 index 00000000000..0a4d0562797 --- /dev/null +++ b/packages/i18n/package.json @@ -0,0 +1,20 @@ +{ + "name": "@plane/i18n", + "version": "0.24.1", + "description": "I18n shared across multiple apps internally", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "lint": "eslint src --ext .ts,.tsx", + "lint:errors": "eslint src --ext .ts,.tsx --quiet" + }, + "dependencies": { + "@plane/utils": "*" + }, + "devDependencies": { + "@plane/eslint-config": "*", + "@types/node": "^22.5.4", + "typescript": "^5.3.3" + } +} diff --git a/packages/i18n/src/components/index.tsx b/packages/i18n/src/components/index.tsx new file mode 100644 index 00000000000..705b0ee4a15 --- /dev/null +++ b/packages/i18n/src/components/index.tsx @@ -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); + +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 {children}; +}); diff --git a/packages/i18n/src/components/store.ts b/packages/i18n/src/components/store.ts new file mode 100644 index 00000000000..6524c0df271 --- /dev/null +++ b/packages/i18n/src/components/store.ts @@ -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); + } + } +} diff --git a/packages/i18n/src/config/index.ts b/packages/i18n/src/config/index.ts new file mode 100644 index 00000000000..3f55d8cf6f0 --- /dev/null +++ b/packages/i18n/src/config/index.ts @@ -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", + }, +]; diff --git a/packages/i18n/src/hooks/index.ts b/packages/i18n/src/hooks/index.ts new file mode 100644 index 00000000000..fb4e297e216 --- /dev/null +++ b/packages/i18n/src/hooks/index.ts @@ -0,0 +1 @@ +export * from "./use-translation"; diff --git a/packages/i18n/src/hooks/use-translation.ts b/packages/i18n/src/hooks/use-translation.ts new file mode 100644 index 00000000000..f947d1d5eb5 --- /dev/null +++ b/packages/i18n/src/hooks/use-translation.ts @@ -0,0 +1,17 @@ +import { useContext } from "react"; +import { TranslationContext } from "../components"; +import { Language } from "../config"; + +export function useTranslation() { + const store = useContext(TranslationContext); + if (!store) { + throw new Error("useTranslation must be used within a TranslationProvider"); + } + + return { + t: (key: string) => store.t(key), + currentLocale: store.currentLocale, + changeLanguage: (lng: Language) => store.setLanguage(lng), + languages: store.availableLanguages, + }; +} diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts new file mode 100644 index 00000000000..639ef4b59a4 --- /dev/null +++ b/packages/i18n/src/index.ts @@ -0,0 +1,3 @@ +export * from "./config"; +export * from "./components"; +export * from "./hooks"; diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json new file mode 100644 index 00000000000..596c3093fa9 --- /dev/null +++ b/packages/i18n/src/locales/en/translations.json @@ -0,0 +1,320 @@ +{ + "submit": "Submit", + "cancel": "Cancel", + "loading": "Loading", + "error": "Error", + "success": "Success", + "warning": "Warning", + "info": "Info", + "close": "Close", + "yes": "Yes", + "no": "No", + "ok": "OK", + "name": "Name", + "description": "Description", + "search": "Search", + "add_member": "Add member", + "remove_member": "Remove member", + "add_members": "Add members", + "remove_members": "Remove members", + "add": "Add", + "remove": "Remove", + "add_new": "Add new", + "remove_selected": "Remove selected", + "first_name": "First name", + "last_name": "Last name", + "email": "Email", + "display_name": "Display name", + "role": "Role", + "timezone": "Timezone", + "avatar": "Avatar", + "cover_image": "Cover image", + "password": "Password", + "change_cover": "Change cover", + "language": "Language", + "saving": "Saving...", + "save_changes": "Save changes", + "deactivate_account": "Deactivate account", + "deactivate_account_description": "When deactivating an account, all of the data and resources within that account will be permanently removed and cannot be recovered.", + "profile_settings": "Profile settings", + "your_account": "Your account", + "profile": "Profile", + "security": "Security", + "activity": "Activity", + "appearance": "Appearance", + "notifications": "Notifications", + "inbox": "Inbox", + "workspaces": "Workspaces", + "create_workspace": "Create workspace", + "invitations": "Invitations", + "summary": "Summary", + "assigned": "Assigned", + "created": "Created", + "subscribed": "Subscribed", + "you_do_not_have_the_permission_to_access_this_page": "You do not have the permission to access this page.", + "failed_to_sign_out_please_try_again": "Failed to sign out. Please try again.", + "password_changed_successfully": "Password changed successfully.", + "something_went_wrong_please_try_again": "Something went wrong. Please try again.", + "change_password": "Change password", + "passwords_dont_match": "Passwords don't match", + "current_password": "Current password", + "new_password": "New password", + "confirm_password": "Confirm password", + "this_field_is_required": "This field is required", + "changing_password": "Changing password", + "please_enter_your_password": "Please enter your password.", + "password_length_should_me_more_than_8_characters": "Password length should me more than 8 characters.", + "password_is_weak": "Password is weak.", + "password_is_strong": "Password is strong.", + "load_more": "Load more", + "select_or_customize_your_interface_color_scheme": "Select or customize your interface color scheme.", + "theme": "Theme", + "system_preference": "System preference", + "light": "Light", + "dark": "Dark", + "light_contrast": "Light high contrast", + "dark_contrast": "Dark high contrast", + "custom": "Custom theme", + "select_your_theme": "Select your theme", + "customize_your_theme": "Customize your theme", + "background_color": "Background color", + "text_color": "Text color", + "primary_color": "Primary(Theme) color", + "sidebar_background_color": "Sidebar background color", + "sidebar_text_color": "Sidebar text color", + "set_theme": "Set theme", + "enter_a_valid_hex_code_of_6_characters": "Enter a valid hex code of 6 characters", + "background_color_is_required": "Background color is required", + "text_color_is_required": "Text color is required", + "primary_color_is_required": "Primary color is required", + "sidebar_background_color_is_required": "Sidebar background color is required", + "sidebar_text_color_is_required": "Sidebar text color is required", + "updating_theme": "Updating theme", + "theme_updated_successfully": "Theme updated successfully", + "failed_to_update_the_theme": "Failed to update the theme", + "email_notifications": "Email notifications", + "stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "Stay in the loop on Issues you are subscribed to. Enable this to get notified.", + "email_notification_setting_updated_successfully": "Email notification setting updated successfully", + "failed_to_update_email_notification_setting": "Failed to update email notification setting", + "notify_me_when": "Notify me when", + "property_changes": "Property changes", + "property_changes_description": "Notify me when issue's properties like assignees, priority, estimates or anything else changes.", + "state_change": "State change", + "state_change_description": "Notify me when the issues moves to a different state", + "issue_completed": "Issue completed", + "issue_completed_description": "Notify me only when an issue is completed", + "comments": "Comments", + "comments_description": "Notify me when someone leaves a comment on the issue", + "mentions": "Mentions", + "mentions_description": "Notify me only when someone mentions me in the comments or description", + "create_your_workspace": "Create your workspace", + "only_your_instance_admin_can_create_workspaces": "Only your instance admin can create workspaces", + "only_your_instance_admin_can_create_workspaces_description": "If you know your instance admin's email address, click the button below to get in touch with them.", + "go_back": "Go back", + "request_instance_admin": "Request instance admin", + "plane_logo": "Plane logo", + "workspace_creation_disabled": "Workspace creation disabled", + "workspace_request_subject": "Requesting a new workspace", + "workspace_request_body": "Hi instance admin(s),\n\nPlease create a new workspace with the URL [/workspace-name] for [purpose of creating the workspace].\n\nThanks,\n{{firstName}} {{lastName}}\n{{email}}", + "creating_workspace": "Creating workspace", + "workspace_created_successfully": "Workspace created successfully", + "create_workspace_page": "Create workspace page", + "workspace_could_not_be_created_please_try_again": "Workspace could not be created. Please try again.", + "workspace_could_not_be_created_please_try_again_description": "Some error occurred while creating workspace. Please try again.", + "this_is_a_required_field": "This is a required field.", + "name_your_workspace": "Name your workspace", + "workspaces_names_can_contain_only_space_dash_and_alphanumeric_characters": "Workspaces names can contain only (' '), ('-'), ('_') and alphanumeric characters.", + "limit_your_name_to_80_characters": "Limit your name to 80 characters.", + "set_your_workspace_url": "Set your workspace's URL", + "limit_your_url_to_48_characters": "Limit your URL to 48 characters.", + "how_many_people_will_use_this_workspace": "How many people will use this workspace?", + "how_many_people_will_use_this_workspace_description": "This will help us to determine the number of seats you need to purchase.", + "select_a_range": "Select a range", + "urls_can_contain_only_dash_and_alphanumeric_characters": "URLs can contain only ('-') and alphanumeric characters.", + "something_familiar_and_recognizable_is_always_best": "Something familiar and recognizable is always best.", + "workspace_url_is_already_taken": "Workspace URL is already taken!", + "old_password": "Old password", + "general_settings": "General settings", + "sign_out": "Sign out", + "signing_out": "Signing out", + "active_cycles": "Active cycles", + "active_cycles_description": "Monitor cycles across projects, track high-priority issues, and zoom in cycles that need attention.", + "on_demand_snapshots_of_all_your_cycles": "On-demand snapshots of all your cycles", + "upgrade": "Upgrade", + "10000_feet_view": "10,000-feet view of all active cycles.", + "10000_feet_view_description": "Zoom out to see running cycles across all your projects at once instead of going from Cycle to Cycle in each project.", + "get_snapshot_of_each_active_cycle": "Get a snapshot of each active cycle.", + "get_snapshot_of_each_active_cycle_description": "Track high-level metrics for all active cycles, see their state of progress, and get a sense of scope against deadlines.", + "compare_burndowns": "Compare burndowns.", + "compare_burndowns_description": "Monitor how each of your teams are performing with a peek into each cycle's burndown report.", + "quickly_see_make_or_break_issues": "Quickly see make-or-break issues.", + "quickly_see_make_or_break_issues_description": "Preview high-priority issues for each cycle against due dates. See all of them per cycle in one click.", + "zoom_into_cycles_that_need_attention": "Zoom into cycles that need attention.", + "zoom_into_cycles_that_need_attention_description": "Investigate the state of any cycle that doesn't conform to expectations in one click.", + "stay_ahead_of_blockers": "Stay ahead of blockers.", + "stay_ahead_of_blockers_description": "Spot challenges from one project to another and see inter-cycle dependencies that aren't obvious from any other view.", + "analytics": "Analytics", + "workspace_invites": "Workspace invites", + "workspace_settings": "Workspace settings", + "enter_god_mode": "Enter god mode", + "workspace_logo": "Workspace logo", + "new_issue": "New issue", + "home": "Home", + "your_work": "Your work", + "drafts": "Drafts", + "projects": "Projects", + "views": "Views", + "workspace": "Workspace", + "archives": "Archives", + "settings": "Settings", + "failed_to_move_favorite": "Failed to move favorite", + "your_favorites": "Your favorites", + "no_favorites_yet": "No favorites yet", + "create_folder": "Create folder", + "new_folder": "New folder", + "favorite_updated_successfully": "Favorite updated successfully", + "favorite_created_successfully": "Favorite created successfully", + "folder_already_exists": "Folder already exists", + "folder_name_cannot_be_empty": "Folder name cannot be empty", + "something_went_wrong": "Something went wrong", + "failed_to_reorder_favorite": "Failed to reorder favorite", + "favorite_removed_successfully": "Favorite removed successfully", + "failed_to_create_favorite": "Failed to create favorite", + "failed_to_rename_favorite": "Failed to rename favorite", + "project_link_copied_to_clipboard": "Project link copied to clipboard", + "link_copied": "Link copied", + "your_projects": "Your projects", + "add_project": "Add project", + "create_project": "Create project", + "failed_to_remove_project_from_favorites": "Couldn't remove the project from favorites. Please try again.", + "project_created_successfully": "Project created successfully", + "project_created_successfully_description": "Project created successfully. You can now start adding issues to it.", + "project_cover_image_alt": "Project cover image", + "name_is_required": "Name is required", + "title_should_be_less_than_255_characters": "Title should be less than 255 characters", + "project_name": "Project name", + "project_id_must_be_at_least_1_character": "Project ID must at least be of 1 character", + "project_id_must_be_at_most_5_characters": "Project ID must at most be of 5 characters", + "project_id": "Project ID", + "project_id_tooltip_content": "Helps you identify issues in the project uniquely. Max 5 characters.", + "description_placeholder": "Description...", + "only_alphanumeric_non_latin_characters_allowed": "Only Alphanumeric & Non-latin characters are allowed.", + "project_id_is_required": "Project ID is required", + "select_network": "Select network", + "lead": "Lead", + "private": "Private", + "public": "Public", + "accessible_only_by_invite": "Accessible only by invite", + "anyone_in_the_workspace_except_guests_can_join": "Anyone in the workspace except Guests can join", + "creating": "Creating", + "creating_project": "Creating project", + "adding_project_to_favorites": "Adding project to favorites", + "project_added_to_favorites": "Project added to favorites", + "couldnt_add_the_project_to_favorites": "Couldn't add the project to favorites. Please try again.", + "removing_project_from_favorites": "Removing project from favorites", + "project_removed_from_favorites": "Project removed from favorites", + "couldnt_remove_the_project_from_favorites": "Couldn't remove the project from favorites. Please try again.", + "add_to_favorites": "Add to favorites", + "remove_from_favorites": "Remove from favorites", + "publish_settings": "Publish settings", + "publish": "Publish", + "copy_link": "Copy link", + "leave_project": "Leave project", + "join_the_project_to_rearrange": "Join the project to rearrange", + "drag_to_rearrange": "Drag to rearrange", + "congrats": "Congrats!", + "project": "Project", + "open_project": "Open project", + "issues": "Issues", + "cycles": "Cycles", + "modules": "Modules", + "pages": "Pages", + "intake": "Intake", + "time_tracking": "Time Tracking", + "work_management": "Work management", + "projects_and_issues": "Projects and issues", + "projects_and_issues_description": "Toggle these on or off this project.", + "cycles_description": "Timebox work as you see fit per project and change frequency from one period to the next.", + "modules_description": "Group work into sub-project-like set-ups with their own leads and assignees.", + "views_description": "Save sorts, filters, and display options for later or share them.", + "pages_description": "Write anything like you write anything.", + "intake_description": "Stay in the loop on Issues you are subscribed to. Enable this to get notified.", + "time_tracking_description": "Track time spent on issues and projects.", + "work_management_description": "Manage your work and projects with ease.", + "documentation": "Documentation", + "message_support": "Message support", + "contact_sales": "Contact sales", + "hyper_mode": "Hyper Mode", + "keyboard_shortcuts": "Keyboard shortcuts", + "whats_new": "What's new?", + "version": "Version", + "we_are_having_trouble_fetching_the_updates": "We are having trouble fetching the updates.", + "our_changelogs": "our changelogs", + "for_the_latest_updates": "for the latest updates.", + "please_visit": "Please visit", + "docs": "Docs", + "full_changelog": "Full changelog", + "support": "Support", + "discord": "Discord", + "powered_by_plane_pages": "Powered by Plane Pages", + "please_select_at_least_one_invitation": "Please select at least one invitation.", + "please_select_at_least_one_invitation_description": "Please select at least one invitation to join the workspace.", + "we_see_that_someone_has_invited_you_to_join_a_workspace": "We see that someone has invited you to join a workspace", + "join_a_workspace": "Join a workspace", + "we_see_that_someone_has_invited_you_to_join_a_workspace_description": "We see that someone has invited you to join a workspace", + "join_a_workspace_description": "Join a workspace", + "accept_and_join": "Accept & Join", + "go_home": "Go Home", + "no_pending_invites": "No pending invites", + "you_can_see_here_if_someone_invites_you_to_a_workspace": "You can see here if someone invites you to a workspace", + "back_to_home": "Back to home", + "workspace_name": "workspace-name", + "deactivate_your_account": "Deactivate your account", + "deactivate_your_account_description": "Once deactivated, you can't be assigned issues and be billed for your workspace. To reactivate your account, you will need an invite to a workspace at this email address.", + "deactivating": "Deactivating", + "confirm": "Confirm", + "draft_created": "Draft created", + "issue_created_successfully": "Issue created successfully", + "draft_creation_failed": "Draft creation failed", + "issue_creation_failed": "Issue creation failed", + "draft_issue": "Draft issue", + "issue_updated_successfully": "Issue updated successfully", + "issue_could_not_be_updated": "Issue could not be updated", + "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", + "failed_to_move_issue_to_project": "Failed to move issue to project", + "create_more": "Create more", + "add_to_project": "Add to project", + "discard": "Discard", + "duplicate_issue_found": "Duplicate issue found", + "duplicate_issues_found": "Duplicate issues found", + "no_matching_results": "No matching results", + "title_is_required": "Title is required", + "title": "Title", + "state": "State", + "priority": "Priority", + "none": "None", + "urgent": "Urgent", + "high": "High", + "medium": "Medium", + "low": "Low", + "members": "Members", + "assignee": "Assignee", + "assignees": "Assignees", + "you": "You", + "labels": "Labels", + "create_new_label": "Create new label", + "start_date": "Start date", + "due_date": "Due date", + "cycle": "Cycle", + "estimate": "Estimate", + "change_parent_issue": "Change parent issue", + "remove_parent_issue": "Remove parent issue", + "add_parent": "Add parent", + "loading_members": "Loading members..." +} diff --git a/packages/i18n/src/locales/es/translations.json b/packages/i18n/src/locales/es/translations.json new file mode 100644 index 00000000000..9f2b98792c2 --- /dev/null +++ b/packages/i18n/src/locales/es/translations.json @@ -0,0 +1,320 @@ +{ + "submit": "Enviar", + "cancel": "Cancelar", + "loading": "Cargando", + "error": "Error", + "success": "Éxito", + "warning": "Advertencia", + "info": "Información", + "close": "Cerrar", + "yes": "Sí", + "no": "No", + "ok": "OK", + "name": "Nombre", + "description": "Descripción", + "search": "Buscar", + "add_member": "Agregar miembro", + "remove_member": "Eliminar miembro", + "add_members": "Agregar miembros", + "remove_members": "Eliminar miembros", + "add": "Agregar", + "remove": "Eliminar", + "add_new": "Agregar nuevo", + "remove_selected": "Eliminar seleccionados", + "first_name": "Nombre", + "last_name": "Apellido", + "email": "Correo electrónico", + "display_name": "Nombre para mostrar", + "role": "Rol", + "timezone": "Zona horaria", + "avatar": "Avatar", + "cover_image": "Imagen de portada", + "password": "Contraseña", + "change_cover": "Cambiar portada", + "language": "Idioma", + "saving": "Guardando...", + "save_changes": "Guardar cambios", + "deactivate_account": "Desactivar cuenta", + "deactivate_account_description": "Al desactivar una cuenta, todos los datos y recursos dentro de esa cuenta se eliminarán permanentemente y no se podrán recuperar.", + "profile_settings": "Configuración de perfil", + "your_account": "Tu cuenta", + "profile": "Perfil", + "security": "Seguridad", + "activity": "Actividad", + "appearance": "Apariencia", + "notifications": "Notificaciones", + "workspaces": "Espacios de trabajo", + "create_workspace": "Crear espacio de trabajo", + "invitations": "Invitaciones", + "summary": "Resumen", + "assigned": "Asignado", + "created": "Creado", + "subscribed": "Suscrito", + "you_do_not_have_the_permission_to_access_this_page": "No tienes permiso para acceder a esta página.", + "failed_to_sign_out_please_try_again": "Error al cerrar sesión. Por favor, inténtalo de nuevo.", + "password_changed_successfully": "Contraseña cambiada con éxito.", + "something_went_wrong_please_try_again": "Algo salió mal. Por favor, inténtalo de nuevo.", + "change_password": "Cambiar contraseña", + "passwords_dont_match": "Las contraseñas no coinciden", + "current_password": "Contraseña actual", + "new_password": "Nueva contraseña", + "confirm_password": "Confirmar contraseña", + "this_field_is_required": "Este campo es obligatorio", + "changing_password": "Cambiando contraseña", + "please_enter_your_password": "Por favor, introduce tu contraseña.", + "password_length_should_me_more_than_8_characters": "La longitud de la contraseña debe ser más de 8 caracteres.", + "password_is_weak": "La contraseña es débil.", + "password_is_strong": "La contraseña es fuerte.", + "load_more": "Cargar más", + "select_or_customize_your_interface_color_scheme": "Selecciona o personaliza el esquema de color de tu interfaz.", + "theme": "Tema", + "system_preference": "Preferencia del sistema", + "light": "Claro", + "dark": "Oscuro", + "light_contrast": "Alto contraste claro", + "dark_contrast": "Alto contraste oscuro", + "custom": "Tema personalizado", + "select_your_theme": "Selecciona tu tema", + "customize_your_theme": "Personaliza tu tema", + "background_color": "Color de fondo", + "text_color": "Color del texto", + "primary_color": "Color primario (Tema)", + "sidebar_background_color": "Color de fondo de la barra lateral", + "sidebar_text_color": "Color del texto de la barra lateral", + "set_theme": "Establecer tema", + "enter_a_valid_hex_code_of_6_characters": "Introduce un código hexadecimal válido de 6 caracteres", + "background_color_is_required": "El color de fondo es obligatorio", + "text_color_is_required": "El color del texto es obligatorio", + "primary_color_is_required": "El color primario es obligatorio", + "sidebar_background_color_is_required": "El color de fondo de la barra lateral es obligatorio", + "sidebar_text_color_is_required": "El color del texto de la barra lateral es obligatorio", + "updating_theme": "Actualizando tema", + "theme_updated_successfully": "Tema actualizado con éxito", + "failed_to_update_the_theme": "Error al actualizar el tema", + "email_notifications": "Notificaciones por correo electrónico", + "stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "Mantente al tanto de los problemas a los que estás suscrito. Activa esto para recibir notificaciones.", + "email_notification_setting_updated_successfully": "Configuración de notificaciones por correo electrónico actualizada con éxito", + "failed_to_update_email_notification_setting": "Error al actualizar la configuración de notificaciones por correo electrónico", + "notify_me_when": "Notifícame cuando", + "property_changes": "Cambios de propiedad", + "property_changes_description": "Notifícame cuando cambien las propiedades del problema como asignados, prioridad, estimaciones o cualquier otra cosa.", + "state_change": "Cambio de estado", + "state_change_description": "Notifícame cuando el problema se mueva a un estado diferente", + "issue_completed": "Problema completado", + "issue_completed_description": "Notifícame solo cuando un problema esté completado", + "comments": "Comentarios", + "comments_description": "Notifícame cuando alguien deje un comentario en el problema", + "mentions": "Menciones", + "mentions_description": "Notifícame solo cuando alguien me mencione en los comentarios o en la descripción", + "create_your_workspace": "Crea tu espacio de trabajo", + "only_your_instance_admin_can_create_workspaces": "Solo tu administrador de instancia puede crear espacios de trabajo", + "only_your_instance_admin_can_create_workspaces_description": "Si conoces el correo electrónico de tu administrador de instancia, haz clic en el botón de abajo para ponerte en contacto con él.", + "go_back": "Regresar", + "request_instance_admin": "Solicitar administrador de instancia", + "plane_logo": "Logo de Plane", + "workspace_creation_disabled": "Creación de espacio de trabajo deshabilitada", + "workspace_request_subject": "Solicitando un nuevo espacio de trabajo", + "workspace_request_body": "Hola administrador(es) de instancia,\n\nPor favor, crea un nuevo espacio de trabajo con la URL [/nombre-del-espacio-de-trabajo] para [propósito de crear el espacio de trabajo].\n\nGracias,\n{{firstName}} {{lastName}}\n{{email}}", + "creating_workspace": "Creando espacio de trabajo", + "workspace_created_successfully": "Espacio de trabajo creado con éxito", + "create_workspace_page": "Página de creación de espacio de trabajo", + "workspace_could_not_be_created_please_try_again": "No se pudo crear el espacio de trabajo. Por favor, inténtalo de nuevo.", + "workspace_could_not_be_created_please_try_again_description": "Ocurrió un error al crear el espacio de trabajo. Por favor, inténtalo de nuevo.", + "this_is_a_required_field": "Este es un campo obligatorio.", + "name_your_workspace": "Nombra tu espacio de trabajo", + "workspaces_names_can_contain_only_space_dash_and_alphanumeric_characters": "Los nombres de los espacios de trabajo solo pueden contener (' '), ('-'), ('_') y caracteres alfanuméricos.", + "limit_your_name_to_80_characters": "Limita tu nombre a 80 caracteres.", + "set_your_workspace_url": "Establece la URL de tu espacio de trabajo", + "limit_your_url_to_48_characters": "Limita tu URL a 48 caracteres.", + "how_many_people_will_use_this_workspace": "¿Cuántas personas usarán este espacio de trabajo?", + "how_many_people_will_use_this_workspace_description": "Esto nos ayudará a determinar el número de asientos que necesitas comprar.", + "select_a_range": "Selecciona un rango", + "urls_can_contain_only_dash_and_alphanumeric_characters": "Las URLs solo pueden contener ('-') y caracteres alfanuméricos.", + "something_familiar_and_recognizable_is_always_best": "Algo familiar y reconocible siempre es mejor.", + "workspace_url_is_already_taken": "¡La URL del espacio de trabajo ya está tomada!", + "old_password": "Contraseña antigua", + "general_settings": "Configuración general", + "sign_out": "Cerrar sesión", + "signing_out": "Cerrando sesión", + "active_cycles": "Ciclos activos", + "active_cycles_description": "Monitorea ciclos a través de proyectos, rastrea problemas de alta prioridad y enfócate en ciclos que necesitan atención.", + "on_demand_snapshots_of_all_your_cycles": "Instantáneas bajo demanda de todos tus ciclos", + "upgrade": "Actualizar", + "10000_feet_view": "Vista de 10,000 pies de todos los ciclos activos.", + "10000_feet_view_description": "Amplía para ver ciclos en ejecución en todos tus proyectos a la vez en lugar de ir de ciclo en ciclo en cada proyecto.", + "get_snapshot_of_each_active_cycle": "Obtén una instantánea de cada ciclo activo.", + "get_snapshot_of_each_active_cycle_description": "Rastrea métricas de alto nivel para todos los ciclos activos, ve su estado de progreso y obtén una idea del alcance frente a los plazos.", + "compare_burndowns": "Compara burndowns.", + "compare_burndowns_description": "Monitorea cómo se están desempeñando cada uno de tus equipos con un vistazo al informe de burndown de cada ciclo.", + "quickly_see_make_or_break_issues": "Ve rápidamente problemas críticos.", + "quickly_see_make_or_break_issues_description": "Previsualiza problemas de alta prioridad para cada ciclo contra fechas de vencimiento. Vélos todos por ciclo en un clic.", + "zoom_into_cycles_that_need_attention": "Enfócate en ciclos que necesitan atención.", + "zoom_into_cycles_that_need_attention_description": "Investiga el estado de cualquier ciclo que no cumpla con las expectativas en un clic.", + "stay_ahead_of_blockers": "Anticípate a los bloqueadores.", + "stay_ahead_of_blockers_description": "Detecta desafíos de un proyecto a otro y ve dependencias entre ciclos que no son obvias desde ninguna otra vista.", + "analytics": "Analítica", + "workspace_invites": "Invitaciones al espacio de trabajo", + "workspace_settings": "Configuración del espacio de trabajo", + "enter_god_mode": "Entrar en modo dios", + "workspace_logo": "Logo del espacio de trabajo", + "new_issue": "Nuevo problema", + "home": "Inicio", + "your_work": "Tu trabajo", + "drafts": "Borradores", + "projects": "Proyectos", + "views": "Vistas", + "workspace": "Espacio de trabajo", + "archives": "Archivos", + "settings": "Configuración", + "failed_to_move_favorite": "Error al mover favorito", + "your_favorites": "Tus favoritos", + "no_favorites_yet": "Aún no hay favoritos", + "create_folder": "Crear carpeta", + "new_folder": "Nueva carpeta", + "favorite_updated_successfully": "Favorito actualizado con éxito", + "favorite_created_successfully": "Favorito creado con éxito", + "folder_already_exists": "La carpeta ya existe", + "folder_name_cannot_be_empty": "El nombre de la carpeta no puede estar vacío", + "something_went_wrong": "Algo salió mal", + "failed_to_reorder_favorite": "Error al reordenar favorito", + "favorite_removed_successfully": "Favorito eliminado con éxito", + "failed_to_create_favorite": "Error al crear favorito", + "failed_to_rename_favorite": "Error al renombrar favorito", + "project_link_copied_to_clipboard": "Enlace del proyecto copiado al portapapeles", + "link_copied": "Enlace copiado", + "your_projects": "Tus proyectos", + "add_project": "Agregar proyecto", + "create_project": "Crear proyecto", + "failed_to_remove_project_from_favorites": "No se pudo eliminar el proyecto de favoritos. Por favor, inténtalo de nuevo.", + "project_created_successfully": "Proyecto creado con éxito", + "project_created_successfully_description": "Proyecto creado con éxito. Ahora puedes comenzar a agregar problemas a él.", + "project_cover_image_alt": "Imagen de portada del proyecto", + "name_is_required": "El nombre es obligatorio", + "title_should_be_less_than_255_characters": "El título debe tener menos de 255 caracteres", + "project_name": "Nombre del proyecto", + "project_id_must_be_at_least_1_character": "El ID del proyecto debe tener al menos 1 carácter", + "project_id_must_be_at_most_5_characters": "El ID del proyecto debe tener como máximo 5 caracteres", + "project_id": "ID del proyecto", + "project_id_tooltip_content": "Te ayuda a identificar problemas en el proyecto de manera única. Máximo 5 caracteres.", + "description_placeholder": "Descripción...", + "only_alphanumeric_non_latin_characters_allowed": "Solo se permiten caracteres alfanuméricos y no latinos.", + "project_id_is_required": "El ID del proyecto es obligatorio", + "select_network": "Seleccionar red", + "lead": "Líder", + "private": "Privado", + "public": "Público", + "accessible_only_by_invite": "Accesible solo por invitación", + "anyone_in_the_workspace_except_guests_can_join": "Cualquiera en el espacio de trabajo excepto invitados puede unirse", + "creating": "Creando", + "creating_project": "Creando proyecto", + "adding_project_to_favorites": "Agregando proyecto a favoritos", + "project_added_to_favorites": "Proyecto agregado a favoritos", + "couldnt_add_the_project_to_favorites": "No se pudo agregar el proyecto a favoritos. Por favor, inténtalo de nuevo.", + "removing_project_from_favorites": "Eliminando proyecto de favoritos", + "project_removed_from_favorites": "Proyecto eliminado de favoritos", + "couldnt_remove_the_project_from_favorites": "No se pudo eliminar el proyecto de favoritos. Por favor, inténtalo de nuevo.", + "add_to_favorites": "Agregar a favoritos", + "remove_from_favorites": "Eliminar de favoritos", + "publish_settings": "Configuración de publicación", + "publish": "Publicar", + "copy_link": "Copiar enlace", + "leave_project": "Abandonar proyecto", + "join_the_project_to_rearrange": "Únete al proyecto para reordenar", + "drag_to_rearrange": "Arrastra para reordenar", + "congrats": "¡Felicitaciones!", + "project": "Proyecto", + "open_project": "Abrir proyecto", + "issues": "Problemas", + "cycles": "Ciclos", + "modules": "Módulos", + "pages": "Páginas", + "intake": "Entrada", + "time_tracking": "Seguimiento de tiempo", + "work_management": "Gestión del trabajo", + "projects_and_issues": "Proyectos y problemas", + "projects_and_issues_description": "Activa o desactiva estos en este proyecto.", + "cycles_description": "Organiza el trabajo como mejor te parezca por proyecto y cambia la frecuencia de un período a otro.", + "modules_description": "Agrupa el trabajo en configuraciones similares a subproyectos con sus propios líderes y asignados.", + "views_description": "Guarda ordenamientos, filtros y opciones de visualización para más tarde o compártelos.", + "pages_description": "Escribe cualquier cosa como escribes cualquier cosa.", + "intake_description": "Mantente al tanto de los problemas a los que estás suscrito. Activa esto para recibir notificaciones.", + "time_tracking_description": "Rastrea el tiempo dedicado a problemas y proyectos.", + "work_management_description": "Gestiona tu trabajo y proyectos con facilidad.", + "documentation": "Documentación", + "message_support": "Mensaje al soporte", + "contact_sales": "Contactar ventas", + "hyper_mode": "Modo Hyper", + "keyboard_shortcuts": "Atajos de teclado", + "whats_new": "¿Qué hay de nuevo?", + "version": "Versión", + "we_are_having_trouble_fetching_the_updates": "Estamos teniendo problemas para obtener las actualizaciones.", + "our_changelogs": "nuestros registros de cambios", + "for_the_latest_updates": "para las últimas actualizaciones.", + "please_visit": "Por favor, visita", + "docs": "Documentos", + "full_changelog": "Registro de cambios completo", + "support": "Soporte", + "discord": "Discord", + "powered_by_plane_pages": "Desarrollado por Plane Pages", + "please_select_at_least_one_invitation": "Por favor, selecciona al menos una invitación.", + "please_select_at_least_one_invitation_description": "Por favor, selecciona al menos una invitación para unirte al espacio de trabajo.", + "we_see_that_someone_has_invited_you_to_join_a_workspace": "Vemos que alguien te ha invitado a unirte a un espacio de trabajo", + "join_a_workspace": "Unirse a un espacio de trabajo", + "we_see_that_someone_has_invited_you_to_join_a_workspace_description": "Vemos que alguien te ha invitado a unirte a un espacio de trabajo", + "join_a_workspace_description": "Unirse a un espacio de trabajo", + "accept_and_join": "Aceptar y unirse", + "go_home": "Ir a inicio", + "no_pending_invites": "No hay invitaciones pendientes", + "you_can_see_here_if_someone_invites_you_to_a_workspace": "Puedes ver aquí si alguien te invita a un espacio de trabajo", + "back_to_home": "Volver al inicio", + "workspace_name": "nombre-del-espacio-de-trabajo", + "deactivate_your_account": "Desactivar tu cuenta", + "deactivate_your_account_description": "Una vez desactivada, no podrás ser asignado a problemas ni se te facturará por tu espacio de trabajo. Para reactivar tu cuenta, necesitarás una invitación a un espacio de trabajo con esta dirección de correo electrónico.", + "deactivating": "Desactivando", + "confirm": "Confirmar", + "draft_created": "Borrador creado", + "issue_created_successfully": "Problema creado con éxito", + "draft_creation_failed": "Creación del borrador fallida", + "issue_creation_failed": "Creación del problema fallida", + "draft_issue": "Borrador de problema", + "issue_updated_successfully": "Problema actualizado con éxito", + "issue_could_not_be_updated": "No se pudo actualizar el problema", + "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", + "failed_to_move_issue_to_project": "Error al mover el problema al proyecto", + "create_more": "Crear más", + "add_to_project": "Agregar al proyecto", + "discard": "Descartar", + "duplicate_issue_found": "Problema duplicado encontrado", + "duplicate_issues_found": "Problemas duplicados encontrados", + "no_matching_results": "No hay resultados coincidentes", + "title_is_required": "El título es obligatorio", + "title": "Título", + "state": "Estado", + "priority": "Prioridad", + "none": "Ninguno", + "urgent": "Urgente", + "high": "Alta", + "medium": "Media", + "low": "Baja", + "members": "Miembros", + "assignee": "Asignado", + "assignees": "Asignados", + "you": "Tú", + "labels": "Etiquetas", + "create_new_label": "Crear nueva etiqueta", + "start_date": "Fecha de inicio", + "due_date": "Fecha de vencimiento", + "cycle": "Ciclo", + "estimate": "Estimación", + "change_parent_issue": "Cambiar problema padre", + "remove_parent_issue": "Eliminar problema padre", + "add_parent": "Agregar padre", + "loading_members": "Cargando miembros...", + "inbox": "bandeja de entrada" +} diff --git a/packages/i18n/src/locales/fr/translations.json b/packages/i18n/src/locales/fr/translations.json new file mode 100644 index 00000000000..e711d71860d --- /dev/null +++ b/packages/i18n/src/locales/fr/translations.json @@ -0,0 +1,320 @@ +{ + "submit": "Soumettre", + "cancel": "Annuler", + "loading": "Chargement", + "error": "Erreur", + "success": "Succès", + "warning": "Avertissement", + "info": "Info", + "close": "Fermer", + "yes": "Oui", + "no": "Non", + "ok": "OK", + "name": "Nom", + "description": "Description", + "search": "Rechercher", + "add_member": "Ajouter un membre", + "remove_member": "Supprimer un membre", + "add_members": "Ajouter des membres", + "remove_members": "Supprimer des membres", + "add": "Ajouter", + "remove": "Supprimer", + "add_new": "Ajouter nouveau", + "remove_selected": "Supprimer la sélection", + "first_name": "Prénom", + "last_name": "Nom de famille", + "email": "Email", + "display_name": "Nom d'affichage", + "role": "Rôle", + "timezone": "Fuseau horaire", + "avatar": "Avatar", + "cover_image": "Image de couverture", + "password": "Mot de passe", + "change_cover": "Modifier la couverture", + "language": "Langue", + "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.", + "profile_settings": "Paramètres du profil", + "your_account": "Votre compte", + "profile": "Profil", + "security": " Sécurité", + "activity": "Activité", + "appearance": "Apparence", + "notifications": "Notifications", + "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 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.", + "change_password": "Changer le mot de passe", + "changing_password": "Changement de mot de passe", + "current_password": "Mot de passe actuel", + "new_password": "Nouveau mot de passe", + "confirm_password": "Confirmer le mot de passe", + "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 ê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", + "select_or_customize_your_interface_color_scheme": "Sélectionnez ou personnalisez votre schéma de couleurs de l'interface.", + "theme": "Thème", + "system_preference": "Préférence du système", + "light": "Clair", + "dark": "Foncé", + "light_contrast": "Clair de haut contraste", + "dark_contrast": "Foncé de haut contraste", + "custom": "Thème personnalisé", + "select_your_theme": "Sélectionnez votre thème", + "customize_your_theme": "Personnalisez votre thème", + "background_color": "Couleur de fond", + "text_color": "Couleur de texte", + "primary_color": "Couleur primaire (thème)", + "sidebar_background_color": "Couleur de fond du sidebar", + "sidebar_text_color": "Couleur de texte du sidebar", + "set_theme": "Définir le thème", + "enter_a_valid_hex_code_of_6_characters": "Entrez un code hexadécimal valide de 6 caractères", + "background_color_is_required": "La couleur de fond est requise", + "text_color_is_required": "La couleur de texte est requise", + "primary_color_is_required": "La couleur primaire est requise", + "sidebar_background_color_is_required": "La couleur de fond du sidebar est requise", + "sidebar_text_color_is_required": "La couleur de texte du sidebar est requise", + "updating_theme": "Mise à jour du thème", + "theme_updated_successfully": "Thème mis à jour avec succès", + "failed_to_update_the_theme": "Impossible de mettre à jour le thème", + "email_notifications": "Notifications par email", + "stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "Restez dans la boucle sur les problèmes auxquels vous êtes abonné. Activez cela pour être notifié.", + "email_notification_setting_updated_successfully": "Paramètres de notification par email mis à jour avec succès", + "failed_to_update_email_notification_setting": "Impossible de mettre à jour les paramètres de notification par email", + "notify_me_when": "Me notifier lorsque", + "property_changes": "Changements de propriété", + "property_changes_description": "Me notifier lorsque les propriétés du problème comme les assignés, la priorité, les estimations ou tout autre chose changent.", + "state_change": "Changement d'état", + "state_change_description": "Me notifier lorsque le problème passe à un autre état", + "issue_completed": "Problème terminé", + "issue_completed_description": "Me notifier uniquement lorsqu'un problème est terminé", + "comments": "Commentaires", + "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 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", + "plane_logo": "Logo de Plane", + "workspace_creation_disabled": "Création d'espace de travail désactivée", + "workspace_request_subject": "Demande de création d'un espace de travail", + "workspace_request_body": "Bonjour administrateur(s) de l'instance,\n\nVeuillez créer un nouveau espace de travail avec l'URL [/workspace-name] pour [raison de la création de l'espace de travail].\n\nMerci,\n{{firstName}} {{lastName}}\n{{email}}", + "creating_workspace": "Création de l'espace de travail", + "workspace_created_successfully": "Espace de travail créé avec succès", + "create_workspace_page": "Page de création d'espace de travail", + "workspace_could_not_be_created_please_try_again": "L'espace de travail ne peut pas être créé. Veuillez réessayer.", + "workspace_could_not_be_created_please_try_again_description": "Une erreur est survenue lors de la création de l'espace de travail. Veuillez réessayer.", + "this_is_a_required_field": "Ce champ est requis.", + "name_your_workspace": "Nommez votre espace de travail", + "workspaces_names_can_contain_only_space_dash_and_alphanumeric_characters": "Les noms des espaces de travail peuvent contenir uniquement des espaces, des tirets et des caractères alphanumériques.", + "limit_your_name_to_80_characters": "Limitez votre nom à 80 caractères.", + "set_your_workspace_url": "Définir l'URL de votre espace de travail", + "limit_your_url_to_48_characters": "Limitez votre URL à 48 caractères.", + "how_many_people_will_use_this_workspace": "Combien de personnes utiliseront cet espace de travail ?", + "how_many_people_will_use_this_workspace_description": "Cela nous aidera à déterminer le nombre de sièges que vous devez acheter.", + "select_a_range": "Sélectionner une plage", + "urls_can_contain_only_dash_and_alphanumeric_characters": "Les URLs peuvent contenir uniquement des tirets et des caractères alphanumériques.", + "something_familiar_and_recognizable_is_always_best": "Ce qui est familier et reconnaissable est toujours le meilleur.", + "workspace_url_is_already_taken": "L'URL de l'espace de travail est déjà utilisée !", + "old_password": "Mot de passe actuel", + "general_settings": "Paramètres généraux", + "sign_out": "Déconnexion", + "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", + "upgrade": "Mettre à niveau", + "10000_feet_view": "Vue d'ensemble de tous les cycles actifs", + "10000_feet_view_description": "Prenez du recul pour voir les cycles en cours dans tous vos projets en même temps au lieu de passer d'un cycle à l'autre dans chaque projet.", + "get_snapshot_of_each_active_cycle": "Obtenez un aperçu de chaque cycle actif", + "get_snapshot_of_each_active_cycle_description": "Suivez les métriques de haut niveau pour tous les cycles actifs, observez leur état d'avancement et évaluez leur portée par rapport aux échéances.", + "compare_burndowns": "Comparez les graphiques d'avancement", + "compare_burndowns_description": "Surveillez les performances de chacune de vos équipes en consultant le rapport d'avancement de chaque cycle.", + "quickly_see_make_or_break_issues": "Identifiez rapidement les problèmes critiques", + "quickly_see_make_or_break_issues_description": "Visualisez les problèmes hautement prioritaires de chaque cycle par rapport aux dates d'échéance. Consultez-les tous par cycle en un seul clic.", + "zoom_into_cycles_that_need_attention": "Concentrez-vous sur les cycles nécessitant attention", + "zoom_into_cycles_that_need_attention_description": "Examinez en un clic l'état de tout cycle qui ne répond pas aux attentes.", + "stay_ahead_of_blockers": "Anticipez les blocages", + "stay_ahead_of_blockers_description": "Repérez les défis d'un projet à l'autre et identifiez les dépendances entre cycles qui ne sont pas évidentes depuis d'autres vues.", + "analytics": "Analyse", + "workspace_invites": "Invitations de l'espace de travail", + "workspace_settings": "Paramètres de l'espace de travail", + "enter_god_mode": "Entrer en mode dieu", + "workspace_logo": "Logo de l'espace de travail", + "new_issue": "Nouveau problème", + "home": "Accueil", + "your_work": "Votre travail", + "drafts": "Brouillons", + "projects": "Projets", + "views": "Vues", + "workspace": "Espace de travail", + "archives": "Archives", + "settings": "Paramètres", + "failed_to_move_favorite": "Impossible de déplacer le favori", + "your_favorites": "Vos favoris", + "no_favorites_yet": "Aucun favori pour le moment", + "create_folder": "Créer un dossier", + "new_folder": "Nouveau dossier", + "favorite_updated_successfully": "Favori mis à jour avec succès", + "favorite_created_successfully": "Favori créé avec succès", + "folder_already_exists": "Le dossier existe déjà", + "folder_name_cannot_be_empty": "Le nom du dossier ne peut pas être vide", + "something_went_wrong": "Quelque chose s'est mal passé", + "failed_to_reorder_favorite": "Impossible de réordonner le favori", + "favorite_removed_successfully": "Favori supprimé avec succès", + "failed_to_create_favorite": "Impossible de créer le favori", + "failed_to_rename_favorite": "Impossible de renommer le favori", + "project_link_copied_to_clipboard": "Lien du projet copié dans le presse-papiers", + "link_copied": "Lien copié", + "your_projects": "Vos projets", + "add_project": "Ajouter un projet", + "create_project": "Créer un projet", + "failed_to_remove_project_from_favorites": "Impossible de supprimer le projet des favoris. Veuillez réessayer.", + "project_created_successfully": "Projet créé avec succès", + "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 doit être inférieur à 255 caractères", + "project_name": "Nom du projet", + "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": "Le projet ID est requis", + "select_network": "Sélectionner le réseau", + "lead": "Lead", + "private": "Privé", + "public": "Public", + "accessible_only_by_invite": "Accessible uniquement par invitation", + "anyone_in_the_workspace_except_guests_can_join": "Tout le monde dans l'espace de travail, sauf les invités, peut rejoindre", + "creating": "Création", + "creating_project": "Création du projet", + "adding_project_to_favorites": "Ajout du projet aux favoris", + "project_added_to_favorites": "Projet ajouté aux favoris", + "couldnt_add_the_project_to_favorites": "Impossible d'ajouter le projet aux favoris. Veuillez réessayer.", + "removing_project_from_favorites": "Suppression du projet des favoris", + "project_removed_from_favorites": "Projet supprimé des favoris", + "couldnt_remove_the_project_from_favorites": "Impossible de supprimer le projet des favoris. Veuillez réessayer.", + "add_to_favorites": "Ajouter aux favoris", + "remove_from_favorites": "Supprimer des favoris", + "publish_settings": "Paramètres de publication", + "publish": "Publier", + "copy_link": "Copier le lien", + "leave_project": "Quitter le projet", + "join_the_project_to_rearrange": "Rejoindre le projet pour réorganiser", + "drag_to_rearrange": "Glisser pour réorganiser", + "congrats": "Félicitations !", + "project": "Projet", + "open_project": "Ouvrir le projet", + "issues": "Problèmes", + "cycles": "Cycles", + "modules": "Modules", + "pages": "Pages", + "intake": "Intake", + "time_tracking": "Suivi du temps", + "work_management": "Gestion du travail", + "projects_and_issues": "Projets et problèmes", + "projects_and_issues_description": "Activer ou désactiver ces fonctionnalités pour ce projet.", + "cycles_description": "Organisez votre travail en périodes définies selon vos besoins par projet et modifiez la fréquence d'une période à l'autre.", + "modules_description": "Regroupez le travail en sous-projets avec leurs propres responsables et assignés.", + "views_description": "Enregistrez vos tris, filtres et options d'affichage pour plus tard ou partagez-les.", + "pages_description": "Rédigez tout type de contenu librement.", + "intake_description": "Restez informé des tickets auxquels vous êtes abonné. Activez cette option pour recevoir des notifications.", + "time_tracking_description": "Suivez le temps passé sur les tickets et les projets.", + "work_management_description": "Gérez votre travail et vos projets en toute simplicité.", + "documentation": "Documentation", + "message_support": "Contacter le support", + "contact_sales": "Contacter les ventes", + "hyper_mode": "Mode hyper", + "keyboard_shortcuts": "Raccourcis clavier", + "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": "nos changelogs", + "for_the_latest_updates": "pour les dernières mises à jour.", + "please_visit": "Veuillez visiter", + "docs": "Documentation", + "full_changelog": "Journal complet", + "support": "Support", + "discord": "Discord", + "powered_by_plane_pages": "Propulsé par Plane Pages", + "please_select_at_least_one_invitation": "Veuillez sélectionner au moins une invitation.", + "please_select_at_least_one_invitation_description": "Veuillez sélectionner au moins une invitation pour rejoindre l'espace de travail.", + "we_see_that_someone_has_invited_you_to_join_a_workspace": "Nous voyons que quelqu'un vous a invité à rejoindre un espace de travail", + "join_a_workspace": "Rejoindre un espace de travail", + "we_see_that_someone_has_invited_you_to_join_a_workspace_description": "Nous voyons que quelqu'un vous a invité à rejoindre un espace de travail", + "join_a_workspace_description": "Rejoindre un espace de travail", + "accept_and_join": "Accepter et rejoindre", + "go_home": "Retour à l'accueil", + "no_pending_invites": "Aucune invitation en attente", + "you_can_see_here_if_someone_invites_you_to_a_workspace": "Vous pouvez voir ici si quelqu'un vous invite à rejoindre un espace de travail", + "back_to_home": "Retour à l'accueil", + "workspace_name": "espace-de-travail", + "deactivate_your_account": "Désactiver votre compte", + "deactivate_your_account_description": "Une fois désactivé, vous ne pourrez pas être assigné à des problèmes et être facturé pour votre espace de travail. Pour réactiver votre compte, vous aurez besoin d'une invitation à un espace de travail à cette adresse email.", + "deactivating": "Désactivation", + "confirm": "Confirmer", + "draft_created": "Brouillon créé", + "issue_created_successfully": "Problème créé avec succès", + "draft_creation_failed": "Création du brouillon échouée", + "issue_creation_failed": "Création du problème échouée", + "draft_issue": "Problème en brouillon", + "issue_updated_successfully": "Problème mis à jour avec succès", + "issue_could_not_be_updated": "Le problème n'a pas pu être mis à jour", + "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", + "failed_to_move_issue_to_project": "Impossible de déplacer le problème vers le projet", + "create_more": "Créer plus", + "add_to_project": "Ajouter au projet", + "discard": "Annuler", + "duplicate_issue_found": "Problème en double trouvé", + "duplicate_issues_found": "Problèmes en double trouvés", + "no_matching_results": "Aucun résultat correspondant", + "title_is_required": "Le titre est requis", + "title": "Titre", + "state": "État", + "priority": "Priorité", + "none": "Aucune", + "urgent": "Urgent", + "high": "Haute", + "medium": "Moyenne", + "low": "Basse", + "members": "Membres", + "assignee": "Assigné", + "assignees": "Assignés", + "you": "Vous", + "labels": "Étiquettes", + "create_new_label": "Créer une nouvelle étiquette", + "start_date": "Date de début", + "due_date": "Date d'échéance", + "cycle": "Cycle", + "estimate": "Estimation", + "change_parent_issue": "Modifier le problème parent", + "remove_parent_issue": "Supprimer le problème parent", + "add_parent": "Ajouter un parent", + "loading_members": "Chargement des membres...", + "inbox": "boîte de réception" +} diff --git a/packages/i18n/src/locales/ja/translations.json b/packages/i18n/src/locales/ja/translations.json new file mode 100644 index 00000000000..fa2b244cc5e --- /dev/null +++ b/packages/i18n/src/locales/ja/translations.json @@ -0,0 +1,320 @@ +{ + "submit": "送信", + "cancel": "キャンセル", + "loading": "読み込み中", + "error": "エラー", + "success": "成功", + "warning": "警告", + "info": "情報", + "close": "閉じる", + "yes": "はい", + "no": "いいえ", + "ok": "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文字の有効な16進コードを入力してください", + "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": "プレーンロゴ", + "workspace_creation_disabled": "ワークスペースの作成が無効化されています", + "workspace_request_subject": "新しいワークスペースのリクエスト", + "workspace_request_body": "インスタンス管理者様\n\nURL [/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": "すべてのアクティブサイクルの10,000フィートビュー。", + "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": "各サイクルの期限に対する高優先度の問題をプレビューします。1クリックでサイクルごとにすべてを確認できます。", + "zoom_into_cycles_that_need_attention": "注意が必要なサイクルにズームインします。", + "zoom_into_cycles_that_need_attention_description": "期待に沿わないサイクルの状態を1クリックで調査します。", + "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": "ディスコード", + "powered_by_plane_pages": "Plane Pagesによって提供されています", + "please_select_at_least_one_invitation": "少なくとも1つの招待を選択してください。", + "please_select_at_least_one_invitation_description": "ワークスペースに参加するために少なくとも1つの招待を選択してください。", + "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": "保存", + "update": "更新", + "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": "受信箱" +} diff --git a/packages/i18n/tsconfig.json b/packages/i18n/tsconfig.json new file mode 100644 index 00000000000..6599e6e82aa --- /dev/null +++ b/packages/i18n/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@plane/typescript-config/react-library.json", + "compilerOptions": { + "jsx": "react", + "lib": ["esnext", "dom"], + "resolveJsonModule": true + }, + "include": ["./src"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/services/package.json b/packages/services/package.json index e3473942f73..792186de7eb 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -9,6 +9,6 @@ }, "dependencies": { "@plane/constants": "*", - "axios": "^1.4.0" + "axios": "^1.7.9" } } diff --git a/packages/services/src/ai/ai.service.ts b/packages/services/src/ai/ai.service.ts index 6a3b3c637c6..261cf9df5f8 100644 --- a/packages/services/src/ai/ai.service.ts +++ b/packages/services/src/ai/ai.service.ts @@ -1,7 +1,7 @@ // plane web constants import { AI_EDITOR_TASKS, API_BASE_URL } from "@plane/constants"; // services -import { APIService } from "@/api.service"; +import { APIService } from "../api.service"; /** * Payload type for AI editor tasks diff --git a/packages/services/src/auth/auth.service.ts b/packages/services/src/auth/auth.service.ts index 87e75e536f6..1890533d2e0 100644 --- a/packages/services/src/auth/auth.service.ts +++ b/packages/services/src/auth/auth.service.ts @@ -9,7 +9,7 @@ import { APIService } from "../api.service"; * Provides methods for user authentication, password management, and session handling * @extends {APIService} */ -export default class AuthService extends APIService { +export class AuthService extends APIService { /** * Creates an instance of AuthService * Initializes with the base API URL @@ -22,9 +22,10 @@ export default class AuthService extends APIService { * Requests a CSRF token for form submission security * @returns {Promise} Object containing the CSRF token * @throws {Error} Throws the complete error object if the request fails + * @remarks This method uses the validateStatus: null option to bypass interceptors for unauthorized errors. */ async requestCSRFToken(): Promise { - return this.get("/auth/get-csrf-token/") + return this.get("/auth/get-csrf-token/", { validateStatus: null }) .then((response) => response.data) .catch((error) => { throw error; diff --git a/packages/services/src/cycle/cycle-analytics.service.ts b/packages/services/src/cycle/cycle-analytics.service.ts index 4897926a925..c9e14441e83 100644 --- a/packages/services/src/cycle/cycle-analytics.service.ts +++ b/packages/services/src/cycle/cycle-analytics.service.ts @@ -1,6 +1,6 @@ import { API_BASE_URL } from "@plane/constants"; import type { TCycleDistribution, TProgressSnapshot, TCycleEstimateDistribution } from "@plane/types"; -import { APIService } from "@/api.service"; +import { APIService } from "../api.service"; /** * Service class for managing cycles within a workspace and project context. diff --git a/packages/services/src/cycle/cycle-archive.service.ts b/packages/services/src/cycle/cycle-archive.service.ts index 8c40f0a298a..784fd32e7d3 100644 --- a/packages/services/src/cycle/cycle-archive.service.ts +++ b/packages/services/src/cycle/cycle-archive.service.ts @@ -1,6 +1,6 @@ import { API_BASE_URL } from "@plane/constants"; import { ICycle } from "@plane/types"; -import { APIService } from "@/api.service"; +import { APIService } from "../api.service"; /** * Service class for managing archived cycles in a project diff --git a/packages/services/src/cycle/cycle-operations.service.ts b/packages/services/src/cycle/cycle-operations.service.ts index 3e6f32cd9a4..43c01396cb0 100644 --- a/packages/services/src/cycle/cycle-operations.service.ts +++ b/packages/services/src/cycle/cycle-operations.service.ts @@ -1,5 +1,5 @@ import { API_BASE_URL } from "@plane/constants"; -import { APIService } from "@/api.service"; +import { APIService } from "../api.service"; export class CycleOperationsService extends APIService { constructor(BASE_URL?: string) { diff --git a/packages/services/src/cycle/cycle.service.ts b/packages/services/src/cycle/cycle.service.ts index c697c2da4a7..961e5588a2a 100644 --- a/packages/services/src/cycle/cycle.service.ts +++ b/packages/services/src/cycle/cycle.service.ts @@ -1,6 +1,6 @@ import { API_BASE_URL } from "@plane/constants"; import type { CycleDateCheckData, ICycle, TIssuesResponse, IWorkspaceActiveCyclesResponse } from "@plane/types"; -import { APIService } from "@/api.service"; +import { APIService } from "../api.service"; /** * Service class for managing cycles within a workspace and project context. diff --git a/packages/services/src/developer/api-token.service.ts b/packages/services/src/developer/api-token.service.ts index 92ee523ea6e..74dc9135d60 100644 --- a/packages/services/src/developer/api-token.service.ts +++ b/packages/services/src/developer/api-token.service.ts @@ -1,6 +1,6 @@ import { API_BASE_URL } from "@plane/constants"; import { IApiToken } from "@plane/types"; -import { APIService } from "@/api.service"; +import { APIService } from "../api.service"; export class APITokenService extends APIService { constructor(BASE_URL?: string) { diff --git a/packages/services/src/instance/instance.service.ts b/packages/services/src/instance/instance.service.ts index 0ffe451fbc3..637c81cad01 100644 --- a/packages/services/src/instance/instance.service.ts +++ b/packages/services/src/instance/instance.service.ts @@ -1,13 +1,22 @@ +// plane imports import { API_BASE_URL } from "@plane/constants"; -import type { IInstanceInfo, TPage } from "@plane/types"; -import { APIService } from "@/api.service"; +import type { + IFormattedInstanceConfiguration, + IInstance, + IInstanceAdmin, + IInstanceConfiguration, + IInstanceInfo, + TPage, +} from "@plane/types"; +// api service +import { APIService } from "../api.service"; /** * Service class for managing instance-related operations * Handles retrieval of instance information and changelog * @extends {APIService} */ -export default class InstanceService extends APIService { +export class InstanceService extends APIService { /** * Creates an instance of InstanceService * Initializes the service with the base API URL @@ -20,12 +29,13 @@ export default class InstanceService extends APIService { * Retrieves information about the current instance * @returns {Promise} Promise resolving to instance information * @throws {Error} If the API request fails + * @remarks This method uses the validateStatus: null option to bypass interceptors for unauthorized errors. */ async info(): Promise { - return this.get("/api/instances/") + return this.get("/api/instances/", { validateStatus: null }) .then((response) => response.data) .catch((error) => { - throw error; + throw error?.response?.data; }); } @@ -38,7 +48,78 @@ export default class InstanceService extends APIService { return this.get("/api/instances/changelog/") .then((response) => response.data) .catch((error) => { - throw error; + throw error?.response?.data; + }); + } + + /** + * Fetches the list of instance admins + * @returns {Promise} Promise resolving to an array of instance admins + * @throws {Error} If the API request fails + * @remarks This method uses the validateStatus: null option to bypass interceptors for unauthorized errors. + */ + async admins(): Promise { + return this.get("/api/instances/admins/", { validateStatus: null }) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Updates the instance information + * @param {Partial} data Data to update the instance with + * @returns {Promise} Promise resolving to the updated instance information + * @throws {Error} If the API request fails + */ + async update(data: Partial): Promise { + return this.patch("/api/instances/", data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Fetches the list of instance configurations + * @returns {Promise} Promise resolving to an array of instance configurations + * @throws {Error} If the API request fails + */ + async configurations(): Promise { + return this.get("/api/instances/configurations/") + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Updates the instance configurations + * @param {Partial} data Data to update the instance configurations with + * @returns {Promise} The updated instance configurations + * @throws {Error} If the API request fails + */ + async updateConfigurations(data: Partial): Promise { + return this.patch("/api/instances/configurations/", data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Sends a test email to the specified receiver to test SMTP configuration + * @param {string} receiverEmail Email address to send the test email to + * @returns {Promise} Promise resolving to void + * @throws {Error} If the API request fails + */ + async sendTestEmail(receiverEmail: string): Promise { + return this.post("/api/instances/email-credentials-check/", { + receiver_email: receiverEmail, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; }); } } diff --git a/packages/services/src/intake/intake.service.ts b/packages/services/src/intake/intake.service.ts index 1f7f7229561..1c4a93c18a8 100644 --- a/packages/services/src/intake/intake.service.ts +++ b/packages/services/src/intake/intake.service.ts @@ -1,5 +1,5 @@ import { API_BASE_URL } from "@plane/constants"; -import { APIService } from "@/api.service"; +import { APIService } from "../api.service"; export default class IntakeService extends APIService { constructor(BASE_URL?: string) { diff --git a/packages/services/src/intake/issue.service.ts b/packages/services/src/intake/issue.service.ts index 37e1f81dca2..b48328df49b 100644 --- a/packages/services/src/intake/issue.service.ts +++ b/packages/services/src/intake/issue.service.ts @@ -1,5 +1,5 @@ import { API_BASE_URL } from "@plane/constants"; -import { APIService } from "@/api.service"; +import { APIService } from "../api.service"; export default class IntakeIssueService extends APIService { constructor(BASE_URL?: string) { diff --git a/packages/services/src/module/link.service.ts b/packages/services/src/module/link.service.ts index 0caee9e1983..8c8ec3f474e 100644 --- a/packages/services/src/module/link.service.ts +++ b/packages/services/src/module/link.service.ts @@ -1,7 +1,7 @@ // types import type { ILinkDetails, ModuleLink } from "@plane/types"; // services -import { APIService } from "@/api.service"; +import { APIService } from "../api.service"; /** * Service class for handling module link related operations. diff --git a/packages/services/src/module/module.service.ts b/packages/services/src/module/module.service.ts index 1d1732aa914..21321b36ec5 100644 --- a/packages/services/src/module/module.service.ts +++ b/packages/services/src/module/module.service.ts @@ -1,7 +1,7 @@ // types import type { IModule, ILinkDetails, ModuleLink, TIssuesResponse } from "@plane/types"; // services -import { APIService } from "@/api.service"; +import { APIService } from "../api.service"; export class ModuleService extends APIService { constructor(baseURL: string) { diff --git a/packages/services/src/module/operations.service.ts b/packages/services/src/module/operations.service.ts index b8fddb37d51..9185873dab3 100644 --- a/packages/services/src/module/operations.service.ts +++ b/packages/services/src/module/operations.service.ts @@ -1,7 +1,7 @@ // types // import type { IModule, ILinkDetails, ModuleLink, TIssuesResponse } from "@plane/types"; // services -import { APIService } from "@/api.service"; +import { APIService } from "../api.service"; export class ModuleOperationService extends APIService { constructor(baseURL: string) { diff --git a/packages/services/src/project/index.ts b/packages/services/src/project/index.ts index 6ec55d7f7a9..a470a732864 100644 --- a/packages/services/src/project/index.ts +++ b/packages/services/src/project/index.ts @@ -1 +1 @@ -export * from "./view.service"; +export * from "./view.service"; \ No newline at end of file diff --git a/packages/services/src/project/view.service.ts b/packages/services/src/project/view.service.ts index e69de29bb2d..7eef7191156 100644 --- a/packages/services/src/project/view.service.ts +++ b/packages/services/src/project/view.service.ts @@ -0,0 +1,14 @@ +// plane imports +import { API_BASE_URL } from "@plane/constants"; +// api services +import { APIService } from "../api.service"; + +export class ProjectViewService extends APIService { + /** + * Creates an instance of ProjectViewService + * @param {string} baseUrl - The base URL for API requests + */ + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } +} diff --git a/packages/services/src/user/favorite.service.ts b/packages/services/src/user/favorite.service.ts index 7e838a3c6c8..0c6e0497f23 100644 --- a/packages/services/src/user/favorite.service.ts +++ b/packages/services/src/user/favorite.service.ts @@ -1,6 +1,6 @@ import { API_BASE_URL } from "@plane/constants"; import type { IFavorite } from "@plane/types"; -import { APIService } from "@/api.service"; +import { APIService } from "../api.service"; /** * Service class for managing user favorites diff --git a/packages/services/src/user/index.ts b/packages/services/src/user/index.ts index 41df23a178d..c738d93ee81 100644 --- a/packages/services/src/user/index.ts +++ b/packages/services/src/user/index.ts @@ -1 +1,2 @@ export * from "./favorite.service"; +export * from "./user.service"; diff --git a/packages/services/src/user/user.service.ts b/packages/services/src/user/user.service.ts new file mode 100644 index 00000000000..016c2e7e675 --- /dev/null +++ b/packages/services/src/user/user.service.ts @@ -0,0 +1,34 @@ +// plane imports +import { API_BASE_URL } from "@plane/constants"; +import type { IUser } from "@plane/types"; +// api service +import { APIService } from "../api.service"; + +/** + * Service class for managing user operations + * Handles operations for retrieving the current user's details and perform CRUD operations + * @extends {APIService} + */ +export class UserService extends APIService { + /** + * Constructor for UserService + * @param BASE_URL - Base URL for API requests + */ + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Retrieves the current instance admin details + * @returns {Promise} Promise resolving to the current instance admin details + * @throws {Error} If the API request fails + * @remarks This method uses the validateStatus: null option to bypass interceptors for unauthorized errors. + */ + async adminDetails(): Promise { + return this.get("/api/instances/admins/me/", { validateStatus: null }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/workspace/index.ts b/packages/services/src/workspace/index.ts index 4076a51f524..a48efdee2c2 100644 --- a/packages/services/src/workspace/index.ts +++ b/packages/services/src/workspace/index.ts @@ -3,3 +3,4 @@ export * from "./member.service"; export * from "./notification.service"; export * from "./view.service"; export * from "./workspace.service"; +export * from "./instance-workspace.service"; diff --git a/packages/services/src/workspace/instance-workspace.service.ts b/packages/services/src/workspace/instance-workspace.service.ts new file mode 100644 index 00000000000..7ac3d4ce4d1 --- /dev/null +++ b/packages/services/src/workspace/instance-workspace.service.ts @@ -0,0 +1,65 @@ +import { API_BASE_URL } from "@plane/constants"; +import type { IWorkspace, TWorkspacePaginationInfo } from "@plane/types"; +import { APIService } from "../api.service"; + +/** + * Service class for managing instance workspaces + * Handles CRUD operations on instance workspaces + * @extends APIService + */ +export class InstanceWorkspaceService extends APIService { + /** + * Constructor for InstanceWorkspaceService + * @param BASE_URL - Base URL for API requests + */ + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Retrieves a paginated list of workspaces for the current instance + * @param {string} nextPageCursor - Optional cursor to retrieve the next page of results + * @returns {Promise} Promise resolving to a paginated list of workspaces + * @throws {Error} If the API request fails + */ + async list(nextPageCursor?: string): Promise { + return this.get(`/api/instances/workspaces/`, { + params: { + cursor: nextPageCursor, + }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Checks if a workspace slug is available + * @param {string} slug - The workspace slug to check + * @returns {Promise} Promise resolving to slug availability status + * @throws {Error} If the API request fails + */ + async slugCheck(slug: string): Promise { + 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; + }); + } + + /** + * Creates a new workspace + * @param {Partial} data - Workspace data for creation + * @returns {Promise} Promise resolving to the created workspace + * @throws {Error} If the API request fails + */ + async create(data: Partial): Promise { + return this.post("/api/instances/workspaces/", data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/workspace/invitation.service.ts b/packages/services/src/workspace/invitation.service.ts index fa344602025..46088371f5c 100644 --- a/packages/services/src/workspace/invitation.service.ts +++ b/packages/services/src/workspace/invitation.service.ts @@ -1,6 +1,6 @@ import { API_BASE_URL } from "@plane/constants"; import { IWorkspaceMemberInvitation, IWorkspaceBulkInviteFormData, IWorkspaceMember } from "@plane/types"; -import { APIService } from "@/api.service"; +import { APIService } from "../api.service"; /** * Service class for managing workspace invitations diff --git a/packages/services/src/workspace/view.service.ts b/packages/services/src/workspace/view.service.ts index ca782d43358..0ea21f742ee 100644 --- a/packages/services/src/workspace/view.service.ts +++ b/packages/services/src/workspace/view.service.ts @@ -1,6 +1,6 @@ import { API_BASE_URL } from "@plane/constants"; import { IWorkspaceView, TIssuesResponse } from "@plane/types"; -import { APIService } from "@/api.service"; +import { APIService } from "../api.service"; export class WorkspaceViewService extends APIService { /** diff --git a/packages/services/tsconfig.json b/packages/services/tsconfig.json index 0c2f64d1a8c..efce2a9fe8b 100644 --- a/packages/services/tsconfig.json +++ b/packages/services/tsconfig.json @@ -3,9 +3,6 @@ "compilerOptions": { "jsx": "react", "lib": ["esnext", "dom"], - "paths": { - "@/*": ["./src/*"] - } }, "include": ["./src"], "exclude": ["dist", "build", "node_modules"] diff --git a/packages/types/src/charts.d.ts b/packages/types/src/charts.d.ts new file mode 100644 index 00000000000..04234d6e725 --- /dev/null +++ b/packages/types/src/charts.d.ts @@ -0,0 +1,73 @@ +export type TStackItem = { + key: T; + fillClassName: string; + textClassName: string; + dotClassName?: string; + showPercentage?: boolean; +}; + +export type TStackChartData = { + [key in K]: string | number; +} & Record; + +export type TStackedBarChartProps = { + data: TStackChartData[]; + stacks: TStackItem[]; + xAxis: { + key: keyof TStackChartData; + label: string; + }; + yAxis: { + key: keyof TStackChartData; + label: string; + domain?: [number, number]; + allowDecimals?: boolean; + }; + barSize?: number; + className?: string; + tickCount?: { + x?: number; + y?: number; + }; + showTooltip?: boolean; +}; + +export type TreeMapItem = { + name: string; + value: number; + label?: string; + textClassName?: string; + icon?: React.ReactElement; +} & ( + | { + fillColor: string; + } + | { + fillClassName: string; + } + ); + +export type TreeMapChartProps = { + data: TreeMapItem[]; + className?: string; + isAnimationActive?: boolean; + showTooltip?: boolean; +}; + +export type TTopSectionConfig = { + showIcon: boolean; + showName: boolean; + nameTruncated: boolean; +}; + +export type TBottomSectionConfig = { + show: boolean; + showValue: boolean; + showLabel: boolean; + labelTruncated: boolean; +}; + +export type TContentVisibility = { + top: TTopSectionConfig; + bottom: TBottomSectionConfig; +}; diff --git a/packages/types/src/epics.d.ts b/packages/types/src/epics.d.ts new file mode 100644 index 00000000000..1ba50e2f2f3 --- /dev/null +++ b/packages/types/src/epics.d.ts @@ -0,0 +1,16 @@ +export type TEpicAnalyticsGroup = + | "backlog_issues" + | "unstarted_issues" + | "started_issues" + | "completed_issues" + | "cancelled_issues" + | "overdue_issues"; + +export type TEpicAnalytics = { + backlog_issues: number; + unstarted_issues: number; + started_issues: number; + completed_issues: number; + cancelled_issues: number; + overdue_issues: number; +}; diff --git a/packages/types/src/home.d.ts b/packages/types/src/home.d.ts new file mode 100644 index 00000000000..c93fb448008 --- /dev/null +++ b/packages/types/src/home.d.ts @@ -0,0 +1,76 @@ +import { TLogoProps } from "./common"; +import { TIssuePriorities } from "./issues"; + +export type TRecentActivityFilterKeys = "all item" | "issue" | "page" | "project"; +export type THomeWidgetKeys = "quick_links" | "recents" | "my_stickies" | "quick_tutorial" | "new_at_plane"; + +export type THomeWidgetProps = { + workspaceSlug: string; +}; + +export type TPageEntityData = { + id: string; + name: string; + logo_props: TLogoProps; + project_id: string; + owned_by: string; + project_identifier: string; +}; + +export type TProjectEntityData = { + id: string; + name: string; + logo_props: TLogoProps; + project_members: string[]; + identifier: string; +}; + +export type TIssueEntityData = { + id: string; + name: string; + state: string; + priority: TIssuePriorities; + assignees: string[]; + type: string | null; + sequence_id: number; + project_id: string; + project_identifier: string; +}; + +export type TActivityEntityData = { + id: string; + entity_name: "page" | "project" | "issue"; + entity_identifier: string; + visited_at: string; + entity_data: TPageEntityData | TProjectEntityData | TIssueEntityData; +}; + +export type TLinkEditableFields = { + title: string; + url: string; +}; + +export type TLink = TLinkEditableFields & { + created_by_id: string; + id: string; + metadata: any; + workspace_slug: string; + + //need + created_at: Date; +}; + +export type TLinkMap = { + [workspace_slug: string]: TLink; +}; + +export type TLinkIdMap = { + [workspace_slug: string]: string[]; +}; + +export type TWidgetEntityData = { + key: THomeWidgetKeys; + name: string; + is_enabled: boolean; + sort_order: number; +}; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 43cc3084a39..9ec3846b7c5 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -36,3 +36,7 @@ export * from "./workspace-draft-issues/base"; export * from "./command-palette"; export * from "./timezone"; export * from "./activity"; +export * from "./epics"; +export * from "./charts"; +export * from "./home"; +export * from "./stickies"; diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index 011f92d69ba..183d015bf69 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -15,7 +15,8 @@ export type TPage = { label_ids: string[] | undefined; name: string | undefined; owned_by: string | undefined; - project_ids: string[] | undefined; + project_ids?: string[] | undefined; + team: string | null | undefined; updated_at: Date | undefined; updated_by: string | undefined; workspace: string | undefined; @@ -25,11 +26,7 @@ export type TPage = { // page filters export type TPageNavigationTabs = "public" | "private" | "archived"; -export type TPageFiltersSortKey = - | "name" - | "created_at" - | "updated_at" - | "opened_at"; +export type TPageFiltersSortKey = "name" | "created_at" | "updated_at" | "opened_at"; export type TPageFiltersSortBy = "asc" | "desc"; @@ -63,10 +60,17 @@ export type TPageVersion = { updated_at: string; updated_by: string; workspace: string; -} +}; export type TDocumentPayload = { description_binary: string; description_html: string; description: object; -} \ No newline at end of file +}; + +export type TWebhookConnectionQueryParams = { + documentType: "project_page" | "team_page" | "workspace_page"; + projectId?: string; + teamId?: string; + workspaceSlug: string; +}; diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts index f878266b75d..d992bc7105b 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -15,6 +15,7 @@ export interface IProject { archived_at: string | null; archived_issues: number; archived_sub_issues: number; + completed_issues: number; close_in: number; created_at: Date; created_by: string; diff --git a/packages/types/src/search.d.ts b/packages/types/src/search.d.ts index 5ab01549c92..41138a46eb8 100644 --- a/packages/types/src/search.d.ts +++ b/packages/types/src/search.d.ts @@ -6,13 +6,7 @@ import { IProject } from "./project"; import { IUser } from "./users"; import { IWorkspace } from "./workspace"; -export type TSearchEntities = - | "user_mention" - | "issue_mention" - | "project_mention" - | "cycle_mention" - | "module_mention" - | "page_mention"; +export type TSearchEntities = "user_mention" | "issue" | "project" | "cycle" | "module" | "page"; export type TUserSearchResponse = { member__avatar_url: IUser["avatar_url"]; @@ -66,16 +60,19 @@ export type TPageSearchResponse = { }; export type TSearchResponse = { - cycle_mention?: TCycleSearchResponse[]; - issue_mention?: TIssueSearchResponse[]; - module_mention?: TModuleSearchResponse[]; - page_mention?: TPageSearchResponse[]; - project_mention?: TProjectSearchResponse[]; + cycle?: TCycleSearchResponse[]; + issue?: TIssueSearchResponse[]; + module?: TModuleSearchResponse[]; + page?: TPageSearchResponse[]; + project?: TProjectSearchResponse[]; user_mention?: TUserSearchResponse[]; }; export type TSearchEntityRequestPayload = { count: number; + project_id?: string; query_type: TSearchEntities[]; query: string; + team_id?: string; + issue_id?: string; }; diff --git a/packages/types/src/stickies.d copy.ts b/packages/types/src/stickies.d copy.ts new file mode 100644 index 00000000000..55f8b23c587 --- /dev/null +++ b/packages/types/src/stickies.d copy.ts @@ -0,0 +1,8 @@ +export type TSticky = { + id: string; + name?: string; + description_html?: string; + color?: string; + createdAt?: Date; + updatedAt?: Date; +}; diff --git a/packages/types/src/stickies.d.ts b/packages/types/src/stickies.d.ts new file mode 100644 index 00000000000..55f8b23c587 --- /dev/null +++ b/packages/types/src/stickies.d.ts @@ -0,0 +1,8 @@ +export type TSticky = { + id: string; + name?: string; + description_html?: string; + color?: string; + createdAt?: Date; + updatedAt?: Date; +}; diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index 452bc23c238..c562e7c246b 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -25,7 +25,6 @@ export interface IUser extends IUserLite { is_password_autoset: boolean; is_tour_completed: boolean; mobile_number: string | null; - role: string | null; last_workspace_id: string; user_timezone: string; username: string; @@ -62,6 +61,7 @@ export type TUserProfile = { billing_address_country: string | undefined; billing_address: string | undefined; has_billing_address: boolean; + language: string; created_at: Date | string; updated_at: Date | string; }; diff --git a/packages/ui/src/dropdowns/context-menu/item.tsx b/packages/ui/src/dropdowns/context-menu/item.tsx index 99ef790e3f6..83124392082 100644 --- a/packages/ui/src/dropdowns/context-menu/item.tsx +++ b/packages/ui/src/dropdowns/context-menu/item.tsx @@ -36,19 +36,23 @@ export const ContextMenuItem: React.FC = (props) => { onMouseEnter={handleActiveItem} disabled={item.disabled} > - {item.icon && } -
-
{item.title}
- {item.description && ( -

- {item.description} -

- )} -
+ {item.customContent ?? ( + <> + {item.icon && } +
+
{item.title}
+ {item.description && ( +

+ {item.description} +

+ )} +
+ + )} ); }; diff --git a/packages/ui/src/dropdowns/context-menu/root.tsx b/packages/ui/src/dropdowns/context-menu/root.tsx index f251696d212..e4265f1007b 100644 --- a/packages/ui/src/dropdowns/context-menu/root.tsx +++ b/packages/ui/src/dropdowns/context-menu/root.tsx @@ -11,7 +11,8 @@ import { usePlatformOS } from "../../hooks/use-platform-os"; export type TContextMenuItem = { key: string; - title: string; + customContent?: React.ReactNode; + title?: string; description?: string; icon?: React.FC; action: () => void; diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 39f01d1ed27..f21da438196 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -54,7 +54,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { if (referenceElement) referenceElement.focus(); }; const closeDropdown = () => { - isOpen && onMenuClose && onMenuClose(); + if (isOpen) onMenuClose?.(); setIsOpen(false); }; @@ -216,7 +216,7 @@ const MenuItem: React.FC = (props) => { )} onClick={(e) => { close(); - onClick && onClick(e); + onClick?.(e); }} disabled={disabled} > diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index 01480c78d2f..c09c26057de 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -47,3 +47,5 @@ export * from "./overview-icon"; export * from "./on-track-icon"; export * from "./off-track-icon"; export * from "./at-risk-icon"; +export * from "./multiple-sticky"; +export * from "./sticky-note-icon"; diff --git a/packages/ui/src/icons/multiple-sticky.tsx b/packages/ui/src/icons/multiple-sticky.tsx new file mode 100644 index 00000000000..9c33205e992 --- /dev/null +++ b/packages/ui/src/icons/multiple-sticky.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const RecentStickyIcon: React.FC = ({ className = "text-current", ...rest }) => ( + + + + +); diff --git a/packages/ui/src/icons/sticky-note-icon.tsx b/packages/ui/src/icons/sticky-note-icon.tsx new file mode 100644 index 00000000000..87195028990 --- /dev/null +++ b/packages/ui/src/icons/sticky-note-icon.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const StickyNoteIcon: React.FC = ({ width = "17", height = "17", className, color }) => ( + + + + + + +); diff --git a/packages/ui/src/icons/transfer-icon.tsx b/packages/ui/src/icons/transfer-icon.tsx index 9a5286f94d5..f762f9611e0 100644 --- a/packages/ui/src/icons/transfer-icon.tsx +++ b/packages/ui/src/icons/transfer-icon.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { ISvgIcons } from "./type"; export const TransferIcon: React.FC = ({ className = "fill-current", ...rest }) => ( - + ); diff --git a/packages/utils/package.json b/packages/utils/package.json index 6fa156f626a..f1c2cdd9b69 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -18,6 +18,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "isomorphic-dompurify": "^2.16.0", + "lodash": "^4.17.21", "react": "^18.3.1", "tailwind-merge": "^2.5.5", "zxcvbn": "^4.4.2" diff --git a/packages/utils/src/array.ts b/packages/utils/src/array.ts new file mode 100644 index 00000000000..12727d3a005 --- /dev/null +++ b/packages/utils/src/array.ts @@ -0,0 +1,197 @@ +import isEmpty from "lodash/isEmpty"; +import { IIssueLabel, IIssueLabelTree } from "@plane/types"; + +/** + * @description Groups an array of objects by a specified key + * @param {any[]} array Array to group + * @param {string} key Key to group by (supports dot notation for nested objects) + * @returns {Object} Grouped object with keys being the grouped values + * @example + * const array = [{type: 'A', value: 1}, {type: 'B', value: 2}, {type: 'A', value: 3}]; + * groupBy(array, 'type') // returns { A: [{type: 'A', value: 1}, {type: 'A', value: 3}], B: [{type: 'B', value: 2}] } + */ +export const groupBy = (array: any[], key: string) => { + const innerKey = key.split("."); // split the key by dot + return array.reduce((result, currentValue) => { + const key = innerKey.reduce((obj, i) => obj?.[i], currentValue) ?? "None"; // get the value of the inner key + (result[key] = result[key] || []).push(currentValue); + return result; + }, {}); +}; + +/** + * @description Orders an array by a specified key in ascending or descending order + * @param {any[]} orgArray Original array to order + * @param {string} key Key to order by (supports dot notation for nested objects) + * @param {"ascending" | "descending"} ordering Sort order + * @returns {any[]} Ordered array + * @example + * const array = [{value: 2}, {value: 1}, {value: 3}]; + * orderArrayBy(array, 'value', 'ascending') // returns [{value: 1}, {value: 2}, {value: 3}] + */ +export const orderArrayBy = (orgArray: any[], key: string, ordering: "ascending" | "descending" = "ascending") => { + if (!orgArray || !Array.isArray(orgArray) || orgArray.length === 0) return []; + + const array = [...orgArray]; + + if (key[0] === "-") { + ordering = "descending"; + key = key.slice(1); + } + + const innerKey = key.split("."); // split the key by dot + + return array.sort((a, b) => { + const keyA = innerKey.reduce((obj, i) => obj[i], a); // get the value of the inner key + const keyB = innerKey.reduce((obj, i) => obj[i], b); // get the value of the inner key + if (keyA < keyB) { + return ordering === "ascending" ? -1 : 1; + } + if (keyA > keyB) { + return ordering === "ascending" ? 1 : -1; + } + return 0; + }); +}; + +/** + * @description Checks if an array contains duplicate values + * @param {any[]} array Array to check for duplicates + * @returns {boolean} True if duplicates exist, false otherwise + * @example + * checkDuplicates([1, 2, 2, 3]) // returns true + * checkDuplicates([1, 2, 3]) // returns false + */ +export const checkDuplicates = (array: any[]) => new Set(array).size !== array.length; + +/** + * @description Finds the string with the most characters in an array of strings + * @param {string[]} strings Array of strings to check + * @returns {string} String with the most characters + * @example + * findStringWithMostCharacters(['a', 'bb', 'ccc']) // returns 'ccc' + */ +export const findStringWithMostCharacters = (strings: string[]): string => { + if (!strings || strings.length === 0) return ""; + + return strings.reduce((longestString, currentString) => + currentString.length > longestString.length ? currentString : longestString + ); +}; + +/** + * @description Checks if two arrays have the same elements regardless of order + * @param {any[] | null} arr1 First array + * @param {any[] | null} arr2 Second array + * @returns {boolean} True if arrays have same elements, false otherwise + * @example + * checkIfArraysHaveSameElements([1, 2], [2, 1]) // returns true + * checkIfArraysHaveSameElements([1, 2], [1, 3]) // returns false + */ +export const checkIfArraysHaveSameElements = (arr1: any[] | null, arr2: any[] | null): boolean => { + if (!arr1 || !arr2) return false; + if (!Array.isArray(arr1) || !Array.isArray(arr2)) return false; + if (arr1.length === 0 && arr2.length === 0) return true; + + return arr1.length === arr2.length && arr1.every((e) => arr2.includes(e)); +}; + + +type GroupedItems = { [key: string]: T[] }; + +/** + * @description Groups an array of objects by a specified field + * @param {T[]} array Array to group + * @param {keyof T} field Field to group by + * @returns {GroupedItems} Grouped object + * @example + * const array = [{type: 'A', value: 1}, {type: 'B', value: 2}]; + * groupByField(array, 'type') // returns { A: [{type: 'A', value: 1}], B: [{type: 'B', value: 2}] } + */ +export const groupByField = (array: T[], field: keyof T): GroupedItems => + array.reduce((grouped: GroupedItems, item: T) => { + const key = String(item[field]); + grouped[key] = (grouped[key] || []).concat(item); + return grouped; + }, {}); + +/** + * @description Sorts an array of objects by a specified field + * @param {any[]} array Array to sort + * @param {string} field Field to sort by + * @returns {any[]} Sorted array + * @example + * const array = [{value: 2}, {value: 1}]; + * sortByField(array, 'value') // returns [{value: 1}, {value: 2}] + */ +export const sortByField = (array: any[], field: string): any[] => + array.sort((a, b) => (a[field] < b[field] ? -1 : a[field] > b[field] ? 1 : 0)); + +/** + * @description Orders grouped data by a specified field + * @param {GroupedItems} groupedData Grouped data object + * @param {keyof T} orderBy Field to order by + * @returns {GroupedItems} Ordered grouped data + */ +export const orderGroupedDataByField = (groupedData: GroupedItems, orderBy: keyof T): GroupedItems => { + for (const key in groupedData) { + if (groupedData.hasOwnProperty(key)) { + groupedData[key] = groupedData[key].sort((a, b) => { + if (a[orderBy] < b[orderBy]) return -1; + if (a[orderBy] > b[orderBy]) return 1; + return 0; + }); + } + } + return groupedData; +}; + +/** + * @description Builds a tree structure from an array of labels + * @param {IIssueLabel[]} array Array of labels + * @param {any} parent Parent ID + * @returns {IIssueLabelTree[]} Tree structure + */ +export const buildTree = (array: IIssueLabel[], parent = null) => { + const tree: IIssueLabelTree[] = []; + + array.forEach((item: any) => { + if (item.parent === parent) { + const children = buildTree(array, item.id); + item.children = children; + tree.push(item); + } + }); + + return tree; +}; + +/** + * @description Returns valid keys from object whose value is not falsy + * @param {any} obj Object to check + * @returns {string[]} Array of valid keys + * @example + * getValidKeysFromObject({a: 1, b: 0, c: null}) // returns ['a'] + */ +export const getValidKeysFromObject = (obj: any) => { + if (!obj || isEmpty(obj) || typeof obj !== "object" || Array.isArray(obj)) return []; + + return Object.keys(obj).filter((key) => !!obj[key]); +}; + +/** + * @description Converts an array of strings into an object with boolean true values + * @param {string[]} arrayStrings Array of strings + * @returns {Object} Object with string keys and boolean values + * @example + * convertStringArrayToBooleanObject(['a', 'b']) // returns {a: true, b: true} + */ +export const convertStringArrayToBooleanObject = (arrayStrings: string[]) => { + const obj: { [key: string]: boolean } = {}; + + for (const arrayString of arrayStrings) { + obj[arrayString] = true; + } + + return obj; +}; diff --git a/packages/utils/src/auth.ts b/packages/utils/src/auth.ts index bea3eb275f5..297b4c9ed17 100644 --- a/packages/utils/src/auth.ts +++ b/packages/utils/src/auth.ts @@ -1,8 +1,71 @@ import { ReactNode } from "react"; import zxcvbn from "zxcvbn"; -import { E_PASSWORD_STRENGTH, PASSWORD_CRITERIA, PASSWORD_MIN_LENGTH } from "@plane/constants"; +import { + E_PASSWORD_STRENGTH, + SPACE_PASSWORD_CRITERIA, + PASSWORD_MIN_LENGTH, + EErrorAlertType, + EAuthErrorCodes, +} from "@plane/constants"; -import { EPageTypes, EErrorAlertType, EAuthErrorCodes } from "@plane/constants"; +/** + * @description Password strength levels + */ +export enum PasswordStrength { + EMPTY = "empty", + WEAK = "weak", + FAIR = "fair", + GOOD = "good", + STRONG = "strong", +} + +/** + * @description Password strength criteria type + */ +export type PasswordCriterion = { + regex: RegExp; + description: string; +}; + +/** + * @description Password strength criteria + */ +export const PASSWORD_CRITERIA: PasswordCriterion[] = [ + { regex: /[a-z]/, description: "lowercase" }, + { regex: /[A-Z]/, description: "uppercase" }, + { regex: /[0-9]/, description: "number" }, + { regex: /[^a-zA-Z0-9]/, description: "special character" }, +]; + +/** + * @description Checks if password meets all criteria + * @param {string} password - Password to check + * @returns {boolean} Whether password meets all criteria + */ +export const checkPasswordCriteria = (password: string): boolean => + PASSWORD_CRITERIA.every((criterion) => criterion.regex.test(password)); + +/** + * @description Checks password strength against criteria + * @param {string} password - Password to check + * @returns {PasswordStrength} Password strength level + * @example + * checkPasswordStrength("abc") // returns PasswordStrength.WEAK + * checkPasswordStrength("Abc123!@#") // returns PasswordStrength.STRONG + */ +export const checkPasswordStrength = (password: string): PasswordStrength => { + if (!password || password.length === 0) return PasswordStrength.EMPTY; + if (password.length < PASSWORD_MIN_LENGTH) return PasswordStrength.WEAK; + + const criteriaCount = PASSWORD_CRITERIA.filter((criterion) => criterion.regex.test(password)).length; + + const zxcvbnScore = zxcvbn(password).score; + + if (criteriaCount <= 1 || zxcvbnScore <= 1) return PasswordStrength.WEAK; + if (criteriaCount === 2 || zxcvbnScore === 2) return PasswordStrength.FAIR; + if (criteriaCount === 3 || zxcvbnScore === 3) return PasswordStrength.GOOD; + return PasswordStrength.STRONG; +}; export type TAuthErrorInfo = { type: EErrorAlertType; @@ -26,9 +89,9 @@ export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => { return passwordStrength; } - const passwordCriteriaValidation = PASSWORD_CRITERIA.map((criteria) => criteria.isCriteriaValid(password)).every( - (criterion) => criterion - ); + const passwordCriteriaValidation = SPACE_PASSWORD_CRITERIA.map((criteria) => + criteria.isCriteriaValid(password) + ).every((criterion) => criterion); const passwordStrengthScore = zxcvbn(password).score; if (passwordCriteriaValidation === false || passwordStrengthScore <= 2) { @@ -76,7 +139,7 @@ const errorCodeMessages: { // sign up [EAuthErrorCodes.USER_ALREADY_EXIST]: { title: `User already exists`, - message: (email = undefined) => `Your account is already registered. Sign in now.`, + message: () => `Your account is already registered. Sign in now.`, }, [EAuthErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP]: { title: `Email and password required`, diff --git a/packages/utils/src/color.ts b/packages/utils/src/color.ts index 702719c7962..77a5c15c539 100644 --- a/packages/utils/src/color.ts +++ b/packages/utils/src/color.ts @@ -8,9 +8,13 @@ export type RGB = { r: number; g: number; b: number }; /** - * Validates and clamps color values to RGB range (0-255) + * @description Validates and clamps color values to RGB range (0-255) * @param {number} value - The color value to validate * @returns {number} Clamped and floored value between 0-255 + * @example + * validateColor(-10) // returns 0 + * validateColor(300) // returns 255 + * validateColor(128) // returns 128 */ export const validateColor = (value: number) => { if (value < 0) return 0; diff --git a/packages/utils/src/datetime.ts b/packages/utils/src/datetime.ts index d558d1661b9..0a12a227084 100644 --- a/packages/utils/src/datetime.ts +++ b/packages/utils/src/datetime.ts @@ -1,4 +1,4 @@ -import { format, isValid } from "date-fns"; +import { differenceInDays, format, formatDistanceToNow, isAfter, isEqual, isValid, parseISO } from "date-fns"; /** * This method returns a date from string of type yyyy-mm-dd @@ -31,16 +31,305 @@ export const getDate = (date: string | Date | undefined | null): Date | undefine * @param {Date | string} date * @example renderFormattedDate("2024-01-01") // Jan 01, 2024 */ -export const renderFormattedDate = (date: string | Date | undefined | null): string | null => { +/** + * @description Returns date in the formatted format + * @param {Date | string} date Date to format + * @param {string} formatToken Format token (optional, default: MMM dd, yyyy) + * @returns {string | undefined} Formatted date in the desired format + * @example + * renderFormattedDate("2024-01-01") // returns "Jan 01, 2024" + * renderFormattedDate("2024-01-01", "MM-DD-YYYY") // returns "01-01-2024" + */ +export const renderFormattedDate = ( + date: string | Date | undefined | null, + formatToken: string = "MMM dd, yyyy" +): string | undefined => { // Parse the date to check if it is valid const parsedDate = getDate(date); // return if undefined - if (!parsedDate) return null; + if (!parsedDate) return; // Check if the parsed date is valid before formatting - if (!isValid(parsedDate)) return null; // Return null for invalid dates - // Format the date in format (MMM dd, yyyy) - const formattedDate = format(parsedDate, "MMM dd, yyyy"); + if (!isValid(parsedDate)) return; // Return undefined for invalid dates + let formattedDate; + try { + // Format the date in the format provided or default format (MMM dd, yyyy) + formattedDate = format(parsedDate, formatToken); + } catch (e) { + // Format the date in format (MMM dd, yyyy) in case of any error + formattedDate = format(parsedDate, "MMM dd, yyyy"); + } return formattedDate; }; -// Note: timeAgo function was incomplete in the original file, so it has been omitted +/** + * @description Returns total number of days in range + * @param {string | Date} startDate - Start date + * @param {string | Date} endDate - End date + * @param {boolean} inclusive - Include start and end dates (optional, default: true) + * @returns {number | undefined} Total number of days + * @example + * findTotalDaysInRange("2024-01-01", "2024-01-08") // returns 8 + */ +export const findTotalDaysInRange = ( + startDate: Date | string | undefined | null, + endDate: Date | string | undefined | null, + inclusive: boolean = true +): number | undefined => { + // Parse the dates to check if they are valid + const parsedStartDate = getDate(startDate); + const parsedEndDate = getDate(endDate); + // return if undefined + if (!parsedStartDate || !parsedEndDate) return; + // Check if the parsed dates are valid before calculating the difference + if (!isValid(parsedStartDate) || !isValid(parsedEndDate)) return 0; // Return 0 for invalid dates + // Calculate the difference in days + const diffInDays = differenceInDays(parsedEndDate, parsedStartDate); + // Return the difference in days based on inclusive flag + return inclusive ? diffInDays + 1 : diffInDays; +}; + +/** + * @description Add number of days to the provided date + * @param {string | Date} startDate - Start date + * @param {number} numberOfDays - Number of days to add + * @returns {Date | undefined} Resulting date + * @example + * addDaysToDate("2024-01-01", 7) // returns Date(2024-01-08) + */ +export const addDaysToDate = (startDate: Date | string | undefined | null, numberOfDays: number): Date | undefined => { + // Parse the dates to check if they are valid + const parsedStartDate = getDate(startDate); + // return if undefined + if (!parsedStartDate) return; + const newDate = new Date(parsedStartDate); + newDate.setDate(newDate.getDate() + numberOfDays); + return newDate; +}; + +/** + * @description Returns number of days left from today + * @param {string | Date} date - Target date + * @param {boolean} inclusive - Include today (optional, default: true) + * @returns {number | undefined} Number of days left + * @example + * findHowManyDaysLeft("2024-01-08") // returns days between today and Jan 8, 2024 + */ +export const findHowManyDaysLeft = ( + date: Date | string | undefined | null, + inclusive: boolean = true +): number | undefined => { + if (!date) return undefined; + return findTotalDaysInRange(new Date(), date, inclusive); +}; + +/** + * @description Returns time passed since the event happened + * @param {string | number | Date} time - Time to calculate from + * @returns {string} Formatted time ago string + * @example + * calculateTimeAgo("2023-01-01") // returns "1 year ago" + */ +export const calculateTimeAgo = (time: string | number | Date | null): string => { + if (!time) return ""; + const parsedTime = typeof time === "string" || typeof time === "number" ? parseISO(String(time)) : time; + if (!parsedTime) return ""; + const distance = formatDistanceToNow(parsedTime, { addSuffix: true }); + return distance; +}; + +/** + * @description Returns short form of time passed (e.g., 1y, 2mo, 3d) + * @param {string | number | Date} date - Date to calculate from + * @returns {string} Short form time ago + * @example + * calculateTimeAgoShort("2023-01-01") // returns "1y" + */ +export const calculateTimeAgoShort = (date: string | number | Date | null): string => { + if (!date) return ""; + + const parsedDate = typeof date === "string" ? parseISO(date) : new Date(date); + const now = new Date(); + const diffInSeconds = (now.getTime() - parsedDate.getTime()) / 1000; + + if (diffInSeconds < 60) return `${Math.floor(diffInSeconds)}s`; + const diffInMinutes = diffInSeconds / 60; + if (diffInMinutes < 60) return `${Math.floor(diffInMinutes)}m`; + const diffInHours = diffInMinutes / 60; + if (diffInHours < 24) return `${Math.floor(diffInHours)}h`; + const diffInDays = diffInHours / 24; + if (diffInDays < 30) return `${Math.floor(diffInDays)}d`; + const diffInMonths = diffInDays / 30; + if (diffInMonths < 12) return `${Math.floor(diffInMonths)}mo`; + const diffInYears = diffInMonths / 12; + return `${Math.floor(diffInYears)}y`; +}; + +/** + * @description Checks if a date is greater than today + * @param {string} dateStr - Date string to check + * @returns {boolean} True if date is greater than today + * @example + * isDateGreaterThanToday("2024-12-31") // returns true + */ +export const isDateGreaterThanToday = (dateStr: string): boolean => { + if (!dateStr) return false; + const date = parseISO(dateStr); + const today = new Date(); + if (!isValid(date)) return false; + return isAfter(date, today); +}; + +/** + * @description Returns week number of date + * @param {Date} date - Date to get week number from + * @returns {number} Week number (1-52) + * @example + * getWeekNumberOfDate(new Date("2023-09-01")) // returns 35 + */ +export const getWeekNumberOfDate = (date: Date): number => { + const currentDate = date; + const startDate = new Date(currentDate.getFullYear(), 0, 1); + const days = Math.floor((currentDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000)); + const weekNumber = Math.ceil((days + 1) / 7); + return weekNumber; +}; + +/** + * @description Checks if two dates are equal + * @param {Date | string} date1 - First date + * @param {Date | string} date2 - Second date + * @returns {boolean} True if dates are equal + * @example + * checkIfDatesAreEqual("2024-01-01", "2024-01-01") // returns true + */ +export const checkIfDatesAreEqual = ( + date1: Date | string | null | undefined, + date2: Date | string | null | undefined +): boolean => { + const parsedDate1 = getDate(date1); + const parsedDate2 = getDate(date2); + if (!parsedDate1 && !parsedDate2) return true; + if (!parsedDate1 || !parsedDate2) return false; + return isEqual(parsedDate1, parsedDate2); +}; + +/** + * @description Checks if a string matches date format YYYY-MM-DD + * @param {string} date - Date string to check + * @returns {boolean} True if string matches date format + * @example + * isInDateFormat("2024-01-01") // returns true + */ +export const isInDateFormat = (date: string): boolean => { + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + return datePattern.test(date); +}; + +/** + * @description Converts date string to ISO format + * @param {string} dateString - Date string to convert + * @returns {string | undefined} ISO date string + * @example + * convertToISODateString("2024-01-01") // returns "2024-01-01T00:00:00.000Z" + */ +export const convertToISODateString = (dateString: string | undefined): string | undefined => { + if (!dateString) return dateString; + const date = new Date(dateString); + return date.toISOString(); +}; + +/** + * @description Converts date string to epoch timestamp + * @param {string} dateString - Date string to convert + * @returns {number | undefined} Epoch timestamp + * @example + * convertToEpoch("2024-01-01") // returns 1704067200000 + */ +export const convertToEpoch = (dateString: string | undefined): number | undefined => { + if (!dateString) return undefined; + const date = new Date(dateString); + return date.getTime(); +}; + +/** + * @description Gets current date time in ISO format + * @returns {string} Current date time in ISO format + * @example + * getCurrentDateTimeInISO() // returns "2024-01-01T12:00:00.000Z" + */ +export const getCurrentDateTimeInISO = (): string => { + const date = new Date(); + return date.toISOString(); +}; + +/** + * @description Converts hours and minutes to total minutes + * @param {number} hours - Number of hours + * @param {number} minutes - Number of minutes + * @returns {number} Total minutes + * @example + * convertHoursMinutesToMinutes(2, 30) // returns 150 + */ +export const convertHoursMinutesToMinutes = (hours: number, minutes: number): number => hours * 60 + minutes; + +/** + * @description Converts total minutes to hours and minutes + * @param {number} mins - Total minutes + * @returns {{ hours: number; minutes: number }} Hours and minutes + * @example + * convertMinutesToHoursAndMinutes(150) // returns { hours: 2, minutes: 30 } + */ +export const convertMinutesToHoursAndMinutes = (mins: number): { hours: number; minutes: number } => { + const hours = Math.floor(mins / 60); + const minutes = Math.floor(mins % 60); + return { hours, minutes }; +}; + +/** + * @description Converts minutes to hours and minutes string + * @param {number} totalMinutes - Total minutes + * @returns {string} Formatted string (e.g., "2h 30m") + * @example + * convertMinutesToHoursMinutesString(150) // returns "2h 30m" + */ +export const convertMinutesToHoursMinutesString = (totalMinutes: number): string => { + const { hours, minutes } = convertMinutesToHoursAndMinutes(totalMinutes); + return `${hours ? `${hours}h ` : ``}${minutes ? `${minutes}m ` : ``}`; +}; + +/** + * @description Calculates read time in seconds from word count + * @param {number} wordsCount - Number of words + * @returns {number} Read time in seconds + * @example + * getReadTimeFromWordsCount(400) // returns 120 + */ +export const getReadTimeFromWordsCount = (wordsCount: number): number => { + const wordsPerMinute = 200; + const minutes = wordsCount / wordsPerMinute; + return minutes * 60; +}; + +/** + * @description Generates array of dates between start and end dates + * @param {string | Date} startDate - Start date + * @param {string | Date} endDate - End date + * @returns {Array<{ date: string }>} Array of dates + * @example + * generateDateArray("2024-01-01", "2024-01-03") + * // returns [{ date: "2024-01-02" }, { date: "2024-01-03" }] + */ +export const generateDateArray = (startDate: string | Date, endDate: string | Date): Array<{ date: string }> => { + const start = new Date(startDate); + const end = new Date(endDate); + end.setDate(end.getDate() + 1); + + const dateArray = []; + while (start <= end) { + start.setDate(start.getDate() + 1); + dateArray.push({ + date: new Date(start).toISOString().split("T")[0], + }); + } + return dateArray; +}; diff --git a/packages/utils/src/editor.ts b/packages/utils/src/editor.ts deleted file mode 100644 index 809c1dd3d2a..00000000000 --- a/packages/utils/src/editor.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { MAX_FILE_SIZE } from "@plane/constants"; -import { getFileURL } from "./file"; - -// Define image-related types locally -type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; -type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise; -type UploadImage = (file: File) => Promise; - -// Define the FileService interface based on usage -interface IFileService { - deleteOldEditorAsset: (workspaceId: string, src: string) => Promise; - deleteNewAsset: (url: string) => Promise; - restoreOldEditorAsset: (workspaceId: string, src: string) => Promise; - restoreNewAsset: (anchor: string, src: string) => Promise; - cancelUpload: () => void; -} - -// Define TFileHandler locally since we can't import from @plane/editor -interface TFileHandler { - getAssetSrc: (path: string) => Promise; - cancel: () => void; - delete: DeleteImage; - upload: UploadImage; - restore: RestoreImage; - validation: { - maxFileSize: number; - }; -} - -/** - * @description generate the file source using assetId - * @param {string} anchor - * @param {string} assetId - */ -export const getEditorAssetSrc = (anchor: string, assetId: string): string | undefined => { - const url = getFileURL(`/api/public/assets/v2/anchor/${anchor}/${assetId}/`); - return url; -}; - -type TArgs = { - anchor: string; - uploadFile: (file: File) => Promise; - workspaceId: string; - fileService: IFileService; -}; - -/** - * @description this function returns the file handler required by the editors - * @param {TArgs} args - */ -export const getEditorFileHandlers = (args: TArgs): TFileHandler => { - const { anchor, uploadFile, workspaceId, fileService } = args; - - return { - getAssetSrc: async (path: string) => { - if (!path) return ""; - if (path?.startsWith("http")) { - return path; - } else { - return getEditorAssetSrc(anchor, path) ?? ""; - } - }, - upload: uploadFile, - delete: async (src: string) => { - if (src?.startsWith("http")) { - await fileService.deleteOldEditorAsset(workspaceId, src); - } else { - await fileService.deleteNewAsset(getEditorAssetSrc(anchor, src) ?? ""); - } - }, - restore: async (src: string) => { - if (src?.startsWith("http")) { - await fileService.restoreOldEditorAsset(workspaceId, src); - } else { - await fileService.restoreNewAsset(anchor, src); - } - }, - cancel: fileService.cancelUpload, - validation: { - maxFileSize: MAX_FILE_SIZE, - }, - }; -}; - -/** - * @description this function returns the file handler required by the read-only editors - */ -export const getReadOnlyEditorFileHandlers = ( - args: Pick -): { getAssetSrc: TFileHandler["getAssetSrc"] } => { - const { anchor } = args; - - return { - getAssetSrc: async (path: string) => { - if (!path) return ""; - if (path?.startsWith("http")) { - return path; - } else { - return getEditorAssetSrc(anchor, path) ?? ""; - } - }, - }; -}; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 597fb5db950..510155f6a1e 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,8 +1,9 @@ +export * from "./array"; export * from "./auth"; +export * from "./datetime"; export * from "./color"; export * from "./common"; export * from "./datetime"; -export * from "./editor"; export * from "./emoji"; export * from "./file"; export * from "./issue"; diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index 7b2ffa858af..2fc52a254ef 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -1,9 +1,182 @@ import DOMPurify from "isomorphic-dompurify"; -export const addSpaceIfCamelCase = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2"); +/** + * @description Adds space between camelCase words + * @param {string} str - String to add spaces to + * @returns {string} String with spaces between camelCase words + * @example + * addSpaceIfCamelCase("camelCase") // returns "camel Case" + * addSpaceIfCamelCase("thisIsATest") // returns "this Is A Test" + */ +export const addSpaceIfCamelCase = (str: string) => { + if (str === undefined || str === null) return ""; + + if (typeof str !== "string") str = `${str}`; + + return str.replace(/([a-z])([A-Z])/g, "$1 $2"); +}; +/** + * @description Replaces underscores with spaces in snake_case strings + * @param {string} str - String to replace underscores in + * @returns {string} String with underscores replaced by spaces + * @example + * replaceUnderscoreIfSnakeCase("snake_case") // returns "snake case" + */ export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " "); +/** + * @description Truncates text to specified length and adds ellipsis + * @param {string} str - String to truncate + * @param {number} length - Maximum length before truncation + * @returns {string} Truncated string with ellipsis if needed + * @example + * truncateText("This is a long text", 7) // returns "This is..." + */ +export const truncateText = (str: string, length: number) => { + if (!str || str === "") return ""; + + return str.length > length ? `${str.substring(0, length)}...` : str; +}; + +/** + * @description Creates a similar string by randomly shuffling characters + * @param {string} str - String to shuffle + * @returns {string} Shuffled string with same characters + * @example + * createSimilarString("hello") // might return "olleh" or "lehol" + */ +export const createSimilarString = (str: string) => { + const shuffled = str + .split("") + .sort(() => Math.random() - 0.5) + .join(""); + + return shuffled; +}; + +/** + * @description Copies full URL (origin + path) to clipboard + * @param {string} path - URL path to copy + * @returns {Promise} Promise that resolves when copying is complete + * @example + * await copyUrlToClipboard("issues/123") // copies "https://example.com/issues/123" + */ +/** + * @description Copies text to clipboard + * @param {string} text - Text to copy + * @returns {Promise} Promise that resolves when copying is complete + * @example + * await copyTextToClipboard("Hello, World!") // copies "Hello, World!" to clipboard + */ +export const copyTextToClipboard = async (text: string): Promise => { + if (typeof navigator === "undefined") return; + try { + await navigator.clipboard.writeText(text); + } catch (err) { + console.error("Failed to copy text: ", err); + } +}; + +/** + * @description Copies full URL (origin + path) to clipboard + * @param {string} path - URL path to copy + * @returns {Promise} Promise that resolves when copying is complete + * @example + * await copyUrlToClipboard("issues/123") // copies "https://example.com/issues/123" + */ +export const copyUrlToClipboard = async (path: string) => { + const originUrl = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + await copyTextToClipboard(`${originUrl}/${path}`); +}; + +/** + * @description Generates a deterministic HSL color based on input string + * @param {string} string - Input string to generate color from + * @returns {string} HSL color string + * @example + * generateRandomColor("hello") // returns consistent HSL color for "hello" + * generateRandomColor("") // returns "rgb(var(--color-primary-100))" + */ +export const generateRandomColor = (string: string): string => { + if (!string) return "rgb(var(--color-primary-100))"; + + string = `${string}`; + + const uniqueId = string.length.toString() + string; + const combinedString = uniqueId + string; + + const hash = Array.from(combinedString).reduce((acc, char) => { + const charCode = char.charCodeAt(0); + return (acc << 5) - acc + charCode; + }, 0); + + const hue = hash % 360; + const saturation = 70; + const lightness = 60; + + const randomColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`; + + return randomColor; +}; + +/** + * @description Gets first character of first word or first characters of first two words + * @param {string} str - Input string + * @returns {string} First character(s) + * @example + * getFirstCharacters("John") // returns "J" + * getFirstCharacters("John Doe") // returns "JD" + */ +export const getFirstCharacters = (str: string) => { + const words = str.trim().split(" "); + if (words.length === 1) { + return words[0].charAt(0); + } else { + return words[0].charAt(0) + words[1].charAt(0); + } +}; + +/** + * @description Formats number count, showing "99+" for numbers over 99 + * @param {number} number - Number to format + * @returns {string} Formatted number string + * @example + * getNumberCount(50) // returns "50" + * getNumberCount(100) // returns "99+" + */ +export const getNumberCount = (number: number): string => { + if (number > 99) { + return "99+"; + } + return number.toString(); +}; + +/** + * @description Converts object to URL query parameters string + * @param {Object} obj - Object to convert + * @returns {string} URL query parameters string + * @example + * objToQueryParams({ page: 1, search: "test" }) // returns "page=1&search=test" + * objToQueryParams({ a: null, b: "test" }) // returns "b=test" + */ +export const objToQueryParams = (obj: any) => { + const params = new URLSearchParams(); + + if (!obj) return params.toString(); + + for (const [key, value] of Object.entries(obj)) { + if (value !== undefined && value !== null) params.append(key, value as string); + } + + return params.toString(); +}; + +/** + * @description: This function will capitalize the first letter of a string + * @param str String + * @returns String + */ export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); /** @@ -15,9 +188,30 @@ export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase( * const text = stripHTML(html); * console.log(text); // Some text */ +/** + * @description Sanitizes HTML string by removing tags and properly escaping entities + * @param {string} htmlString - HTML string to sanitize + * @returns {string} Sanitized string with escaped HTML entities + * @example + * sanitizeHTML("

Hello & 'world'

") // returns "Hello & 'world'" + */ export const sanitizeHTML = (htmlString: string) => { - const sanitizedText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: [] }); // sanitize the string to remove all HTML tags - return sanitizedText.trim(); // trim the string to remove leading and trailing whitespaces + if (!htmlString) return ""; + + // First use DOMPurify to remove all HTML tags while preserving text content + const sanitizedText = DOMPurify.sanitize(htmlString, { + ALLOWED_TAGS: [], + ALLOWED_ATTR: [], + USE_PROFILES: { + html: false, + svg: false, + svgFilters: false, + mathMl: false, + }, + }); + + // Additional escaping for quotes and apostrophes + return sanitizedText.trim().replace(/'/g, "'").replace(/"/g, """); }; /** @@ -86,42 +280,42 @@ export const checkURLValidity = (url: string): boolean => { }; // Browser-only clipboard functions -let copyTextToClipboard: (text: string) => Promise; - -if (typeof window !== "undefined") { - const fallbackCopyTextToClipboard = (text: string) => { - const textArea = document.createElement("textarea"); - textArea.value = text; - - // Avoid scrolling to bottom - textArea.style.top = "0"; - textArea.style.left = "0"; - textArea.style.position = "fixed"; - - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - - try { - // FIXME: Even though we are using this as a fallback, execCommand is deprecated 👎. We should find a better way to do this. - // https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand - document.execCommand("copy"); - } catch (err) {} - - document.body.removeChild(textArea); - }; - - copyTextToClipboard = async (text: string) => { - if (!navigator.clipboard) { - fallbackCopyTextToClipboard(text); - return; - } - await navigator.clipboard.writeText(text); - }; -} else { - copyTextToClipboard = async () => { - throw new Error("copyTextToClipboard is only available in browser environments"); - }; -} - -export { copyTextToClipboard }; +// let copyTextToClipboard: (text: string) => Promise; + +// if (typeof window !== "undefined") { +// const fallbackCopyTextToClipboard = (text: string) => { +// const textArea = document.createElement("textarea"); +// textArea.value = text; + +// // Avoid scrolling to bottom +// textArea.style.top = "0"; +// textArea.style.left = "0"; +// textArea.style.position = "fixed"; + +// document.body.appendChild(textArea); +// textArea.focus(); +// textArea.select(); + +// try { +// // FIXME: Even though we are using this as a fallback, execCommand is deprecated 👎. We should find a better way to do this. +// // https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand +// document.execCommand("copy"); +// } catch (err) {} + +// document.body.removeChild(textArea); +// }; + +// copyTextToClipboard = async (text: string) => { +// if (!navigator.clipboard) { +// fallbackCopyTextToClipboard(text); +// return; +// } +// await navigator.clipboard.writeText(text); +// }; +// } else { +// copyTextToClipboard = async () => { +// throw new Error("copyTextToClipboard is only available in browser environments"); +// }; +// } + +// export { copyTextToClipboard }; diff --git a/space/Dockerfile.dev b/space/Dockerfile.dev index 213f3fb3c08..b7e42dab84e 100644 --- a/space/Dockerfile.dev +++ b/space/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM node:18-alpine +FROM node:20-alpine RUN apk add --no-cache libc6-compat # Set working directory WORKDIR /app diff --git a/space/Dockerfile.space b/space/Dockerfile.space index 4e53cfc8a0a..ecb3fbec78d 100644 --- a/space/Dockerfile.space +++ b/space/Dockerfile.space @@ -1,7 +1,8 @@ +FROM node:20-alpine as base # ***************************************************************************** # STAGE 1: Build the project # ***************************************************************************** -FROM node:18-alpine AS builder +FROM base AS builder RUN apk add --no-cache libc6-compat WORKDIR /app @@ -13,7 +14,7 @@ RUN turbo prune --scope=space --docker # ***************************************************************************** # STAGE 2: Install dependencies & build the project # ***************************************************************************** -FROM node:18-alpine AS installer +FROM base AS installer RUN apk add --no-cache libc6-compat WORKDIR /app @@ -49,7 +50,7 @@ RUN yarn turbo run build --filter=space # ***************************************************************************** # STAGE 3: Copy the project and start it # ***************************************************************************** -FROM node:18-alpine AS runner +FROM base AS runner WORKDIR /app COPY --from=installer /app/space/next.config.js . diff --git a/space/core/store/profile.store.ts b/space/core/store/profile.store.ts index d0332805890..5e001a875e8 100644 --- a/space/core/store/profile.store.ts +++ b/space/core/store/profile.store.ts @@ -54,6 +54,7 @@ export class ProfileStore implements IProfileStore { has_billing_address: false, created_at: "", updated_at: "", + language: "", }; // services diff --git a/space/package.json b/space/package.json index 2468e2e21ab..e8109d35bb9 100644 --- a/space/package.json +++ b/space/package.json @@ -23,7 +23,7 @@ "@plane/types": "*", "@plane/ui": "*", "@sentry/nextjs": "^8.32.0", - "axios": "^1.7.4", + "axios": "^1.7.9", "clsx": "^2.0.0", "date-fns": "^4.1.0", "dompurify": "^3.0.11", diff --git a/web/Dockerfile.dev b/web/Dockerfile.dev index 5fa751338d9..64465755ee7 100644 --- a/web/Dockerfile.dev +++ b/web/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM node:18-alpine +FROM node:20-alpine RUN apk add --no-cache libc6-compat # Set working directory WORKDIR /app diff --git a/web/Dockerfile.web b/web/Dockerfile.web index d7d924d7a47..56f931adc17 100644 --- a/web/Dockerfile.web +++ b/web/Dockerfile.web @@ -1,7 +1,9 @@ +FROM node:20-alpine as base + # ***************************************************************************** # STAGE 1: Build the project # ***************************************************************************** -FROM node:18-alpine AS builder +FROM base AS builder RUN apk add --no-cache libc6-compat # Set working directory WORKDIR /app @@ -15,7 +17,7 @@ RUN turbo prune --scope=web --docker # STAGE 2: Install dependencies & build the project # ***************************************************************************** # Add lockfile and package.json's of isolated subworkspace -FROM node:18-alpine AS installer +FROM base AS installer RUN apk add --no-cache libc6-compat WORKDIR /app @@ -62,7 +64,7 @@ RUN yarn turbo run build --filter=web # ***************************************************************************** # STAGE 3: Copy the project and start it # ***************************************************************************** -FROM node:18-alpine AS runner +FROM base AS runner WORKDIR /app COPY --from=installer /app/web/next.config.js . diff --git a/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx b/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx index 4edf41bbdba..6416aee125c 100644 --- a/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx @@ -1,6 +1,6 @@ "use client"; - import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; // ui import { Breadcrumbs, ContrastIcon, Header } from "@plane/ui"; // components @@ -8,15 +8,17 @@ import { BreadcrumbLink } from "@/components/common"; // plane web components import { UpgradeBadge } from "@/plane-web/components/workspace"; -export const WorkspaceActiveCycleHeader = observer(() => ( -
- - +export const WorkspaceActiveCycleHeader = observer(() => { + const { t } = useTranslation(); + return ( +
+ + } /> } @@ -25,4 +27,5 @@ export const WorkspaceActiveCycleHeader = observer(() => (
-)); + ); +}); diff --git a/web/app/[workspaceSlug]/(projects)/analytics/header.tsx b/web/app/[workspaceSlug]/(projects)/analytics/header.tsx index 4aa66e2a433..fe55f8cbdc9 100644 --- a/web/app/[workspaceSlug]/(projects)/analytics/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/analytics/header.tsx @@ -3,8 +3,8 @@ import { useEffect } from "react"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; -// icons import { BarChart2, PanelRight } from "lucide-react"; +import { useTranslation } from "@plane/i18n"; // ui import { Breadcrumbs, Header } from "@plane/ui"; // components @@ -13,8 +13,8 @@ import { BreadcrumbLink } from "@/components/common"; import { cn } from "@/helpers/common.helper"; // hooks import { useAppTheme } from "@/hooks/store"; - export const WorkspaceAnalyticsHeader = observer(() => { + const { t } = useTranslation(); const searchParams = useSearchParams(); const analytics_tab = searchParams.get("analytics_tab"); // store hooks @@ -41,7 +41,7 @@ export const WorkspaceAnalyticsHeader = observer(() => { } />} + link={} />} /> {analytics_tab === "custom" ? ( diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx index f31ff959dce..d6743e8f2ba 100644 --- a/web/app/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx @@ -4,6 +4,7 @@ import { observer } from "mobx-react"; import { useParams, usePathname } from "next/navigation"; import useSWR from "swr"; // components +import { useTranslation } from "@plane/i18n"; import { AppHeader, ContentWrapper } from "@/components/core"; import { ProfileSidebar } from "@/components/profile"; // constants @@ -32,6 +33,7 @@ const UseProfileLayout: React.FC = observer((props) => { const pathname = usePathname(); // store hooks const { allowPermissions } = useUserPermissions(); + const { t } = useTranslation(); // derived values const isAuthorized = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], @@ -79,7 +81,7 @@ const UseProfileLayout: React.FC = observer((props) => {
{children}
) : (
- You do not have the permission to access this page. + {t("you_do_not_have_the_permission_to_access_this_page")}
)}
diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx index 4acb93217f6..e002f8f6641 100644 --- a/web/app/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx @@ -2,6 +2,7 @@ import React from "react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; +import { useTranslation } from "@plane/i18n"; // components // constants @@ -14,7 +15,7 @@ type Props = { export const ProfileNavbar: React.FC = (props) => { const { isAuthorized } = props; - + const { t } = useTranslation(); const { workspaceSlug, userId } = useParams(); const pathname = usePathname(); @@ -32,7 +33,7 @@ export const ProfileNavbar: React.FC = (props) => { : "border-transparent" }`} > - {tab.label} + {t(tab.label)} ))} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx index 17df19b2201..76167703af9 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx @@ -7,13 +7,15 @@ import { EIssuesStoreType } from "@plane/constants"; // ui import { ArchiveIcon, Breadcrumbs, Tooltip, Header } from "@plane/ui"; // components -import { BreadcrumbLink, Logo } from "@/components/common"; +import { BreadcrumbLink } from "@/components/common"; // constants import { PROJECT_ARCHIVES_BREADCRUMB_LIST } from "@/constants/archives"; // hooks import { useIssues, useProject } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web +import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; type TProps = { activeTab: "issues" | "cycles" | "modules"; @@ -28,7 +30,7 @@ export const ProjectArchivesHeader: FC = observer((props: TProps) => { const { issues: { getGroupIssueCount }, } = useIssues(EIssuesStoreType.ARCHIVED); - const { currentProjectDetails, loader } = useProject(); + const { loader } = useProject(); // hooks const { isMobile } = usePlatformOS(); @@ -42,21 +44,7 @@ export const ProjectArchivesHeader: FC = observer((props: TProps) => {
- - - - ) - } - /> - } - /> + {
- - - - ) - } - /> - } - /> + = ({ cycleId }) => { @@ -167,16 +169,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { link={ - - - - ) - } - /> + { // router const router = useAppRouter(); - const { workspaceSlug } = useParams(); // store hooks const { toggleCreateCycleModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); @@ -33,21 +33,7 @@ export const CyclesListHeader: FC = observer(() => {
- - - - ) - } - /> - } - /> + } />} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx index 59c5e995bf5..3c996a8d14a 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx @@ -10,7 +10,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption // ui import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui"; // components -import { BreadcrumbLink, Logo } from "@/components/common"; +import { BreadcrumbLink } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; // constants import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; @@ -19,6 +19,8 @@ import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web +import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; export const ProjectDraftIssueHeader: FC = observer(() => { // router @@ -92,21 +94,7 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
- - - - ) - } - /> - } - /> + { // router @@ -28,21 +30,7 @@ export const ProjectIssueDetailsHeader = observer(() => {
- - - - ) - } - /> - } - /> + = ({ moduleId }) => { @@ -166,16 +168,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => { link={ - - - - ) - } - /> + { // router const router = useAppRouter(); - const { workspaceSlug } = useParams(); // store hooks const { toggleCreateModuleModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); const { allowPermissions } = useUserPermissions(); - const { currentProjectDetails, loader } = useProject(); + const { loader } = useProject(); // auth const canUserCreateModule = allowPermissions( @@ -35,21 +35,7 @@ export const ModulesListHeader: React.FC = observer(() => {
- - - - ) - } - /> - } - /> + } />} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx index 4d3f395ea00..8d7c5135725 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -1,29 +1,58 @@ "use client"; +import { useCallback, useMemo } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams } from "next/navigation"; import useSWR from "swr"; -// ui +// plane types +import { TSearchEntityRequestPayload } from "@plane/types"; +import { EFileAssetType } from "@plane/types/src/enums"; +// plane ui import { getButtonStyling } from "@plane/ui"; +// plane utils +import { cn } from "@plane/utils"; // components import { LogoSpinner } from "@/components/common"; import { PageHead } from "@/components/core"; import { IssuePeekOverview } from "@/components/issues"; -import { PageRoot } from "@/components/pages"; +import { PageRoot, TPageRootConfig, TPageRootHandlers } from "@/components/pages"; // helpers -import { cn } from "@/helpers/common.helper"; +import { getEditorFileHandlers } from "@/helpers/editor.helper"; // hooks -import { usePage, useProjectPages } from "@/hooks/store"; +import { useProjectPage, useProjectPages, useWorkspace } from "@/hooks/store"; +// plane web hooks +import { useFileSize } from "@/plane-web/hooks/use-file-size"; +// plane web services +import { WorkspaceService } from "@/plane-web/services"; +// services +import { FileService } from "@/services/file.service"; +import { ProjectPageService, ProjectPageVersionService } from "@/services/page"; +const workspaceService = new WorkspaceService(); +const fileService = new FileService(); +const projectPageService = new ProjectPageService(); +const projectPageVersionService = new ProjectPageVersionService(); const PageDetailsPage = observer(() => { const { workspaceSlug, projectId, pageId } = useParams(); - // store hooks - const { getPageById } = useProjectPages(); - const page = usePage(pageId?.toString() ?? ""); - const { id, name } = page; - + const { createPage, getPageById } = useProjectPages(); + const page = useProjectPage(pageId?.toString() ?? ""); + const { getWorkspaceBySlug } = useWorkspace(); + // derived values + const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : ""; + const { canCurrentUserAccessPage, id, name, updateDescription } = page; + // entity search handler + const fetchEntityCallback = useCallback( + async (payload: TSearchEntityRequestPayload) => + await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", { + ...payload, + project_id: projectId?.toString() ?? "", + }), + [projectId, workspaceSlug] + ); + // file size + const { maxFileSize } = useFileSize(); // fetch page details const { error: pageDetailsError } = useSWR( workspaceSlug && projectId && pageId ? `PAGE_DETAILS_${pageId}` : null, @@ -36,6 +65,62 @@ const PageDetailsPage = observer(() => { revalidateOnReconnect: true, } ); + // page root handlers + const pageRootHandlers: TPageRootHandlers = useMemo( + () => ({ + create: createPage, + fetchAllVersions: async (pageId) => { + if (!workspaceSlug || !projectId) return; + return await projectPageVersionService.fetchAllVersions(workspaceSlug.toString(), projectId.toString(), pageId); + }, + fetchDescriptionBinary: async () => { + if (!workspaceSlug || !projectId || !page.id) return; + return await projectPageService.fetchDescriptionBinary(workspaceSlug.toString(), projectId.toString(), page.id); + }, + fetchEntity: fetchEntityCallback, + fetchVersionDetails: async (pageId, versionId) => { + if (!workspaceSlug || !projectId) return; + return await projectPageVersionService.fetchVersionById( + workspaceSlug.toString(), + projectId.toString(), + pageId, + versionId + ); + }, + getRedirectionLink: (pageId) => `/${workspaceSlug}/projects/${projectId}/pages/${pageId}`, + updateDescription, + }), + [createPage, fetchEntityCallback, page.id, projectId, updateDescription, workspaceSlug] + ); + // page root config + const pageRootConfig: TPageRootConfig = useMemo( + () => ({ + fileHandler: getEditorFileHandlers({ + maxFileSize, + projectId: projectId?.toString() ?? "", + uploadFile: async (file) => { + const { asset_id } = await fileService.uploadProjectAsset( + workspaceSlug?.toString() ?? "", + projectId?.toString() ?? "", + { + entity_identifier: id ?? "", + entity_type: EFileAssetType.PAGE_DESCRIPTION, + }, + file + ); + return asset_id; + }, + workspaceId, + workspaceSlug: workspaceSlug?.toString() ?? "", + }), + webhookConnectionParams: { + documentType: "project_page", + projectId: projectId?.toString() ?? "", + workspaceSlug: workspaceSlug?.toString() ?? "", + }, + }), + [id, maxFileSize, projectId, workspaceId, workspaceSlug] + ); if ((!page || !id) && !pageDetailsError) return ( @@ -44,7 +129,7 @@ const PageDetailsPage = observer(() => {
); - if (pageDetailsError) + if (pageDetailsError || !canCurrentUserAccessPage) return (

Page not found

@@ -65,7 +150,12 @@ const PageDetailsPage = observer(() => {
- +
diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx index 1c3d96b5718..ebb4af3bd1b 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx @@ -15,11 +15,11 @@ import { PageEditInformationPopover } from "@/components/pages"; import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper"; import { getPageName } from "@/helpers/page.helper"; // hooks -import { usePage, useProject, useUser, useUserPermissions } from "@/hooks/store"; +import { useProjectPage, useProject } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web components +import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages"; -import { EUserPermissions, EUserPermissionsLevel } from "ee/constants/user-permissions"; export interface IPagesHeaderProps { showButton?: boolean; @@ -32,18 +32,11 @@ export const PageDetailsHeader = observer(() => { const [isOpen, setIsOpen] = useState(false); // store hooks const { currentProjectDetails, loader } = useProject(); - const page = usePage(pageId?.toString() ?? ""); - const { name, logo_props, updatePageLogo, owned_by } = page; - const { allowPermissions } = useUserPermissions(); - const { data: currentUser } = useUser(); + const page = useProjectPage(pageId?.toString() ?? ""); + const { name, logo_props, updatePageLogo, isContentEditable } = page; // use platform const { isMobile } = usePlatformOS(); - const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); - const isOwner = owned_by === currentUser?.id; - - const isEditable = isAdmin || isOwner; - const handlePageLogoUpdate = async (data: TLogoProps) => { if (data) { updatePageLogo(data) @@ -76,16 +69,7 @@ export const PageDetailsHeader = observer(() => { link={ - - - - ) - } - /> + { ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON } - disabled={!isEditable} + disabled={!isContentEditable} />
@@ -169,7 +153,7 @@ export const PageDetailsHeader = observer(() => {
- +
); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx index d3646b31b8b..41af33a74d1 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx @@ -9,11 +9,13 @@ import { TPage } from "@plane/types"; // plane ui import { Breadcrumbs, Button, Header, setToast, TOAST_TYPE } from "@plane/ui"; // helpers -import { BreadcrumbLink, Logo } from "@/components/common"; +import { BreadcrumbLink } from "@/components/common"; // constants import { EPageAccess } from "@/constants/page"; // hooks import { useEventTracker, useProject, useProjectPages } from "@/hooks/store"; +// plane web +import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; export const PagesListHeader = observer(() => { // states @@ -56,21 +58,7 @@ export const PagesListHeader = observer(() => {
- - - - ) - } - /> - } - /> + } />} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx index 4171e1f332d..93f37ea83b0 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx @@ -51,11 +51,7 @@ const ProjectPagesPage = observer(() => { projectId={projectId.toString()} pageType={currentPageType()} > - + ); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx index 404a11f276c..2dfd24ea051 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx @@ -7,12 +7,12 @@ import { useParams } from "next/navigation"; import { Settings } from "lucide-react"; import { Breadcrumbs, CustomMenu, Header } from "@plane/ui"; // components -import { BreadcrumbLink, Logo } from "@/components/common"; -// constants +import { BreadcrumbLink } from "@/components/common"; // hooks import { useProject, useUserPermissions } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; -// plane web constants +// plane web +import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project"; import { EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; @@ -22,7 +22,7 @@ export const ProjectSettingHeader: FC = observer(() => { const { workspaceSlug, projectId } = useParams(); // store hooks const { allowPermissions } = useUserPermissions(); - const { currentProjectDetails, loader } = useProject(); + const { loader } = useProject(); return (
@@ -30,21 +30,7 @@ export const ProjectSettingHeader: FC = observer(() => {
- - - - ) - } - /> - } - /> +
{ @@ -137,21 +139,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
- - - - ) - } - /> - } - /> + { - // router - const { workspaceSlug } = useParams(); // store hooks const { toggleCreateViewModal } = useCommandPalette(); - const { currentProjectDetails, loader } = useProject(); + const { loader } = useProject(); return ( <>
- - - - ) - } - /> - } - /> + } />} diff --git a/web/app/create-workspace/page.tsx b/web/app/create-workspace/page.tsx index 36bc8978ad2..77b71492f3a 100644 --- a/web/app/create-workspace/page.tsx +++ b/web/app/create-workspace/page.tsx @@ -5,6 +5,7 @@ import { observer } from "mobx-react"; import Image from "next/image"; import Link from "next/link"; import { useTheme } from "next-themes"; +import { useTranslation } from "@plane/i18n"; import { IWorkspace } from "@plane/types"; // components import { Button, getButtonStyling } from "@plane/ui"; @@ -22,6 +23,7 @@ import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue import WorkspaceCreationDisabled from "@/public/workspace/workspace-creation-disabled.png"; const CreateWorkspacePage = observer(() => { + const { t } = useTranslation(); // router const router = useAppRouter(); // store hooks @@ -38,6 +40,17 @@ const CreateWorkspacePage = observer(() => { // derived values const isWorkspaceCreationDisabled = getIsWorkspaceCreationDisabled(); + // methods + const getMailtoHref = () => { + const subject = t("workspace_request_subject"); + const body = t("workspace_request_body") + .replace("{{firstName}}", currentUser?.first_name || "") + .replace("{{lastName}}", currentUser?.last_name || "") + .replace("{{email}}", currentUser?.email || ""); + + return `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; + }; + const onSubmit = async (workspace: IWorkspace) => { await updateUserProfile({ last_workspace_id: workspace.id }).then(() => router.push(`/${workspace.slug}`)); }; @@ -54,7 +67,7 @@ const CreateWorkspacePage = observer(() => { href="/" >
- Plane logo + {t("plane_logo")}
@@ -64,27 +77,30 @@ const CreateWorkspacePage = observer(() => {
{isWorkspaceCreationDisabled ? (
- Workspace creation disabled -
Only your instance admin can create workspaces
-

- If you know your instance admin's email address,
click the button below to get in touch with - them. + {t("workspace_creation_disabled")} +

+ {t("only_your_instance_admin_can_create_workspaces")} +
+

+ {t("only_your_instance_admin_can_create_workspaces_description")}

- - Request instance admin + + {t("request_instance_admin")}
) : (
-

Create your workspace

+

{t("create_your_workspace")}

{ - // Sentry.captureException(error); - // }, [error]); - return ( diff --git a/web/app/invitations/page.tsx b/web/app/invitations/page.tsx index 73ef15b6308..705294430d5 100644 --- a/web/app/invitations/page.tsx +++ b/web/app/invitations/page.tsx @@ -7,8 +7,8 @@ import Link from "next/link"; import { useTheme } from "next-themes"; import useSWR, { mutate } from "swr"; -// icons import { CheckCircle2 } from "lucide-react"; +import { useTranslation } from "@plane/i18n"; // types import type { IWorkspaceMemberInvitation } from "@plane/types"; // ui @@ -45,6 +45,7 @@ const UserInvitationsPage = observer(() => { // router const router = useAppRouter(); // store hooks + const { t } = useTranslation(); const { captureEvent, joinWorkspaceMetricGroup } = useEventTracker(); const { data: currentUser } = useUser(); const { updateUserProfile } = useUserProfile(); @@ -72,8 +73,8 @@ const UserInvitationsPage = observer(() => { if (invitationsRespond.length === 0) { setToast({ type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Please select at least one invitation.", + title: t("error"), + message: t("please_select_at_least_one_invitation"), }); return; } @@ -107,8 +108,8 @@ const UserInvitationsPage = observer(() => { .catch(() => { setToast({ type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Something went wrong, Please try again.", + title: t("error"), + message: t("something_went_wrong_please_try_again"), }); setIsJoiningWorkspaces(false); }); @@ -122,8 +123,8 @@ const UserInvitationsPage = observer(() => { }); setToast({ type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Something went wrong, Please try again.", + title: t("error"), + message: t("something_went_wrong_please_try_again"), }); setIsJoiningWorkspaces(false); }); @@ -152,8 +153,8 @@ const UserInvitationsPage = observer(() => { invitations.length > 0 ? (
-
We see that someone has invited you to
-

Join a workspace

+
{t("we_see_that_someone_has_invited_you_to_join_a_workspace")}
+

{t("join_a_workspace")}

{invitations.map((invitation) => { const isSelected = invitationsRespond.includes(invitation.id); @@ -207,12 +208,12 @@ const UserInvitationsPage = observer(() => { disabled={isJoiningWorkspaces || invitationsRespond.length === 0} loading={isJoiningWorkspaces} > - Accept & Join + {t("accept_and_join")} @@ -222,11 +223,11 @@ const UserInvitationsPage = observer(() => { ) : (
router.push("/"), }} /> diff --git a/web/app/profile/activity/page.tsx b/web/app/profile/activity/page.tsx index afc9b29bf8d..a2a8cad851f 100644 --- a/web/app/profile/activity/page.tsx +++ b/web/app/profile/activity/page.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; // ui import { Button } from "@plane/ui"; // components @@ -18,6 +19,7 @@ import { EmptyStateType } from "@/constants/empty-state"; const PER_PAGE = 100; const ProfileActivityPage = observer(() => { + const { t } = useTranslation(); // states const [pageCount, setPageCount] = useState(1); const [totalPages, setTotalPages] = useState(0); @@ -55,12 +57,12 @@ const ProfileActivityPage = observer(() => { <> - + {activityPages} {isLoadMoreVisible && (
)} diff --git a/web/app/profile/appearance/page.tsx b/web/app/profile/appearance/page.tsx index 775ff637b04..5b1a96c5be1 100644 --- a/web/app/profile/appearance/page.tsx +++ b/web/app/profile/appearance/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { useTheme } from "next-themes"; +import { useTranslation } from "@plane/i18n"; import { IUserTheme } from "@plane/types"; import { setPromiseToast } from "@plane/ui"; // components @@ -15,8 +16,8 @@ import { I_THEME_OPTION, THEME_OPTIONS } from "@/constants/themes"; import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper"; // hooks import { useUserProfile } from "@/hooks/store"; - const ProfileAppearancePage = observer(() => { + const { t } = useTranslation(); const { setTheme } = useTheme(); // states const [currentTheme, setCurrentTheme] = useState(null); @@ -62,11 +63,11 @@ const ProfileAppearancePage = observer(() => { {userProfile ? ( - +
-

Theme

-

Select or customize your interface color scheme.

+

{t("theme")}

+

{t("select_or_customize_your_interface_color_scheme")}

diff --git a/web/app/profile/notifications/page.tsx b/web/app/profile/notifications/page.tsx index b39563378b1..cbdcd147d73 100644 --- a/web/app/profile/notifications/page.tsx +++ b/web/app/profile/notifications/page.tsx @@ -2,6 +2,7 @@ import useSWR from "swr"; // components +import { useTranslation } from "@plane/i18n"; import { PageHead } from "@/components/core"; import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile"; import { EmailNotificationForm } from "@/components/profile/notification"; @@ -12,6 +13,7 @@ import { UserService } from "@/services/user.service"; const userService = new UserService(); export default function ProfileNotificationPage() { + const { t } = useTranslation(); // fetching user email notification settings const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () => userService.currentUserEmailNotificationSettings() @@ -23,11 +25,11 @@ export default function ProfileNotificationPage() { return ( <> - + diff --git a/web/app/profile/page.tsx b/web/app/profile/page.tsx index d451042d9d6..22a0d4ba142 100644 --- a/web/app/profile/page.tsx +++ b/web/app/profile/page.tsx @@ -1,124 +1,18 @@ "use client"; -import React, { useEffect, useState } from "react"; import { observer } from "mobx-react"; -import { Controller, useForm } from "react-hook-form"; -import { ChevronDown, CircleUserRound } from "lucide-react"; -import { Disclosure, Transition } from "@headlessui/react"; -import type { IUser } from "@plane/types"; -import { - Button, - CustomSelect, - CustomSearchSelect, - Input, - TOAST_TYPE, - setPromiseToast, - setToast, - Tooltip, -} from "@plane/ui"; +import { useTranslation } from "@plane/i18n"; // components -import { DeactivateAccountModal } from "@/components/account"; import { LogoSpinner } from "@/components/common"; -import { ImagePickerPopover, UserImageUploadModal, PageHead } from "@/components/core"; -import { TimezoneSelect } from "@/components/global"; -import { ProfileSettingContentWrapper } from "@/components/profile"; -// constants -import { USER_ROLES } from "@/constants/workspace"; -// helpers -import { getFileURL } from "@/helpers/file.helper"; +import { PageHead } from "@/components/core"; +import { ProfileSettingContentWrapper, ProfileForm } from "@/components/profile"; // hooks import { useUser } from "@/hooks/store"; -const defaultValues: Partial = { - avatar_url: "", - cover_image_url: "", - first_name: "", - last_name: "", - display_name: "", - email: "", - role: "Product / Project Manager", - user_timezone: "Asia/Kolkata", -}; - const ProfileSettingsPage = observer(() => { - // states - const [isLoading, setIsLoading] = useState(false); - const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false); - const [deactivateAccountModal, setDeactivateAccountModal] = useState(false); - // form info - const { - handleSubmit, - reset, - watch, - control, - setValue, - formState: { errors }, - } = useForm({ defaultValues }); - // derived values - const userAvatar = watch("avatar_url"); - const userCover = watch("cover_image_url"); + const { t } = useTranslation(); // store hooks - const { data: currentUser, updateCurrentUser } = useUser(); - - useEffect(() => { - reset({ ...defaultValues, ...currentUser }); - }, [currentUser, reset]); - - const onSubmit = async (formData: IUser) => { - setIsLoading(true); - const payload: Partial = { - first_name: formData.first_name, - last_name: formData.last_name, - avatar_url: formData.avatar_url, - role: formData.role, - display_name: formData?.display_name, - user_timezone: formData.user_timezone, - }; - // if unsplash or a pre-defined image is uploaded, delete the old uploaded asset - if (formData.cover_image_url?.startsWith("http")) { - payload.cover_image = formData.cover_image_url; - payload.cover_image_asset = null; - } - - const updateCurrentUserDetail = updateCurrentUser(payload).finally(() => setIsLoading(false)); - setPromiseToast(updateCurrentUserDetail, { - loading: "Updating...", - success: { - title: "Success!", - message: () => `Profile updated successfully.`, - }, - error: { - title: "Error!", - message: () => `There was some error in updating your profile. Please try again.`, - }, - }); - }; - - const handleDelete = async (url: string | null | undefined) => { - if (!url) return; - - await updateCurrentUser({ - avatar_url: "", - }) - .then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Profile picture deleted successfully.", - }); - setValue("avatar_url", ""); - }) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "There was some error in deleting your profile picture. Please try again.", - }); - }) - .finally(() => { - setIsImageUploadModalOpen(false); - }); - }; + const { data: currentUser, userProfile } = useUser(); if (!currentUser) return ( @@ -129,307 +23,9 @@ const ProfileSettingsPage = observer(() => { return ( <> - + - ( - setIsImageUploadModalOpen(false)} - handleRemove={async () => await handleDelete(currentUser?.avatar_url)} - onSuccess={(url) => { - onChange(url); - handleSubmit(onSubmit)(); - setIsImageUploadModalOpen(false); - }} - value={value && value.trim() !== "" ? value : null} - /> - )} - /> - setDeactivateAccountModal(false)} /> -
-
-
- {currentUser?.first_name -
-
-
- -
-
-
-
- ( - onChange(imageUrl)} - control={control} - value={value ?? "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"} - isProfileCover - /> - )} - /> -
-
-
-
-
- {`${watch("first_name")} ${watch("last_name")}`} -
- {watch("email")} -
-
-
-
-
-

- First name* -

- ( - - )} - /> - {errors.first_name && {errors.first_name.message}} -
-
-

Last name

- ( - - )} - /> -
-
-

- Display name* -

- { - if (value.trim().length < 1) return "Display name can't be empty."; - if (value.split(" ").length > 1) return "Display name can't have two consecutive spaces."; - if (value.replace(/\s/g, "").length < 1) - return "Display name must be at least 1 character long."; - if (value.replace(/\s/g, "").length > 20) - return "Display name must be less than 20 characters long."; - return true; - }, - }} - render={({ field: { value, onChange, ref } }) => ( - - )} - /> - {errors?.display_name && ( - {errors?.display_name?.message} - )} -
-
-

- Email* -

- ( - - )} - /> -
-
-

- Role* -

- ( - - {USER_ROLES.map((item) => ( - - {item.label} - - ))} - - )} - /> - {errors.role && Please select a role} -
-
-
-
-
-
-

- Timezone* -

- ( - { - onChange(value); - }} - error={Boolean(errors.user_timezone)} - /> - )} - /> - {errors.user_timezone && {errors.user_timezone.message}} -
- -
-

Language

- {}} - className="rounded-md bg-custom-background-90" - input - disabled - /> -
-
-
-
- -
-
-
-
- - {({ open }) => ( - <> - - Deactivate account - - - - -
- - When deactivating an account, all of the data and resources within that account will be - permanently removed and cannot be recovered. - -
- -
-
-
-
- - )} -
+
); diff --git a/web/app/profile/security/page.tsx b/web/app/profile/security/page.tsx index 594816cc165..48996de34f0 100644 --- a/web/app/profile/security/page.tsx +++ b/web/app/profile/security/page.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; import { Eye, EyeOff } from "lucide-react"; +import { useTranslation } from "@plane/i18n"; // ui import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; // components @@ -55,6 +56,8 @@ const SecurityPage = observer(() => { const oldPassword = watch("old_password"); const password = watch("new_password"); const confirmPassword = watch("confirm_password"); + // i18n + const { t } = useTranslation(); const isNewPasswordSameAsOldPassword = oldPassword !== "" && password !== "" && password === oldPassword; @@ -76,8 +79,8 @@ const SecurityPage = observer(() => { setShowPassword(defaultShowPassword); setToast({ type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Password changed successfully.", + title: t("success"), + message: t("password_changed_successfully"), }); } catch (err: any) { const errorInfo = authErrorHandler(err.error_code?.toString()); @@ -85,7 +88,7 @@ const SecurityPage = observer(() => { type: TOAST_TYPE.ERROR, title: errorInfo?.title ?? "Error!", message: - typeof errorInfo?.message === "string" ? errorInfo.message : "Something went wrong. Please try again 2.", + typeof errorInfo?.message === "string" ? errorInfo.message : t("something_went_wrong_please_try_again"), }); } }; @@ -109,17 +112,17 @@ const SecurityPage = observer(() => { <> - +
-

Current password

+

{t("current_password")}

( { type={showPassword?.oldPassword ? "text" : "password"} value={value} onChange={onChange} - placeholder="Old password" + placeholder={t("old_password")} className="w-full" hasError={Boolean(errors.old_password)} /> @@ -148,20 +151,20 @@ const SecurityPage = observer(() => { {errors.old_password && {errors.old_password.message}}
-

New password

+

{t("new_password")}

( {
{passwordSupport} {isNewPasswordSameAsOldPassword && !isPasswordInputFocused && ( - New password must be different from old password + {t("new_password_must_be_different_from_old_password")} )}
-

Confirm password

+

{t("confirm_password")}

( { )}
{!!confirmPassword && password !== confirmPassword && renderPasswordMatchError && ( - Passwords don{"'"}t match + {t("passwords_dont_match")} )}
diff --git a/web/app/profile/sidebar.tsx b/web/app/profile/sidebar.tsx index 479ef21f515..d3b98642161 100644 --- a/web/app/profile/sidebar.tsx +++ b/web/app/profile/sidebar.tsx @@ -6,9 +6,9 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; // icons import { ChevronLeft, LogOut, MoveLeft, Plus, UserPlus } from "lucide-react"; -// plane helpers +// plane imports import { useOutsideClickDetector } from "@plane/hooks"; -// ui +import { useTranslation } from "@plane/i18n"; import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; // components import { SidebarNavItem } from "@/components/sidebar"; @@ -23,7 +23,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os"; const WORKSPACE_ACTION_LINKS = [ { - key: "create-workspace", + key: "create_workspace", Icon: Plus, label: "Create workspace", href: "/create-workspace", @@ -47,6 +47,7 @@ export const ProfileLayoutSidebar = observer(() => { const { data: currentUserSettings } = useUserSettings(); const { workspaces } = useWorkspace(); const { isMobile } = usePlatformOS(); + const { t } = useTranslation(); const workspacesList = Object.values(workspaces ?? {}); @@ -91,8 +92,8 @@ export const ProfileLayoutSidebar = observer(() => { .catch(() => setToast({ type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Failed to sign out. Please try again.", + title: t("error"), + message: t("failed_to_sign_out_please_try_again"), }) ) .finally(() => setIsSigningOut(false)); @@ -117,13 +118,13 @@ export const ProfileLayoutSidebar = observer(() => { {!sidebarCollapsed && ( -

Profile settings

+

{t("profile_settings")}

)}
{!sidebarCollapsed && ( -
Your account
+
{t("your_account")}
)}
{PROFILE_ACTION_LINKS.map((link) => { @@ -132,7 +133,7 @@ export const ProfileLayoutSidebar = observer(() => { return ( { >
- {!sidebarCollapsed &&

{link.label}

} + {!sidebarCollapsed &&

{t(link.key)}

}
@@ -156,7 +157,7 @@ export const ProfileLayoutSidebar = observer(() => {
{!sidebarCollapsed && ( -
Workspaces
+
{t("workspaces")}
)} {workspacesList && workspacesList.length > 0 && (
{ {WORKSPACE_ACTION_LINKS.map((link) => ( { }`} > {} - {!sidebarCollapsed && link.label} + {!sidebarCollapsed && t(link.key)}
@@ -238,7 +239,7 @@ export const ProfileLayoutSidebar = observer(() => { disabled={isSigningOut} > - {!sidebarCollapsed && {isSigningOut ? "Signing out..." : "Sign out"}} + {!sidebarCollapsed && {isSigningOut ? `${t("signing_out")}...` : t("sign_out")}}
} @@ -77,7 +79,7 @@ const ProjectAttributes: FC = (props) => { onChange(lead === value ? null : lead)} - placeholder="Lead" + placeholder={t("lead")} multiple={false} buttonVariant="border-with-text" tabIndex={5} diff --git a/web/ce/components/projects/create/root.tsx b/web/ce/components/projects/create/root.tsx index c7dabd20e55..8d77d2c0150 100644 --- a/web/ce/components/projects/create/root.tsx +++ b/web/ce/components/projects/create/root.tsx @@ -3,6 +3,7 @@ import { useState, FC } from "react"; import { observer } from "mobx-react"; import { FormProvider, useForm } from "react-hook-form"; +import { useTranslation } from "@plane/i18n"; // ui import { setToast, TOAST_TYPE } from "@plane/ui"; // constants @@ -47,6 +48,7 @@ const defaultValues: Partial = { export const CreateProjectForm: FC = observer((props) => { const { setToFavorite, workspaceSlug, onClose, handleNextStep, updateCoverImageStatus } = props; // store + const { t } = useTranslation(); const { captureProjectEvent } = useEventTracker(); const { addProjectToFavorites, createProject } = useProject(); // states @@ -64,8 +66,8 @@ export const CreateProjectForm: FC = observer((props) = addProjectToFavorites(workspaceSlug.toString(), projectId).catch(() => { setToast({ type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Couldn't remove the project from favorites. Please try again.", + title: t("error"), + message: t("failed_to_remove_project_from_favorites"), }); }); }; @@ -95,8 +97,8 @@ export const CreateProjectForm: FC = observer((props) = }); setToast({ type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Project created successfully.", + title: t("success"), + message: t("project_created_successfully"), }); if (setToFavorite) { handleAddToFavorites(res.id); @@ -107,7 +109,7 @@ export const CreateProjectForm: FC = observer((props) = Object.keys(err.data).map((key) => { setToast({ type: TOAST_TYPE.ERROR, - title: "Error!", + title: t("error"), message: err.data[key], }); captureProjectEvent({ diff --git a/web/ce/components/projects/settings/intake/header.tsx b/web/ce/components/projects/settings/intake/header.tsx index cffd1ba56e2..9ed39733bb5 100644 --- a/web/ce/components/projects/settings/intake/header.tsx +++ b/web/ce/components/projects/settings/intake/header.tsx @@ -7,10 +7,12 @@ import { RefreshCcw } from "lucide-react"; // ui import { Breadcrumbs, Button, Intake, Header } from "@plane/ui"; // components -import { BreadcrumbLink, Logo } from "@/components/common"; +import { BreadcrumbLink } from "@/components/common"; import { InboxIssueCreateModalRoot } from "@/components/inbox"; // hooks import { useProject, useProjectInbox, useUserPermissions } from "@/hooks/store"; +// plane web +import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; export const ProjectInboxHeader: FC = observer(() => { @@ -35,21 +37,7 @@ export const ProjectInboxHeader: FC = observer(() => {
- - - - ) - } - /> - } - /> + { const { isMobile } = usePlatformOS(); + const { t } = useTranslation(); // states const [isPaidPlanPurchaseModalOpen, setIsPaidPlanPurchaseModalOpen] = useState(false); @@ -27,7 +29,7 @@ export const WorkspaceEditionBadge = observer(() => { className="w-fit min-w-24 cursor-pointer rounded-2xl px-2 py-1 text-center text-sm font-medium outline-none" onClick={() => setIsPaidPlanPurchaseModalOpen(true)} > - Upgrade + {t("upgrade")} diff --git a/web/ce/constants/dashboard.ts b/web/ce/constants/dashboard.ts deleted file mode 100644 index 0df2719a772..00000000000 --- a/web/ce/constants/dashboard.ts +++ /dev/null @@ -1,104 +0,0 @@ -"use client"; - -// icons -import { Briefcase, Home, Inbox, Layers, PenSquare, BarChart2 } from "lucide-react"; -// ui -import { UserActivityIcon, ContrastIcon } from "@plane/ui"; -import { Props } from "@/components/icons/types"; -// constants -import { TLinkOptions } from "@/constants/dashboard"; -// plane web constants -import { EUserPermissions } from "@/plane-web/constants/user-permissions"; -// plane web types -import { TSidebarUserMenuItemKeys, TSidebarWorkspaceMenuItemKeys } from "@/plane-web/types/dashboard"; - -export type TSidebarMenuItems = { - key: T; - label: string; - href: string; - access: EUserPermissions[]; - highlight: (pathname: string, baseUrl: string, options?: TLinkOptions) => boolean; - Icon: React.FC; -}; - -export type TSidebarUserMenuItems = TSidebarMenuItems; - -export const SIDEBAR_USER_MENU_ITEMS: TSidebarUserMenuItems[] = [ - { - key: "home", - label: "Home", - href: ``, - access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/`, - Icon: Home, - }, - { - key: "your-work", - label: "Your work", - href: "/profile", - access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - highlight: (pathname: string, baseUrl: string, options?: TLinkOptions) => - options?.userId ? pathname.includes(`${baseUrl}/profile/${options?.userId}`) : false, - Icon: UserActivityIcon, - }, - { - key: "notifications", - label: "Inbox", - href: `/notifications`, - access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], - highlight: (pathname: string, baseUrl: string) => pathname.includes(`${baseUrl}/notifications/`), - Icon: Inbox, - }, - { - key: "drafts", - label: "Drafts", - href: `/drafts`, - access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - highlight: (pathname: string, baseUrl: string) => pathname.includes(`${baseUrl}/drafts/`), - Icon: PenSquare, - }, -]; - -export type TSidebarWorkspaceMenuItems = TSidebarMenuItems; - -export const SIDEBAR_WORKSPACE_MENU: Partial> = { - projects: { - key: "projects", - label: "Projects", - href: `/projects`, - access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/projects/`, - Icon: Briefcase, - }, - "all-issues": { - key: "all-issues", - label: "Views", - href: `/workspace-views/all-issues`, - access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], - highlight: (pathname: string, baseUrl: string) => pathname.includes(`${baseUrl}/workspace-views/`), - Icon: Layers, - }, - "active-cycles": { - key: "active-cycles", - label: "Cycles", - href: `/active-cycles`, - access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/active-cycles/`, - Icon: ContrastIcon, - }, - analytics: { - key: "analytics", - label: "Analytics", - href: `/analytics`, - access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - highlight: (pathname: string, baseUrl: string) => pathname.includes(`${baseUrl}/analytics/`), - Icon: BarChart2, - }, -}; - -export const SIDEBAR_WORKSPACE_MENU_ITEMS: TSidebarWorkspaceMenuItems[] = [ - SIDEBAR_WORKSPACE_MENU?.projects, - SIDEBAR_WORKSPACE_MENU?.["all-issues"], - SIDEBAR_WORKSPACE_MENU?.["active-cycles"], - SIDEBAR_WORKSPACE_MENU?.analytics, -].filter((item): item is TSidebarWorkspaceMenuItems => item !== undefined); diff --git a/web/ce/constants/project/settings/features.tsx b/web/ce/constants/project/settings/features.tsx index b3601e27b15..786767c015c 100644 --- a/web/ce/constants/project/settings/features.tsx +++ b/web/ce/constants/project/settings/features.tsx @@ -4,6 +4,7 @@ import { IProject } from "@plane/types"; import { ContrastIcon, DiceIcon, Intake } from "@plane/ui"; export type TProperties = { + key: string; property: string; title: string; description: string; @@ -23,6 +24,7 @@ export type TFeatureList = { export type TProjectFeatures = { [key: string]: { + key: string; title: string; description: string; featureList: TFeatureList; @@ -31,10 +33,12 @@ export type TProjectFeatures = { export const PROJECT_FEATURES_LIST: TProjectFeatures = { project_features: { + key: "projects_and_issues", title: "Projects and issues", description: "Toggle these on or off this project.", featureList: { cycles: { + key: "cycles", property: "cycle_view", title: "Cycles", description: "Timebox work as you see fit per project and change frequency from one period to the next.", @@ -43,6 +47,7 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = { isEnabled: true, }, modules: { + key: "modules", property: "module_view", title: "Modules", description: "Group work into sub-project-like set-ups with their own leads and assignees.", @@ -51,6 +56,7 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = { isEnabled: true, }, views: { + key: "views", property: "issue_views_view", title: "Views", description: "Save sorts, filters, and display options for later or share them.", @@ -59,6 +65,7 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = { isEnabled: true, }, pages: { + key: "pages", property: "page_view", title: "Pages", description: "Write anything like you write anything.", @@ -67,6 +74,7 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = { isEnabled: true, }, inbox: { + key: "intake", property: "inbox_view", title: "Intake", description: "Consider and discuss issues before you add them to your project.", @@ -77,10 +85,12 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = { }, }, project_others: { + key: "work_management", title: "Work management", description: "Available only on some plans as indicated by the label next to the feature below.", featureList: { is_time_tracking_enabled: { + key: "time_tracking", property: "is_time_tracking_enabled", title: "Time Tracking", description: "Log time, see timesheets, and download full CSVs for your entire workspace.", diff --git a/web/ce/helpers/dashboard.helper.ts b/web/ce/helpers/dashboard.helper.ts deleted file mode 100644 index c96c818a1f0..00000000000 --- a/web/ce/helpers/dashboard.helper.ts +++ /dev/null @@ -1,8 +0,0 @@ -// plane web types -import { TSidebarUserMenuItemKeys, TSidebarWorkspaceMenuItemKeys } from "@/plane-web/types/dashboard"; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export const isUserFeatureEnabled = (featureKey: TSidebarUserMenuItemKeys) => true; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export const isWorkspaceFeatureEnabled = (featureKey: TSidebarWorkspaceMenuItemKeys, workspaceSlug: string) => true; diff --git a/web/ce/helpers/epic-analytics.ts b/web/ce/helpers/epic-analytics.ts new file mode 100644 index 00000000000..43e6ffef05d --- /dev/null +++ b/web/ce/helpers/epic-analytics.ts @@ -0,0 +1,15 @@ +import { TEpicAnalyticsGroup } from "@plane/types"; + +export const updateEpicAnalytics = () => { + const updateAnalytics = ( + workspaceSlug: string, + projectId: string, + epicId: string, + data: { + incrementStateGroupCount?: TEpicAnalyticsGroup; + decrementStateGroupCount?: TEpicAnalyticsGroup; + } + ) => {}; + + return { updateAnalytics }; +}; diff --git a/web/ce/hooks/use-issue-embed.tsx b/web/ce/hooks/use-issue-embed.tsx index 5d02d978fa2..fcb6a0f3c35 100644 --- a/web/ce/hooks/use-issue-embed.tsx +++ b/web/ce/hooks/use-issue-embed.tsx @@ -1,12 +1,18 @@ // editor import { TEmbedConfig } from "@plane/editor"; -// types -import { TPageEmbedType } from "@plane/types"; +// plane types +import { TSearchEntityRequestPayload, TSearchResponse } from "@plane/types"; // plane web components import { IssueEmbedUpgradeCard } from "@/plane-web/components/pages"; +export type TIssueEmbedHookProps = { + fetchEmbedSuggestions?: (payload: TSearchEntityRequestPayload) => Promise; + projectId?: string; + workspaceSlug?: string; +}; + // eslint-disable-next-line @typescript-eslint/no-unused-vars -export const useIssueEmbed = (workspaceSlug: string, projectId: string, queryType: TPageEmbedType = "issue") => { +export const useIssueEmbed = (props: TIssueEmbedHookProps) => { const widgetCallback = () => ; const issueEmbedProps: TEmbedConfig["issue"] = { diff --git a/web/ce/hooks/use-page-flag.ts b/web/ce/hooks/use-page-flag.ts new file mode 100644 index 00000000000..84dc31c0d21 --- /dev/null +++ b/web/ce/hooks/use-page-flag.ts @@ -0,0 +1,14 @@ +export type TPageFlagHookArgs = { + workspaceSlug: string; +}; + +export type TPageFlagHookReturnType = { + isMovePageEnabled: boolean; +}; + +export const usePageFlag = (args: TPageFlagHookArgs): TPageFlagHookReturnType => { + const {} = args; + return { + isMovePageEnabled: false, + }; +}; diff --git a/web/ce/store/issue/issue-details/activity.store.ts b/web/ce/store/issue/issue-details/activity.store.ts index de84fb87d9a..cf180eebf77 100644 --- a/web/ce/store/issue/issue-details/activity.store.ts +++ b/web/ce/store/issue/issue-details/activity.store.ts @@ -16,6 +16,7 @@ import { TIssueServiceType, } from "@plane/types"; // plane web constants +import { TSORT_ORDER } from "@/constants/common"; import { EActivityFilterType } from "@/plane-web/constants/issues"; // services import { IssueActivityService } from "@/services/issue"; @@ -36,20 +37,17 @@ export interface IIssueActivityStoreActions { export interface IIssueActivityStore extends IIssueActivityStoreActions { // observables - sortOrder: "asc" | "desc"; loader: TActivityLoader; activities: TIssueActivityIdMap; activityMap: TIssueActivityMap; // helper methods getActivitiesByIssueId: (issueId: string) => string[] | undefined; getActivityById: (activityId: string) => TIssueActivity | undefined; - getActivityCommentByIssueId: (issueId: string) => TIssueActivityComment[] | undefined; - toggleSortOrder: () => void; + getActivityCommentByIssueId: (issueId: string, sortOrder: TSORT_ORDER) => TIssueActivityComment[] | undefined; } export class IssueActivityStore implements IIssueActivityStore { // observables - sortOrder: "asc" | "desc" = "asc"; loader: TActivityLoader = "fetch"; activities: TIssueActivityIdMap = {}; activityMap: TIssueActivityMap = {}; @@ -64,13 +62,11 @@ export class IssueActivityStore implements IIssueActivityStore { ) { makeObservable(this, { // observables - sortOrder: observable.ref, loader: observable.ref, activities: observable, activityMap: observable, // actions fetchActivities: action, - toggleSortOrder: action, }); this.serviceType = serviceType; // services @@ -88,15 +84,16 @@ export class IssueActivityStore implements IIssueActivityStore { return this.activityMap[activityId] ?? undefined; }; - getActivityCommentByIssueId = computedFn((issueId: string) => { + getActivityCommentByIssueId = computedFn((issueId: string, sortOrder: TSORT_ORDER) => { if (!issueId) return undefined; let activityComments: TIssueActivityComment[] = []; - const currentStore = this.serviceType === EIssueServiceType.EPICS ? this.store.epic : this.store.issue; + const currentStore = + this.serviceType === EIssueServiceType.EPICS ? this.store.issue.epicDetail : this.store.issue.issueDetail; const activities = this.getActivitiesByIssueId(issueId) || []; - const comments = currentStore.issueDetail.comment.getCommentsByIssueId(issueId) || []; + const comments = currentStore.comment.getCommentsByIssueId(issueId) || []; activities.forEach((activityId) => { const activity = this.getActivityById(activityId); @@ -109,7 +106,7 @@ export class IssueActivityStore implements IIssueActivityStore { }); comments.forEach((commentId) => { - const comment = currentStore.issueDetail.comment.getCommentById(commentId); + const comment = currentStore.comment.getCommentById(commentId); if (!comment) return; activityComments.push({ id: comment.id, @@ -118,15 +115,11 @@ export class IssueActivityStore implements IIssueActivityStore { }); }); - activityComments = orderBy(activityComments, (e) => new Date(e.created_at || 0), this.sortOrder); + activityComments = orderBy(activityComments, (e) => new Date(e.created_at || 0), sortOrder); return activityComments; }); - toggleSortOrder = () => { - this.sortOrder = this.sortOrder === "asc" ? "desc" : "asc"; - }; - // actions public async fetchActivities( workspaceSlug: string, diff --git a/web/ce/types/dashboard.ts b/web/ce/types/dashboard.ts deleted file mode 100644 index de35f60c6a7..00000000000 --- a/web/ce/types/dashboard.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type TSidebarUserMenuItemKeys = "home" | "your-work" | "notifications" | "drafts"; - -export type TSidebarWorkspaceMenuItemKeys = "projects" | "all-issues" | "active-cycles" | "analytics"; diff --git a/web/ce/types/projects/project-activity.ts b/web/ce/types/projects/project-activity.ts index d060b4cc88a..bd61cf5ef3b 100644 --- a/web/ce/types/projects/project-activity.ts +++ b/web/ce/types/projects/project-activity.ts @@ -1,3 +1,21 @@ import { TProjectBaseActivity } from "@plane/types"; -export type TProjectActivity = TProjectBaseActivity; +export type TProjectActivity = TProjectBaseActivity & { + content: string; + userId: string; + projectId: string; + + actor_detail: { + display_name: string; + id: string; + }; + workspace_detail: { + slug: string; + }; + project_detail: { + name: string; + }; + + createdAt: string; + updatedAt: string; +}; diff --git a/web/core/components/account/deactivate-account-modal.tsx b/web/core/components/account/deactivate-account-modal.tsx index aa27af4664c..1132a8d74cb 100644 --- a/web/core/components/account/deactivate-account-modal.tsx +++ b/web/core/components/account/deactivate-account-modal.tsx @@ -3,6 +3,7 @@ import React, { useState } from "react"; import { Trash2 } from "lucide-react"; import { Dialog, Transition } from "@headlessui/react"; +import { useTranslation } from "@plane/i18n"; // ui import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // hooks @@ -18,6 +19,7 @@ export const DeactivateAccountModal: React.FC = (props) => { const router = useAppRouter(); const { isOpen, onClose } = props; // hooks + const { t } = useTranslation(); const { deactivateAccount, signOut } = useUser(); // states @@ -90,11 +92,10 @@ export const DeactivateAccountModal: React.FC = (props) => {
- Deactivate your account? + {t("deactivate_your_account")}

- Once deactivated, you can{"'"}t be assigned issues and be billed for your workspace.To - reactivate your account, you will need an invite to a workspace at this email address. + {t("deactivate_your_account_description")}

@@ -102,10 +103,10 @@ export const DeactivateAccountModal: React.FC = (props) => {
diff --git a/web/core/components/account/password-strength-meter.tsx b/web/core/components/account/password-strength-meter.tsx index 342f77efb70..358320eb518 100644 --- a/web/core/components/account/password-strength-meter.tsx +++ b/web/core/components/account/password-strength-meter.tsx @@ -1,6 +1,7 @@ "use client"; import { FC, useMemo } from "react"; +import { useTranslation } from "@plane/i18n"; // import { CircleCheck } from "lucide-react"; // helpers import { cn } from "@/helpers/common.helper"; @@ -17,6 +18,7 @@ type TPasswordStrengthMeter = { export const PasswordStrengthMeter: FC = (props) => { const { password, isFocused = false } = props; + const { t } = useTranslation(); // derived values const strength = useMemo(() => getPasswordStrength(password), [password]); const strengthBars = useMemo(() => { @@ -24,40 +26,40 @@ export const PasswordStrengthMeter: FC = (props) => { case E_PASSWORD_STRENGTH.EMPTY: { return { bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`], - text: "Please enter your password.", + text: t("please_enter_your_password"), textColor: "text-custom-text-100", }; } case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID: { return { bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`], - text: "Password length should me more than 8 characters.", + text: t("password_length_should_me_more_than_8_characters"), textColor: "text-red-500", }; } case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID: { return { bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`], - text: "Password is weak.", + text: t("password_is_weak"), textColor: "text-red-500", }; } case E_PASSWORD_STRENGTH.STRENGTH_VALID: { return { bars: [`bg-green-500`, `bg-green-500`, `bg-green-500`], - text: "Password is strong.", + text: t("password_is_strong"), textColor: "text-green-500", }; } default: { return { bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`], - text: "Please enter your password.", + text: t("please_enter_your_password"), textColor: "text-custom-text-100", }; } } - }, [strength]); + }, [strength,t]); const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true; diff --git a/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx b/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx index 84ac73b7550..f9eee044ae4 100644 --- a/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx +++ b/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx @@ -33,31 +33,34 @@ export const ChangeIssueAssignee: React.FC = observer((props) => { } = useMember(); const options = - projectMemberIds?.map((userId) => { - const memberDetails = getProjectMemberDetails(userId); + projectMemberIds + ?.map((userId) => { + if (!projectId) return; + const memberDetails = getProjectMemberDetails(userId, projectId.toString()); - return { - value: `${memberDetails?.member?.id}`, - query: `${memberDetails?.member?.display_name}`, - content: ( - <> -
- - {memberDetails?.member?.display_name} -
- {issue.assignee_ids.includes(memberDetails?.member?.id ?? "") && ( -
- + return { + value: `${memberDetails?.member?.id}`, + query: `${memberDetails?.member?.display_name}`, + content: ( + <> +
+ + {memberDetails?.member?.display_name}
- )} - - ), - }; - }) ?? []; + {issue.assignee_ids.includes(memberDetails?.member?.id ?? "") && ( +
+ +
+ )} + + ), + }; + }) + .filter((o) => o !== undefined) ?? []; const handleUpdateIssue = async (formData: Partial) => { if (!workspaceSlug || !projectId || !issue) return; @@ -80,15 +83,18 @@ export const ChangeIssueAssignee: React.FC = observer((props) => { return ( <> - {options.map((option) => ( - handleIssueAssignees(option.value)} - className="focus:outline-none" - > - {option.content} - - ))} + {options.map( + (option) => + option && ( + handleIssueAssignees(option.value)} + className="focus:outline-none" + > + {option.content} + + ) + )} ); }); diff --git a/web/core/components/core/charts/stacked-bar-chart/bar.tsx b/web/core/components/core/charts/stacked-bar-chart/bar.tsx new file mode 100644 index 00000000000..96fd9b3ceaf --- /dev/null +++ b/web/core/components/core/charts/stacked-bar-chart/bar.tsx @@ -0,0 +1,63 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from "react"; +// plane imports +import { TStackChartData } from "@plane/types"; +import { cn } from "@plane/utils"; + +// Helper to calculate percentage +const calculatePercentage = ( + data: TStackChartData, + stackKeys: T[], + currentKey: T +): number => { + const total = stackKeys.reduce((sum, key) => sum + data[key], 0); + return total === 0 ? 0 : Math.round((data[currentKey] / total) * 100); +}; + +export const CustomStackBar = React.memo((props: any) => { + const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage } = props; + // Calculate text position + const MIN_BAR_HEIGHT_FOR_INTERNAL = 14; // Minimum height needed to show text inside + const TEXT_PADDING = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL - height / 2)); + const textY = y + height - TEXT_PADDING; // Position inside bar if tall enough + // derived values + const RADIUS = 2; + const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey); + + if (!height) return null; + return ( + + + {showPercentage && + height >= MIN_BAR_HEIGHT_FOR_INTERNAL && + currentBarPercentage !== undefined && + !Number.isNaN(currentBarPercentage) && ( + + {currentBarPercentage}% + + )} + + ); +}); +CustomStackBar.displayName = "CustomStackBar"; diff --git a/web/core/components/core/charts/stacked-bar-chart/index.ts b/web/core/components/core/charts/stacked-bar-chart/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/web/core/components/core/charts/stacked-bar-chart/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/core/components/core/charts/stacked-bar-chart/root.tsx b/web/core/components/core/charts/stacked-bar-chart/root.tsx new file mode 100644 index 00000000000..2fd8ccfc6cd --- /dev/null +++ b/web/core/components/core/charts/stacked-bar-chart/root.tsx @@ -0,0 +1,130 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +"use client"; + +import React from "react"; +import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip } from "recharts"; +// plane imports +import { TStackedBarChartProps } from "@plane/types"; +import { cn } from "@plane/utils"; +// local components +import { CustomStackBar } from "./bar"; +import { CustomXAxisTick, CustomYAxisTick } from "./tick"; +import { CustomTooltip } from "./tooltip"; + +// Common classnames +const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide"; +const AXIS_LINE_CLASSNAME = "text-custom-text-400/70"; + +export const StackedBarChart = React.memo( + ({ + data, + stacks, + xAxis, + yAxis, + barSize = 40, + className = "w-full h-96", + tickCount = { + x: undefined, + y: 10, + }, + showTooltip = true, + }: TStackedBarChartProps) => { + // derived values + const stackKeys = React.useMemo(() => stacks.map((stack) => stack.key), [stacks]); + const stackDotClassNames = React.useMemo( + () => stacks.reduce((acc, stack) => ({ ...acc, [stack.key]: stack.dotClassName }), {}), + [stacks] + ); + + const renderBars = React.useMemo( + () => + stacks.map((stack) => ( + ( + + )} + /> + )), + [stackKeys, stacks] + ); + + return ( +
+ + + } + tickLine={{ + stroke: "currentColor", + className: AXIS_LINE_CLASSNAME, + }} + axisLine={{ + stroke: "currentColor", + className: AXIS_LINE_CLASSNAME, + }} + label={{ + value: xAxis.label, + dy: 28, + className: LABEL_CLASSNAME, + }} + tickCount={tickCount.x} + /> + } + tickCount={tickCount.y} + allowDecimals={yAxis.allowDecimals ?? false} + /> + {showTooltip && ( + ( + + )} + /> + )} + {renderBars} + + +
+ ); + } +); +StackedBarChart.displayName = "StackedBarChart"; diff --git a/web/core/components/core/charts/stacked-bar-chart/tick.tsx b/web/core/components/core/charts/stacked-bar-chart/tick.tsx new file mode 100644 index 00000000000..c631d7d6e2b --- /dev/null +++ b/web/core/components/core/charts/stacked-bar-chart/tick.tsx @@ -0,0 +1,23 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from "react"; + +// Common classnames +const AXIS_TICK_CLASSNAME = "fill-custom-text-400 text-sm capitalize"; + +export const CustomXAxisTick = React.memo(({ x, y, payload }: any) => ( + + + {payload.value} + + +)); +CustomXAxisTick.displayName = "CustomXAxisTick"; + +export const CustomYAxisTick = React.memo(({ x, y, payload }: any) => ( + + + {payload.value} + + +)); +CustomYAxisTick.displayName = "CustomYAxisTick"; diff --git a/web/core/components/core/charts/stacked-bar-chart/tooltip.tsx b/web/core/components/core/charts/stacked-bar-chart/tooltip.tsx new file mode 100644 index 00000000000..32226db3f12 --- /dev/null +++ b/web/core/components/core/charts/stacked-bar-chart/tooltip.tsx @@ -0,0 +1,39 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from "react"; +// plane imports +import { Card, ECardSpacing } from "@plane/ui"; +import { cn } from "@plane/utils"; + +type TStackedBarChartProps = { + active: boolean | undefined; + label: string | undefined; + payload: any[] | undefined; + stackKeys: string[]; + stackDotClassNames: Record; +}; + +export const CustomTooltip = React.memo( + ({ active, label, payload, stackKeys, stackDotClassNames }: TStackedBarChartProps) => { + // derived values + const filteredPayload = payload?.filter((item: any) => item.dataKey && stackKeys.includes(item.dataKey)); + + if (!active || !filteredPayload || !filteredPayload.length) return null; + return ( + +

+ {label} +

+ {filteredPayload.map((item: any) => ( +
+ {stackDotClassNames[item?.dataKey] && ( +
+ )} + {item?.name}: + {item?.value} +
+ ))} + + ); + } +); +CustomTooltip.displayName = "CustomTooltip"; diff --git a/web/core/components/core/charts/tree-map/index.ts b/web/core/components/core/charts/tree-map/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/web/core/components/core/charts/tree-map/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/core/components/core/charts/tree-map/map-content.tsx b/web/core/components/core/charts/tree-map/map-content.tsx new file mode 100644 index 00000000000..e2f2959f74f --- /dev/null +++ b/web/core/components/core/charts/tree-map/map-content.tsx @@ -0,0 +1,276 @@ +import React, { useMemo } from "react"; +// plane imports +import { TBottomSectionConfig, TContentVisibility, TTopSectionConfig } from "@plane/types"; +import { cn } from "@plane/utils"; + +const LAYOUT = { + PADDING: 2, + RADIUS: 6, + TEXT: { + PADDING_LEFT: 8, + PADDING_RIGHT: 8, + VERTICAL_OFFSET: 20, + ELLIPSIS_OFFSET: -4, + FONT_SIZES: { + SM: 12.6, + XS: 10.8, + }, + }, + ICON: { + SIZE: 16, + GAP: 6, + }, + MIN_DIMENSIONS: { + HEIGHT_FOR_BOTH: 42, + HEIGHT_FOR_TOP: 35, + HEIGHT_FOR_DOTS: 20, + WIDTH_FOR_ICON: 30, + WIDTH_FOR_DOTS: 15, + }, +}; + +const calculateContentWidth = (text: string | number, fontSize: number): number => String(text).length * fontSize * 0.7; + +const calculateTopSectionConfig = (effectiveWidth: number, name: string, hasIcon: boolean): TTopSectionConfig => { + const iconWidth = hasIcon ? LAYOUT.ICON.SIZE + LAYOUT.ICON.GAP : 0; + const nameWidth = calculateContentWidth(name, LAYOUT.TEXT.FONT_SIZES.SM); + const totalPadding = LAYOUT.TEXT.PADDING_LEFT + LAYOUT.TEXT.PADDING_RIGHT; + + // First check if we can show icon + const canShowIcon = hasIcon && effectiveWidth >= LAYOUT.MIN_DIMENSIONS.WIDTH_FOR_ICON; + + // If we can't even show icon, check if we can show dots + if (!canShowIcon) { + return { + showIcon: false, + showName: effectiveWidth >= LAYOUT.MIN_DIMENSIONS.WIDTH_FOR_DOTS, + nameTruncated: true, + }; + } + + // We can show icon, now check if we have space for name + const availableWidthForName = effectiveWidth - (canShowIcon ? iconWidth : 0) - totalPadding; + const canShowFullName = availableWidthForName >= nameWidth; + + return { + showIcon: canShowIcon, + showName: availableWidthForName > 0, + nameTruncated: !canShowFullName, + }; +}; + +const calculateBottomSectionConfig = ( + effectiveWidth: number, + effectiveHeight: number, + value: number | undefined, + label: string | undefined +): TBottomSectionConfig => { + // If height is not enough for bottom section + if (effectiveHeight < LAYOUT.MIN_DIMENSIONS.HEIGHT_FOR_BOTH) { + return { + show: false, + showValue: false, + showLabel: false, + labelTruncated: false, + }; + } + + // Calculate widths + const totalPadding = LAYOUT.TEXT.PADDING_LEFT + LAYOUT.TEXT.PADDING_RIGHT; + const valueWidth = value ? calculateContentWidth(value, LAYOUT.TEXT.FONT_SIZES.XS) : 0; + const labelWidth = label ? calculateContentWidth(label, LAYOUT.TEXT.FONT_SIZES.XS) + 4 : 0; // 4px for spacing + const availableWidth = effectiveWidth - totalPadding; + + // If we can't even show value + if (availableWidth < Math.max(valueWidth, LAYOUT.MIN_DIMENSIONS.WIDTH_FOR_DOTS)) { + return { + show: true, + showValue: false, + showLabel: false, + labelTruncated: false, + }; + } + + // If we can show value but not full label + const canShowFullLabel = availableWidth >= valueWidth + labelWidth; + + return { + show: true, + showValue: true, + showLabel: true, + labelTruncated: !canShowFullLabel, + }; +}; + +const calculateVisibility = ( + width: number, + height: number, + hasIcon: boolean, + name: string, + value: number | undefined, + label: string | undefined +): TContentVisibility => { + const effectiveWidth = width - LAYOUT.PADDING * 2; + const effectiveHeight = height - LAYOUT.PADDING * 2; + + // If extremely small, show only dots + if ( + effectiveHeight < LAYOUT.MIN_DIMENSIONS.HEIGHT_FOR_DOTS || + effectiveWidth < LAYOUT.MIN_DIMENSIONS.WIDTH_FOR_DOTS + ) { + return { + top: { showIcon: false, showName: false, nameTruncated: false }, + bottom: { show: false, showValue: false, showLabel: false, labelTruncated: false }, + }; + } + + const topSection = calculateTopSectionConfig(effectiveWidth, name, hasIcon); + const bottomSection = calculateBottomSectionConfig(effectiveWidth, effectiveHeight, value, label); + + return { + top: topSection, + bottom: bottomSection, + }; +}; + +const truncateText = (text: string | number, maxWidth: number, fontSize: number, reservedWidth: number = 0): string => { + const availableWidth = maxWidth - reservedWidth; + if (availableWidth <= 0) return ""; + + const avgCharWidth = fontSize * 0.7; + const maxChars = Math.floor(availableWidth / avgCharWidth); + const stringText = String(text); + + if (maxChars <= 3) return ""; + if (stringText.length <= maxChars) return stringText; + return `${stringText.slice(0, maxChars - 3)}...`; +}; + +export const CustomTreeMapContent: React.FC = ({ + x, + y, + width, + height, + name, + value, + label, + fillColor, + fillClassName, + textClassName, + icon, +}) => { + const dimensions = useMemo(() => { + const pX = x + LAYOUT.PADDING; + const pY = y + LAYOUT.PADDING; + const pWidth = Math.max(0, width - LAYOUT.PADDING * 2); + const pHeight = Math.max(0, height - LAYOUT.PADDING * 2); + return { pX, pY, pWidth, pHeight }; + }, [x, y, width, height]); + + const visibility = useMemo( + () => calculateVisibility(width, height, !!icon, name, value, label), + [width, height, icon, name, value, label] + ); + + if (!name || width <= 0 || height <= 0) return null; + + const renderContent = () => { + const { pX, pY, pWidth, pHeight } = dimensions; + const { top, bottom } = visibility; + + const availableTextWidth = pWidth - LAYOUT.TEXT.PADDING_LEFT - LAYOUT.TEXT.PADDING_RIGHT; + const iconSpace = top.showIcon ? LAYOUT.ICON.SIZE + LAYOUT.ICON.GAP : 0; + + return ( + + {/* Background shape */} + + + {/* Top section */} + + {top.showIcon && icon && ( + + {React.cloneElement(icon, { + className: cn("size-4", icon?.props?.className), + "aria-hidden": true, + })} + + )} + {top.showName && ( + + {top.nameTruncated ? truncateText(name, availableTextWidth, LAYOUT.TEXT.FONT_SIZES.SM, iconSpace) : name} + + )} + + + {/* Bottom section */} + {bottom.show && ( + + {bottom.showValue && value !== undefined && ( + + {value.toLocaleString()} + {bottom.showLabel && label && ( + + {bottom.labelTruncated + ? truncateText( + label, + availableTextWidth - calculateContentWidth(value, LAYOUT.TEXT.FONT_SIZES.XS) - 4, + LAYOUT.TEXT.FONT_SIZES.XS + ) + : label} + + )} + {!bottom.showLabel && label && ...} + + )} + + )} + + ); + }; + + return ( + + + {renderContent()} + + ); +}; diff --git a/web/core/components/core/charts/tree-map/root.tsx b/web/core/components/core/charts/tree-map/root.tsx new file mode 100644 index 00000000000..47ea21d7275 --- /dev/null +++ b/web/core/components/core/charts/tree-map/root.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { Treemap, ResponsiveContainer, Tooltip } from "recharts"; +// plane imports +import { TreeMapChartProps } from "@plane/types"; +import { cn } from "@plane/utils"; +// local imports +import { CustomTreeMapContent } from "./map-content"; +import { TreeMapTooltip } from "./tooltip"; + +export const TreeMapChart = React.memo((props: TreeMapChartProps) => { + const { data, className = "w-full h-96", isAnimationActive = false, showTooltip = true } = props; + return ( +
+ + } + animationEasing="ease-out" + isUpdateAnimationActive={isAnimationActive} + animationBegin={100} + animationDuration={500} + > + {showTooltip && ( + } + cursor={{ + fill: "currentColor", + className: "text-custom-background-90/80 cursor-pointer", + }} + /> + )} + + +
+ ); +}); +TreeMapChart.displayName = "TreeMapChart"; diff --git a/web/core/components/core/charts/tree-map/tooltip.tsx b/web/core/components/core/charts/tree-map/tooltip.tsx new file mode 100644 index 00000000000..55c6e687ead --- /dev/null +++ b/web/core/components/core/charts/tree-map/tooltip.tsx @@ -0,0 +1,29 @@ +import React from "react"; +// plane imports +import { Card, ECardSpacing } from "@plane/ui"; + +interface TreeMapTooltipProps { + active: boolean | undefined; + payload: any[] | undefined; +} + +export const TreeMapTooltip = React.memo(({ active, payload }: TreeMapTooltipProps) => { + if (!active || !payload || !payload[0]?.payload) return null; + + const data = payload[0].payload; + + return ( + +
+ {data?.icon} +

{data?.name}

+
+ + {data?.value.toLocaleString()} + {data.label && ` ${data.label}`} + +
+ ); +}); + +TreeMapTooltip.displayName = "TreeMapTooltip"; diff --git a/web/core/components/core/content-overflow-HOC.tsx b/web/core/components/core/content-overflow-HOC.tsx new file mode 100644 index 00000000000..f6a2423cd61 --- /dev/null +++ b/web/core/components/core/content-overflow-HOC.tsx @@ -0,0 +1,148 @@ +import { ReactNode, useEffect, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { cn } from "@plane/utils"; + +interface IContentOverflowWrapper { + children: ReactNode; + maxHeight?: number; + gradientColor?: string; + buttonClassName?: string; + containerClassName?: string; + fallback?: ReactNode; +} + +export const ContentOverflowWrapper = observer((props: IContentOverflowWrapper) => { + const { + children, + maxHeight = 625, + buttonClassName = "text-sm font-medium text-custom-primary-100", + containerClassName, + fallback = null, + } = props; + + // states + const [containerHeight, setContainerHeight] = useState(0); + const [showAll, setShowAll] = useState(false); + const [isTransitioning, setIsTransitioning] = useState(false); + + // refs + const contentRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + if (!contentRef?.current) return; + + const updateHeight = () => { + if (contentRef.current) { + const height = contentRef.current.getBoundingClientRect().height; + setContainerHeight(height); + } + }; + + // Initial height measurement + updateHeight(); + + // Create ResizeObserver for size changes + const resizeObserver = new ResizeObserver(updateHeight); + resizeObserver.observe(contentRef.current); + + // Create MutationObserver for content changes + const mutationObserver = new MutationObserver((mutations) => { + const shouldUpdate = mutations.some( + (mutation) => + mutation.type === "childList" || + (mutation.type === "attributes" && (mutation.attributeName === "style" || mutation.attributeName === "class")) + ); + + if (shouldUpdate) { + updateHeight(); + } + }); + + mutationObserver.observe(contentRef.current, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["style", "class"], + }); + + return () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + }, [contentRef?.current]); + + useEffect(() => { + if (!containerRef.current) return; + + const handleTransitionEnd = () => { + setIsTransitioning(false); + }; + + containerRef.current.addEventListener("transitionend", handleTransitionEnd); + + return () => { + containerRef.current?.removeEventListener("transitionend", handleTransitionEnd); + }; + }, []); + + const handleToggle = () => { + setIsTransitioning(true); + setShowAll((prev) => !prev); + }; + + if (!children) return fallback; + + return ( +
maxHeight, + }, + containerClassName + )} + style={{ + height: showAll ? `${containerHeight}px` : `${Math.min(maxHeight, containerHeight)}px`, + }} + > +
+ {children} +
+ + {containerHeight > maxHeight && ( +
+ +
+ )} +
+ ); +}); diff --git a/web/core/components/core/list/list-item.tsx b/web/core/components/core/list/list-item.tsx index 930f630cc04..223879334b8 100644 --- a/web/core/components/core/list/list-item.tsx +++ b/web/core/components/core/list/list-item.tsx @@ -18,6 +18,7 @@ interface IListItemProps { parentRef: React.RefObject; disableLink?: boolean; className?: string; + itemClassName?: string; actionItemContainerClassName?: string; isSidebarOpen?: boolean; quickActionElement?: JSX.Element; @@ -38,6 +39,7 @@ export const ListItem: FC = (props) => { actionItemContainerClassName = "", isSidebarOpen = false, quickActionElement, + itemClassName = "", } = props; // router @@ -61,7 +63,7 @@ export const ListItem: FC = (props) => { className )} > -
+
) => void; }; @@ -32,7 +19,7 @@ export const CustomThemeSelector: React.FC = observer((pro const { applyThemeChange } = props; // hooks const { data: userProfile, updateUserTheme } = useUserProfile(); - + const { t } = useTranslation(); const { control, formState: { errors, isSubmitting }, @@ -51,6 +38,24 @@ export const CustomThemeSelector: React.FC = observer((pro }, }); + const inputRules = useMemo( + () => ({ + minLength: { + value: 7, + message: t("enter_a_valid_hex_code_of_6_characters"), + }, + maxLength: { + value: 7, + message: t("enter_a_valid_hex_code_of_6_characters"), + }, + pattern: { + value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, + message: t("enter_a_valid_hex_code_of_6_characters"), + }, + }), + [t] // Empty dependency array since these rules never change + ); + const handleUpdateTheme = async (formData: Partial) => { const payload: IUserTheme = { background: formData.background, @@ -66,14 +71,14 @@ export const CustomThemeSelector: React.FC = observer((pro const updateCurrentUserThemePromise = updateUserTheme(payload); setPromiseToast(updateCurrentUserThemePromise, { - loading: "Updating theme...", + loading: t("updating_theme"), success: { - title: "Success!", - message: () => "Theme updated successfully!", + title: t("success"), + message: () => t("theme_updated_successfully"), }, error: { - title: "Error!", - message: () => "Failed to Update the theme", + title: t("error"), + message: () => t("failed_to_update_the_theme"), }, }); @@ -91,16 +96,16 @@ export const CustomThemeSelector: React.FC = observer((pro return (
-

Customize your theme

+

{t("customize_your_theme")}

-

Background color

+

{t("background_color")}

( = observer((pro
-

Text color

+

{t("text_color")}

( = observer((pro
-

Primary(Theme) color

+

{t("primary_color")}

( = observer((pro
-

Sidebar background color

+

{t("sidebar_background_color")}

( = observer((pro
-

Sidebar text color

+

{t("sidebar_text_color")}

( = observer((pro
diff --git a/web/core/components/core/theme/theme-switch.tsx b/web/core/components/core/theme/theme-switch.tsx index b79e2104eb2..7a188a48aa0 100644 --- a/web/core/components/core/theme/theme-switch.tsx +++ b/web/core/components/core/theme/theme-switch.tsx @@ -1,6 +1,7 @@ "use client"; import { FC } from "react"; +import { useTranslation } from "@plane/i18n"; // constants import { CustomSelect } from "@plane/ui"; import { THEME_OPTIONS, I_THEME_OPTION } from "@/constants/themes"; @@ -13,7 +14,7 @@ type Props = { export const ThemeSwitch: FC = (props) => { const { value, onChange } = props; - + const { t } = useTranslation(); return ( = (props) => { }} />
- {value.label} + {t(value.key)}
) : ( - "Select your theme" + t("select_your_theme") ) } onChange={onChange} @@ -72,7 +73,7 @@ export const ThemeSwitch: FC = (props) => { }} />
- {themeOption.label} + {t(themeOption.key)}
))} diff --git a/web/core/components/cycles/active-cycle/use-cycles-details.ts b/web/core/components/cycles/active-cycle/use-cycles-details.ts index 4412319ba37..2bfe9951e2e 100644 --- a/web/core/components/cycles/active-cycle/use-cycles-details.ts +++ b/web/core/components/cycles/active-cycle/use-cycles-details.ts @@ -30,21 +30,23 @@ const useCyclesDetails = (props: IActiveCycleDetails) => { // fetch cycle details useSWR( - workspaceSlug && projectId && cycle ? `PROJECT_ACTIVE_CYCLE_${projectId}_PROGRESS` : null, - workspaceSlug && projectId && cycle ? () => fetchActiveCycleProgress(workspaceSlug, projectId, cycle.id) : null, + workspaceSlug && projectId && cycle?.id ? `PROJECT_ACTIVE_CYCLE_${projectId}_PROGRESS` : null, + workspaceSlug && projectId && cycle?.id ? () => fetchActiveCycleProgress(workspaceSlug, projectId, cycle.id) : null, { revalidateIfStale: false, revalidateOnFocus: false } ); useSWR( - workspaceSlug && projectId && cycle && !cycle?.distribution ? `PROJECT_ACTIVE_CYCLE_${projectId}_DURATION` : null, - workspaceSlug && projectId && cycle && !cycle?.distribution + workspaceSlug && projectId && cycle?.id && !cycle?.distribution + ? `PROJECT_ACTIVE_CYCLE_${projectId}_DURATION` + : null, + workspaceSlug && projectId && cycle?.id && !cycle?.distribution ? () => fetchActiveCycleAnalytics(workspaceSlug, projectId, cycle.id, "issues") : null ); useSWR( - workspaceSlug && projectId && cycle && !cycle?.estimate_distribution + workspaceSlug && projectId && cycle?.id && !cycle?.estimate_distribution ? `PROJECT_ACTIVE_CYCLE_${projectId}_ESTIMATE_DURATION` : null, - workspaceSlug && projectId && cycle && !cycle?.estimate_distribution + workspaceSlug && projectId && cycle?.id && !cycle?.estimate_distribution ? () => fetchActiveCycleAnalytics(workspaceSlug, projectId, cycle.id, "points") : null ); diff --git a/web/core/components/cycles/list/cycle-list-item-action.tsx b/web/core/components/cycles/list/cycle-list-item-action.tsx index 73dca345d7c..2fa3d4fd346 100644 --- a/web/core/components/cycles/list/cycle-list-item-action.tsx +++ b/web/core/components/cycles/list/cycle-list-item-action.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { FC, MouseEvent, useEffect } from "react"; +import React, { FC, MouseEvent, useEffect, useMemo, useState } from "react"; import { observer } from "mobx-react"; import { usePathname, useSearchParams } from "next/navigation"; import { Controller, useForm } from "react-hook-form"; @@ -8,9 +8,19 @@ import { Eye, Users } from "lucide-react"; // types import { ICycle, TCycleGroups } from "@plane/types"; // ui -import { Avatar, AvatarGroup, FavoriteStar, TOAST_TYPE, Tooltip, setPromiseToast, setToast } from "@plane/ui"; +import { + Avatar, + AvatarGroup, + FavoriteStar, + LayersIcon, + TOAST_TYPE, + Tooltip, + TransferIcon, + setPromiseToast, + setToast, +} from "@plane/ui"; // components -import { CycleQuickActions } from "@/components/cycles"; +import { CycleQuickActions, TransferIssuesModal } from "@/components/cycles"; import { DateRangeDropdown } from "@/components/dropdowns"; import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; // constants @@ -23,6 +33,8 @@ import { generateQueryParams } from "@/helpers/router.helper"; import { useCycle, useEventTracker, useMember, useUserPermissions } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web components +import { CycleAdditionalActions } from "@/plane-web/components/cycles"; // plane web constants import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; // services @@ -46,6 +58,8 @@ const defaultValues: Partial = { export const CycleListItemAction: FC = observer((props) => { const { workspaceSlug, projectId, cycleId, cycleDetails, parentRef, isActive = false } = props; + //states + const [transferIssuesModal, setTransferIssuesModal] = useState(false); // hooks const { isMobile } = usePlatformOS(); // router @@ -66,6 +80,9 @@ export const CycleListItemAction: FC = observer((props) => { // derived values const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; + const showIssueCount = useMemo(() => cycleStatus === "draft" || cycleStatus === "upcoming", [cycleStatus]); + const transferableIssuesCount = cycleDetails ? cycleDetails.total_issues - cycleDetails.completed_issues : 0; + const showTransferIssues = transferableIssuesCount > 0 && cycleStatus === "completed"; const isEditingAllowed = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT, @@ -140,7 +157,7 @@ export const CycleListItemAction: FC = observer((props) => { try { const res = await cycleService.cycleDateCheck(workspaceSlug as string, projectId as string, payload); return res.status; - } catch (err) { + } catch { return false; } }; @@ -208,6 +225,11 @@ export const CycleListItemAction: FC = observer((props) => { return ( <> + setTransferIssuesModal(false)} + isOpen={transferIssuesModal} + cycleId={cycleId.toString()} + /> + {showIssueCount && ( +
+ + {cycleDetails.total_issues} +
+ )} + + + {showTransferIssues && ( +
{ + setTransferIssuesModal(true); + }} + > + + Transfer {transferableIssuesCount} issues +
+ )} + {!isActive && ( = (props) => { if (payload.start_date && payload.end_date) { if (data?.start_date && data?.end_date) isDateValid = await dateChecker(payload.project_id ?? projectId, { - start_date: payload.start_date, - end_date: payload.end_date, + start_date: format(payload.start_date, "yyyy-MM-dd"), + end_date: format(payload.end_date, "yyyy-MM-dd"), cycle_id: data.id, }); else diff --git a/web/core/components/cycles/quick-actions.tsx b/web/core/components/cycles/quick-actions.tsx index 49f78cb5c0a..b72d7b7bd61 100644 --- a/web/core/components/cycles/quick-actions.tsx +++ b/web/core/components/cycles/quick-actions.tsx @@ -15,6 +15,7 @@ import { copyUrlToClipboard } from "@/helpers/string.helper"; // hooks import { useCycle, useEventTracker, useUserPermissions } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; +import { useEndCycle, EndCycleModal } from "@/plane-web/components/cycles"; import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; type Props = { @@ -40,6 +41,8 @@ export const CycleQuickActions: React.FC = observer((props) => { const cycleDetails = getCycleById(cycleId); const isArchived = !!cycleDetails?.archived_at; const isCompleted = cycleDetails?.status?.toLowerCase() === "completed"; + const isCurrentCycle = cycleDetails?.status?.toLowerCase() === "current"; + const transferableIssuesCount = cycleDetails ? cycleDetails.total_issues - cycleDetails.completed_issues : 0; // auth const isEditingAllowed = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], @@ -48,6 +51,8 @@ export const CycleQuickActions: React.FC = observer((props) => { projectId ); + const { isEndCycleModalOpen, setEndCycleModalOpen, endCycleContextMenu } = useEndCycle(isCurrentCycle); + const cycleLink = `${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`; const handleCopyText = () => copyUrlToClipboard(cycleLink).then(() => { @@ -138,6 +143,8 @@ export const CycleQuickActions: React.FC = observer((props) => { }, ]; + if (endCycleContextMenu) MENU_ITEMS.splice(3, 0, endCycleContextMenu); + return ( <> {cycleDetails && ( @@ -163,6 +170,14 @@ export const CycleQuickActions: React.FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={projectId} /> + setEndCycleModalOpen(false)} + cycleId={cycleId} + projectId={projectId} + workspaceSlug={workspaceSlug} + transferrableIssuesCount={transferableIssuesCount} + />
)} diff --git a/web/core/components/cycles/transfer-issues-modal.tsx b/web/core/components/cycles/transfer-issues-modal.tsx index 1d065bf85bb..fc069687321 100644 --- a/web/core/components/cycles/transfer-issues-modal.tsx +++ b/web/core/components/cycles/transfer-issues-modal.tsx @@ -17,42 +17,58 @@ import { useCycle, useIssues } from "@/hooks/store"; type Props = { isOpen: boolean; handleClose: () => void; + cycleId: string; }; export const TransferIssuesModal: React.FC = observer((props) => { - const { isOpen, handleClose } = props; + const { isOpen, handleClose, cycleId } = props; // states const [query, setQuery] = useState(""); // store hooks - const { currentProjectIncompleteCycleIds, getCycleById } = useCycle(); + const { currentProjectIncompleteCycleIds, getCycleById, fetchCycleDetails } = useCycle(); const { issues: { transferIssuesFromCycle }, } = useIssues(EIssuesStoreType.CYCLE); - const { workspaceSlug, projectId, cycleId } = useParams(); + const { workspaceSlug, projectId } = useParams(); - const transferIssue = async (payload: any) => { + const transferIssue = async (payload: { new_cycle_id: string }) => { if (!workspaceSlug || !projectId || !cycleId) return; - // TODO: import transferIssuesFromCycle from store await transferIssuesFromCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), payload) - .then(() => { + .then(async () => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Success!", message: "Issues have been transferred successfully", }); + await getCycleDetails(payload.new_cycle_id); }) .catch(() => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: "Issues cannot be transfer. Please try again.", + message: "Unable to transfer Issues. Please try again.", }); }); }; + /**To update issue counts in target cycle and current cycle */ + const getCycleDetails = async (newCycleId: string) => { + const cyclesFetch = [ + fetchCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId), + fetchCycleDetails(workspaceSlug.toString(), projectId.toString(), newCycleId), + ]; + await Promise.all(cyclesFetch).catch((error) => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error", + message: error.error || "Unable to fetch cycle details", + }); + }); + }; + const filteredOptions = currentProjectIncompleteCycleIds?.filter((optionId) => { const cycleDetails = getCycleById(optionId); @@ -96,8 +112,8 @@ export const TransferIssuesModal: React.FC = observer((props) => {
-
- +
+

Transfer Issues

diff --git a/web/core/components/dropdowns/project.tsx b/web/core/components/dropdowns/project.tsx index f94014eb8b8..88c13bdb91e 100644 --- a/web/core/components/dropdowns/project.tsx +++ b/web/core/components/dropdowns/project.tsx @@ -1,9 +1,10 @@ import { ReactNode, useRef, useState } from "react"; import { observer } from "mobx-react"; import { usePopper } from "react-popper"; -import { Check, ChevronDown, Search } from "lucide-react"; +import { Briefcase, Check, ChevronDown, Search } from "lucide-react"; import { Combobox } from "@headlessui/react"; // ui +import { useTranslation } from "@plane/i18n"; import { ComboDropDown } from "@plane/ui"; // components import { Logo } from "@/components/common"; @@ -86,7 +87,7 @@ export const ProjectDropdown: React.FC = observer((props) => { }); // store hooks const { joinedProjectIds, getProjectById } = useProject(); - + const { t } = useTranslation(); const options = joinedProjectIds?.map((projectId) => { const projectDetails = getProjectById(projectId); if (renderCondition && projectDetails && !renderCondition(projectDetails)) return; @@ -143,10 +144,14 @@ export const ProjectDropdown: React.FC = observer((props) => { if (Array.isArray(value)) { return (
- {value.map((projectId) => { - const projectDetails = getProjectById(projectId); - return projectDetails ? renderIcon(projectDetails) : null; - })} + {value.length > 0 ? ( + value.map((projectId) => { + const projectDetails = getProjectById(projectId); + return projectDetails ? renderIcon(projectDetails) : null; + }) + ) : ( + + )}
); } else { @@ -234,7 +239,7 @@ export const ProjectDropdown: React.FC = observer((props) => { className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none" value={query} onChange={(e) => setQuery(e.target.value)} - placeholder="Search" + placeholder={t("search")} displayValue={(assigned: any) => assigned?.name} onKeyDown={searchInputKeyDown} /> @@ -264,10 +269,10 @@ export const ProjectDropdown: React.FC = observer((props) => { ); }) ) : ( -

No matching results

+

{t("no_matching_results")}

) ) : ( -

Loading...

+

{t("loading")}

)}
diff --git a/web/core/components/dropdowns/state.tsx b/web/core/components/dropdowns/state.tsx index 86d8d80521c..59178c14968 100644 --- a/web/core/components/dropdowns/state.tsx +++ b/web/core/components/dropdowns/state.tsx @@ -6,6 +6,7 @@ import { useParams } from "next/navigation"; import { usePopper } from "react-popper"; import { ChevronDown, Search } from "lucide-react"; import { Combobox } from "@headlessui/react"; +import { useTranslation } from "@plane/i18n"; // ui import { ComboDropDown, Spinner, StateGroupIcon } from "@plane/ui"; // helpers @@ -82,6 +83,7 @@ export const StateDropdown: React.FC = observer((props) => { ], }); // store hooks + const { t } = useTranslation(); const { workspaceSlug } = useParams(); const { fetchProjectStates, getProjectStates, getStateById } = useProjectState(); const statesList = stateIds @@ -160,8 +162,8 @@ export const StateDropdown: React.FC = observer((props) => { = observer((props) => { /> )} {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( - {selectedState?.name ?? "State"} + {selectedState?.name ?? t("state")} )} {dropdownArrow && (
diff --git a/web/core/components/editor/embeds/mentions/user.tsx b/web/core/components/editor/embeds/mentions/user.tsx index 20cfeb2baa5..76b8a0f4967 100644 --- a/web/core/components/editor/embeds/mentions/user.tsx +++ b/web/core/components/editor/embeds/mentions/user.tsx @@ -19,6 +19,8 @@ type Props = { export const EditorUserMention: React.FC = observer((props) => { const { id } = props; + // router + const { projectId } = useParams(); // states const [popperElement, setPopperElement] = useState(null); const [referenceElement, setReferenceElement] = useState(null); @@ -44,7 +46,7 @@ export const EditorUserMention: React.FC = observer((props) => { }); // derived values const userDetails = getUserDetails(id); - const roleDetails = getProjectMemberDetails(id)?.role; + const roleDetails = projectId ? getProjectMemberDetails(id, projectId.toString())?.role : null; const profileLink = `/${workspaceSlug}/profile/${id}`; if (!userDetails) { diff --git a/web/core/components/editor/index.ts b/web/core/components/editor/index.ts index e7651069ed4..674bbdf1582 100644 --- a/web/core/components/editor/index.ts +++ b/web/core/components/editor/index.ts @@ -2,3 +2,4 @@ export * from "./embeds"; export * from "./lite-text-editor"; export * from "./pdf"; export * from "./rich-text-editor"; +export * from "./sticky-editor"; diff --git a/web/core/components/editor/lite-text-editor/lite-text-editor.tsx b/web/core/components/editor/lite-text-editor/lite-text-editor.tsx index d1a7b06405d..afaff3d7ea6 100644 --- a/web/core/components/editor/lite-text-editor/lite-text-editor.tsx +++ b/web/core/components/editor/lite-text-editor/lite-text-editor.tsx @@ -14,9 +14,9 @@ import { useEditorMention } from "@/hooks/use-editor-mention"; // plane web hooks import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; import { useFileSize } from "@/plane-web/hooks/use-file-size"; -// services -import { ProjectService } from "@/services/project"; -const projectService = new ProjectService(); +// plane web services +import { WorkspaceService } from "@/plane-web/services"; +const workspaceService = new WorkspaceService(); interface LiteTextEditorWrapperProps extends Omit { @@ -30,6 +30,7 @@ interface LiteTextEditorWrapperProps isSubmitting?: boolean; showToolbarInitially?: boolean; uploadFile: (file: File) => Promise; + issue_id?: string; } export const LiteTextEditor = React.forwardRef((props, ref) => { @@ -38,6 +39,7 @@ export const LiteTextEditor = React.forwardRef - await projectService.searchEntity(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "", payload), + await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", { + ...payload, + project_id: projectId?.toString() ?? "", + issue_id: issue_id, + }), }); // file size const { maxFileSize } = useFileSize(); diff --git a/web/core/components/editor/sticky-editor/color-pallete.tsx b/web/core/components/editor/sticky-editor/color-pallete.tsx new file mode 100644 index 00000000000..3060650f7d3 --- /dev/null +++ b/web/core/components/editor/sticky-editor/color-pallete.tsx @@ -0,0 +1,36 @@ +import { TSticky } from "@plane/types"; + +export const STICKY_COLORS = [ + "#D4DEF7", // light periwinkle + "#B4E4FF", // light blue + "#FFF2B4", // light yellow + "#E3E3E3", // light gray + "#FFE2DD", // light pink + "#F5D1A5", // light orange + "#D1F7C4", // light green + "#E5D4FF", // light purple +]; + +type TProps = { + handleUpdate: (data: Partial) => Promise; +}; + +export const ColorPalette = (props: TProps) => { + const { handleUpdate } = props; + return ( +
+
Background colors
+
+ {STICKY_COLORS.map((color, index) => ( +
+
+ ); +}; diff --git a/web/core/components/editor/sticky-editor/editor.tsx b/web/core/components/editor/sticky-editor/editor.tsx new file mode 100644 index 00000000000..3dad67477f7 --- /dev/null +++ b/web/core/components/editor/sticky-editor/editor.tsx @@ -0,0 +1,109 @@ +import React, { useState } from "react"; +// plane constants +import { EIssueCommentAccessSpecifier } from "@plane/constants"; +// plane editor +import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor"; +// components +import { TSticky } from "@plane/types"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { getEditorFileHandlers } from "@/helpers/editor.helper"; +// hooks +// plane web hooks +import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; +import { useFileSize } from "@/plane-web/hooks/use-file-size"; +import { Toolbar } from "./toolbar"; + +interface StickyEditorWrapperProps + extends Omit { + workspaceSlug: string; + workspaceId: string; + projectId?: string; + accessSpecifier?: EIssueCommentAccessSpecifier; + handleAccessChange?: (accessKey: EIssueCommentAccessSpecifier) => void; + showAccessSpecifier?: boolean; + showSubmitButton?: boolean; + isSubmitting?: boolean; + showToolbarInitially?: boolean; + showToolbar?: boolean; + uploadFile: (file: File) => Promise; + parentClassName?: string; + handleColorChange: (data: Partial) => Promise; + handleDelete: () => void; +} + +export const StickyEditor = React.forwardRef((props, ref) => { + const { + containerClassName, + workspaceSlug, + workspaceId, + projectId, + handleDelete, + handleColorChange, + showToolbarInitially = true, + showToolbar = true, + parentClassName = "", + placeholder = "Add comment...", + uploadFile, + ...rest + } = props; + // states + const [isFocused, setIsFocused] = useState(showToolbarInitially); + // editor flaggings + const { liteTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString()); + // file size + const { maxFileSize } = useFileSize(); + function isMutableRefObject(ref: React.ForwardedRef): ref is React.MutableRefObject { + return !!ref && typeof ref === "object" && "current" in ref; + } + // derived values + const editorRef = isMutableRefObject(ref) ? ref.current : null; + + return ( +
!showToolbarInitially && setIsFocused(true)} + onBlur={() => !showToolbarInitially && setIsFocused(false)} + > + <>, + }} + placeholder={placeholder} + containerClassName={cn(containerClassName, "relative")} + {...rest} + /> +
+ { + // TODO: update this while toolbar homogenization + // @ts-expect-error type mismatch here + editorRef?.executeMenuItemCommand({ + itemKey: item.itemKey, + ...item.extraProps, + }); + }} + handleDelete={handleDelete} + handleColorChange={handleColorChange} + editorRef={editorRef} + /> +
+
+ ); +}); + +StickyEditor.displayName = "StickyEditor"; diff --git a/web/core/components/editor/sticky-editor/index.ts b/web/core/components/editor/sticky-editor/index.ts new file mode 100644 index 00000000000..f73ee92ef6e --- /dev/null +++ b/web/core/components/editor/sticky-editor/index.ts @@ -0,0 +1,2 @@ +export * from "./editor"; +export * from "./toolbar"; diff --git a/web/core/components/editor/sticky-editor/toolbar.tsx b/web/core/components/editor/sticky-editor/toolbar.tsx new file mode 100644 index 00000000000..c1686b44699 --- /dev/null +++ b/web/core/components/editor/sticky-editor/toolbar.tsx @@ -0,0 +1,131 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { Palette, Trash2 } from "lucide-react"; +// editor +import { EditorRefApi } from "@plane/editor"; +// ui +import { useOutsideClickDetector } from "@plane/hooks"; +import { TSticky } from "@plane/types"; +import { Tooltip } from "@plane/ui"; +// constants +import { TOOLBAR_ITEMS, ToolbarMenuItem } from "@/constants/editor"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { ColorPalette } from "./color-pallete"; + +type Props = { + executeCommand: (item: ToolbarMenuItem) => void; + editorRef: EditorRefApi | null; + handleColorChange: (data: Partial) => Promise; + handleDelete: () => void; +}; + +const toolbarItems = TOOLBAR_ITEMS.sticky; + +export const Toolbar: React.FC = (props) => { + const { executeCommand, editorRef, handleColorChange, handleDelete } = props; + + // State to manage active states of toolbar items + const [activeStates, setActiveStates] = useState>({}); + const [showColorPalette, setShowColorPalette] = useState(false); + const colorPaletteRef = React.useRef(null); + // Function to update active states + const updateActiveStates = useCallback(() => { + if (!editorRef) return; + const newActiveStates: Record = {}; + Object.values(toolbarItems) + .flat() + .forEach((item) => { + // TODO: update this while toolbar homogenization + // @ts-expect-error type mismatch here + newActiveStates[item.renderKey] = editorRef.isMenuItemActive({ + itemKey: item.itemKey, + ...item.extraProps, + }); + }); + setActiveStates(newActiveStates); + }, [editorRef]); + + // useEffect to call updateActiveStates when isActive prop changes + useEffect(() => { + if (!editorRef) return; + const unsubscribe = editorRef.onStateChange(updateActiveStates); + updateActiveStates(); + return () => unsubscribe(); + }, [editorRef, updateActiveStates]); + + useOutsideClickDetector(colorPaletteRef, () => setShowColorPalette(false)); + + return ( +
+
+ {/* color palette */} + {showColorPalette && } + + Background color +

+ } + > + +
+ +
+
+ {Object.keys(toolbarItems).map((key) => ( +
+ {toolbarItems[key].map((item) => { + const isItemActive = activeStates[item.renderKey]; + + return ( + + {item.name} + {item.shortcut && {item.shortcut.join(" + ")}} +

+ } + > + +
+ ); + })} +
+ ))} +
+
+
+ {/* delete action */} + + Delete +

+ } + > + +
+
+ ); +}; diff --git a/web/core/components/gantt-chart/sidebar/modules/sidebar.tsx b/web/core/components/gantt-chart/sidebar/modules/sidebar.tsx index daa2cf973c1..182fcefacc3 100644 --- a/web/core/components/gantt-chart/sidebar/modules/sidebar.tsx +++ b/web/core/components/gantt-chart/sidebar/modules/sidebar.tsx @@ -1,9 +1,14 @@ "use client"; +import { observer } from "mobx-react"; // ui import { Loader } from "@plane/ui"; // components -import { ChartDataType, IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart"; +import { IBlockUpdateData } from "@/components/gantt-chart"; +// hooks +import { useTimeLineChart } from "@/hooks/use-timeline-chart"; +// +import { ETimeLineTypeType } from "../../contexts"; import { GanttDnDHOC } from "../gantt-dnd-HOC"; import { handleOrderChange } from "../utils"; import { ModulesSidebarBlock } from "./block"; @@ -12,13 +17,14 @@ import { ModulesSidebarBlock } from "./block"; type Props = { title: string; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; - getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock; blockIds: string[]; enableReorder: boolean; }; -export const ModuleGanttSidebar: React.FC = (props) => { - const { blockUpdateHandler, blockIds, getBlockById, enableReorder } = props; +export const ModuleGanttSidebar: React.FC = observer((props) => { + const { blockUpdateHandler, blockIds, enableReorder } = props; + + const { getBlockById } = useTimeLineChart(ETimeLineTypeType.MODULE); const handleOnDrop = ( draggingBlockId: string | undefined, @@ -52,4 +58,4 @@ export const ModuleGanttSidebar: React.FC = (props) => { )}
); -}; +}); diff --git a/web/core/components/global/product-updates/footer.tsx b/web/core/components/global/product-updates/footer.tsx index 6dd2638332b..5d402e85eef 100644 --- a/web/core/components/global/product-updates/footer.tsx +++ b/web/core/components/global/product-updates/footer.tsx @@ -1,4 +1,5 @@ import Image from "next/image"; +import { useTranslation } from "@plane/i18n"; // ui import { getButtonStyling } from "@plane/ui"; // helpers @@ -6,38 +7,40 @@ import { cn } from "@/helpers/common.helper"; // assets import PlaneLogo from "@/public/plane-logos/blue-without-text.png"; -export const ProductUpdatesFooter = () => ( -
-
- { + const { t } = useTranslation(); + return ( +
+ -); + Plane + {t("powered_by_plane_pages")} + +
+ ); +}; diff --git a/web/core/components/global/product-updates/modal.tsx b/web/core/components/global/product-updates/modal.tsx index 4288d68ee35..c2700e18143 100644 --- a/web/core/components/global/product-updates/modal.tsx +++ b/web/core/components/global/product-updates/modal.tsx @@ -1,5 +1,6 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; +import { useTranslation } from "@plane/i18n"; // ui import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; // components @@ -16,7 +17,7 @@ export type ProductUpdatesModalProps = { export const ProductUpdatesModal: FC = observer((props) => { const { isOpen, handleClose } = props; - + const { t } = useTranslation(); const { config } = useInstance(); return ( @@ -27,17 +28,17 @@ export const ProductUpdatesModal: FC = observer((props