diff --git a/.dockerignore b/.dockerignore index 6d52ca7c8..32158cd9b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,7 @@ *.pyc .env venv +.venv node_modules/ **/node_modules/ npm-debug.log @@ -14,4 +15,4 @@ build/ out/ **/out/ dist/ -**/dist/ \ No newline at end of file +**/dist/ diff --git a/.gitignore b/.gitignore index 36f85dc78..a6a407ba9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules .next +.yarn ### NextJS ### # Dependencies @@ -52,6 +53,8 @@ mediafiles .env .DS_Store logs/ +htmlcov/ +.coverage node_modules/ assets/dist/ diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 000000000..3186f3f07 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 68ef89085..4a1567520 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,11 +35,12 @@ This helps us triage and manage issues more efficiently. ### Requirements -- Node.js version v16.18.0 +- Docker Engine installed and running +- Node.js version 20+ [LTS version](https://nodejs.org/en/about/previous-releases) - Python version 3.8+ - Postgres version v14 - Redis version v6.2.7 -- **Memory**: Minimum **12 GB RAM** recommended +- **Memory**: Minimum **12 GB RAM** recommended > ⚠️ Running the project on a system with only 8 GB RAM may lead to setup failures or memory crashes (especially during Docker container build/start or dependency install). Use cloud environments like GitHub Codespaces or upgrade local RAM if possible. ### Setup the project @@ -68,6 +69,17 @@ chmod +x setup.sh docker compose -f docker-compose-local.yml up ``` +4. Start web apps: + +```bash +yarn dev +``` + +5. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin +6. Open up your browser to http://localhost:3000 then log in using the same credentials from the previous step + +That’s it! You’re all set to begin coding. Remember to refresh your browser if changes don’t auto-reload. Happy contributing! 🎉 + ## Missing a Feature? If a feature is missing, you can directly _request_ a new one [here](https://github.com/makeplane/plane/issues/new?assignees=&labels=feature&template=feature_request.yml&title=%F0%9F%9A%80+Feature%3A+). You also can do the same by choosing "🚀 Feature" when raising a [New Issue](https://github.com/makeplane/plane/issues/new/choose) on our GitHub Repository. @@ -93,7 +105,7 @@ To ensure consistency throughout the source code, please keep these rules in min - **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations. ## Contributing to language support -This guide is designed to help contributors understand how to add or update translations in the application. +This guide is designed to help contributors understand how to add or update translations in the application. ### Understanding translation structure @@ -108,7 +120,7 @@ packages/i18n/src/locales/ ├── fr/ │ └── translations.json └── [language]/ - └── translations.json + └── translations.json ``` #### Nested structure To keep translations organized, we use a nested structure for keys. This makes it easier to manage and locate specific translations. For example: @@ -128,14 +140,14 @@ To keep translations organized, we use a nested structure for keys. This makes i We use [IntlMessageFormat](https://formatjs.github.io/docs/intl-messageformat/) to handle dynamic content, such as variables and pluralization. Here's how to format your translations: #### Examples -- **Simple variables** +- **Simple variables** ```json { "greeting": "Hello, {name}!" } ``` -- **Pluralization** +- **Pluralization** ```json { "items": "{count, plural, one {Work item} other {Work items}}" @@ -160,15 +172,15 @@ We use [IntlMessageFormat](https://formatjs.github.io/docs/intl-messageformat/) ### Adding new languages Adding a new language involves several steps to ensure it integrates seamlessly with the project. Follow these instructions carefully: -1. **Update type definitions** +1. **Update type definitions** Add the new language to the TLanguage type in the language definitions file: ```typescript // types/language.ts export type TLanguage = "en" | "fr" | "your-lang"; - ``` + ``` -2. **Add language configuration** +2. **Add language configuration** Include the new language in the list of supported languages: ```typescript @@ -179,14 +191,14 @@ Include the new language in the list of supported languages: ]; ``` -3. **Create translation files** +3. **Create translation files** 1. Create a new folder for your language under locales (e.g., `locales/your-lang/`). 2. Add a `translations.json` file inside the folder. 3. Copy the structure from an existing translation file and translate all keys. -4. **Update import logic** +4. **Update import logic** Modify the language import logic to include your new language: ```typescript diff --git a/README.md b/README.md index dad8b4558..da57f38d8 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,10 @@ Meet [Plane](https://plane.so/), an open-source project management tool to track Getting started with Plane is simple. Choose the setup that works best for you: -- **Plane Cloud** +- **Plane Cloud** Sign up for a free account on [Plane Cloud](https://app.plane.so)—it's the fastest way to get up and running without worrying about infrastructure. -- **Self-host Plane** +- **Self-host Plane** Prefer full control over your data and infrastructure? Install and run Plane on your own servers. Follow our detailed [deployment guides](https://developers.plane.so/self-hosting/overview) to get started. | Installation methods | Docs link | @@ -62,22 +62,22 @@ Prefer full control over your data and infrastructure? Install and run Plane on ## 🌟 Features -- **Issues** +- **Issues** Efficiently create and manage tasks with a robust rich text editor that supports file uploads. Enhance organization and tracking by adding sub-properties and referencing related issues. -- **Cycles** +- **Cycles** Maintain your team’s momentum with Cycles. Track progress effortlessly using burn-down charts and other insightful tools. -- **Modules** -Simplify complex projects by dividing them into smaller, manageable modules. +- **Modules** +Simplify complex projects by dividing them into smaller, manageable modules. -- **Views** +- **Views** Customize your workflow by creating filters to display only the most relevant issues. Save and share these views with ease. -- **Pages** +- **Pages** Capture and organize ideas using Plane Pages, complete with AI capabilities and a rich text editor. Format text, insert images, add hyperlinks, or convert your notes into actionable items. -- **Analytics** +- **Analytics** Access real-time insights across all your Plane data. Visualize trends, remove blockers, and keep your projects moving forward. - **Drive** (_coming soon_): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution. @@ -85,38 +85,7 @@ Access real-time insights across all your Plane data. Visualize trends, remove b ## 🛠️ Local development -### Pre-requisites -- Ensure Docker Engine is installed and running. - -### Development setup -Setting up your local environment is simple and straightforward. Follow these steps to get started: - -1. Clone the repository: - ``` - git clone https://github.com/makeplane/plane.git - ``` -2. Navigate to the project folder: - ``` - cd plane - ``` -3. Create a new branch for your feature or fix: - ``` - git checkout -b - ``` -4. Run the setup script in the terminal: - ``` - ./setup.sh - ``` -5. Open the project in an IDE such as VS Code. - -6. Review the `.env` files in the relevant folders. Refer to [Environment Setup](./ENV_SETUP.md) for details on the environment variables used. - -7. Start the services using Docker: - ``` - docker compose -f docker-compose-local.yml up -d - ``` - -That’s it! You’re all set to begin coding. Remember to refresh your browser if changes don’t auto-reload. Happy contributing! 🎉 +See [CONTRIBUTING](./CONTRIBUTING.md) ## ⚙️ Built with [![Next JS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/) @@ -194,7 +163,7 @@ Feel free to ask questions, report bugs, participate in discussions, share ideas If you discover a security vulnerability in Plane, please report it responsibly instead of opening a public issue. We take all legitimate reports seriously and will investigate them promptly. See [Security policy](https://github.com/makeplane/plane/blob/master/SECURITY.md) for more info. -To disclose any security issues, please email us at security@plane.so. +To disclose any security issues, please email us at security@plane.so. ## 🤝 Contributing @@ -219,4 +188,4 @@ Please read [CONTRIBUTING.md](https://github.com/makeplane/plane/blob/master/CON ## License -This project is licensed under the [GNU Affero General Public License v3.0](https://github.com/makeplane/plane/blob/master/LICENSE.txt). \ No newline at end of file +This project is licensed under the [GNU Affero General Public License v3.0](https://github.com/makeplane/plane/blob/master/LICENSE.txt). diff --git a/admin/.env.example b/admin/.env.example index fdeb05c4d..15d7a36a9 100644 --- a/admin/.env.example +++ b/admin/.env.example @@ -1,3 +1,12 @@ -NEXT_PUBLIC_API_BASE_URL="" +NEXT_PUBLIC_API_BASE_URL="http://localhost:8000" + +NEXT_PUBLIC_WEB_BASE_URL="http://localhost:3000" + +NEXT_PUBLIC_ADMIN_BASE_URL="http://localhost:3001" NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" -NEXT_PUBLIC_WEB_BASE_URL="" \ No newline at end of file + +NEXT_PUBLIC_SPACE_BASE_URL="http://localhost:3002" +NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" + +NEXT_PUBLIC_LIVE_BASE_URL="http://localhost:3100" +NEXT_PUBLIC_LIVE_BASE_PATH="/live" diff --git a/admin/app/ai/form.tsx b/admin/app/ai/form.tsx index 4258a99fb..47ab9480e 100644 --- a/admin/app/ai/form.tsx +++ b/admin/app/ai/form.tsx @@ -26,16 +26,16 @@ export const InstanceAIForm: FC = (props) => { formState: { errors, isSubmitting }, } = useForm({ defaultValues: { - OPENAI_API_KEY: config["OPENAI_API_KEY"], - GPT_ENGINE: config["GPT_ENGINE"], + LLM_API_KEY: config["LLM_API_KEY"], + LLM_MODEL: config["LLM_MODEL"], }, }); const aiFormFields: TControllerInputFormField[] = [ { - key: "GPT_ENGINE", + key: "LLM_MODEL", type: "text", - label: "GPT_ENGINE", + label: "LLM Model", description: ( <> Choose an OpenAI engine.{" "} @@ -49,12 +49,12 @@ export const InstanceAIForm: FC = (props) => { ), - placeholder: "gpt-3.5-turbo", - error: Boolean(errors.GPT_ENGINE), + placeholder: "gpt-4o-mini", + error: Boolean(errors.LLM_MODEL), required: false, }, { - key: "OPENAI_API_KEY", + key: "LLM_API_KEY", type: "password", label: "API key", description: ( @@ -71,7 +71,7 @@ export const InstanceAIForm: FC = (props) => { ), placeholder: "sk-asddassdfasdefqsdfasd23das3dasdcasd", - error: Boolean(errors.OPENAI_API_KEY), + error: Boolean(errors.LLM_API_KEY), required: false, }, ]; diff --git a/admin/app/authentication/github/form.tsx b/admin/app/authentication/github/form.tsx index 91795ea70..0c6d81ae6 100644 --- a/admin/app/authentication/github/form.tsx +++ b/admin/app/authentication/github/form.tsx @@ -98,11 +98,7 @@ export const InstanceGithubConfigForm: FC = (props) => { key: "GITHUB_ORGANIZATION_ID", type: "text", label: "Organization ID", - description: ( - <> - The organization github ID. - - ), + description: <>The organization github ID., placeholder: "123456789", error: Boolean(errors.GITHUB_ORGANIZATION_ID), required: false, diff --git a/admin/app/layout.tsx b/admin/app/layout.tsx index ef9559af8..b10e9186c 100644 --- a/admin/app/layout.tsx +++ b/admin/app/layout.tsx @@ -3,18 +3,16 @@ import { ReactNode } from "react"; import { ThemeProvider, useTheme } from "next-themes"; import { SWRConfig } from "swr"; -// ui +// plane imports import { ADMIN_BASE_PATH, DEFAULT_SWR_CONFIG } from "@plane/constants"; import { Toast } from "@plane/ui"; import { resolveGeneralTheme } from "@plane/utils"; -// constants -// helpers // lib import { InstanceProvider } from "@/lib/instance-provider"; import { StoreProvider } from "@/lib/store-provider"; import { UserProvider } from "@/lib/user-provider"; // styles -import "./globals.css"; +import "@/styles/globals.css"; const ToastWithTheme = () => { const { resolvedTheme } = useTheme(); diff --git a/admin/core/components/admin-sidebar/sidebar-dropdown.tsx b/admin/core/components/admin-sidebar/sidebar-dropdown.tsx index 501d501d8..0cde7f551 100644 --- a/admin/core/components/admin-sidebar/sidebar-dropdown.tsx +++ b/admin/core/components/admin-sidebar/sidebar-dropdown.tsx @@ -7,7 +7,7 @@ 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 { AuthService } from "@plane/services"; import { Avatar } from "@plane/ui"; import { getFileURL, cn } from "@plane/utils"; // hooks diff --git a/admin/core/components/auth-header.tsx b/admin/core/components/auth-header.tsx index 5edcb6118..b97dd7c9e 100644 --- a/admin/core/components/auth-header.tsx +++ b/admin/core/components/auth-header.tsx @@ -67,9 +67,8 @@ export const InstanceHeader: FC = observer(() => { {breadcrumbItems.length >= 0 && (
- { {breadcrumbItems.map( (item) => item.title && ( - } + component={} /> ) )} diff --git a/admin/core/components/authentication/auth-banner.tsx b/admin/core/components/authentication/auth-banner.tsx index 7c1e5ea29..5d63808f1 100644 --- a/admin/core/components/authentication/auth-banner.tsx +++ b/admin/core/components/authentication/auth-banner.tsx @@ -1,11 +1,11 @@ import { FC } from "react"; import { Info, X } from "lucide-react"; // plane constants -import { TAuthErrorInfo } from "@plane/constants"; +import { TAdminAuthErrorInfo } from "@plane/constants"; type TAuthBanner = { - bannerData: TAuthErrorInfo | undefined; - handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void; + bannerData: TAdminAuthErrorInfo | undefined; + handleBannerData?: (bannerData: TAdminAuthErrorInfo | undefined) => void; }; export const AuthBanner: FC = (props) => { diff --git a/admin/core/components/login/sign-in-form.tsx b/admin/core/components/login/sign-in-form.tsx index 986e5cebe..553ffe6c5 100644 --- a/admin/core/components/login/sign-in-form.tsx +++ b/admin/core/components/login/sign-in-form.tsx @@ -4,7 +4,7 @@ import { FC, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import { Eye, EyeOff } from "lucide-react"; // plane internal packages -import { API_BASE_URL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants"; +import { API_BASE_URL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants"; import { AuthService } from "@plane/services"; import { Button, Input, Spinner } from "@plane/ui"; // components @@ -54,7 +54,7 @@ export const InstanceSignInForm: FC = (props) => { const [csrfToken, setCsrfToken] = useState(undefined); const [formData, setFormData] = useState(defaultFromData); const [isSubmitting, setIsSubmitting] = useState(false); - const [errorInfo, setErrorInfo] = useState(undefined); + const [errorInfo, setErrorInfo] = useState(undefined); const handleFormChange = (key: keyof TFormData, value: string | boolean) => setFormData((prev) => ({ ...prev, [key]: value })); diff --git a/admin/core/lib/auth-helpers.tsx b/admin/core/lib/auth-helpers.tsx index 582b56e29..f9882b5e5 100644 --- a/admin/core/lib/auth-helpers.tsx +++ b/admin/core/lib/auth-helpers.tsx @@ -3,7 +3,7 @@ import Image from "next/image"; import Link from "next/link"; import { KeyRound, Mails } from "lucide-react"; // plane packages -import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants"; +import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants"; import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types"; import { resolveGeneralTheme } from "@plane/utils"; // components @@ -89,7 +89,7 @@ const errorCodeMessages: { export const authErrorHandler = ( errorCode: EAdminAuthErrorCodes, email?: string | undefined -): TAuthErrorInfo | undefined => { +): TAdminAuthErrorInfo | undefined => { const bannerAlertErrorCodes = [ EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST, EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME, diff --git a/admin/core/store/instance.store.ts b/admin/core/store/instance.store.ts index 9b25a2469..33954fe73 100644 --- a/admin/core/store/instance.store.ts +++ b/admin/core/store/instance.store.ts @@ -2,7 +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 { InstanceService } from "@plane/services"; import { IInstance, IInstanceAdmin, diff --git a/admin/ee/components/authentication/authentication-modes.tsx b/admin/ee/components/authentication/authentication-modes.tsx index 3a8ab7d1d..4e3b05a52 100644 --- a/admin/ee/components/authentication/authentication-modes.tsx +++ b/admin/ee/components/authentication/authentication-modes.tsx @@ -1 +1 @@ -export * from "ce/components/authentication/authentication-modes"; \ No newline at end of file +export * from "ce/components/authentication/authentication-modes"; diff --git a/admin/package.json b/admin/package.json index b8e18dc2c..0925e9d95 100644 --- a/admin/package.json +++ b/admin/package.json @@ -1,7 +1,7 @@ { "name": "admin", "description": "Admin UI for Plane", - "version": "0.26.1", + "version": "0.27.0", "license": "AGPL-3.0", "private": true, "scripts": { @@ -10,6 +10,7 @@ "build": "next build", "preview": "next build && next start", "start": "next start", + "format": "prettier --write .", "lint": "eslint . --ext .ts,.tsx", "lint:errors": "eslint . --ext .ts,.tsx --quiet" }, @@ -17,10 +18,11 @@ "@headlessui/react": "^1.7.19", "@plane/constants": "*", "@plane/hooks": "*", + "@plane/propel": "*", + "@plane/services": "*", "@plane/types": "*", "@plane/ui": "*", "@plane/utils": "*", - "@plane/services": "*", "@tailwindcss/typography": "^0.5.9", "@types/lodash": "^4.17.0", "autoprefixer": "10.4.14", @@ -29,7 +31,7 @@ "lucide-react": "^0.469.0", "mobx": "^6.12.0", "mobx-react": "^9.1.1", - "next": "^14.2.29", + "next": "14.2.30", "next-themes": "^0.2.1", "postcss": "^8.4.38", "react": "^18.3.1", @@ -48,6 +50,6 @@ "@types/react-dom": "^18.2.18", "@types/uuid": "^9.0.8", "@types/zxcvbn": "^4.4.4", - "typescript": "5.3.3" + "typescript": "5.8.3" } } diff --git a/admin/postcss.config.js b/admin/postcss.config.js index 6887c8262..9b1e55fc4 100644 --- a/admin/postcss.config.js +++ b/admin/postcss.config.js @@ -1,8 +1,2 @@ -module.exports = { - plugins: { - "postcss-import": {}, - "tailwindcss/nesting": {}, - tailwindcss: {}, - autoprefixer: {}, - }, -}; +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = require("@plane/tailwind-config/postcss.config.js"); diff --git a/admin/app/globals.css b/admin/styles/globals.css similarity index 92% rename from admin/app/globals.css rename to admin/styles/globals.css index 0a2218c21..d5554ce2f 100644 --- a/admin/app/globals.css +++ b/admin/styles/globals.css @@ -1,5 +1,4 @@ -@import url("https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700;800&display=swap"); -@import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@48,400,0,0&display=swap"); +@import "@plane/propel/styles/fonts"; @tailwind base; @tailwind components; @@ -60,23 +59,31 @@ --color-border-300: 212, 212, 212; /* strong border- 1 */ --color-border-400: 185, 185, 185; /* strong border- 2 */ - --color-shadow-2xs: 0px 0px 1px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.06), + --color-shadow-2xs: + 0px 0px 1px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.14); - --color-shadow-xs: 0px 1px 2px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(16, 24, 40, 0.12), + --color-shadow-xs: + 0px 1px 2px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(16, 24, 40, 0.12), 0px 1px 8px -1px rgba(16, 24, 40, 0.1); - --color-shadow-sm: 0px 1px 4px 0px rgba(0, 0, 0, 0.01), 0px 4px 8px 0px rgba(0, 0, 0, 0.02), - 0px 1px 12px 0px rgba(0, 0, 0, 0.12); - --color-shadow-rg: 0px 3px 6px 0px rgba(0, 0, 0, 0.1), 0px 4px 4px 0px rgba(16, 24, 40, 0.08), + --color-shadow-sm: + 0px 1px 4px 0px rgba(0, 0, 0, 0.01), 0px 4px 8px 0px rgba(0, 0, 0, 0.02), 0px 1px 12px 0px rgba(0, 0, 0, 0.12); + --color-shadow-rg: + 0px 3px 6px 0px rgba(0, 0, 0, 0.1), 0px 4px 4px 0px rgba(16, 24, 40, 0.08), 0px 1px 12px 0px rgba(16, 24, 40, 0.04); - --color-shadow-md: 0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), + --color-shadow-md: + 0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), 0px 1px 16px 0px rgba(16, 24, 40, 0.12); - --color-shadow-lg: 0px 6px 12px 0px rgba(0, 0, 0, 0.12), 0px 8px 16px 0px rgba(0, 0, 0, 0.12), + --color-shadow-lg: + 0px 6px 12px 0px rgba(0, 0, 0, 0.12), 0px 8px 16px 0px rgba(0, 0, 0, 0.12), 0px 1px 24px 0px rgba(16, 24, 40, 0.12); - --color-shadow-xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.16), 0px 0px 24px 0px rgba(16, 24, 40, 0.16), + --color-shadow-xl: + 0px 0px 18px 0px rgba(0, 0, 0, 0.16), 0px 0px 24px 0px rgba(16, 24, 40, 0.16), 0px 0px 52px 0px rgba(16, 24, 40, 0.16); - --color-shadow-2xl: 0px 8px 16px 0px rgba(0, 0, 0, 0.12), 0px 12px 24px 0px rgba(16, 24, 40, 0.12), + --color-shadow-2xl: + 0px 8px 16px 0px rgba(0, 0, 0, 0.12), 0px 12px 24px 0px rgba(16, 24, 40, 0.12), 0px 1px 32px 0px rgba(16, 24, 40, 0.12); - --color-shadow-3xl: 0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12), + --color-shadow-3xl: + 0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12), 0px 1px 48px 0px rgba(16, 24, 40, 0.12); --color-shadow-4xl: 0px 8px 40px 0px rgba(0, 0, 61, 0.05), 0px 12px 32px -16px rgba(0, 0, 0, 0.05); diff --git a/admin/tsconfig.json b/admin/tsconfig.json index f9bb7cf10..df72d07b4 100644 --- a/admin/tsconfig.json +++ b/admin/tsconfig.json @@ -1,13 +1,19 @@ { "extends": "@plane/typescript-config/nextjs.json", "compilerOptions": { - "plugins": [{ "name": "next" }], + "plugins": [ + { + "name": "next" + } + ], "baseUrl": ".", "paths": { "@/*": ["core/*"], "@/public/*": ["public/*"], - "@/plane-admin/*": ["ce/*"] - } + "@/plane-admin/*": ["ce/*"], + "@/styles/*": ["styles/*"] + }, + "strictNullChecks": true }, "include": ["next-env.d.ts", "next.config.js", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] diff --git a/apiserver/.coveragerc b/apiserver/.coveragerc new file mode 100644 index 000000000..bd829d141 --- /dev/null +++ b/apiserver/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = plane +omit = + */tests/* + */migrations/* + */settings/* + */wsgi.py + */asgi.py + */urls.py + manage.py + */admin.py + */apps.py + +[report] +exclude_lines = + pragma: no cover + def __repr__ + if self.debug: + raise NotImplementedError + if __name__ == .__main__. + pass + raise ImportError + +[html] +directory = htmlcov \ No newline at end of file diff --git a/apiserver/.env.example b/apiserver/.env.example index b56494c35..7fdffd179 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -1,7 +1,7 @@ # Backend # Debug value for api server use it as 0 for production use DEBUG=0 -CORS_ALLOWED_ORIGINS="http://localhost" +CORS_ALLOWED_ORIGINS="http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3100" # Database Settings POSTGRES_USER="plane" @@ -27,7 +27,7 @@ RABBITMQ_VHOST="plane" AWS_REGION="" AWS_ACCESS_KEY_ID="access-key" AWS_SECRET_ACCESS_KEY="secret-key" -AWS_S3_ENDPOINT_URL="http://plane-minio:9000" +AWS_S3_ENDPOINT_URL="http://localhost:9000" # Changing this requires change in the nginx.conf for uploads if using minio setup AWS_S3_BUCKET_NAME="uploads" # Maximum file upload limit @@ -37,22 +37,31 @@ FILE_SIZE_LIMIT=5242880 DOCKERIZED=1 # deprecated # set to 1 If using the pre-configured minio setup -USE_MINIO=1 +USE_MINIO=0 # Nginx Configuration NGINX_PORT=80 # Email redirections and minio domain settings -WEB_URL="http://localhost" +WEB_URL="http://localhost:8000" # Gunicorn Workers GUNICORN_WORKERS=2 # Base URLs -ADMIN_BASE_URL= -SPACE_BASE_URL= -APP_BASE_URL= +ADMIN_BASE_URL="http://localhost:3001" +ADMIN_BASE_PATH="/god-mode" +SPACE_BASE_URL="http://localhost:3002" +SPACE_BASE_PATH="/spaces" + +APP_BASE_URL="http://localhost:3000" +APP_BASE_PATH="" + +LIVE_BASE_URL="http://localhost:3100" +LIVE_BASE_PATH="/live" + +LIVE_SERVER_SECRET_KEY="secret-key" # Hard delete files after days HARD_DELETE_AFTER_DAYS=60 diff --git a/apiserver/package.json b/apiserver/package.json index a6ae894c3..6b3118020 100644 --- a/apiserver/package.json +++ b/apiserver/package.json @@ -1,6 +1,6 @@ { "name": "plane-api", - "version": "0.26.1", + "version": "0.27.0", "license": "AGPL-3.0", "private": true, "description": "API server powering Plane's backend" diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 4eb1457ce..8c84b2328 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -15,4 +15,4 @@ from .state import StateLiteSerializer, StateSerializer from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer from .intake import IntakeIssueSerializer -from .estimate import EstimatePointSerializer \ No newline at end of file +from .estimate import EstimatePointSerializer diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index ba22e25f9..7a78b6664 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -48,11 +48,6 @@ class CycleSerializer(BaseSerializer): if not project_id: raise serializers.ValidationError("Project ID is required") - is_start_date_end_date_equal = ( - True - if str(data.get("start_date")) == str(data.get("end_date")) - else False - ) data["start_date"] = convert_to_utc( date=str(data.get("start_date").date()), project_id=project_id, @@ -61,7 +56,6 @@ class CycleSerializer(BaseSerializer): data["end_date"] = convert_to_utc( date=str(data.get("end_date", None).date()), project_id=project_id, - is_start_date_end_date_equal=is_start_date_end_date_equal, ) return data diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 82969efe7..10738b97f 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -160,12 +160,15 @@ class IssueSerializer(BaseSerializer): else: try: # Then assign it to default assignee, if it is a valid assignee - if default_assignee_id is not None and ProjectMember.objects.filter( - member_id=default_assignee_id, - project_id=project_id, - role__gte=15, - is_active=True - ).exists(): + if ( + default_assignee_id is not None + and ProjectMember.objects.filter( + member_id=default_assignee_id, + project_id=project_id, + role__gte=15, + is_active=True, + ).exists() + ): IssueAssignee.objects.create( assignee_id=default_assignee_id, issue=issue, diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 3e27ffdc4..9005821f3 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -788,6 +788,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, issue_cycle__deleted_at__isnull=True, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -799,6 +800,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, issue_cycle__deleted_at__isnull=True, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -847,6 +849,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): ) ) ) + old_cycle = old_cycle.first() estimate_type = Project.objects.filter( workspace__slug=slug, @@ -966,7 +969,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): ) estimate_completion_chart = burndown_plot( - queryset=old_cycle.first(), + queryset=old_cycle, slug=slug, project_id=project_id, plot_type="points", @@ -1114,7 +1117,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): # Pass the new_cycle queryset to burndown_plot completion_chart = burndown_plot( - queryset=old_cycle.first(), + queryset=old_cycle, slug=slug, project_id=project_id, plot_type="issues", @@ -1126,12 +1129,12 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): ).first() current_cycle.progress_snapshot = { - "total_issues": old_cycle.first().total_issues, - "completed_issues": old_cycle.first().completed_issues, - "cancelled_issues": old_cycle.first().cancelled_issues, - "started_issues": old_cycle.first().started_issues, - "unstarted_issues": old_cycle.first().unstarted_issues, - "backlog_issues": old_cycle.first().backlog_issues, + "total_issues": old_cycle.total_issues, + "completed_issues": old_cycle.completed_issues, + "cancelled_issues": old_cycle.cancelled_issues, + "started_issues": old_cycle.started_issues, + "unstarted_issues": old_cycle.unstarted_issues, + "backlog_issues": old_cycle.backlog_issues, "distribution": { "labels": label_distribution_data, "assignees": assignee_distribution_data, diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index efbdf07f9..9e8579526 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -58,7 +58,7 @@ from plane.bgtasks.storage_metadata_task import get_asset_object_metadata from .base import BaseAPIView from plane.utils.host import base_host from plane.bgtasks.webhook_task import model_activity - +from plane.bgtasks.work_item_link_task import crawl_work_item_link_title class WorkspaceIssueAPIEndpoint(BaseAPIView): """ @@ -692,6 +692,9 @@ class IssueLinkAPIEndpoint(BaseAPIView): serializer = IssueLinkSerializer(data=request.data) if serializer.is_valid(): serializer.save(project_id=project_id, issue_id=issue_id) + crawl_work_item_link_title.delay( + serializer.data.get("id"), serializer.data.get("url") + ) link = IssueLink.objects.get(pk=serializer.data["id"]) link.created_by_id = request.data.get("created_by", request.user.id) @@ -719,6 +722,9 @@ class IssueLinkAPIEndpoint(BaseAPIView): serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) if serializer.is_valid(): serializer.save() + crawl_work_item_link_title.delay( + serializer.data.get("id"), serializer.data.get("url") + ) issue_activity.delay( type="link.activity.updated", requested_data=requested_data, diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 5ceb06a63..038d4faec 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -172,14 +172,14 @@ class ProjectAPIEndpoint(BaseAPIView): states = [ { "name": "Backlog", - "color": "#A3A3A3", + "color": "#60646C", "sequence": 15000, "group": "backlog", "default": True, }, { "name": "Todo", - "color": "#3A3A3A", + "color": "#60646C", "sequence": 25000, "group": "unstarted", }, @@ -191,13 +191,13 @@ class ProjectAPIEndpoint(BaseAPIView): }, { "name": "Done", - "color": "#16A34A", + "color": "#46A758", "sequence": 45000, "group": "completed", }, { "name": "Cancelled", - "color": "#EF4444", + "color": "#9AA4BC", "sequence": 55000, "group": "cancelled", }, diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index 623071573..f0d98886e 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -39,7 +39,7 @@ from .project import ( ProjectMemberRoleSerializer, ) from .state import StateSerializer, StateLiteSerializer -from .view import IssueViewSerializer +from .view import IssueViewSerializer, ViewIssueListSerializer from .cycle import ( CycleSerializer, CycleIssueSerializer, @@ -74,6 +74,7 @@ from .issue import ( IssueLinkLiteSerializer, IssueVersionDetailSerializer, IssueDescriptionVersionDetailSerializer, + IssueListDetailSerializer, ) from .module import ( diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py index b56b08350..b3b69e375 100644 --- a/apiserver/plane/app/serializers/cycle.py +++ b/apiserver/plane/app/serializers/cycle.py @@ -25,11 +25,6 @@ class CycleWriteSerializer(BaseSerializer): 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( date=str(data.get("start_date").date()), project_id=project_id, @@ -38,7 +33,6 @@ class CycleWriteSerializer(BaseSerializer): data["end_date"] = convert_to_utc( date=str(data.get("end_date", None).date()), project_id=project_id, - is_start_date_end_date_equal=is_start_date_end_date_equal, ) return data diff --git a/apiserver/plane/app/serializers/favorite.py b/apiserver/plane/app/serializers/favorite.py index 18f92f3ea..940b8ee82 100644 --- a/apiserver/plane/app/serializers/favorite.py +++ b/apiserver/plane/app/serializers/favorite.py @@ -53,6 +53,7 @@ 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/issue.py b/apiserver/plane/app/serializers/issue.py index e2e943805..c2aca4f81 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -725,6 +725,110 @@ class IssueSerializer(DynamicBaseSerializer): read_only_fields = fields +class IssueListDetailSerializer(serializers.Serializer): + + def __init__(self, *args, **kwargs): + # Extract expand parameter and store it as instance variable + self.expand = kwargs.pop("expand", []) or [] + # Extract fields parameter and store it as instance variable + self.fields = kwargs.pop("fields", []) or [] + super().__init__(*args, **kwargs) + + def get_module_ids(self, obj): + return [module.module_id for module in obj.issue_module.all()] + + def get_label_ids(self, obj): + return [label.label_id for label in obj.label_issue.all()] + + def get_assignee_ids(self, obj): + return [assignee.assignee_id for assignee in obj.issue_assignee.all()] + + def to_representation(self, instance): + data = { + # Basic fields + "id": instance.id, + "name": instance.name, + "state_id": instance.state_id, + "sort_order": instance.sort_order, + "completed_at": instance.completed_at, + "estimate_point": instance.estimate_point_id, + "priority": instance.priority, + "start_date": instance.start_date, + "target_date": instance.target_date, + "sequence_id": instance.sequence_id, + "project_id": instance.project_id, + "parent_id": instance.parent_id, + "created_at": instance.created_at, + "updated_at": instance.updated_at, + "created_by": instance.created_by_id, + "updated_by": instance.updated_by_id, + "is_draft": instance.is_draft, + "archived_at": instance.archived_at, + # Computed fields + "cycle_id": instance.cycle_id, + "module_ids": self.get_module_ids(instance), + "label_ids": self.get_label_ids(instance), + "assignee_ids": self.get_assignee_ids(instance), + "sub_issues_count": instance.sub_issues_count, + "attachment_count": instance.attachment_count, + "link_count": instance.link_count, + } + + # Handle expanded fields only when requested - using direct field access + if self.expand: + if "issue_relation" in self.expand: + relations = [] + for relation in instance.issue_relation.all(): + related_issue = relation.related_issue + # If the related issue is deleted, skip it + if not related_issue: + continue + # Add the related issue to the relations list + relations.append( + { + "id": related_issue.id, + "project_id": related_issue.project_id, + "sequence_id": related_issue.sequence_id, + "name": related_issue.name, + "relation_type": relation.relation_type, + "state_id": related_issue.state_id, + "priority": related_issue.priority, + "created_by": related_issue.created_by_id, + "created_at": related_issue.created_at, + "updated_at": related_issue.updated_at, + "updated_by": related_issue.updated_by_id, + } + ) + data["issue_relation"] = relations + + if "issue_related" in self.expand: + related = [] + for relation in instance.issue_related.all(): + issue = relation.issue + # If the related issue is deleted, skip it + if not issue: + continue + # Add the related issue to the related list + related.append( + { + "id": issue.id, + "project_id": issue.project_id, + "sequence_id": issue.sequence_id, + "name": issue.name, + "relation_type": relation.relation_type, + "state_id": issue.state_id, + "priority": issue.priority, + "created_by": issue.created_by_id, + "created_at": issue.created_at, + "updated_at": issue.updated_at, + "updated_by": issue.updated_by_id, + } + ) + data["issue_related"] = related + + return data + + class IssueLiteSerializer(DynamicBaseSerializer): class Meta: model = Issue diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py index 73c8a85d9..8d521e8e8 100644 --- a/apiserver/plane/app/serializers/project.py +++ b/apiserver/plane/app/serializers/project.py @@ -148,10 +148,13 @@ class ProjectMemberAdminSerializer(BaseSerializer): fields = "__all__" -class ProjectMemberRoleSerializer(DynamicBaseSerializer): +class ProjectMemberRoleSerializer(DynamicBaseSerializer): + original_role = serializers.IntegerField(source='role', read_only=True) + class Meta: model = ProjectMember - fields = ("id", "role", "member", "project") + fields = ("id", "role", "member", "project", "original_role", "created_at") + read_only_fields = ["original_role", "created_at"] class ProjectMemberInviteSerializer(BaseSerializer): diff --git a/apiserver/plane/app/serializers/state.py b/apiserver/plane/app/serializers/state.py index 61af5cab7..29d8cf302 100644 --- a/apiserver/plane/app/serializers/state.py +++ b/apiserver/plane/app/serializers/state.py @@ -1,11 +1,13 @@ # Module imports from .base import BaseSerializer - +from rest_framework import serializers from plane.db.models import State class StateSerializer(BaseSerializer): + order = serializers.FloatField(required=False) + class Meta: model = State fields = [ @@ -18,6 +20,7 @@ class StateSerializer(BaseSerializer): "default", "description", "sequence", + "order", ] read_only_fields = ["workspace", "project"] diff --git a/apiserver/plane/app/serializers/user.py b/apiserver/plane/app/serializers/user.py index ebc002c9c..c5a3d35df 100644 --- a/apiserver/plane/app/serializers/user.py +++ b/apiserver/plane/app/serializers/user.py @@ -3,11 +3,22 @@ from rest_framework import serializers # Module import from plane.db.models import Account, Profile, User, Workspace, WorkspaceMemberInvite +from plane.utils.url import contains_url from .base import BaseSerializer class UserSerializer(BaseSerializer): + def validate_first_name(self, value): + if contains_url(value): + raise serializers.ValidationError("First name cannot contain a URL.") + return value + + def validate_last_name(self, value): + if contains_url(value): + raise serializers.ValidationError("Last name cannot contain a URL.") + return value + class Meta: model = User # Exclude password field from the serializer @@ -99,11 +110,16 @@ class UserMeSettingsSerializer(BaseSerializer): workspace_member__member=obj.id, workspace_member__is_active=True, ).first() + logo_asset_url = workspace.logo_asset.asset_url if workspace.logo_asset is not None else "" return { "last_workspace_id": profile.last_workspace_id, "last_workspace_slug": ( workspace.slug if workspace is not None else "" ), + "last_workspace_name": ( + workspace.name if workspace is not None else "" + ), + "last_workspace_logo": (logo_asset_url), "fallback_workspace_id": profile.last_workspace_id, "fallback_workspace_slug": ( workspace.slug if workspace is not None else "" diff --git a/apiserver/plane/app/serializers/view.py b/apiserver/plane/app/serializers/view.py index b8376a047..94ff68de3 100644 --- a/apiserver/plane/app/serializers/view.py +++ b/apiserver/plane/app/serializers/view.py @@ -7,6 +7,49 @@ from plane.db.models import IssueView from plane.utils.issue_filters import issue_filters +class ViewIssueListSerializer(serializers.Serializer): + + def get_assignee_ids(self, instance): + return [assignee.assignee_id for assignee in instance.issue_assignee.all()] + + def get_label_ids(self, instance): + return [label.label_id for label in instance.label_issue.all()] + + def get_module_ids(self, instance): + return [module.module_id for module in instance.issue_module.all()] + + def to_representation(self, instance): + data = { + "id": instance.id, + "name": instance.name, + "state_id": instance.state_id, + "sort_order": instance.sort_order, + "completed_at": instance.completed_at, + "estimate_point": instance.estimate_point_id, + "priority": instance.priority, + "start_date": instance.start_date, + "target_date": instance.target_date, + "sequence_id": instance.sequence_id, + "project_id": instance.project_id, + "parent_id": instance.parent_id, + "cycle_id": instance.cycle_id, + "sub_issues_count": instance.sub_issues_count, + "created_at": instance.created_at, + "updated_at": instance.updated_at, + "created_by": instance.created_by_id, + "updated_by": instance.updated_by_id, + "attachment_count": instance.attachment_count, + "link_count": instance.link_count, + "is_draft": instance.is_draft, + "archived_at": instance.archived_at, + "state__group": instance.state.group if instance.state else None, + "assignee_ids": self.get_assignee_ids(instance), + "label_ids": self.get_label_ids(instance), + "module_ids": self.get_module_ids(instance), + } + return data + + class IssueViewSerializer(DynamicBaseSerializer): is_favorite = serializers.BooleanField(read_only=True) diff --git a/apiserver/plane/app/serializers/workspace.py b/apiserver/plane/app/serializers/workspace.py index 52333c246..b4e75a506 100644 --- a/apiserver/plane/app/serializers/workspace.py +++ b/apiserver/plane/app/serializers/workspace.py @@ -1,7 +1,5 @@ # 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 @@ -25,10 +23,12 @@ from plane.db.models import ( WorkspaceUserPreference, ) from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS +from plane.utils.url import contains_url # Django imports from django.core.validators import URLValidator from django.core.exceptions import ValidationError +import re class WorkSpaceSerializer(DynamicBaseSerializer): @@ -36,10 +36,21 @@ class WorkSpaceSerializer(DynamicBaseSerializer): logo_url = serializers.CharField(read_only=True) role = serializers.IntegerField(read_only=True) + def validate_name(self, value): + # Check if the name contains a URL + if contains_url(value): + raise serializers.ValidationError("Name must not contain URLs") + return value + def validate_slug(self, value): # Check if the slug is restricted if value in RESTRICTED_WORKSPACE_SLUGS: raise serializers.ValidationError("Slug is not valid") + # Slug should only contain alphanumeric characters, hyphens, and underscores + if not re.match(r"^[a-zA-Z0-9_-]+$", value): + raise serializers.ValidationError( + "Slug can only contain letters, numbers, hyphens (-), and underscores (_)" + ) return value class Meta: @@ -148,7 +159,6 @@ class WorkspaceUserLinkSerializer(BaseSerializer): return value - def create(self, validated_data): # Filtering the WorkspaceUserLink with the given url to check if the link already exists. @@ -157,7 +167,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer): workspace_user_link = WorkspaceUserLink.objects.filter( url=url, workspace_id=validated_data.get("workspace_id"), - owner_id=validated_data.get("owner_id") + owner_id=validated_data.get("owner_id"), ) if workspace_user_link.exists(): @@ -173,10 +183,8 @@ class WorkspaceUserLinkSerializer(BaseSerializer): url = validated_data.get("url") workspace_user_link = WorkspaceUserLink.objects.filter( - url=url, - workspace_id=instance.workspace_id, - owner=instance.owner - ) + url=url, workspace_id=instance.workspace_id, owner=instance.owner + ) if workspace_user_link.exclude(pk=instance.id).exists(): raise serializers.ValidationError( @@ -185,8 +193,10 @@ class WorkspaceUserLinkSerializer(BaseSerializer): return super().update(instance, validated_data) + class IssueRecentVisitSerializer(serializers.ModelSerializer): project_identifier = serializers.SerializerMethodField() + assignees = serializers.SerializerMethodField() class Meta: model = Issue @@ -204,9 +214,15 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer): def get_project_identifier(self, obj): project = obj.project - return project.identifier if project else None + def get_assignees(self, obj): + return list( + obj.assignees.filter(issue_assignee__deleted_at__isnull=True).values_list( + "id", flat=True + ) + ) + class ProjectRecentVisitSerializer(serializers.ModelSerializer): project_members = serializers.SerializerMethodField() diff --git a/apiserver/plane/app/urls/analytic.py b/apiserver/plane/app/urls/analytic.py index abe18f2ad..3e4172771 100644 --- a/apiserver/plane/app/urls/analytic.py +++ b/apiserver/plane/app/urls/analytic.py @@ -6,8 +6,14 @@ from plane.app.views import ( AnalyticViewViewset, SavedAnalyticEndpoint, ExportAnalyticsEndpoint, + AdvanceAnalyticsEndpoint, + AdvanceAnalyticsStatsEndpoint, + AdvanceAnalyticsChartEndpoint, DefaultAnalyticsEndpoint, ProjectStatsEndpoint, + ProjectAdvanceAnalyticsEndpoint, + ProjectAdvanceAnalyticsStatsEndpoint, + ProjectAdvanceAnalyticsChartEndpoint, ) @@ -49,4 +55,34 @@ urlpatterns = [ ProjectStatsEndpoint.as_view(), name="project-analytics", ), + path( + "workspaces//advance-analytics/", + AdvanceAnalyticsEndpoint.as_view(), + name="advance-analytics", + ), + path( + "workspaces//advance-analytics-stats/", + AdvanceAnalyticsStatsEndpoint.as_view(), + name="advance-analytics-stats", + ), + path( + "workspaces//advance-analytics-charts/", + AdvanceAnalyticsChartEndpoint.as_view(), + name="advance-analytics-chart", + ), + path( + "workspaces//projects//advance-analytics/", + ProjectAdvanceAnalyticsEndpoint.as_view(), + name="project-advance-analytics", + ), + path( + "workspaces//projects//advance-analytics-stats/", + ProjectAdvanceAnalyticsStatsEndpoint.as_view(), + name="project-advance-analytics-stats", + ), + path( + "workspaces//projects//advance-analytics-charts/", + ProjectAdvanceAnalyticsChartEndpoint.as_view(), + name="project-advance-analytics-chart", + ), ] diff --git a/apiserver/plane/app/urls/api.py b/apiserver/plane/app/urls/api.py index 592ff53b5..c74aeddbf 100644 --- a/apiserver/plane/app/urls/api.py +++ b/apiserver/plane/app/urls/api.py @@ -4,14 +4,14 @@ from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint urlpatterns = [ # API Tokens path( - "workspaces//api-tokens/", + "users/api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens", ), path( - "workspaces//api-tokens//", + "users/api-tokens//", ApiTokenEndpoint.as_view(), - name="api-tokens", + name="api-tokens-details", ), path( "workspaces//service-api-tokens/", diff --git a/apiserver/plane/app/urls/asset.py b/apiserver/plane/app/urls/asset.py index ec0f41b62..93356b04c 100644 --- a/apiserver/plane/app/urls/asset.py +++ b/apiserver/plane/app/urls/asset.py @@ -12,6 +12,9 @@ from plane.app.views import ( AssetRestoreEndpoint, ProjectAssetEndpoint, ProjectBulkAssetEndpoint, + AssetCheckEndpoint, + WorkspaceAssetDownloadEndpoint, + ProjectAssetDownloadEndpoint, ) @@ -81,5 +84,21 @@ urlpatterns = [ path( "assets/v2/workspaces//projects///bulk/", ProjectBulkAssetEndpoint.as_view(), + name="bulk-asset-update", + ), + path( + "assets/v2/workspaces//check//", + AssetCheckEndpoint.as_view(), + name="asset-check", + ), + path( + "assets/v2/workspaces//download//", + WorkspaceAssetDownloadEndpoint.as_view(), + name="workspace-asset-download", + ), + path( + "assets/v2/workspaces//projects//download//", + ProjectAssetDownloadEndpoint.as_view(), + name="project-asset-download", ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 7baba9bb0..6d56473e3 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -106,6 +106,9 @@ from .asset.v2 import ( AssetRestoreEndpoint, ProjectAssetEndpoint, ProjectBulkAssetEndpoint, + AssetCheckEndpoint, + WorkspaceAssetDownloadEndpoint, + ProjectAssetDownloadEndpoint, ) from .issue.base import ( IssueListEndpoint, @@ -199,6 +202,18 @@ from .analytic.base import ( ProjectStatsEndpoint, ) +from .analytic.advance import ( + AdvanceAnalyticsEndpoint, + AdvanceAnalyticsStatsEndpoint, + AdvanceAnalyticsChartEndpoint, +) + +from .analytic.project_analytics import ( + ProjectAdvanceAnalyticsEndpoint, + ProjectAdvanceAnalyticsStatsEndpoint, + ProjectAdvanceAnalyticsChartEndpoint, +) + from .notification.base import ( NotificationViewSet, UnreadNotificationEndpoint, diff --git a/apiserver/plane/app/views/analytic/advance.py b/apiserver/plane/app/views/analytic/advance.py new file mode 100644 index 000000000..8a2aea90b --- /dev/null +++ b/apiserver/plane/app/views/analytic/advance.py @@ -0,0 +1,366 @@ +from rest_framework.response import Response +from rest_framework import status +from typing import Dict, List, Any +from django.db.models import QuerySet, Q, Count +from django.http import HttpRequest +from django.db.models.functions import TruncMonth +from django.utils import timezone +from plane.app.views.base import BaseAPIView +from plane.app.permissions import ROLE, allow_permission +from plane.db.models import ( + WorkspaceMember, + Project, + Issue, + Cycle, + Module, + IssueView, + ProjectPage, + Workspace, + CycleIssue, + ModuleIssue, + ProjectMember, +) +from plane.utils.build_chart import build_analytics_chart +from plane.utils.date_utils import ( + get_analytics_filters, +) + + +class AdvanceAnalyticsBaseView(BaseAPIView): + def initialize_workspace(self, slug: str, type: str) -> None: + self._workspace_slug = slug + self.filters = get_analytics_filters( + slug=slug, + type=type, + user=self.request.user, + date_filter=self.request.GET.get("date_filter", None), + project_ids=self.request.GET.get("project_ids", None), + ) + + +class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView): + def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]: + def get_filtered_count() -> int: + if self.filters["analytics_date_range"]: + return queryset.filter( + created_at__gte=self.filters["analytics_date_range"]["current"][ + "gte" + ], + created_at__lte=self.filters["analytics_date_range"]["current"][ + "lte" + ], + ).count() + return queryset.count() + + def get_previous_count() -> int: + if self.filters["analytics_date_range"] and self.filters[ + "analytics_date_range" + ].get("previous"): + return queryset.filter( + created_at__gte=self.filters["analytics_date_range"]["previous"][ + "gte" + ], + created_at__lte=self.filters["analytics_date_range"]["previous"][ + "lte" + ], + ).count() + return 0 + + return { + "count": get_filtered_count(), + # "filter_count": get_previous_count(), + } + + def get_overview_data(self) -> Dict[str, Dict[str, int]]: + members_query = WorkspaceMember.objects.filter( + workspace__slug=self._workspace_slug, is_active=True + ) + + if self.request.GET.get("project_ids", None): + project_ids = self.request.GET.get("project_ids", None) + project_ids = [str(project_id) for project_id in project_ids.split(",")] + members_query = ProjectMember.objects.filter( + project_id__in=project_ids, is_active=True + ) + + return { + "total_users": self.get_filtered_counts(members_query), + "total_admins": self.get_filtered_counts( + members_query.filter(role=ROLE.ADMIN.value) + ), + "total_members": self.get_filtered_counts( + members_query.filter(role=ROLE.MEMBER.value) + ), + "total_guests": self.get_filtered_counts( + members_query.filter(role=ROLE.GUEST.value) + ), + "total_projects": self.get_filtered_counts( + Project.objects.filter(**self.filters["project_filters"]) + ), + "total_work_items": self.get_filtered_counts( + Issue.issue_objects.filter(**self.filters["base_filters"]) + ), + "total_cycles": self.get_filtered_counts( + Cycle.objects.filter(**self.filters["base_filters"]) + ), + "total_intake": self.get_filtered_counts( + Issue.objects.filter(**self.filters["base_filters"]).filter( + issue_intake__status__in=["-2", "0"] + ) + ), + } + + def get_work_items_stats(self) -> Dict[str, Dict[str, int]]: + base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) + + return { + "total_work_items": self.get_filtered_counts(base_queryset), + "started_work_items": self.get_filtered_counts( + base_queryset.filter(state__group="started") + ), + "backlog_work_items": self.get_filtered_counts( + base_queryset.filter(state__group="backlog") + ), + "un_started_work_items": self.get_filtered_counts( + base_queryset.filter(state__group="unstarted") + ), + "completed_work_items": self.get_filtered_counts( + base_queryset.filter(state__group="completed") + ), + } + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def get(self, request: HttpRequest, slug: str) -> Response: + self.initialize_workspace(slug, type="analytics") + tab = request.GET.get("tab", "overview") + + if tab == "overview": + return Response( + self.get_overview_data(), + status=status.HTTP_200_OK, + ) + elif tab == "work-items": + return Response( + self.get_work_items_stats(), + status=status.HTTP_200_OK, + ) + return Response({"message": "Invalid tab"}, status=status.HTTP_400_BAD_REQUEST) + + +class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView): + def get_project_issues_stats(self) -> QuerySet: + # Get the base queryset with workspace and project filters + base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) + + # Apply date range filter if available + if self.filters["chart_period_range"]: + start_date, end_date = self.filters["chart_period_range"] + base_queryset = base_queryset.filter( + created_at__date__gte=start_date, created_at__date__lte=end_date + ) + + return ( + base_queryset.values("project_id", "project__name").annotate( + cancelled_work_items=Count("id", filter=Q(state__group="cancelled")), + completed_work_items=Count("id", filter=Q(state__group="completed")), + backlog_work_items=Count("id", filter=Q(state__group="backlog")), + un_started_work_items=Count("id", filter=Q(state__group="unstarted")), + started_work_items=Count("id", filter=Q(state__group="started")), + ) + .order_by("project_id") + ) + + def get_work_items_stats(self) -> Dict[str, Dict[str, int]]: + base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) + return ( + base_queryset + .values("project_id", "project__name") + .annotate( + cancelled_work_items=Count("id", filter=Q(state__group="cancelled")), + completed_work_items=Count("id", filter=Q(state__group="completed")), + backlog_work_items=Count("id", filter=Q(state__group="backlog")), + un_started_work_items=Count("id", filter=Q(state__group="unstarted")), + started_work_items=Count("id", filter=Q(state__group="started")), + ) + .order_by("project_id") + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def get(self, request: HttpRequest, slug: str) -> Response: + self.initialize_workspace(slug, type="chart") + type = request.GET.get("type", "work-items") + + if type == "work-items": + return Response( + self.get_work_items_stats(), + status=status.HTTP_200_OK, + ) + + return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST) + + +class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView): + def project_chart(self) -> List[Dict[str, Any]]: + # Get the base queryset with workspace and project filters + base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) + date_filter = {} + + # Apply date range filter if available + if self.filters["chart_period_range"]: + start_date, end_date = self.filters["chart_period_range"] + date_filter = { + "created_at__date__gte": start_date, + "created_at__date__lte": end_date, + } + + total_work_items = base_queryset.filter(**date_filter).count() + total_cycles = Cycle.objects.filter( + **self.filters["base_filters"], **date_filter + ).count() + total_modules = Module.objects.filter( + **self.filters["base_filters"], **date_filter + ).count() + total_intake = Issue.objects.filter( + issue_intake__isnull=False, **self.filters["base_filters"], **date_filter + ).count() + total_members = WorkspaceMember.objects.filter( + workspace__slug=self._workspace_slug, is_active=True, **date_filter + ).count() + total_pages = ProjectPage.objects.filter( + **self.filters["base_filters"], **date_filter + ).count() + total_views = IssueView.objects.filter( + **self.filters["base_filters"], **date_filter + ).count() + + data = { + "work_items": total_work_items, + "cycles": total_cycles, + "modules": total_modules, + "intake": total_intake, + "members": total_members, + "pages": total_pages, + "views": total_views, + } + + return [ + { + "key": key, + "name": key.replace("_", " ").title(), + "count": value or 0, + } + for key, value in data.items() + ] + + def work_item_completion_chart(self) -> Dict[str, Any]: + # Get the base queryset + queryset = ( + Issue.issue_objects.filter(**self.filters["base_filters"]) + .select_related("workspace", "state", "parent") + .prefetch_related( + "assignees", "labels", "issue_module__module", "issue_cycle__cycle" + ) + ) + + workspace = Workspace.objects.get(slug=self._workspace_slug) + start_date = workspace.created_at.date().replace(day=1) + + # Apply date range filter if available + if self.filters["chart_period_range"]: + start_date, end_date = self.filters["chart_period_range"] + queryset = queryset.filter( + created_at__date__gte=start_date, created_at__date__lte=end_date + ) + + # Annotate by month and count + monthly_stats = ( + queryset.annotate(month=TruncMonth("created_at")) + .values("month") + .annotate( + created_count=Count("id"), + completed_count=Count("id", filter=Q(state__group="completed")), + ) + .order_by("month") + ) + + # Create dictionary of month -> counts + stats_dict = { + stat["month"].strftime("%Y-%m-%d"): { + "created_count": stat["created_count"], + "completed_count": stat["completed_count"], + } + for stat in monthly_stats + } + + # Generate monthly data (ensure months with 0 count are included) + data = [] + # include the current date at the end + end_date = timezone.now().date() + last_month = end_date.replace(day=1) + current_month = start_date + + while current_month <= last_month: + date_str = current_month.strftime("%Y-%m-%d") + stats = stats_dict.get(date_str, {"created_count": 0, "completed_count": 0}) + data.append( + { + "key": date_str, + "name": date_str, + "count": stats["created_count"], + "completed_issues": stats["completed_count"], + "created_issues": stats["created_count"], + } + ) + # Move to next month + if current_month.month == 12: + current_month = current_month.replace( + year=current_month.year + 1, month=1 + ) + else: + current_month = current_month.replace(month=current_month.month + 1) + + schema = { + "completed_issues": "completed_issues", + "created_issues": "created_issues", + } + + return {"data": data, "schema": schema} + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def get(self, request: HttpRequest, slug: str) -> Response: + self.initialize_workspace(slug, type="chart") + type = request.GET.get("type", "projects") + group_by = request.GET.get("group_by", None) + x_axis = request.GET.get("x_axis", "PRIORITY") + + if type == "projects": + return Response(self.project_chart(), status=status.HTTP_200_OK) + + elif type == "custom-work-items": + queryset = ( + Issue.issue_objects.filter(**self.filters["base_filters"]) + .select_related("workspace", "state", "parent") + .prefetch_related( + "assignees", "labels", "issue_module__module", "issue_cycle__cycle" + ) + ) + + # Apply date range filter if available + if self.filters["chart_period_range"]: + start_date, end_date = self.filters["chart_period_range"] + queryset = queryset.filter( + created_at__date__gte=start_date, created_at__date__lte=end_date + ) + + return Response( + build_analytics_chart(queryset, x_axis, group_by), + status=status.HTTP_200_OK, + ) + + elif type == "work-items": + return Response( + self.work_item_completion_chart(), + status=status.HTTP_200_OK, + ) + + return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/views/analytic/project_analytics.py b/apiserver/plane/app/views/analytic/project_analytics.py new file mode 100644 index 000000000..655f8e989 --- /dev/null +++ b/apiserver/plane/app/views/analytic/project_analytics.py @@ -0,0 +1,421 @@ +from rest_framework.response import Response +from rest_framework import status +from typing import Dict, Any +from django.db.models import QuerySet, Q, Count +from django.http import HttpRequest +from django.db.models.functions import TruncMonth +from django.utils import timezone +from datetime import timedelta +from plane.app.views.base import BaseAPIView +from plane.app.permissions import ROLE, allow_permission +from plane.db.models import ( + Project, + Issue, + Cycle, + Module, + CycleIssue, + ModuleIssue, +) +from django.db import models +from django.db.models import F, Case, When, Value +from django.db.models.functions import Concat +from plane.utils.build_chart import build_analytics_chart +from plane.utils.date_utils import ( + get_analytics_filters, +) + + +class ProjectAdvanceAnalyticsBaseView(BaseAPIView): + def initialize_workspace(self, slug: str, type: str) -> None: + self._workspace_slug = slug + self.filters = get_analytics_filters( + slug=slug, + type=type, + user=self.request.user, + date_filter=self.request.GET.get("date_filter", None), + project_ids=self.request.GET.get("project_ids", None), + ) + + +class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView): + def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]: + def get_filtered_count() -> int: + if self.filters["analytics_date_range"]: + return queryset.filter( + created_at__gte=self.filters["analytics_date_range"]["current"][ + "gte" + ], + created_at__lte=self.filters["analytics_date_range"]["current"][ + "lte" + ], + ).count() + return queryset.count() + + return { + "count": get_filtered_count(), + } + + def get_work_items_stats( + self, project_id, cycle_id=None, module_id=None + ) -> Dict[str, Dict[str, int]]: + """ + Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided. + """ + base_queryset = None + if cycle_id is not None: + cycle_issues = CycleIssue.objects.filter( + **self.filters["base_filters"], cycle_id=cycle_id + ).values_list("issue_id", flat=True) + base_queryset = Issue.issue_objects.filter(id__in=cycle_issues) + elif module_id is not None: + module_issues = ModuleIssue.objects.filter( + **self.filters["base_filters"], module_id=module_id + ).values_list("issue_id", flat=True) + base_queryset = Issue.issue_objects.filter(id__in=module_issues) + else: + base_queryset = Issue.issue_objects.filter( + **self.filters["base_filters"], project_id=project_id + ) + + return { + "total_work_items": self.get_filtered_counts(base_queryset), + "started_work_items": self.get_filtered_counts( + base_queryset.filter(state__group="started") + ), + "backlog_work_items": self.get_filtered_counts( + base_queryset.filter(state__group="backlog") + ), + "un_started_work_items": self.get_filtered_counts( + base_queryset.filter(state__group="unstarted") + ), + "completed_work_items": self.get_filtered_counts( + base_queryset.filter(state__group="completed") + ), + } + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def get(self, request: HttpRequest, slug: str, project_id: str) -> Response: + self.initialize_workspace(slug, type="analytics") + + # Optionally accept cycle_id or module_id as query params + cycle_id = request.GET.get("cycle_id", None) + module_id = request.GET.get("module_id", None) + return Response( + self.get_work_items_stats( + cycle_id=cycle_id, module_id=module_id, project_id=project_id + ), + status=status.HTTP_200_OK, + ) + + +class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView): + def get_project_issues_stats(self) -> QuerySet: + # Get the base queryset with workspace and project filters + base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) + + # Apply date range filter if available + if self.filters["chart_period_range"]: + start_date, end_date = self.filters["chart_period_range"] + base_queryset = base_queryset.filter( + created_at__date__gte=start_date, created_at__date__lte=end_date + ) + + return ( + base_queryset.values("project_id", "project__name") + .annotate( + cancelled_work_items=Count("id", filter=Q(state__group="cancelled")), + completed_work_items=Count("id", filter=Q(state__group="completed")), + backlog_work_items=Count("id", filter=Q(state__group="backlog")), + un_started_work_items=Count("id", filter=Q(state__group="unstarted")), + started_work_items=Count("id", filter=Q(state__group="started")), + ) + .order_by("project_id") + ) + + def get_work_items_stats( + self, project_id, cycle_id=None, module_id=None + ) -> Dict[str, Dict[str, int]]: + base_queryset = None + if cycle_id is not None: + cycle_issues = CycleIssue.objects.filter( + **self.filters["base_filters"], cycle_id=cycle_id + ).values_list("issue_id", flat=True) + base_queryset = Issue.issue_objects.filter(id__in=cycle_issues) + elif module_id is not None: + module_issues = ModuleIssue.objects.filter( + **self.filters["base_filters"], module_id=module_id + ).values_list("issue_id", flat=True) + base_queryset = Issue.issue_objects.filter(id__in=module_issues) + else: + base_queryset = Issue.issue_objects.filter( + **self.filters["base_filters"], project_id=project_id + ) + return ( + base_queryset.annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, then="assignees__avatar" + ), + default=Value(None), + output_field=models.CharField(), + ) + ) + .values("display_name", "assignee_id", "avatar_url") + .annotate( + cancelled_work_items=Count( + "id", filter=Q(state__group="cancelled"), distinct=True + ), + completed_work_items=Count( + "id", filter=Q(state__group="completed"), distinct=True + ), + backlog_work_items=Count( + "id", filter=Q(state__group="backlog"), distinct=True + ), + un_started_work_items=Count( + "id", filter=Q(state__group="unstarted"), distinct=True + ), + started_work_items=Count( + "id", filter=Q(state__group="started"), distinct=True + ), + ) + .order_by("display_name") + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def get(self, request: HttpRequest, slug: str, project_id: str) -> Response: + self.initialize_workspace(slug, type="chart") + type = request.GET.get("type", "work-items") + + if type == "work-items": + # Optionally accept cycle_id or module_id as query params + cycle_id = request.GET.get("cycle_id", None) + module_id = request.GET.get("module_id", None) + return Response( + self.get_work_items_stats( + project_id=project_id, cycle_id=cycle_id, module_id=module_id + ), + status=status.HTTP_200_OK, + ) + + return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST) + + +class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView): + def work_item_completion_chart( + self, project_id, cycle_id=None, module_id=None + ) -> Dict[str, Any]: + # Get the base queryset + queryset = ( + Issue.issue_objects.filter(**self.filters["base_filters"]) + .filter(project_id=project_id) + .select_related("workspace", "state", "parent") + .prefetch_related( + "assignees", "labels", "issue_module__module", "issue_cycle__cycle" + ) + ) + + if cycle_id is not None: + cycle_issues = CycleIssue.objects.filter( + **self.filters["base_filters"], cycle_id=cycle_id + ).values_list("issue_id", flat=True) + cycle = Cycle.objects.filter(id=cycle_id).first() + if cycle and cycle.start_date: + start_date = cycle.start_date.date() + end_date = cycle.end_date.date() + else: + return {"data": [], "schema": {}} + queryset = cycle_issues + + elif module_id is not None: + module_issues = ModuleIssue.objects.filter( + **self.filters["base_filters"], module_id=module_id + ).values_list("issue_id", flat=True) + module = Module.objects.filter(id=module_id).first() + if module and module.start_date: + start_date = module.start_date + end_date = module.target_date + else: + return {"data": [], "schema": {}} + queryset = module_issues + + else: + project = Project.objects.filter(id=project_id).first() + if project.created_at: + start_date = project.created_at.date().replace(day=1) + else: + return {"data": [], "schema": {}} + + if cycle_id or module_id: + # Get daily stats with optimized query + daily_stats = ( + queryset.values("created_at__date") + .annotate( + created_count=Count("id"), + completed_count=Count( + "id", filter=Q(issue__state__group="completed") + ), + ) + .order_by("created_at__date") + ) + + # Create a dictionary of existing stats with summed counts + stats_dict = { + stat["created_at__date"].strftime("%Y-%m-%d"): { + "created_count": stat["created_count"], + "completed_count": stat["completed_count"], + } + for stat in daily_stats + } + + # Generate data for all days in the range + data = [] + current_date = start_date + while current_date <= end_date: + date_str = current_date.strftime("%Y-%m-%d") + stats = stats_dict.get( + date_str, {"created_count": 0, "completed_count": 0} + ) + data.append( + { + "key": date_str, + "name": date_str, + "count": stats["created_count"] + stats["completed_count"], + "completed_issues": stats["completed_count"], + "created_issues": stats["created_count"], + } + ) + current_date += timedelta(days=1) + else: + # Apply date range filter if available + if self.filters["chart_period_range"]: + start_date, end_date = self.filters["chart_period_range"] + queryset = queryset.filter( + created_at__date__gte=start_date, created_at__date__lte=end_date + ) + + # Annotate by month and count + monthly_stats = ( + queryset.annotate(month=TruncMonth("created_at")) + .values("month") + .annotate( + created_count=Count("id"), + completed_count=Count("id", filter=Q(state__group="completed")), + ) + .order_by("month") + ) + + # Create dictionary of month -> counts + stats_dict = { + stat["month"].strftime("%Y-%m-%d"): { + "created_count": stat["created_count"], + "completed_count": stat["completed_count"], + } + for stat in monthly_stats + } + + # Generate monthly data (ensure months with 0 count are included) + data = [] + # include the current date at the end + end_date = timezone.now().date() + last_month = end_date.replace(day=1) + current_month = start_date + + while current_month <= last_month: + date_str = current_month.strftime("%Y-%m-%d") + stats = stats_dict.get( + date_str, {"created_count": 0, "completed_count": 0} + ) + data.append( + { + "key": date_str, + "name": date_str, + "count": stats["created_count"], + "completed_issues": stats["completed_count"], + "created_issues": stats["created_count"], + } + ) + # Move to next month + if current_month.month == 12: + current_month = current_month.replace( + year=current_month.year + 1, month=1 + ) + else: + current_month = current_month.replace(month=current_month.month + 1) + + schema = { + "completed_issues": "completed_issues", + "created_issues": "created_issues", + } + + return {"data": data, "schema": schema} + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request: HttpRequest, slug: str, project_id: str) -> Response: + self.initialize_workspace(slug, type="chart") + type = request.GET.get("type", "projects") + group_by = request.GET.get("group_by", None) + x_axis = request.GET.get("x_axis", "PRIORITY") + cycle_id = request.GET.get("cycle_id", None) + module_id = request.GET.get("module_id", None) + + if type == "custom-work-items": + queryset = ( + Issue.issue_objects.filter(**self.filters["base_filters"]) + .filter(project_id=project_id) + .select_related("workspace", "state", "parent") + .prefetch_related( + "assignees", "labels", "issue_module__module", "issue_cycle__cycle" + ) + ) + + # Apply cycle/module filters if present + if cycle_id is not None: + cycle_issues = CycleIssue.objects.filter( + **self.filters["base_filters"], cycle_id=cycle_id + ).values_list("issue_id", flat=True) + queryset = queryset.filter(id__in=cycle_issues) + + elif module_id is not None: + module_issues = ModuleIssue.objects.filter( + **self.filters["base_filters"], module_id=module_id + ).values_list("issue_id", flat=True) + queryset = queryset.filter(id__in=module_issues) + + # Apply date range filter if available + if self.filters["chart_period_range"]: + start_date, end_date = self.filters["chart_period_range"] + queryset = queryset.filter( + created_at__date__gte=start_date, created_at__date__lte=end_date + ) + + return Response( + build_analytics_chart(queryset, x_axis, group_by), + status=status.HTTP_200_OK, + ) + + elif type == "work-items": + # Optionally accept cycle_id or module_id as query params + cycle_id = request.GET.get("cycle_id", None) + module_id = request.GET.get("module_id", None) + + return Response( + self.work_item_completion_chart( + project_id=project_id, cycle_id=cycle_id, module_id=module_id + ), + status=status.HTTP_200_OK, + ) + + return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/views/api.py b/apiserver/plane/app/views/api.py index 732d96832..fa7cc7466 100644 --- a/apiserver/plane/app/views/api.py +++ b/apiserver/plane/app/views/api.py @@ -1,24 +1,23 @@ # Python import from uuid import uuid4 +from typing import Optional # Third party from rest_framework.response import Response +from rest_framework.request import Request from rest_framework import status # Module import from .base import BaseAPIView from plane.db.models import APIToken, Workspace from plane.app.serializers import APITokenSerializer, APITokenReadSerializer -from plane.app.permissions import WorkspaceOwnerPermission +from plane.app.permissions import WorkspaceEntityPermission class ApiTokenEndpoint(BaseAPIView): - permission_classes = [WorkspaceOwnerPermission] - - def post(self, request, slug): + def post(self, request: Request) -> Response: label = request.data.get("label", str(uuid4().hex)) description = request.data.get("description", "") - workspace = Workspace.objects.get(slug=slug) expired_at = request.data.get("expired_at", None) # Check the user type @@ -28,7 +27,6 @@ class ApiTokenEndpoint(BaseAPIView): label=label, description=description, user=request.user, - workspace=workspace, user_type=user_type, expired_at=expired_at, ) @@ -37,29 +35,23 @@ class ApiTokenEndpoint(BaseAPIView): # Token will be only visible while creating return Response(serializer.data, status=status.HTTP_201_CREATED) - def get(self, request, slug, pk=None): + def get(self, request: Request, pk: Optional[str] = None) -> Response: if pk is None: - api_tokens = APIToken.objects.filter( - user=request.user, workspace__slug=slug, is_service=False - ) + api_tokens = APIToken.objects.filter(user=request.user, is_service=False) serializer = APITokenReadSerializer(api_tokens, many=True) return Response(serializer.data, status=status.HTTP_200_OK) else: - api_tokens = APIToken.objects.get( - user=request.user, workspace__slug=slug, pk=pk - ) + api_tokens = APIToken.objects.get(user=request.user, pk=pk) serializer = APITokenReadSerializer(api_tokens) return Response(serializer.data, status=status.HTTP_200_OK) - def delete(self, request, slug, pk): - api_token = APIToken.objects.get( - workspace__slug=slug, user=request.user, pk=pk, is_service=False - ) + def delete(self, request: Request, pk: str) -> Response: + api_token = APIToken.objects.get(user=request.user, pk=pk, is_service=False) api_token.delete() return Response(status=status.HTTP_204_NO_CONTENT) - def patch(self, request, slug, pk): - api_token = APIToken.objects.get(workspace__slug=slug, user=request.user, pk=pk) + def patch(self, request: Request, pk: str) -> Response: + api_token = APIToken.objects.get(user=request.user, pk=pk) serializer = APITokenSerializer(api_token, data=request.data, partial=True) if serializer.is_valid(): serializer.save() @@ -68,9 +60,9 @@ class ApiTokenEndpoint(BaseAPIView): class ServiceApiTokenEndpoint(BaseAPIView): - permission_classes = [WorkspaceOwnerPermission] + permission_classes = [WorkspaceEntityPermission] - def post(self, request, slug): + def post(self, request: Request, slug: str) -> Response: workspace = Workspace.objects.get(slug=slug) api_token = APIToken.objects.filter( diff --git a/apiserver/plane/app/views/asset/v2.py b/apiserver/plane/app/views/asset/v2.py index 46e988be2..5994ffd8c 100644 --- a/apiserver/plane/app/views/asset/v2.py +++ b/apiserver/plane/app/views/asset/v2.py @@ -707,3 +707,67 @@ class ProjectBulkAssetEndpoint(BaseAPIView): pass return Response(status=status.HTTP_204_NO_CONTENT) + + +class AssetCheckEndpoint(BaseAPIView): + """Endpoint to check if an asset exists.""" + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def get(self, request, slug, asset_id): + asset = FileAsset.all_objects.filter( + id=asset_id, workspace__slug=slug, deleted_at__isnull=True + ).exists() + return Response({"exists": asset}, status=status.HTTP_200_OK) + + +class WorkspaceAssetDownloadEndpoint(BaseAPIView): + """Endpoint to generate a download link for an asset with content-disposition=attachment.""" + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def get(self, request, slug, asset_id): + try: + asset = FileAsset.objects.get( + id=asset_id, + workspace__slug=slug, + is_uploaded=True, + ) + except FileAsset.DoesNotExist: + return Response( + {"error": "The requested asset could not be found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + storage = S3Storage(request=request) + signed_url = storage.generate_presigned_url( + object_name=asset.asset.name, + disposition=f"attachment; filename={asset.asset.name}", + ) + + return HttpResponseRedirect(signed_url) + + +class ProjectAssetDownloadEndpoint(BaseAPIView): + """Endpoint to generate a download link for an asset with content-disposition=attachment.""" + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT") + def get(self, request, slug, project_id, asset_id): + try: + asset = FileAsset.objects.get( + id=asset_id, + workspace__slug=slug, + project_id=project_id, + is_uploaded=True, + ) + except FileAsset.DoesNotExist: + return Response( + {"error": "The requested asset could not be found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + storage = S3Storage(request=request) + signed_url = storage.generate_presigned_url( + object_name=asset.asset.name, + disposition=f"attachment; filename={asset.asset.name}", + ) + + return HttpResponseRedirect(signed_url) diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index e88acaf82..bcce69bf8 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -117,6 +117,7 @@ class CycleViewSet(BaseViewSet): issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, issue_cycle__deleted_at__isnull=True, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -129,6 +130,7 @@ class CycleViewSet(BaseViewSet): issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, issue_cycle__deleted_at__isnull=True, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -141,6 +143,7 @@ class CycleViewSet(BaseViewSet): issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, issue_cycle__deleted_at__isnull=True, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -266,9 +269,7 @@ class CycleViewSet(BaseViewSet): "created_by", ) datetime_fields = ["start_date", "end_date"] - data = user_timezone_converter( - data, datetime_fields, project_timezone - ) + data = user_timezone_converter(data, datetime_fields, project_timezone) return Response(data, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) @@ -415,9 +416,7 @@ class CycleViewSet(BaseViewSet): project_timezone = project.timezone datetime_fields = ["start_date", "end_date"] - cycle = user_timezone_converter( - cycle, datetime_fields, project_timezone - ) + cycle = user_timezone_converter(cycle, datetime_fields, project_timezone) # Send the model activity model_activity.delay( @@ -574,16 +573,12 @@ class CycleDateCheckEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - is_start_date_end_date_equal = ( - True if str(start_date) == str(end_date) else False - ) start_date = convert_to_utc( date=str(start_date), project_id=project_id, is_start_date=True ) end_date = convert_to_utc( date=str(end_date), project_id=project_id, - is_start_date_end_date_equal=is_start_date_end_date_equal, ) # Check if any cycle intersects in the given interval @@ -668,6 +663,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, issue_cycle__deleted_at__isnull=True, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -732,6 +728,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): ) ) ) + old_cycle = old_cycle.first() estimate_type = Project.objects.filter( workspace__slug=slug, @@ -850,7 +847,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): ) estimate_completion_chart = burndown_plot( - queryset=old_cycle.first(), + queryset=old_cycle, slug=slug, project_id=project_id, plot_type="points", @@ -997,7 +994,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): # Pass the new_cycle queryset to burndown_plot completion_chart = burndown_plot( - queryset=old_cycle.first(), + queryset=old_cycle, slug=slug, project_id=project_id, plot_type="issues", @@ -1009,12 +1006,12 @@ class TransferCycleIssueEndpoint(BaseAPIView): ).first() current_cycle.progress_snapshot = { - "total_issues": old_cycle.first().total_issues, - "completed_issues": old_cycle.first().completed_issues, - "cancelled_issues": old_cycle.first().cancelled_issues, - "started_issues": old_cycle.first().started_issues, - "unstarted_issues": old_cycle.first().unstarted_issues, - "backlog_issues": old_cycle.first().backlog_issues, + "total_issues": old_cycle.total_issues, + "completed_issues": old_cycle.completed_issues, + "cancelled_issues": old_cycle.cancelled_issues, + "started_issues": old_cycle.started_issues, + "unstarted_issues": old_cycle.unstarted_issues, + "backlog_issues": old_cycle.backlog_issues, "distribution": { "labels": label_distribution_data, "assignees": assignee_distribution_data, @@ -1122,6 +1119,13 @@ class CycleUserPropertiesEndpoint(BaseAPIView): class CycleProgressEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id, cycle_id): + cycle = Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, id=cycle_id + ).first() + if not cycle: + return Response( + {"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND + ) aggregate_estimates = ( Issue.issue_objects.filter( estimate_point__estimate__type="points", @@ -1172,53 +1176,60 @@ class CycleProgressEndpoint(BaseAPIView): ), ) ) + if cycle.progress_snapshot: + backlog_issues = cycle.progress_snapshot.get("backlog_issues", 0) + unstarted_issues = cycle.progress_snapshot.get("unstarted_issues", 0) + started_issues = cycle.progress_snapshot.get("started_issues", 0) + cancelled_issues = cycle.progress_snapshot.get("cancelled_issues", 0) + completed_issues = cycle.progress_snapshot.get("completed_issues", 0) + total_issues = cycle.progress_snapshot.get("total_issues", 0) + else: + backlog_issues = Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, + workspace__slug=slug, + project_id=project_id, + state__group="backlog", + ).count() - backlog_issues = Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, - issue_cycle__deleted_at__isnull=True, - workspace__slug=slug, - project_id=project_id, - state__group="backlog", - ).count() + unstarted_issues = Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, + workspace__slug=slug, + project_id=project_id, + state__group="unstarted", + ).count() - unstarted_issues = Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, - issue_cycle__deleted_at__isnull=True, - workspace__slug=slug, - project_id=project_id, - state__group="unstarted", - ).count() + started_issues = Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, + workspace__slug=slug, + project_id=project_id, + state__group="started", + ).count() - started_issues = Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, - issue_cycle__deleted_at__isnull=True, - workspace__slug=slug, - project_id=project_id, - state__group="started", - ).count() + cancelled_issues = Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, + workspace__slug=slug, + project_id=project_id, + state__group="cancelled", + ).count() - cancelled_issues = Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, - issue_cycle__deleted_at__isnull=True, - workspace__slug=slug, - project_id=project_id, - state__group="cancelled", - ).count() + completed_issues = Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, + workspace__slug=slug, + project_id=project_id, + state__group="completed", + ).count() - completed_issues = Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, - issue_cycle__deleted_at__isnull=True, - workspace__slug=slug, - project_id=project_id, - state__group="completed", - ).count() - - total_issues = Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, - issue_cycle__deleted_at__isnull=True, - workspace__slug=slug, - project_id=project_id, - ).count() + total_issues = Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, + workspace__slug=slug, + project_id=project_id, + ).count() return Response( { @@ -1279,6 +1290,25 @@ class CycleAnalyticsEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) + # this will tell whether the issues were transferred to the new cycle + """ + if the issues were transferred to the new cycle, then the progress_snapshot will be present + return the progress_snapshot data in the analytics for each date + + else issues were not transferred to the new cycle then generate the stats from the cycle isssue bridge tables + """ + + if cycle.progress_snapshot: + distribution = cycle.progress_snapshot.get("distribution", {}) + return Response( + { + "labels": distribution.get("labels", []), + "assignees": distribution.get("assignees", []), + "completion_chart": distribution.get("completion_chart", {}), + }, + status=status.HTTP_200_OK, + ) + estimate_type = Project.objects.filter( workspace__slug=slug, pk=project_id, diff --git a/apiserver/plane/app/views/cycle/issue.py b/apiserver/plane/app/views/cycle/issue.py index 9b9e1ad30..ad7762629 100644 --- a/apiserver/plane/app/views/cycle/issue.py +++ b/apiserver/plane/app/views/cycle/issue.py @@ -29,6 +29,7 @@ from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPagina from plane.app.permissions import allow_permission, ROLE from plane.utils.host import base_host + class CycleIssueViewSet(BaseViewSet): serializer_class = CycleIssueSerializer model = CycleIssue diff --git a/apiserver/plane/app/views/external/base.py b/apiserver/plane/app/views/external/base.py index 5643da226..864d0ff8c 100644 --- a/apiserver/plane/app/views/external/base.py +++ b/apiserver/plane/app/views/external/base.py @@ -11,8 +11,7 @@ from rest_framework.response import Response # Module import from plane.app.permissions import ROLE, allow_permission -from plane.app.serializers import (ProjectLiteSerializer, - WorkspaceLiteSerializer) +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 @@ -22,6 +21,7 @@ from ..base import BaseAPIView class LLMProvider: """Base class for LLM provider configurations""" + name: str = "" models: List[str] = [] default_model: str = "" @@ -34,11 +34,13 @@ class LLMProvider: "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 = [ @@ -49,40 +51,45 @@ class AnthropicProvider(LLMProvider): "claude-2.1", "claude-2", "claude-instant-1.2", - "claude-instant-1" + "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), - }, - ]) + 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: @@ -99,16 +106,20 @@ def get_llm_config() -> Tuple[str | None, str | None, str | None]: # 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)}" - )) + 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]: +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: @@ -118,10 +129,7 @@ def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> T client = OpenAI(api_key=api_key) chat_completion = client.chat.completions.create( - model=model, - messages=[ - {"role": "user", "content": final_text} - ] + model=model, messages=[{"role": "user", "content": final_text}] ) text = chat_completion.choices[0].message.content return text, None @@ -135,6 +143,7 @@ def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> T 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): @@ -152,7 +161,9 @@ class GPTIntegrationEndpoint(BaseAPIView): {"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST ) - text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider) + 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."}, @@ -190,7 +201,9 @@ class WorkspaceGPTIntegrationEndpoint(BaseAPIView): {"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST ) - text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider) + 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."}, diff --git a/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py index 48b317c84..118d1e7f9 100644 --- a/apiserver/plane/app/views/issue/archive.py +++ b/apiserver/plane/app/views/issue/archive.py @@ -38,6 +38,7 @@ from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPagina from plane.app.permissions import allow_permission, ROLE from plane.utils.error_codes import ERROR_CODES from plane.utils.host import base_host + # Module imports from .. import BaseViewSet, BaseAPIView diff --git a/apiserver/plane/app/views/issue/attachment.py b/apiserver/plane/app/views/issue/attachment.py index 0ff85572f..423710e4a 100644 --- a/apiserver/plane/app/views/issue/attachment.py +++ b/apiserver/plane/app/views/issue/attachment.py @@ -23,6 +23,7 @@ from plane.settings.storage import S3Storage from plane.bgtasks.storage_metadata_task import get_asset_object_metadata from plane.utils.host import base_host + class IssueAttachmentEndpoint(BaseAPIView): serializer_class = IssueAttachmentSerializer model = FileAsset diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index 2a7e9d021..d0b4e7d5e 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -32,6 +32,7 @@ from plane.app.serializers import ( IssueDetailSerializer, IssueUserPropertySerializer, IssueSerializer, + IssueListDetailSerializer, ) from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import ( @@ -46,6 +47,9 @@ from plane.db.models import ( CycleIssue, UserRecentVisit, ModuleIssue, + IssueRelation, + IssueAssignee, + IssueLabel, ) from plane.utils.grouper import ( issue_group_values, @@ -944,10 +948,57 @@ class IssueDetailEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id): filters = issue_filters(request.query_params, "GET") + + # check for the project member role, if the role is 5 then check for the guest_view_all_features + # if it is true then show all the issues else show only the issues created by the user + permission_subquery = ( + Issue.issue_objects.filter( + workspace__slug=slug, project_id=project_id, id=OuterRef("id") + ) + .filter( + Q( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__project_projectmember__role__gt=ROLE.GUEST.value, + ) + | Q( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__project_projectmember__role=ROLE.GUEST.value, + project__guest_view_all_features=True, + ) + | Q( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__project_projectmember__role=ROLE.GUEST.value, + project__guest_view_all_features=False, + created_by=self.request.user, + ) + ) + .values("id") + ) + # Main issue query issue = ( Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id) - .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels", "issue_module__module") + .filter(Exists(permission_subquery)) + .prefetch_related( + Prefetch( + "issue_assignee", + queryset=IssueAssignee.objects.all(), + ) + ) + .prefetch_related( + Prefetch( + "label_issue", + queryset=IssueLabel.objects.all(), + ) + ) + .prefetch_related( + Prefetch( + "issue_module", + queryset=ModuleIssue.objects.all(), + ) + ) .annotate( cycle_id=Subquery( CycleIssue.objects.filter( @@ -955,43 +1006,6 @@ class IssueDetailEndpoint(BaseAPIView): ).values("cycle_id")[:1] ) ) - .annotate( - label_ids=Coalesce( - ArrayAgg( - "labels__id", - distinct=True, - filter=Q( - ~Q(labels__id__isnull=True) - & Q(label_issue__deleted_at__isnull=True) - ), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - assignee_ids=Coalesce( - ArrayAgg( - "assignees__id", - distinct=True, - filter=Q( - ~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True) - & Q(issue_assignee__deleted_at__isnull=True) - ), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - module_ids=Coalesce( - ArrayAgg( - "issue_module__module_id", - distinct=True, - filter=Q( - ~Q(issue_module__module_id__isnull=True) - & Q(issue_module__module__archived_at__isnull=True) - & Q(issue_module__deleted_at__isnull=True) - ), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -1014,6 +1028,24 @@ class IssueDetailEndpoint(BaseAPIView): .values("count") ) ) + + # Add additional prefetch based on expand parameter + if self.expand: + if "issue_relation" in self.expand: + issue = issue.prefetch_related( + Prefetch( + "issue_relation", + queryset=IssueRelation.objects.select_related("related_issue"), + ) + ) + if "issue_related" in self.expand: + issue = issue.prefetch_related( + Prefetch( + "issue_related", + queryset=IssueRelation.objects.select_related("issue"), + ) + ) + issue = issue.filter(**filters) order_by_param = request.GET.get("order_by", "-created_at") # Issue queryset @@ -1024,7 +1056,7 @@ class IssueDetailEndpoint(BaseAPIView): request=request, order_by=order_by_param, queryset=(issue), - on_results=lambda issue: IssueSerializer( + on_results=lambda issue: IssueListDetailSerializer( issue, many=True, fields=self.fields, expand=self.expand ).data, ) diff --git a/apiserver/plane/app/views/issue/comment.py b/apiserver/plane/app/views/issue/comment.py index 2d81201c9..c848b6fcf 100644 --- a/apiserver/plane/app/views/issue/comment.py +++ b/apiserver/plane/app/views/issue/comment.py @@ -19,6 +19,7 @@ from plane.db.models import IssueComment, ProjectMember, CommentReaction, Projec from plane.bgtasks.issue_activities_task import issue_activity from plane.utils.host import base_host + class IssueCommentViewSet(BaseViewSet): serializer_class = IssueCommentSerializer model = IssueComment diff --git a/apiserver/plane/app/views/issue/link.py b/apiserver/plane/app/views/issue/link.py index 45cab8479..0a574dc19 100644 --- a/apiserver/plane/app/views/issue/link.py +++ b/apiserver/plane/app/views/issue/link.py @@ -15,8 +15,10 @@ from plane.app.serializers import IssueLinkSerializer from plane.app.permissions import ProjectEntityPermission from plane.db.models import IssueLink from plane.bgtasks.issue_activities_task import issue_activity +from plane.bgtasks.work_item_link_task import crawl_work_item_link_title from plane.utils.host import base_host + class IssueLinkViewSet(BaseViewSet): permission_classes = [ProjectEntityPermission] @@ -43,6 +45,9 @@ class IssueLinkViewSet(BaseViewSet): serializer = IssueLinkSerializer(data=request.data) if serializer.is_valid(): serializer.save(project_id=project_id, issue_id=issue_id) + crawl_work_item_link_title.delay( + serializer.data.get("id"), serializer.data.get("url") + ) issue_activity.delay( type="link.activity.created", requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), @@ -54,6 +59,10 @@ class IssueLinkViewSet(BaseViewSet): notification=True, origin=base_host(request=request, is_app=True), ) + + issue_link = self.get_queryset().get(id=serializer.data.get("id")) + serializer = IssueLinkSerializer(issue_link) + return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -65,9 +74,14 @@ class IssueLinkViewSet(BaseViewSet): current_instance = json.dumps( IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder ) + serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) if serializer.is_valid(): serializer.save() + crawl_work_item_link_title.delay( + serializer.data.get("id"), serializer.data.get("url") + ) + issue_activity.delay( type="link.activity.updated", requested_data=requested_data, @@ -79,6 +93,9 @@ class IssueLinkViewSet(BaseViewSet): notification=True, origin=base_host(request=request, is_app=True), ) + issue_link = self.get_queryset().get(id=serializer.data.get("id")) + serializer = IssueLinkSerializer(issue_link) + return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/views/issue/reaction.py b/apiserver/plane/app/views/issue/reaction.py index b92970382..8700b6345 100644 --- a/apiserver/plane/app/views/issue/reaction.py +++ b/apiserver/plane/app/views/issue/reaction.py @@ -17,6 +17,7 @@ from plane.db.models import IssueReaction from plane.bgtasks.issue_activities_task import issue_activity from plane.utils.host import base_host + class IssueReactionViewSet(BaseViewSet): serializer_class = IssueReactionSerializer model = IssueReaction diff --git a/apiserver/plane/app/views/issue/relation.py b/apiserver/plane/app/views/issue/relation.py index 0a8ffa2f9..50d319a88 100644 --- a/apiserver/plane/app/views/issue/relation.py +++ b/apiserver/plane/app/views/issue/relation.py @@ -29,6 +29,7 @@ from plane.bgtasks.issue_activities_task import issue_activity from plane.utils.issue_relation_mapper import get_actual_relation from plane.utils.host import base_host + class IssueRelationViewSet(BaseViewSet): serializer_class = IssueRelationSerializer model = IssueRelation diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index e9199ed04..0843a9a51 100644 --- a/apiserver/plane/app/views/issue/sub_issue.py +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -23,6 +23,8 @@ from plane.bgtasks.issue_activities_task import issue_activity from plane.utils.timezone_converter import user_timezone_converter from collections import defaultdict from plane.utils.host import base_host +from plane.utils.order_queryset import order_issue_queryset + class SubIssuesEndpoint(BaseAPIView): permission_classes = [ProjectEntityPermission] @@ -102,6 +104,15 @@ class SubIssuesEndpoint(BaseAPIView): .order_by("-created_at") ) + # Ordering + order_by_param = request.GET.get("order_by", "-created_at") + group_by = request.GET.get("group_by", False) + + if order_by_param: + sub_issues, order_by_param = order_issue_queryset( + sub_issues, order_by_param + ) + # create's a dict with state group name with their respective issue id's result = defaultdict(list) for sub_issue in sub_issues: @@ -138,6 +149,26 @@ class SubIssuesEndpoint(BaseAPIView): sub_issues = user_timezone_converter( sub_issues, datetime_fields, request.user.user_timezone ) + # Grouping + if group_by: + result_dict = defaultdict(list) + + for issue in sub_issues: + if group_by == "assignees__ids": + if issue["assignee_ids"]: + assignee_ids = issue["assignee_ids"] + for assignee_id in assignee_ids: + result_dict[str(assignee_id)].append(issue) + elif issue["assignee_ids"] == []: + result_dict["None"].append(issue) + + elif group_by: + result_dict[str(issue[group_by])].append(issue) + + return Response( + {"sub_issues": result_dict, "state_distribution": result}, + status=status.HTTP_200_OK, + ) return Response( {"sub_issues": sub_issues, "state_distribution": result}, status=status.HTTP_200_OK, diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index 62840f555..69d48ae59 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -63,6 +63,7 @@ from .. import BaseAPIView, BaseViewSet from plane.bgtasks.recent_visited_task import recent_visited_task from plane.utils.host import base_host + class ModuleViewSet(BaseViewSet): model = Module webhook_event = "module" @@ -710,23 +711,31 @@ class ModuleViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def partial_update(self, request, slug, project_id, pk): - module = self.get_queryset().filter(pk=pk) + module_queryset = self.get_queryset().filter(pk=pk) - if module.first().archived_at: + current_module = module_queryset.first() + + if not current_module: + return Response( + {"error": "Module not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + if current_module.archived_at: return Response( {"error": "Archived module cannot be updated"}, status=status.HTTP_400_BAD_REQUEST, ) current_instance = json.dumps( - ModuleSerializer(module.first()).data, cls=DjangoJSONEncoder + ModuleSerializer(current_module).data, cls=DjangoJSONEncoder ) serializer = ModuleWriteSerializer( - module.first(), data=request.data, partial=True + current_module, data=request.data, partial=True ) if serializer.is_valid(): serializer.save() - module = module.values( + module = module_queryset.values( # Required fields "id", "workspace_id", diff --git a/apiserver/plane/app/views/module/issue.py b/apiserver/plane/app/views/module/issue.py index 089d73ef9..96d1f550a 100644 --- a/apiserver/plane/app/views/module/issue.py +++ b/apiserver/plane/app/views/module/issue.py @@ -36,6 +36,7 @@ from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPagina from .. import BaseViewSet from plane.utils.host import base_host + class ModuleIssueViewSet(BaseViewSet): serializer_class = ModuleIssueSerializer model = ModuleIssue @@ -280,7 +281,11 @@ class ModuleIssueViewSet(BaseViewSet): issue_id=str(issue_id), project_id=str(project_id), current_instance=json.dumps( - {"module_name": module_issue.first().module.name if (module_issue.first() and module_issue.first().module) else None} + { + "module_name": module_issue.first().module.name + if (module_issue.first() and module_issue.first().module) + else None + } ), epoch=int(timezone.now().timestamp()), notification=True, diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index e8a3c3ffd..26e9223b8 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -42,6 +42,7 @@ from plane.bgtasks.page_version_task import page_version from plane.bgtasks.recent_visited_task import recent_visited_task from plane.bgtasks.copy_s3_object import copy_s3_objects + def unarchive_archive_page_and_descendants(page_id, archived_at): # Your SQL query sql = """ @@ -198,7 +199,7 @@ class PageViewSet(BaseViewSet): project = Project.objects.get(pk=project_id) """ - if the role is guest and guest_view_all_features is false and owned by is not + if the role is guest and guest_view_all_features is false and owned by is not the requesting user then dont show the page """ @@ -572,6 +573,12 @@ class PageDuplicateEndpoint(BaseAPIView): pk=page_id, workspace__slug=slug, projects__id=project_id ).first() + # check for permission + if page.access == Page.PRIVATE_ACCESS and page.owned_by_id != request.user.id: + return Response( + {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN + ) + # get all the project ids where page is present project_ids = ProjectPage.objects.filter(page_id=page_id).values_list( "project_id", flat=True diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 46290d7a5..2728bf4de 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -275,14 +275,14 @@ class ProjectViewSet(BaseViewSet): states = [ { "name": "Backlog", - "color": "#A3A3A3", + "color": "#60646C", "sequence": 15000, "group": "backlog", "default": True, }, { "name": "Todo", - "color": "#3A3A3A", + "color": "#60646C", "sequence": 25000, "group": "unstarted", }, @@ -294,13 +294,13 @@ class ProjectViewSet(BaseViewSet): }, { "name": "Done", - "color": "#16A34A", + "color": "#46A758", "sequence": 45000, "group": "completed", }, { "name": "Cancelled", - "color": "#EF4444", + "color": "#9AA4BC", "sequence": 55000, "group": "cancelled", }, @@ -341,7 +341,10 @@ class ProjectViewSet(BaseViewSet): except IntegrityError as e: if "already exists" in str(e): return Response( - {"name": "The project name is already taken"}, + { + "name": "The project name is already taken", + "code": "PROJECT_NAME_ALREADY_EXIST", + }, status=status.HTTP_409_CONFLICT, ) except Workspace.DoesNotExist: @@ -350,7 +353,10 @@ class ProjectViewSet(BaseViewSet): ) except serializers.ValidationError: return Response( - {"identifier": "The project identifier is already taken"}, + { + "identifier": "The project identifier is already taken", + "code": "PROJECT_IDENTIFIER_ALREADY_EXIST", + }, status=status.HTTP_409_CONFLICT, ) @@ -445,7 +451,7 @@ class ProjectViewSet(BaseViewSet): is_active=True, ).exists() ): - project = Project.objects.get(pk=pk) + project = Project.objects.get(pk=pk, workspace__slug=slug) project.delete() webhook_activity.delay( event="project", diff --git a/apiserver/plane/app/views/project/invite.py b/apiserver/plane/app/views/project/invite.py index 72c0bae06..c7ae8b19c 100644 --- a/apiserver/plane/app/views/project/invite.py +++ b/apiserver/plane/app/views/project/invite.py @@ -29,6 +29,7 @@ from plane.db.models import ( from plane.db.models.project import ProjectNetwork from plane.utils.host import base_host + class ProjectInvitationsViewset(BaseViewSet): serializer_class = ProjectMemberInviteSerializer model = ProjectMemberInvite diff --git a/apiserver/plane/app/views/project/member.py b/apiserver/plane/app/views/project/member.py index 7b910509c..60d960fe5 100644 --- a/apiserver/plane/app/views/project/member.py +++ b/apiserver/plane/app/views/project/member.py @@ -168,6 +168,8 @@ class ProjectMemberViewSet(BaseViewSet): workspace__slug=slug, member__is_bot=False, is_active=True, + member__member_workspace__workspace__slug=slug, + member__member_workspace__is_active=True, ).select_related("project", "member", "workspace") serializer = ProjectMemberRoleSerializer( @@ -313,7 +315,11 @@ class UserProjectRolesEndpoint(BaseAPIView): def get(self, request, slug): project_members = ProjectMember.objects.filter( - workspace__slug=slug, member_id=request.user.id, is_active=True + workspace__slug=slug, + member_id=request.user.id, + is_active=True, + member__member_workspace__workspace__slug=slug, + member__member_workspace__is_active=True, ).values("project_id", "role") project_members = { diff --git a/apiserver/plane/app/views/search/issue.py b/apiserver/plane/app/views/search/issue.py index 3db9e1cba..ed826782a 100644 --- a/apiserver/plane/app/views/search/issue.py +++ b/apiserver/plane/app/views/search/issue.py @@ -1,5 +1,5 @@ # Django imports -from django.db.models import Q +from django.db.models import Q, QuerySet # Third party imports from rest_framework import status @@ -12,6 +12,95 @@ from plane.utils.issue_search import search_issues class IssueSearchEndpoint(BaseAPIView): + def filter_issues_by_project(self, project_id: int, issues: QuerySet) -> QuerySet: + """ + Filter issues by project + """ + + issues = issues.filter(project_id=project_id) + + return issues + + def search_issues_by_query(self, query: str, issues: QuerySet) -> QuerySet: + """ + Search issues by query + """ + + issues = search_issues(query, issues) + + return issues + + def search_issues_and_excluding_parent( + self, issues: QuerySet, issue_id: str + ) -> QuerySet: + """ + Search issues and epics by query excluding the parent + """ + + issue = Issue.issue_objects.filter(pk=issue_id).first() + if issue: + issues = issues.filter( + ~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id) + ) + return issues + + def filter_issues_excluding_related_issues( + self, issue_id: str, issues: QuerySet + ) -> QuerySet: + """ + Filter issues excluding related issues + """ + + 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(pk__in=related_issue_ids)) + + return issues + + def filter_root_issues_only(self, issue_id: str, issues: QuerySet) -> QuerySet: + """ + Filter root issues only + """ + issue = Issue.issue_objects.filter(pk=issue_id).first() + if issue: + issues = issues.filter(~Q(pk=issue_id), parent__isnull=True) + if issue.parent: + issues = issues.filter(~Q(pk=issue.parent_id)) + return issues + + def exclude_issues_in_cycles(self, issues: QuerySet) -> QuerySet: + """ + Exclude issues in cycles + """ + issues = issues.exclude( + Q(issue_cycle__isnull=False) & Q(issue_cycle__deleted_at__isnull=True) + ) + return issues + + def exclude_issues_in_module(self, issues: QuerySet, module: str) -> QuerySet: + """ + Exclude issues in a module + """ + issues = issues.exclude( + Q(issue_module__module=module) & Q(issue_module__deleted_at__isnull=True) + ) + return issues + + def filter_issues_without_target_date(self, issues: QuerySet) -> QuerySet: + """ + Filter issues without a target date + """ + issues = issues.filter(target_date__isnull=True) + return issues + def get(self, request, slug, project_id): query = request.query_params.get("search", False) workspace_search = request.query_params.get("workspace_search", "false") @@ -21,7 +110,6 @@ class IssueSearchEndpoint(BaseAPIView): module = request.query_params.get("module", False) sub_issue = request.query_params.get("sub_issue", "false") target_date = request.query_params.get("target_date", True) - issue_id = request.query_params.get("issue_id", False) issues = Issue.issue_objects.filter( @@ -32,52 +120,28 @@ class IssueSearchEndpoint(BaseAPIView): ) if workspace_search == "false": - issues = issues.filter(project_id=project_id) + issues = self.filter_issues_by_project(project_id, issues) if query: - issues = search_issues(query, issues) + issues = self.search_issues_by_query(query, issues) if parent == "true" and issue_id: - issue = Issue.issue_objects.filter(pk=issue_id).first() - if issue: - issues = issues.filter( - ~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id) - ) + issues = self.search_issues_and_excluding_parent(issues, issue_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() + issues = self.filter_issues_excluding_related_issues(issue_id, issues) - related_issue_ids = [item for sublist in related_issue_ids for item in sublist] - - if issue: - issues = issues.filter( - ~Q(pk=issue_id), - ~Q(pk__in=related_issue_ids), - ) if sub_issue == "true" and issue_id: - issue = Issue.issue_objects.filter(pk=issue_id).first() - if issue: - issues = issues.filter(~Q(pk=issue_id), parent__isnull=True) - if issue.parent: - issues = issues.filter(~Q(pk=issue.parent_id)) + issues = self.filter_root_issues_only(issue_id, issues) if cycle == "true": - issues = issues.exclude( - Q(issue_cycle__isnull=False) & Q(issue_cycle__deleted_at__isnull=True) - ) + issues = self.exclude_issues_in_cycles(issues) if module: - issues = issues.exclude( - Q(issue_module__module=module) - & Q(issue_module__deleted_at__isnull=True) - ) + issues = self.exclude_issues_in_module(issues, module) if target_date == "none": - issues = issues.filter(target_date__isnull=True) + issues = self.filter_issues_without_target_date(issues) if ProjectMember.objects.filter( project_id=project_id, member=self.request.user, is_active=True, role=5 diff --git a/apiserver/plane/app/views/state/base.py b/apiserver/plane/app/views/state/base.py index 419cd5a35..b735659c5 100644 --- a/apiserver/plane/app/views/state/base.py +++ b/apiserver/plane/app/views/state/base.py @@ -1,5 +1,6 @@ # Python imports from itertools import groupby +from collections import defaultdict # Django imports from django.db.utils import IntegrityError @@ -74,7 +75,19 @@ class StateViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def list(self, request, slug, project_id): states = StateSerializer(self.get_queryset(), many=True).data + + grouped_states = defaultdict(list) + for state in states: + grouped_states[state["group"]].append(state) + + for group, group_states in grouped_states.items(): + count = len(group_states) + + for index, state in enumerate(group_states, start=1): + state["order"] = index / count + grouped = request.GET.get("grouped", False) + if grouped == "true": state_dict = {} for key, value in groupby( @@ -83,6 +96,7 @@ class StateViewSet(BaseViewSet): ): state_dict[str(key)] = list(value) return Response(state_dict, status=status.HTTP_200_OK) + return Response(states, status=status.HTTP_200_OK) @invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False) diff --git a/apiserver/plane/app/views/timezone/base.py b/apiserver/plane/app/views/timezone/base.py index 840fdbdbc..21d1d3560 100644 --- a/apiserver/plane/app/views/timezone/base.py +++ b/apiserver/plane/app/views/timezone/base.py @@ -24,125 +24,152 @@ class TimezoneEndpoint(APIView): @method_decorator(cache_page(60 * 60 * 2)) def get(self, request): timezone_locations = [ - ('Midway Island', 'Pacific/Midway'), # UTC-11:00 - ('American Samoa', 'Pacific/Pago_Pago'), # UTC-11:00 - ('Hawaii', 'Pacific/Honolulu'), # UTC-10:00 - ('Aleutian Islands', 'America/Adak'), # UTC-10:00 (DST: UTC-09:00) - ('Marquesas Islands', 'Pacific/Marquesas'), # UTC-09:30 - ('Alaska', 'America/Anchorage'), # UTC-09:00 (DST: UTC-08:00) - ('Gambier Islands', 'Pacific/Gambier'), # UTC-09:00 - ('Pacific Time (US and Canada)', 'America/Los_Angeles'), # UTC-08:00 (DST: UTC-07:00) - ('Baja California', 'America/Tijuana'), # UTC-08:00 (DST: UTC-07:00) - ('Mountain Time (US and Canada)', 'America/Denver'), # UTC-07:00 (DST: UTC-06:00) - ('Arizona', 'America/Phoenix'), # UTC-07:00 - ('Chihuahua, Mazatlan', 'America/Chihuahua'), # UTC-07:00 (DST: UTC-06:00) - ('Central Time (US and Canada)', 'America/Chicago'), # UTC-06:00 (DST: UTC-05:00) - ('Saskatchewan', 'America/Regina'), # UTC-06:00 - ('Guadalajara, Mexico City, Monterrey', 'America/Mexico_City'), # UTC-06:00 (DST: UTC-05:00) - ('Tegucigalpa, Honduras', 'America/Tegucigalpa'), # UTC-06:00 - ('Costa Rica', 'America/Costa_Rica'), # UTC-06:00 - ('Eastern Time (US and Canada)', 'America/New_York'), # UTC-05:00 (DST: UTC-04:00) - ('Lima', 'America/Lima'), # UTC-05:00 - ('Bogota', 'America/Bogota'), # UTC-05:00 - ('Quito', 'America/Guayaquil'), # UTC-05:00 - ('Chetumal', 'America/Cancun'), # UTC-05:00 (DST: UTC-04:00) - ('Caracas (Old Venezuela Time)', 'America/Caracas'), # UTC-04:30 - ('Atlantic Time (Canada)', 'America/Halifax'), # UTC-04:00 (DST: UTC-03:00) - ('Caracas', 'America/Caracas'), # UTC-04:00 - ('Santiago', 'America/Santiago'), # UTC-04:00 (DST: UTC-03:00) - ('La Paz', 'America/La_Paz'), # UTC-04:00 - ('Manaus', 'America/Manaus'), # UTC-04:00 - ('Georgetown', 'America/Guyana'), # UTC-04:00 - ('Bermuda', 'Atlantic/Bermuda'), # UTC-04:00 (DST: UTC-03:00) - ('Newfoundland Time (Canada)', 'America/St_Johns'), # UTC-03:30 (DST: UTC-02:30) - ('Buenos Aires', 'America/Argentina/Buenos_Aires'), # UTC-03:00 - ('Brasilia', 'America/Sao_Paulo'), # UTC-03:00 - ('Greenland', 'America/Godthab'), # UTC-03:00 (DST: UTC-02:00) - ('Montevideo', 'America/Montevideo'), # UTC-03:00 - ('Falkland Islands', 'Atlantic/Stanley'), # UTC-03:00 - ('South Georgia and the South Sandwich Islands', 'Atlantic/South_Georgia'), # UTC-02:00 - ('Azores', 'Atlantic/Azores'), # UTC-01:00 (DST: UTC+00:00) - ('Cape Verde Islands', 'Atlantic/Cape_Verde'), # UTC-01:00 - ('Dublin', 'Europe/Dublin'), # UTC+00:00 (DST: UTC+01:00) - ('Reykjavik', 'Atlantic/Reykjavik'), # UTC+00:00 - ('Lisbon', 'Europe/Lisbon'), # UTC+00:00 (DST: UTC+01:00) - ('Monrovia', 'Africa/Monrovia'), # UTC+00:00 - ('Casablanca', 'Africa/Casablanca'), # UTC+00:00 (DST: UTC+01:00) - ('Central European Time (Berlin, Rome, Paris)', 'Europe/Paris'), # UTC+01:00 (DST: UTC+02:00) - ('West Central Africa', 'Africa/Lagos'), # UTC+01:00 - ('Algiers', 'Africa/Algiers'), # UTC+01:00 - ('Lagos', 'Africa/Lagos'), # UTC+01:00 - ('Tunis', 'Africa/Tunis'), # UTC+01:00 - ('Eastern European Time (Cairo, Helsinki, Kyiv)', 'Europe/Kiev'), # UTC+02:00 (DST: UTC+03:00) - ('Athens', 'Europe/Athens'), # UTC+02:00 (DST: UTC+03:00) - ('Jerusalem', 'Asia/Jerusalem'), # UTC+02:00 (DST: UTC+03:00) - ('Johannesburg', 'Africa/Johannesburg'), # UTC+02:00 - ('Harare, Pretoria', 'Africa/Harare'), # UTC+02:00 - ('Moscow Time', 'Europe/Moscow'), # UTC+03:00 - ('Baghdad', 'Asia/Baghdad'), # UTC+03:00 - ('Nairobi', 'Africa/Nairobi'), # UTC+03:00 - ('Kuwait, Riyadh', 'Asia/Riyadh'), # UTC+03:00 - ('Tehran', 'Asia/Tehran'), # UTC+03:30 (DST: UTC+04:30) - ('Abu Dhabi', 'Asia/Dubai'), # UTC+04:00 - ('Baku', 'Asia/Baku'), # UTC+04:00 (DST: UTC+05:00) - ('Yerevan', 'Asia/Yerevan'), # UTC+04:00 (DST: UTC+05:00) - ('Astrakhan', 'Europe/Astrakhan'), # UTC+04:00 - ('Tbilisi', 'Asia/Tbilisi'), # UTC+04:00 - ('Mauritius', 'Indian/Mauritius'), # UTC+04:00 - ('Islamabad', 'Asia/Karachi'), # UTC+05:00 - ('Karachi', 'Asia/Karachi'), # UTC+05:00 - ('Tashkent', 'Asia/Tashkent'), # UTC+05:00 - ('Yekaterinburg', 'Asia/Yekaterinburg'), # UTC+05:00 - ('Maldives', 'Indian/Maldives'), # UTC+05:00 - ('Chagos', 'Indian/Chagos'), # UTC+05:00 - ('Chennai', 'Asia/Kolkata'), # UTC+05:30 - ('Kolkata', 'Asia/Kolkata'), # UTC+05:30 - ('Mumbai', 'Asia/Kolkata'), # UTC+05:30 - ('New Delhi', 'Asia/Kolkata'), # UTC+05:30 - ('Sri Jayawardenepura', 'Asia/Colombo'), # UTC+05:30 - ('Kathmandu', 'Asia/Kathmandu'), # UTC+05:45 - ('Dhaka', 'Asia/Dhaka'), # UTC+06:00 - ('Almaty', 'Asia/Almaty'), # UTC+06:00 - ('Bishkek', 'Asia/Bishkek'), # UTC+06:00 - ('Thimphu', 'Asia/Thimphu'), # UTC+06:00 - ('Yangon (Rangoon)', 'Asia/Yangon'), # UTC+06:30 - ('Cocos Islands', 'Indian/Cocos'), # UTC+06:30 - ('Bangkok', 'Asia/Bangkok'), # UTC+07:00 - ('Hanoi', 'Asia/Ho_Chi_Minh'), # UTC+07:00 - ('Jakarta', 'Asia/Jakarta'), # UTC+07:00 - ('Novosibirsk', 'Asia/Novosibirsk'), # UTC+07:00 - ('Krasnoyarsk', 'Asia/Krasnoyarsk'), # UTC+07:00 - ('Beijing', 'Asia/Shanghai'), # UTC+08:00 - ('Singapore', 'Asia/Singapore'), # UTC+08:00 - ('Perth', 'Australia/Perth'), # UTC+08:00 - ('Hong Kong', 'Asia/Hong_Kong'), # UTC+08:00 - ('Ulaanbaatar', 'Asia/Ulaanbaatar'), # UTC+08:00 - ('Palau', 'Pacific/Palau'), # UTC+08:00 - ('Eucla', 'Australia/Eucla'), # UTC+08:45 - ('Tokyo', 'Asia/Tokyo'), # UTC+09:00 - ('Seoul', 'Asia/Seoul'), # UTC+09:00 - ('Yakutsk', 'Asia/Yakutsk'), # UTC+09:00 - ('Adelaide', 'Australia/Adelaide'), # UTC+09:30 (DST: UTC+10:30) - ('Darwin', 'Australia/Darwin'), # UTC+09:30 - ('Sydney', 'Australia/Sydney'), # UTC+10:00 (DST: UTC+11:00) - ('Brisbane', 'Australia/Brisbane'), # UTC+10:00 - ('Guam', 'Pacific/Guam'), # UTC+10:00 - ('Vladivostok', 'Asia/Vladivostok'), # UTC+10:00 - ('Tahiti', 'Pacific/Tahiti'), # UTC+10:00 - ('Lord Howe Island', 'Australia/Lord_Howe'), # UTC+10:30 (DST: UTC+11:00) - ('Solomon Islands', 'Pacific/Guadalcanal'), # UTC+11:00 - ('Magadan', 'Asia/Magadan'), # UTC+11:00 - ('Norfolk Island', 'Pacific/Norfolk'), # UTC+11:00 - ('Bougainville Island', 'Pacific/Bougainville'), # UTC+11:00 - ('Chokurdakh', 'Asia/Srednekolymsk'), # UTC+11:00 - ('Auckland', 'Pacific/Auckland'), # UTC+12:00 (DST: UTC+13:00) - ('Wellington', 'Pacific/Auckland'), # UTC+12:00 (DST: UTC+13:00) - ('Fiji Islands', 'Pacific/Fiji'), # UTC+12:00 (DST: UTC+13:00) - ('Anadyr', 'Asia/Anadyr'), # UTC+12:00 - ('Chatham Islands', 'Pacific/Chatham'), # UTC+12:45 (DST: UTC+13:45) - ("Nuku'alofa", 'Pacific/Tongatapu'), # UTC+13:00 - ('Samoa', 'Pacific/Apia'), # UTC+13:00 (DST: UTC+14:00) - ('Kiritimati Island', 'Pacific/Kiritimati') # UTC+14:00 + ("Midway Island", "Pacific/Midway"), # UTC-11:00 + ("American Samoa", "Pacific/Pago_Pago"), # UTC-11:00 + ("Hawaii", "Pacific/Honolulu"), # UTC-10:00 + ("Aleutian Islands", "America/Adak"), # UTC-10:00 (DST: UTC-09:00) + ("Marquesas Islands", "Pacific/Marquesas"), # UTC-09:30 + ("Alaska", "America/Anchorage"), # UTC-09:00 (DST: UTC-08:00) + ("Gambier Islands", "Pacific/Gambier"), # UTC-09:00 + ( + "Pacific Time (US and Canada)", + "America/Los_Angeles", + ), # UTC-08:00 (DST: UTC-07:00) + ("Baja California", "America/Tijuana"), # UTC-08:00 (DST: UTC-07:00) + ( + "Mountain Time (US and Canada)", + "America/Denver", + ), # UTC-07:00 (DST: UTC-06:00) + ("Arizona", "America/Phoenix"), # UTC-07:00 + ("Chihuahua, Mazatlan", "America/Chihuahua"), # UTC-07:00 (DST: UTC-06:00) + ( + "Central Time (US and Canada)", + "America/Chicago", + ), # UTC-06:00 (DST: UTC-05:00) + ("Saskatchewan", "America/Regina"), # UTC-06:00 + ( + "Guadalajara, Mexico City, Monterrey", + "America/Mexico_City", + ), # UTC-06:00 (DST: UTC-05:00) + ("Tegucigalpa, Honduras", "America/Tegucigalpa"), # UTC-06:00 + ("Costa Rica", "America/Costa_Rica"), # UTC-06:00 + ( + "Eastern Time (US and Canada)", + "America/New_York", + ), # UTC-05:00 (DST: UTC-04:00) + ("Lima", "America/Lima"), # UTC-05:00 + ("Bogota", "America/Bogota"), # UTC-05:00 + ("Quito", "America/Guayaquil"), # UTC-05:00 + ("Chetumal", "America/Cancun"), # UTC-05:00 (DST: UTC-04:00) + ("Caracas (Old Venezuela Time)", "America/Caracas"), # UTC-04:30 + ("Atlantic Time (Canada)", "America/Halifax"), # UTC-04:00 (DST: UTC-03:00) + ("Caracas", "America/Caracas"), # UTC-04:00 + ("Santiago", "America/Santiago"), # UTC-04:00 (DST: UTC-03:00) + ("La Paz", "America/La_Paz"), # UTC-04:00 + ("Manaus", "America/Manaus"), # UTC-04:00 + ("Georgetown", "America/Guyana"), # UTC-04:00 + ("Bermuda", "Atlantic/Bermuda"), # UTC-04:00 (DST: UTC-03:00) + ( + "Newfoundland Time (Canada)", + "America/St_Johns", + ), # UTC-03:30 (DST: UTC-02:30) + ("Buenos Aires", "America/Argentina/Buenos_Aires"), # UTC-03:00 + ("Brasilia", "America/Sao_Paulo"), # UTC-03:00 + ("Greenland", "America/Godthab"), # UTC-03:00 (DST: UTC-02:00) + ("Montevideo", "America/Montevideo"), # UTC-03:00 + ("Falkland Islands", "Atlantic/Stanley"), # UTC-03:00 + ( + "South Georgia and the South Sandwich Islands", + "Atlantic/South_Georgia", + ), # UTC-02:00 + ("Azores", "Atlantic/Azores"), # UTC-01:00 (DST: UTC+00:00) + ("Cape Verde Islands", "Atlantic/Cape_Verde"), # UTC-01:00 + ("Dublin", "Europe/Dublin"), # UTC+00:00 (DST: UTC+01:00) + ("Reykjavik", "Atlantic/Reykjavik"), # UTC+00:00 + ("Lisbon", "Europe/Lisbon"), # UTC+00:00 (DST: UTC+01:00) + ("Monrovia", "Africa/Monrovia"), # UTC+00:00 + ("Casablanca", "Africa/Casablanca"), # UTC+00:00 (DST: UTC+01:00) + ( + "Central European Time (Berlin, Rome, Paris)", + "Europe/Paris", + ), # UTC+01:00 (DST: UTC+02:00) + ("West Central Africa", "Africa/Lagos"), # UTC+01:00 + ("Algiers", "Africa/Algiers"), # UTC+01:00 + ("Lagos", "Africa/Lagos"), # UTC+01:00 + ("Tunis", "Africa/Tunis"), # UTC+01:00 + ( + "Eastern European Time (Cairo, Helsinki, Kyiv)", + "Europe/Kiev", + ), # UTC+02:00 (DST: UTC+03:00) + ("Athens", "Europe/Athens"), # UTC+02:00 (DST: UTC+03:00) + ("Jerusalem", "Asia/Jerusalem"), # UTC+02:00 (DST: UTC+03:00) + ("Johannesburg", "Africa/Johannesburg"), # UTC+02:00 + ("Harare, Pretoria", "Africa/Harare"), # UTC+02:00 + ("Moscow Time", "Europe/Moscow"), # UTC+03:00 + ("Baghdad", "Asia/Baghdad"), # UTC+03:00 + ("Nairobi", "Africa/Nairobi"), # UTC+03:00 + ("Kuwait, Riyadh", "Asia/Riyadh"), # UTC+03:00 + ("Tehran", "Asia/Tehran"), # UTC+03:30 (DST: UTC+04:30) + ("Abu Dhabi", "Asia/Dubai"), # UTC+04:00 + ("Baku", "Asia/Baku"), # UTC+04:00 (DST: UTC+05:00) + ("Yerevan", "Asia/Yerevan"), # UTC+04:00 (DST: UTC+05:00) + ("Astrakhan", "Europe/Astrakhan"), # UTC+04:00 + ("Tbilisi", "Asia/Tbilisi"), # UTC+04:00 + ("Mauritius", "Indian/Mauritius"), # UTC+04:00 + ("Islamabad", "Asia/Karachi"), # UTC+05:00 + ("Karachi", "Asia/Karachi"), # UTC+05:00 + ("Tashkent", "Asia/Tashkent"), # UTC+05:00 + ("Yekaterinburg", "Asia/Yekaterinburg"), # UTC+05:00 + ("Maldives", "Indian/Maldives"), # UTC+05:00 + ("Chagos", "Indian/Chagos"), # UTC+05:00 + ("Chennai", "Asia/Kolkata"), # UTC+05:30 + ("Kolkata", "Asia/Kolkata"), # UTC+05:30 + ("Mumbai", "Asia/Kolkata"), # UTC+05:30 + ("New Delhi", "Asia/Kolkata"), # UTC+05:30 + ("Sri Jayawardenepura", "Asia/Colombo"), # UTC+05:30 + ("Kathmandu", "Asia/Kathmandu"), # UTC+05:45 + ("Dhaka", "Asia/Dhaka"), # UTC+06:00 + ("Almaty", "Asia/Almaty"), # UTC+06:00 + ("Bishkek", "Asia/Bishkek"), # UTC+06:00 + ("Thimphu", "Asia/Thimphu"), # UTC+06:00 + ("Yangon (Rangoon)", "Asia/Yangon"), # UTC+06:30 + ("Cocos Islands", "Indian/Cocos"), # UTC+06:30 + ("Bangkok", "Asia/Bangkok"), # UTC+07:00 + ("Hanoi", "Asia/Ho_Chi_Minh"), # UTC+07:00 + ("Jakarta", "Asia/Jakarta"), # UTC+07:00 + ("Novosibirsk", "Asia/Novosibirsk"), # UTC+07:00 + ("Krasnoyarsk", "Asia/Krasnoyarsk"), # UTC+07:00 + ("Beijing", "Asia/Shanghai"), # UTC+08:00 + ("Singapore", "Asia/Singapore"), # UTC+08:00 + ("Perth", "Australia/Perth"), # UTC+08:00 + ("Hong Kong", "Asia/Hong_Kong"), # UTC+08:00 + ("Ulaanbaatar", "Asia/Ulaanbaatar"), # UTC+08:00 + ("Palau", "Pacific/Palau"), # UTC+08:00 + ("Eucla", "Australia/Eucla"), # UTC+08:45 + ("Tokyo", "Asia/Tokyo"), # UTC+09:00 + ("Seoul", "Asia/Seoul"), # UTC+09:00 + ("Yakutsk", "Asia/Yakutsk"), # UTC+09:00 + ("Adelaide", "Australia/Adelaide"), # UTC+09:30 (DST: UTC+10:30) + ("Darwin", "Australia/Darwin"), # UTC+09:30 + ("Sydney", "Australia/Sydney"), # UTC+10:00 (DST: UTC+11:00) + ("Brisbane", "Australia/Brisbane"), # UTC+10:00 + ("Guam", "Pacific/Guam"), # UTC+10:00 + ("Vladivostok", "Asia/Vladivostok"), # UTC+10:00 + ("Tahiti", "Pacific/Tahiti"), # UTC+10:00 + ("Lord Howe Island", "Australia/Lord_Howe"), # UTC+10:30 (DST: UTC+11:00) + ("Solomon Islands", "Pacific/Guadalcanal"), # UTC+11:00 + ("Magadan", "Asia/Magadan"), # UTC+11:00 + ("Norfolk Island", "Pacific/Norfolk"), # UTC+11:00 + ("Bougainville Island", "Pacific/Bougainville"), # UTC+11:00 + ("Chokurdakh", "Asia/Srednekolymsk"), # UTC+11:00 + ("Auckland", "Pacific/Auckland"), # UTC+12:00 (DST: UTC+13:00) + ("Wellington", "Pacific/Auckland"), # UTC+12:00 (DST: UTC+13:00) + ("Fiji Islands", "Pacific/Fiji"), # UTC+12:00 (DST: UTC+13:00) + ("Anadyr", "Asia/Anadyr"), # UTC+12:00 + ("Chatham Islands", "Pacific/Chatham"), # UTC+12:45 (DST: UTC+13:45) + ("Nuku'alofa", "Pacific/Tongatapu"), # UTC+13:00 + ("Samoa", "Pacific/Apia"), # UTC+13:00 (DST: UTC+14:00) + ("Kiritimati Island", "Pacific/Kiritimati"), # UTC+14:00 ] timezone_list = [] @@ -150,7 +177,6 @@ class TimezoneEndpoint(APIView): # Process timezone mapping for friendly_name, tz_identifier in timezone_locations: - try: tz = pytz.timezone(tz_identifier) current_offset = now.astimezone(tz).strftime("%z") diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py index 5d66fc65c..c1dd2631d 100644 --- a/apiserver/plane/app/views/view/base.py +++ b/apiserver/plane/app/views/view/base.py @@ -1,8 +1,13 @@ # Django imports -from django.contrib.postgres.aggregates import ArrayAgg -from django.contrib.postgres.fields import ArrayField -from django.db.models import Exists, F, Func, OuterRef, Q, UUIDField, Value, Subquery -from django.db.models.functions import Coalesce +from django.db.models import ( + Exists, + F, + Func, + OuterRef, + Q, + Subquery, + Prefetch, +) from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page from django.db import transaction @@ -13,7 +18,7 @@ from rest_framework.response import Response # Module imports from plane.app.permissions import allow_permission, ROLE -from plane.app.serializers import IssueViewSerializer +from plane.app.serializers import IssueViewSerializer, ViewIssueListSerializer from plane.db.models import ( Issue, FileAsset, @@ -25,15 +30,12 @@ from plane.db.models import ( Project, CycleIssue, UserRecentVisit, -) -from plane.utils.grouper import ( - issue_group_values, - issue_on_results, - issue_queryset_grouper, + IssueAssignee, + IssueLabel, + ModuleIssue, ) from plane.utils.issue_filters import issue_filters from plane.utils.order_queryset import order_issue_queryset -from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator from plane.bgtasks.recent_visited_task import recent_visited_task from .. import BaseViewSet from plane.db.models import UserFavorite @@ -143,6 +145,28 @@ class WorkspaceViewViewSet(BaseViewSet): class WorkspaceViewIssuesViewSet(BaseViewSet): + def _get_project_permission_filters(self): + """ + Get common project permission filters for guest users and role-based access control. + Returns Q object for filtering issues based on user role and project settings. + """ + return Q( + Q( + project__project_projectmember__role=5, + project__guest_view_all_features=True, + ) + | Q( + project__project_projectmember__role=5, + project__guest_view_all_features=False, + created_by=self.request.user, + ) + | + # For other roles (role > 5), show all issues + Q(project__project_projectmember__role__gt=5), + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + def get_queryset(self): return ( Issue.issue_objects.annotate( @@ -152,12 +176,25 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): .values("count") ) .filter(workspace__slug=self.kwargs.get("slug")) - .filter( - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, + .select_related("state") + .prefetch_related( + Prefetch( + "issue_assignee", + queryset=IssueAssignee.objects.all(), + ) + ) + .prefetch_related( + Prefetch( + "label_issue", + queryset=IssueLabel.objects.all(), + ) + ) + .prefetch_related( + Prefetch( + "issue_module", + queryset=ModuleIssue.objects.all(), + ) ) - .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels", "issue_module__module") .annotate( cycle_id=Subquery( CycleIssue.objects.filter( @@ -186,43 +223,6 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate( - label_ids=Coalesce( - ArrayAgg( - "labels__id", - distinct=True, - filter=Q( - ~Q(labels__id__isnull=True) - & Q(label_issue__deleted_at__isnull=True) - ), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - assignee_ids=Coalesce( - ArrayAgg( - "assignees__id", - distinct=True, - filter=Q( - ~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True) - & Q(issue_assignee__deleted_at__isnull=True) - ), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - module_ids=Coalesce( - ArrayAgg( - "issue_module__module_id", - distinct=True, - filter=Q( - ~Q(issue_module__module_id__isnull=True) - & Q(issue_module__module__archived_at__isnull=True) - & Q(issue_module__deleted_at__isnull=True) - ), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - ) ) @method_decorator(gzip_page) @@ -233,126 +233,36 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): filters = issue_filters(request.query_params, "GET") order_by_param = request.GET.get("order_by", "-created_at") - issue_queryset = ( - self.get_queryset() - .filter(**filters) - .annotate( - cycle_id=Subquery( - CycleIssue.objects.filter( - issue=OuterRef("id"), deleted_at__isnull=True - ).values("cycle_id")[:1] - ) - ) + issue_queryset = self.get_queryset().filter(**filters) + + # Get common project permission filters + permission_filters = self._get_project_permission_filters() + + # Base query for the counts + total_issue_count = ( + Issue.issue_objects.filter(**filters) + .filter(workspace__slug=slug) + .filter(permission_filters) + .only("id") ) - # check for the project member role, if the role is 5 then check for the guest_view_all_features if it is true then show all the issues else show only the issues created by the user - - issue_queryset = issue_queryset.filter( - Q( - project__project_projectmember__role=5, - project__guest_view_all_features=True, - ) - | Q( - project__project_projectmember__role=5, - project__guest_view_all_features=False, - created_by=self.request.user, - ) - | - # For other roles (role < 5), show all issues - Q(project__project_projectmember__role__gt=5), - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - ) + # Apply project permission filters to the issue queryset + issue_queryset = issue_queryset.filter(permission_filters) # Issue queryset issue_queryset, order_by_param = order_issue_queryset( issue_queryset=issue_queryset, order_by_param=order_by_param ) - # Group by - group_by = request.GET.get("group_by", False) - sub_group_by = request.GET.get("sub_group_by", False) - - # issue queryset - issue_queryset = issue_queryset_grouper( - queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by + # List Paginate + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + on_results=lambda issues: ViewIssueListSerializer(issues, many=True).data, + total_count_queryset=total_issue_count, ) - if group_by: - # Check group and sub group value paginate - if sub_group_by: - if group_by == sub_group_by: - return Response( - { - "error": "Group by and sub group by cannot have same parameters" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - else: - # group and sub group pagination - return self.paginate( - request=request, - order_by=order_by_param, - queryset=issue_queryset, - on_results=lambda issues: issue_on_results( - group_by=group_by, issues=issues, sub_group_by=sub_group_by - ), - paginator_cls=SubGroupedOffsetPaginator, - group_by_fields=issue_group_values( - field=group_by, slug=slug, project_id=None, filters=filters - ), - sub_group_by_fields=issue_group_values( - field=sub_group_by, - slug=slug, - project_id=None, - filters=filters, - ), - group_by_field_name=group_by, - sub_group_by_field_name=sub_group_by, - count_filter=Q( - Q(issue_intake__status=1) - | Q(issue_intake__status=-1) - | Q(issue_intake__status=2) - | Q(issue_intake__isnull=True), - archived_at__isnull=True, - is_draft=False, - ), - ) - # Group Paginate - else: - # Group paginate - return self.paginate( - request=request, - order_by=order_by_param, - queryset=issue_queryset, - on_results=lambda issues: issue_on_results( - group_by=group_by, issues=issues, sub_group_by=sub_group_by - ), - paginator_cls=GroupedOffsetPaginator, - group_by_fields=issue_group_values( - field=group_by, slug=slug, project_id=None, filters=filters - ), - group_by_field_name=group_by, - count_filter=Q( - Q(issue_intake__status=1) - | Q(issue_intake__status=-1) - | Q(issue_intake__status=2) - | Q(issue_intake__isnull=True), - archived_at__isnull=True, - is_draft=False, - ), - ) - else: - # List Paginate - return self.paginate( - order_by=order_by_param, - request=request, - queryset=issue_queryset, - on_results=lambda issues: issue_on_results( - group_by=group_by, issues=issues, sub_group_by=sub_group_by - ), - ) - class IssueViewViewSet(BaseViewSet): serializer_class = IssueViewSerializer diff --git a/apiserver/plane/app/views/workspace/base.py b/apiserver/plane/app/views/workspace/base.py index c627f19b6..922b39cc9 100644 --- a/apiserver/plane/app/views/workspace/base.py +++ b/apiserver/plane/app/views/workspace/base.py @@ -3,6 +3,7 @@ import csv import io import os from datetime import date +import uuid from dateutil.relativedelta import relativedelta from django.db import IntegrityError @@ -35,6 +36,7 @@ from plane.db.models import ( Workspace, WorkspaceMember, WorkspaceTheme, + Profile, ) from plane.app.permissions import ROLE, allow_permission from django.utils.decorators import method_decorator @@ -42,6 +44,8 @@ from django.views.decorators.cache import cache_control from django.views.decorators.vary import vary_on_cookie from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS from plane.license.utils.instance_value import get_configuration_value +from plane.bgtasks.workspace_seed_task import workspace_seed +from plane.utils.url import contains_url class WorkSpaceViewSet(BaseViewSet): @@ -108,6 +112,12 @@ class WorkSpaceViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) + if contains_url(name): + return Response( + {"error": "Name cannot contain a URL"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if serializer.is_valid(raise_exception=True): serializer.save(owner=request.user) # Create Workspace member @@ -126,6 +136,8 @@ class WorkSpaceViewSet(BaseViewSet): data["total_members"] = total_members data["role"] = 20 + workspace_seed.delay(serializer.data["id"]) + return Response(data, status=status.HTTP_201_CREATED) return Response( [serializer.errors[error][0] for error in serializer.errors], @@ -147,8 +159,18 @@ class WorkSpaceViewSet(BaseViewSet): def partial_update(self, request, *args, **kwargs): return super().partial_update(request, *args, **kwargs) + def remove_last_workspace_ids_from_user_settings(self, id: uuid.UUID) -> None: + """ + Remove the last workspace id from the user settings + """ + Profile.objects.filter(last_workspace_id=id).update(last_workspace_id=None) + return + @allow_permission([ROLE.ADMIN], level="WORKSPACE") def destroy(self, request, *args, **kwargs): + # Get the workspace + workspace = self.get_object() + self.remove_last_workspace_ids_from_user_settings(workspace.id) return super().destroy(request, *args, **kwargs) @@ -156,8 +178,6 @@ class UserWorkSpacesEndpoint(BaseAPIView): search_fields = ["name"] filterset_fields = ["owner"] - @method_decorator(cache_control(private=True, max_age=12)) - @method_decorator(vary_on_cookie) def get(self, request): fields = [field for field in request.GET.get("fields", "").split(",") if field] member_count = ( diff --git a/apiserver/plane/app/views/workspace/cycle.py b/apiserver/plane/app/views/workspace/cycle.py index a9398a91d..eb899553d 100644 --- a/apiserver/plane/app/views/workspace/cycle.py +++ b/apiserver/plane/app/views/workspace/cycle.py @@ -12,6 +12,7 @@ from plane.app.permissions import WorkspaceViewerPermission from plane.app.serializers.cycle import CycleSerializer from plane.utils.timezone_converter import user_timezone_converter + class WorkspaceCyclesEndpoint(BaseAPIView): permission_classes = [WorkspaceViewerPermission] @@ -29,6 +30,7 @@ class WorkspaceCyclesEndpoint(BaseAPIView): issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, issue_cycle__deleted_at__isnull=True, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) diff --git a/apiserver/plane/app/views/workspace/draft.py b/apiserver/plane/app/views/workspace/draft.py index 9503781f1..a5e61d6b4 100644 --- a/apiserver/plane/app/views/workspace/draft.py +++ b/apiserver/plane/app/views/workspace/draft.py @@ -38,6 +38,7 @@ from plane.bgtasks.issue_activities_task import issue_activity from plane.utils.issue_filters import issue_filters from plane.utils.host import base_host + class WorkspaceDraftIssueViewSet(BaseViewSet): model = DraftIssue diff --git a/apiserver/plane/app/views/workspace/member.py b/apiserver/plane/app/views/workspace/member.py index 5dde2f78c..eda7c34fd 100644 --- a/apiserver/plane/app/views/workspace/member.py +++ b/apiserver/plane/app/views/workspace/member.py @@ -1,5 +1,6 @@ # Django imports from django.db.models import Count, Q, OuterRef, Subquery, IntegerField +from django.utils import timezone from django.db.models.functions import Coalesce # Third party modules @@ -133,7 +134,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): # Deactivate the users from the projects where the user is part of _ = ProjectMember.objects.filter( workspace__slug=slug, member_id=workspace_member.member_id, is_active=True - ).update(is_active=False) + ).update(is_active=False, updated_at=timezone.now()) workspace_member.is_active = False workspace_member.save() @@ -194,7 +195,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): # # Deactivate the users from the projects where the user is part of _ = ProjectMember.objects.filter( workspace__slug=slug, member_id=workspace_member.member_id, is_active=True - ).update(is_active=False) + ).update(is_active=False, updated_at=timezone.now()) # # Deactivate the user workspace_member.is_active = False diff --git a/apiserver/plane/app/views/workspace/state.py b/apiserver/plane/app/views/workspace/state.py index c00044cff..08bc2be28 100644 --- a/apiserver/plane/app/views/workspace/state.py +++ b/apiserver/plane/app/views/workspace/state.py @@ -8,6 +8,7 @@ from plane.app.views.base import BaseAPIView from plane.db.models import State from plane.app.permissions import WorkspaceEntityPermission from plane.utils.cache import cache_response +from collections import defaultdict class WorkspaceStatesEndpoint(BaseAPIView): @@ -22,5 +23,16 @@ class WorkspaceStatesEndpoint(BaseAPIView): project__archived_at__isnull=True, is_triage=False, ) + + grouped_states = defaultdict(list) + for state in states: + grouped_states[state.group].append(state) + + for group, group_states in grouped_states.items(): + count = len(group_states) + + for index, state in enumerate(group_states, start=1): + state.order = index / count + serializer = StateSerializer(states, many=True).data return Response(serializer, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/workspace/user_preference.py b/apiserver/plane/app/views/workspace/user_preference.py index 07ae70ac0..7cfa740e8 100644 --- a/apiserver/plane/app/views/workspace/user_preference.py +++ b/apiserver/plane/app/views/workspace/user_preference.py @@ -27,10 +27,7 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView): create_preference_keys = [] - keys = [ - key - for key, _ in WorkspaceUserPreference.UserPreferenceKeys.choices - ] + keys = [key for key, _ in WorkspaceUserPreference.UserPreferenceKeys.choices] for preference in keys: if preference not in get_preference.values_list("key", flat=True): @@ -39,7 +36,10 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView): preference = WorkspaceUserPreference.objects.bulk_create( [ WorkspaceUserPreference( - key=key, user=request.user, workspace=workspace, sort_order=(65535 + (i*10000)) + key=key, + user=request.user, + workspace=workspace, + sort_order=(65535 + (i * 10000)), ) for i, key in enumerate(create_preference_keys) ], @@ -47,10 +47,13 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView): ignore_conflicts=True, ) - preferences = WorkspaceUserPreference.objects.filter( - user=request.user, workspace_id=workspace.id - ).order_by("sort_order").values("key", "is_pinned", "sort_order") - + preferences = ( + WorkspaceUserPreference.objects.filter( + user=request.user, workspace_id=workspace.id + ) + .order_by("sort_order") + .values("key", "is_pinned", "sort_order") + ) user_preferences = {} @@ -58,7 +61,7 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView): user_preferences[(str(preference["key"]))] = { "is_pinned": preference["is_pinned"], "sort_order": preference["sort_order"], - } + } return Response( user_preferences, status=status.HTTP_200_OK, diff --git a/apiserver/plane/authentication/adapter/base.py b/apiserver/plane/authentication/adapter/base.py index f788dcb41..b28735120 100644 --- a/apiserver/plane/authentication/adapter/base.py +++ b/apiserver/plane/authentication/adapter/base.py @@ -18,6 +18,7 @@ from plane.bgtasks.user_activation_email_task import user_activation_email from plane.utils.host import base_host from plane.utils.ip_address import get_client_ip + class Adapter: """Common interface for all auth providers""" diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py index dcbe039fb..7c629b441 100644 --- a/apiserver/plane/authentication/adapter/error.py +++ b/apiserver/plane/authentication/adapter/error.py @@ -41,7 +41,6 @@ AUTHENTICATION_ERROR_CODES = { "GOOGLE_OAUTH_PROVIDER_ERROR": 5115, "GITHUB_OAUTH_PROVIDER_ERROR": 5120, "GITLAB_OAUTH_PROVIDER_ERROR": 5121, - # Reset Password "INVALID_PASSWORD_TOKEN": 5125, "EXPIRED_PASSWORD_TOKEN": 5130, diff --git a/apiserver/plane/authentication/provider/oauth/github.py b/apiserver/plane/authentication/provider/oauth/github.py index 4a7808c8a..d8116cec3 100644 --- a/apiserver/plane/authentication/provider/oauth/github.py +++ b/apiserver/plane/authentication/provider/oauth/github.py @@ -25,23 +25,24 @@ class GitHubOAuthProvider(OauthAdapter): organization_scope = "read:org" - def __init__(self, request, code=None, state=None, callback=None): - GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = get_configuration_value( - [ - { - "key": "GITHUB_CLIENT_ID", - "default": os.environ.get("GITHUB_CLIENT_ID"), - }, - { - "key": "GITHUB_CLIENT_SECRET", - "default": os.environ.get("GITHUB_CLIENT_SECRET"), - }, - { - "key": "GITHUB_ORGANIZATION_ID", - "default": os.environ.get("GITHUB_ORGANIZATION_ID"), - }, - ] + GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = ( + get_configuration_value( + [ + { + "key": "GITHUB_CLIENT_ID", + "default": os.environ.get("GITHUB_CLIENT_ID"), + }, + { + "key": "GITHUB_CLIENT_SECRET", + "default": os.environ.get("GITHUB_CLIENT_SECRET"), + }, + { + "key": "GITHUB_ORGANIZATION_ID", + "default": os.environ.get("GITHUB_ORGANIZATION_ID"), + }, + ] + ) ) if not (GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET): @@ -128,7 +129,10 @@ class GitHubOAuthProvider(OauthAdapter): def is_user_in_organization(self, github_username): headers = {"Authorization": f"Bearer {self.token_data.get('access_token')}"} - response = requests.get(f"{self.org_membership_url}/{self.organization_id}/memberships/{github_username}", headers=headers) + response = requests.get( + f"{self.org_membership_url}/{self.organization_id}/memberships/{github_username}", + headers=headers, + ) return response.status_code == 200 # 200 means the user is a member def set_user_data(self): @@ -145,7 +149,6 @@ class GitHubOAuthProvider(OauthAdapter): error_message="GITHUB_USER_NOT_IN_ORG", ) - email = self.__get_email(headers=headers) super().set_user_data( { diff --git a/apiserver/plane/authentication/urls.py b/apiserver/plane/authentication/urls.py index d474fe4df..d8b5799de 100644 --- a/apiserver/plane/authentication/urls.py +++ b/apiserver/plane/authentication/urls.py @@ -42,11 +42,11 @@ urlpatterns = [ # credentials path("sign-in/", SignInAuthEndpoint.as_view(), name="sign-in"), path("sign-up/", SignUpAuthEndpoint.as_view(), name="sign-up"), - path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="sign-in"), - path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="sign-in"), + path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="space-sign-in"), + path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="space-sign-up"), # signout path("sign-out/", SignOutAuthEndpoint.as_view(), name="sign-out"), - path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="sign-out"), + path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="space-sign-out"), # csrf token path("get-csrf-token/", CSRFTokenEndpoint.as_view(), name="get_csrf_token"), # Magic sign in @@ -56,17 +56,17 @@ urlpatterns = [ path( "spaces/magic-generate/", MagicGenerateSpaceEndpoint.as_view(), - name="magic-generate", + name="space-magic-generate", ), path( "spaces/magic-sign-in/", MagicSignInSpaceEndpoint.as_view(), - name="magic-sign-in", + name="space-magic-sign-in", ), path( "spaces/magic-sign-up/", MagicSignUpSpaceEndpoint.as_view(), - name="magic-sign-up", + name="space-magic-sign-up", ), ## Google Oauth path("google/", GoogleOauthInitiateEndpoint.as_view(), name="google-initiate"), @@ -74,12 +74,12 @@ urlpatterns = [ path( "spaces/google/", GoogleOauthInitiateSpaceEndpoint.as_view(), - name="google-initiate", + name="space-google-initiate", ), path( - "google/callback/", + "spaces/google/callback/", GoogleCallbackSpaceEndpoint.as_view(), - name="google-callback", + name="space-google-callback", ), ## Github Oauth path("github/", GitHubOauthInitiateEndpoint.as_view(), name="github-initiate"), @@ -87,12 +87,12 @@ urlpatterns = [ path( "spaces/github/", GitHubOauthInitiateSpaceEndpoint.as_view(), - name="github-initiate", + name="space-github-initiate", ), path( "spaces/github/callback/", GitHubCallbackSpaceEndpoint.as_view(), - name="github-callback", + name="space-github-callback", ), ## Gitlab Oauth path("gitlab/", GitLabOauthInitiateEndpoint.as_view(), name="gitlab-initiate"), @@ -100,12 +100,12 @@ urlpatterns = [ path( "spaces/gitlab/", GitLabOauthInitiateSpaceEndpoint.as_view(), - name="gitlab-initiate", + name="space-gitlab-initiate", ), path( "spaces/gitlab/callback/", GitLabCallbackSpaceEndpoint.as_view(), - name="gitlab-callback", + name="space-gitlab-callback", ), # Email Check path("email-check/", EmailCheckEndpoint.as_view(), name="email-check"), @@ -120,12 +120,12 @@ urlpatterns = [ path( "spaces/forgot-password/", ForgotPasswordSpaceEndpoint.as_view(), - name="forgot-password", + name="space-forgot-password", ), path( "spaces/reset-password///", ResetPasswordSpaceEndpoint.as_view(), - name="forgot-password", + name="space-forgot-password", ), path("change-password/", ChangePasswordEndpoint.as_view(), name="forgot-password"), path("set-password/", SetUserPasswordEndpoint.as_view(), name="set-password"), diff --git a/apiserver/plane/authentication/utils/host.py b/apiserver/plane/authentication/utils/host.py index c4625279c..415791a87 100644 --- a/apiserver/plane/authentication/utils/host.py +++ b/apiserver/plane/authentication/utils/host.py @@ -1,30 +1,53 @@ # Django imports from django.conf import settings from django.http import HttpRequest + # Third party imports from rest_framework.request import Request # Module imports from plane.utils.ip_address import get_client_ip -def base_host(request: Request | HttpRequest, is_admin: bool = False, is_space: bool = False, is_app: bool = False) -> str: + +def base_host( + request: Request | HttpRequest, + is_admin: bool = False, + is_space: bool = False, + is_app: bool = False, +) -> str: """Utility function to return host / origin from the request""" # Calculate the base origin from request base_origin = settings.WEB_URL or settings.APP_BASE_URL - # Admin redirections + # Admin redirection if is_admin: - if settings.ADMIN_BASE_URL: - return settings.ADMIN_BASE_URL - else: - return base_origin + "/god-mode/" + admin_base_path = getattr(settings, "ADMIN_BASE_PATH", None) + if not isinstance(admin_base_path, str): + admin_base_path = "/god-mode/" + if not admin_base_path.startswith("/"): + admin_base_path = "/" + admin_base_path + if not admin_base_path.endswith("/"): + admin_base_path += "/" - # Space redirections - if is_space: - if settings.SPACE_BASE_URL: - return settings.SPACE_BASE_URL + if settings.ADMIN_BASE_URL: + return settings.ADMIN_BASE_URL + admin_base_path else: - return base_origin + "/spaces/" + return base_origin + admin_base_path + + # Space redirection + if is_space: + space_base_path = getattr(settings, "SPACE_BASE_PATH", None) + if not isinstance(space_base_path, str): + space_base_path = "/spaces/" + if not space_base_path.startswith("/"): + space_base_path = "/" + space_base_path + if not space_base_path.endswith("/"): + space_base_path += "/" + + if settings.SPACE_BASE_URL: + return settings.SPACE_BASE_URL + space_base_path + else: + return base_origin + space_base_path # App Redirection if is_app: diff --git a/apiserver/plane/authentication/utils/login.py b/apiserver/plane/authentication/utils/login.py index f8c0ed842..e9437ae44 100644 --- a/apiserver/plane/authentication/utils/login.py +++ b/apiserver/plane/authentication/utils/login.py @@ -6,6 +6,7 @@ from django.conf import settings from plane.utils.host import base_host from plane.utils.ip_address import get_client_ip + def user_login(request, user, is_app=False, is_admin=False, is_space=False): login(request=request, user=user) diff --git a/apiserver/plane/authentication/views/app/email.py b/apiserver/plane/authentication/views/app/email.py index 7e91b21c1..0ac51265e 100644 --- a/apiserver/plane/authentication/views/app/email.py +++ b/apiserver/plane/authentication/views/app/email.py @@ -21,6 +21,7 @@ from plane.authentication.adapter.error import ( ) from plane.utils.path_validator import validate_next_path + class SignInAuthEndpoint(View): def post(self, request): next_path = request.POST.get("next_path") diff --git a/apiserver/plane/authentication/views/app/github.py b/apiserver/plane/authentication/views/app/github.py index f558bcd4b..18cbe7b6c 100644 --- a/apiserver/plane/authentication/views/app/github.py +++ b/apiserver/plane/authentication/views/app/github.py @@ -18,6 +18,7 @@ from plane.authentication.adapter.error import ( ) from plane.utils.path_validator import validate_next_path + class GitHubOauthInitiateEndpoint(View): def get(self, request): # Get host and next path diff --git a/apiserver/plane/authentication/views/app/gitlab.py b/apiserver/plane/authentication/views/app/gitlab.py index c3a0f5876..d6479e954 100644 --- a/apiserver/plane/authentication/views/app/gitlab.py +++ b/apiserver/plane/authentication/views/app/gitlab.py @@ -18,6 +18,7 @@ from plane.authentication.adapter.error import ( ) from plane.utils.path_validator import validate_next_path + class GitLabOauthInitiateEndpoint(View): def get(self, request): # Get host and next path diff --git a/apiserver/plane/authentication/views/app/google.py b/apiserver/plane/authentication/views/app/google.py index 2caf9f51b..66b6f7662 100644 --- a/apiserver/plane/authentication/views/app/google.py +++ b/apiserver/plane/authentication/views/app/google.py @@ -20,6 +20,7 @@ from plane.authentication.adapter.error import ( ) from plane.utils.path_validator import validate_next_path + class GoogleOauthInitiateEndpoint(View): def get(self, request): request.session["host"] = base_host(request=request, is_app=True) @@ -95,7 +96,9 @@ class GoogleCallbackEndpoint(View): # Get the redirection path path = get_redirection_path(user=user) # redirect to referer path - url = urljoin(base_host, str(validate_next_path(next_path)) if next_path else path) + url = urljoin( + base_host, str(validate_next_path(next_path)) if next_path else path + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() diff --git a/apiserver/plane/authentication/views/common.py b/apiserver/plane/authentication/views/common.py index 7a18072ae..ab60e6d04 100644 --- a/apiserver/plane/authentication/views/common.py +++ b/apiserver/plane/authentication/views/common.py @@ -53,12 +53,14 @@ class ChangePasswordEndpoint(APIView): error_message="MISSING_PASSWORD", payload={"error": "Old password is missing"}, ) - return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) + return Response( + exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST + ) # Get the new password new_password = request.data.get("new_password", False) - if not new_password: + if not new_password: exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["MISSING_PASSWORD"], error_message="MISSING_PASSWORD", @@ -66,7 +68,6 @@ class ChangePasswordEndpoint(APIView): ) return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) - # If the user password is not autoset then we need to check the old passwords if not user.is_password_autoset and not user.check_password(old_password): exc = AuthenticationException( diff --git a/apiserver/plane/authentication/views/space/magic.py b/apiserver/plane/authentication/views/space/magic.py index cb682137c..d230af7ed 100644 --- a/apiserver/plane/authentication/views/space/magic.py +++ b/apiserver/plane/authentication/views/space/magic.py @@ -25,6 +25,7 @@ from plane.authentication.adapter.error import ( ) from plane.utils.path_validator import validate_next_path + class MagicGenerateSpaceEndpoint(APIView): permission_classes = [AllowAny] @@ -38,7 +39,6 @@ class MagicGenerateSpaceEndpoint(APIView): ) return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) - email = request.data.get("email", "").strip().lower() try: validate_email(email) diff --git a/apiserver/plane/bgtasks/analytic_plot_export.py b/apiserver/plane/bgtasks/analytic_plot_export.py index 0a335e5b5..0f07ccc85 100644 --- a/apiserver/plane/bgtasks/analytic_plot_export.py +++ b/apiserver/plane/bgtasks/analytic_plot_export.py @@ -459,8 +459,37 @@ def analytic_export_task(email, data, slug): csv_buffer = generate_csv_from_rows(rows) send_export_email(email, slug, csv_buffer, rows) - logging.getLogger("plane").info("Email sent succesfully.") + logging.getLogger("plane.worker").info("Email sent successfully.") return except Exception as e: log_exception(e) return + + +@shared_task +def export_analytics_to_csv_email(data, headers, keys, email, slug): + try: + """ + Prepares a CSV from data and sends it as an email attachment. + + Parameters: + - data: List of dictionaries (e.g. from .values()) + - headers: List of CSV column headers + - keys: Keys to extract from each data item (dict) + - email: Email address to send to + - slug: Used for the filename + """ + # Prepare rows: header + data rows + rows = [headers] + for item in data: + row = [item.get(key, "") for key in keys] + rows.append(row) + + # Generate CSV buffer + csv_buffer = generate_csv_from_rows(rows) + + # Send email with CSV attachment + send_export_email(email=email, slug=slug, csv_buffer=csv_buffer, rows=rows) + except Exception as e: + log_exception(e) + return diff --git a/apiserver/plane/bgtasks/copy_s3_object.py b/apiserver/plane/bgtasks/copy_s3_object.py index d73b96454..a92d7fe4e 100644 --- a/apiserver/plane/bgtasks/copy_s3_object.py +++ b/apiserver/plane/bgtasks/copy_s3_object.py @@ -12,6 +12,7 @@ from plane.db.models import FileAsset, Page, Issue from plane.utils.exception_logger import log_exception from plane.settings.storage import S3Storage from celery import shared_task +from plane.utils.url import normalize_url_path def get_entity_id_field(entity_type, entity_id): @@ -67,11 +68,14 @@ def sync_with_external_service(entity_name, description_html): "description_html": description_html, "variant": "rich" if entity_name == "PAGE" else "document", } - response = requests.post( - f"{settings.LIVE_BASE_URL}/convert-document/", - json=data, - headers=None, - ) + + live_url = settings.LIVE_URL + if not live_url: + return {} + + url = normalize_url_path(f"{live_url}/convert-document/") + + response = requests.post(url, json=data, headers=None) if response.status_code == 200: return response.json() except requests.RequestException as e: diff --git a/apiserver/plane/bgtasks/dummy_data_task.py b/apiserver/plane/bgtasks/dummy_data_task.py index a3f95d0bc..03ac55b4c 100644 --- a/apiserver/plane/bgtasks/dummy_data_task.py +++ b/apiserver/plane/bgtasks/dummy_data_task.py @@ -33,6 +33,7 @@ from plane.db.models import ( Intake, IntakeIssue, ) +from plane.db.models.intake import SourceType def create_project(workspace, user_id): @@ -388,7 +389,7 @@ def create_intake_issues(workspace, project, user_id, intake_issue_count): if status == 0 else None ), - source="in-app", + source=SourceType.IN_APP, workspace=workspace, project=project, ) diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py index b0f75b5dc..dcc37796d 100644 --- a/apiserver/plane/bgtasks/email_notification_task.py +++ b/apiserver/plane/bgtasks/email_notification_task.py @@ -284,6 +284,7 @@ def send_email_notification( "project": str(issue.project.name), "user_preference": f"{base_api}/profile/preferences/email", "comments": comments, + "entity_type": "issue", } html_content = render_to_string( "emails/notifications/issue-updates.html", context @@ -309,7 +310,7 @@ def send_email_notification( ) msg.attach_alternative(html_content, "text/html") msg.send() - logging.getLogger("plane").info("Email Sent Successfully") + logging.getLogger("plane.worker").info("Email Sent Successfully") # Update the logs EmailNotificationLog.objects.filter( @@ -325,7 +326,7 @@ def send_email_notification( release_lock(lock_id=lock_id) return else: - logging.getLogger("plane").info("Duplicate email received skipping") + logging.getLogger("plane.worker").info("Duplicate email received skipping") return except (Issue.DoesNotExist, User.DoesNotExist): release_lock(lock_id=lock_id) diff --git a/apiserver/plane/bgtasks/export_task.py b/apiserver/plane/bgtasks/export_task.py index 33e382f44..4d7fcd5ff 100644 --- a/apiserver/plane/bgtasks/export_task.py +++ b/apiserver/plane/bgtasks/export_task.py @@ -3,34 +3,49 @@ import csv import io import json import zipfile - +from typing import List import boto3 from botocore.client import Config +from uuid import UUID +from datetime import datetime, date # Third party imports from celery import shared_task + # Django imports from django.conf import settings from django.utils import timezone from openpyxl import Workbook +from django.db.models import F, Prefetch + +from collections import defaultdict # Module imports -from plane.db.models import ExporterHistory, Issue +from plane.db.models import ExporterHistory, Issue, FileAsset, Label, User, IssueComment from plane.utils.exception_logger import log_exception -def dateTimeConverter(time): +def dateTimeConverter(time: datetime) -> str | None: + """ + Convert a datetime object to a formatted string. + """ if time: return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z") -def dateConverter(time): +def dateConverter(time: date) -> str | None: + """ + Convert a date object to a formatted string. + """ if time: return time.strftime("%a, %d %b %Y") -def create_csv_file(data): +def create_csv_file(data: List[List[str]]) -> str: + """ + Create a CSV file from the provided data. + """ csv_buffer = io.StringIO() csv_writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL) @@ -41,11 +56,17 @@ def create_csv_file(data): return csv_buffer.getvalue() -def create_json_file(data): +def create_json_file(data: List[dict]) -> str: + """ + Create a JSON file from the provided data. + """ return json.dumps(data) -def create_xlsx_file(data): +def create_xlsx_file(data: List[List[str]]) -> bytes: + """ + Create an XLSX file from the provided data. + """ workbook = Workbook() sheet = workbook.active @@ -58,7 +79,10 @@ def create_xlsx_file(data): return xlsx_buffer.getvalue() -def create_zip_file(files): +def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO: + """ + Create a ZIP file from the provided files. + """ zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf: for filename, file_content in files: @@ -68,7 +92,13 @@ def create_zip_file(files): return zip_buffer -def upload_to_s3(zip_file, workspace_id, token_id, slug): +# TODO: Change the upload_to_s3 function to use the new storage method with entry in file asset table +def upload_to_s3( + zip_file: io.BytesIO, workspace_id: UUID, token_id: str, slug: str +) -> None: + """ + Upload a ZIP file to S3 and generate a presigned URL. + """ file_name = ( f"{workspace_id}/export-{slug}-{token_id[:6]}-{str(timezone.now().date())}.zip" ) @@ -150,75 +180,85 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug): exporter_instance.save(update_fields=["status", "url", "key"]) -def generate_table_row(issue): +def generate_table_row(issue: dict) -> List[str]: + """ + Generate a table row from an issue dictionary. + """ return [ - f"""{issue["project__identifier"]}-{issue["sequence_id"]}""", - issue["project__name"], + f"""{issue["project_identifier"]}-{issue["sequence_id"]}""", + issue["project_name"], issue["name"], - issue["description_stripped"], - issue["state__name"], + issue["description"], + issue["state_name"], dateConverter(issue["start_date"]), dateConverter(issue["target_date"]), issue["priority"], - ( - f"{issue['created_by__first_name']} {issue['created_by__last_name']}" - if issue["created_by__first_name"] and issue["created_by__last_name"] - else "" - ), - ( - f"{issue['assignees__first_name']} {issue['assignees__last_name']}" - if issue["assignees__first_name"] and issue["assignees__last_name"] - else "" - ), - issue["labels__name"] if issue["labels__name"] else "", - issue["issue_cycle__cycle__name"], - dateConverter(issue["issue_cycle__cycle__start_date"]), - dateConverter(issue["issue_cycle__cycle__end_date"]), - issue["issue_module__module__name"], - dateConverter(issue["issue_module__module__start_date"]), - dateConverter(issue["issue_module__module__target_date"]), + issue["created_by"], + ", ".join(issue["labels"]) if issue["labels"] else "", + issue["cycle_name"], + issue["cycle_start_date"], + issue["cycle_end_date"], + ", ".join(issue.get("module_name", "")) if issue.get("module_name") else "", dateTimeConverter(issue["created_at"]), dateTimeConverter(issue["updated_at"]), dateTimeConverter(issue["completed_at"]), dateTimeConverter(issue["archived_at"]), + ( + ", ".join( + [ + f"{comment['comment']} ({comment['created_at']} by {comment['created_by']})" + for comment in issue["comments"] + ] + ) + if issue["comments"] + else "" + ), + issue["estimate"] if issue["estimate"] else "", + ", ".join(issue["link"]) if issue["link"] else "", + ", ".join(issue["assignees"]) if issue["assignees"] else "", + issue["subscribers_count"] if issue["subscribers_count"] else "", + issue["attachment_count"] if issue["attachment_count"] else "", + ", ".join(issue["attachment_links"]) if issue["attachment_links"] else "", ] -def generate_json_row(issue): +def generate_json_row(issue: dict) -> dict: + """ + Generate a JSON row from an issue dictionary. + """ return { - "ID": f"""{issue["project__identifier"]}-{issue["sequence_id"]}""", - "Project": issue["project__name"], + "ID": f"""{issue["project_identifier"]}-{issue["sequence_id"]}""", + "Project": issue["project_name"], "Name": issue["name"], - "Description": issue["description_stripped"], - "State": issue["state__name"], + "Description": issue["description"], + "State": issue["state_name"], "Start Date": dateConverter(issue["start_date"]), "Target Date": dateConverter(issue["target_date"]), "Priority": issue["priority"], - "Created By": ( - f"{issue['created_by__first_name']} {issue['created_by__last_name']}" - if issue["created_by__first_name"] and issue["created_by__last_name"] - else "" - ), - "Assignee": ( - f"{issue['assignees__first_name']} {issue['assignees__last_name']}" - if issue["assignees__first_name"] and issue["assignees__last_name"] - else "" - ), - "Labels": issue["labels__name"] if issue["labels__name"] else "", - "Cycle Name": issue["issue_cycle__cycle__name"], - "Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]), - "Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]), - "Module Name": issue["issue_module__module__name"], - "Module Start Date": dateConverter(issue["issue_module__module__start_date"]), - "Module Target Date": dateConverter(issue["issue_module__module__target_date"]), + "Created By": (f"{issue['created_by']}" if issue["created_by"] else ""), + "Assignee": issue["assignees"], + "Labels": issue["labels"], + "Cycle Name": issue["cycle_name"], + "Cycle Start Date": issue["cycle_start_date"], + "Cycle End Date": issue["cycle_end_date"], + "Module Name": issue["module_name"], "Created At": dateTimeConverter(issue["created_at"]), "Updated At": dateTimeConverter(issue["updated_at"]), "Completed At": dateTimeConverter(issue["completed_at"]), "Archived At": dateTimeConverter(issue["archived_at"]), + "Comments": issue["comments"], + "Estimate": issue["estimate"], + "Link": issue["link"], + "Subscribers Count": issue["subscribers_count"], + "Attachment Count": issue["attachment_count"], + "Attachment Links": issue["attachment_links"], } -def update_json_row(rows, row): +def update_json_row(rows: List[dict], row: dict) -> None: + """ + Update the json row with the new assignee and label. + """ matched_index = next( ( index @@ -247,7 +287,10 @@ def update_json_row(rows, row): rows.append(row) -def update_table_row(rows, row): +def update_table_row(rows: List[List[str]], row: List[str]) -> None: + """ + Update the table row with the new assignee and label. + """ matched_index = next( (index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]), None, @@ -269,7 +312,12 @@ def update_table_row(rows, row): rows.append(row) -def generate_csv(header, project_id, issues, files): +def generate_csv( + header: List[str], + project_id: str, + issues: List[dict], + files: List[tuple[str, str | bytes]], +) -> None: """ Generate CSV export for all the passed issues. """ @@ -281,7 +329,15 @@ def generate_csv(header, project_id, issues, files): files.append((f"{project_id}.csv", csv_file)) -def generate_json(header, project_id, issues, files): +def generate_json( + header: List[str], + project_id: str, + issues: List[dict], + files: List[tuple[str, str | bytes]], +) -> None: + """ + Generate JSON export for all the passed issues. + """ rows = [] for issue in issues: row = generate_json_row(issue) @@ -290,68 +346,169 @@ def generate_json(header, project_id, issues, files): files.append((f"{project_id}.json", json_file)) -def generate_xlsx(header, project_id, issues, files): +def generate_xlsx( + header: List[str], + project_id: str, + issues: List[dict], + files: List[tuple[str, str | bytes]], +) -> None: + """ + Generate XLSX export for all the passed issues. + """ rows = [header] for issue in issues: row = generate_table_row(issue) + update_table_row(rows, row) xlsx_file = create_xlsx_file(rows) files.append((f"{project_id}.xlsx", xlsx_file)) +def get_created_by(obj: Issue | IssueComment) -> str: + """ + Get the created by user for the given object. + """ + if obj.created_by: + return f"{obj.created_by.first_name} {obj.created_by.last_name}" + return "" + + @shared_task -def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, slug): +def issue_export_task( + provider: str, + workspace_id: UUID, + project_ids: List[str], + token_id: str, + multiple: bool, + slug: str, +): + """ + Export issues from the workspace. + provider (str): The provider to export the issues to csv | json | xlsx. + token_id (str): The export object token id. + multiple (bool): Whether to export the issues to multiple files per project. + """ try: exporter_instance = ExporterHistory.objects.get(token=token_id) exporter_instance.status = "processing" exporter_instance.save(update_fields=["status"]) + # Base query to get the issues workspace_issues = ( - ( - Issue.objects.filter( - workspace__id=workspace_id, - project_id__in=project_ids, - project__project_projectmember__member=exporter_instance.initiated_by_id, - project__project_projectmember__is_active=True, - project__archived_at__isnull=True, - ) - .select_related("project", "workspace", "state", "parent", "created_by") - .prefetch_related( - "assignees", "labels", "issue_cycle__cycle", "issue_module__module" - ) - .values( - "id", - "project__identifier", - "project__name", - "project__id", - "sequence_id", - "name", - "description_stripped", - "priority", - "start_date", - "target_date", - "state__name", - "created_at", - "updated_at", - "completed_at", - "archived_at", - "issue_cycle__cycle__name", - "issue_cycle__cycle__start_date", - "issue_cycle__cycle__end_date", - "issue_module__module__name", - "issue_module__module__start_date", - "issue_module__module__target_date", - "created_by__first_name", - "created_by__last_name", - "assignees__first_name", - "assignees__last_name", - "labels__name", - ) + Issue.objects.filter( + workspace__id=workspace_id, + project_id__in=project_ids, + project__project_projectmember__member=exporter_instance.initiated_by_id, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + .select_related( + "project", + "workspace", + "state", + "parent", + "created_by", + "estimate_point", + ) + .prefetch_related( + "labels", + "issue_cycle__cycle", + "issue_module__module", + "issue_comments", + "assignees", + Prefetch( + "assignees", + queryset=User.objects.only("first_name", "last_name").distinct(), + to_attr="assignee_details", + ), + Prefetch( + "labels", + queryset=Label.objects.only("name").distinct(), + to_attr="label_details", + ), + "issue_subscribers", + "issue_link", ) - .order_by("project__identifier", "sequence_id") - .distinct() ) - # CSV header + + # Get the attachments for the issues + file_assets = FileAsset.objects.filter( + issue_id__in=workspace_issues.values_list("id", flat=True), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ).annotate(work_item_id=F("issue_id"), asset_id=F("id")) + + # Create a dictionary to store the attachments for the issues + attachment_dict = defaultdict(list) + for asset in file_assets: + attachment_dict[asset.work_item_id].append(asset.asset_id) + + # Create a list to store the issues data + issues_data = [] + + # Iterate over the issues + for issue in workspace_issues: + attachments = attachment_dict.get(issue.id, []) + + issue_data = { + "id": issue.id, + "project_identifier": issue.project.identifier, + "project_name": issue.project.name, + "project_id": issue.project.id, + "sequence_id": issue.sequence_id, + "name": issue.name, + "description": issue.description_stripped, + "priority": issue.priority, + "start_date": issue.start_date, + "target_date": issue.target_date, + "state_name": issue.state.name if issue.state else None, + "created_at": issue.created_at, + "updated_at": issue.updated_at, + "completed_at": issue.completed_at, + "archived_at": issue.archived_at, + "module_name": [ + module.module.name for module in issue.issue_module.all() + ], + "created_by": get_created_by(issue), + "labels": [label.name for label in issue.label_details], + "comments": [ + { + "comment": comment.comment_stripped, + "created_at": dateConverter(comment.created_at), + "created_by": get_created_by(comment), + } + for comment in issue.issue_comments.all() + ], + "estimate": issue.estimate_point.value + if issue.estimate_point and issue.estimate_point.value + else "", + "link": [link.url for link in issue.issue_link.all()], + "assignees": [ + f"{assignee.first_name} {assignee.last_name}" + for assignee in issue.assignee_details + ], + "subscribers_count": issue.issue_subscribers.count(), + "attachment_count": len(attachments), + "attachment_links": [ + f"/api/assets/v2/workspaces/{issue.workspace.slug}/projects/{issue.project_id}/issues/{issue.id}/attachments/{asset}/" + for asset in attachments + ], + } + + # Get Cycles data for the issue + cycle = issue.issue_cycle.last() + if cycle: + # Update cycle data + issue_data["cycle_name"] = cycle.cycle.name + issue_data["cycle_start_date"] = dateConverter(cycle.cycle.start_date) + issue_data["cycle_end_date"] = dateConverter(cycle.cycle.end_date) + else: + issue_data["cycle_name"] = "" + issue_data["cycle_start_date"] = "" + issue_data["cycle_end_date"] = "" + + issues_data.append(issue_data) + + # CSV header header = [ "ID", "Project", @@ -362,20 +519,25 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s "Target Date", "Priority", "Created By", - "Assignee", "Labels", "Cycle Name", "Cycle Start Date", "Cycle End Date", "Module Name", - "Module Start Date", - "Module Target Date", "Created At", "Updated At", "Completed At", "Archived At", + "Comments", + "Estimate", + "Link", + "Assignees", + "Subscribers Count", + "Attachment Count", + "Attachment Links", ] + # Map the provider to the function EXPORTER_MAPPER = { "csv": generate_csv, "json": generate_json, @@ -384,8 +546,13 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s files = [] if multiple: + project_dict = defaultdict(list) + for issue in issues_data: + project_dict[str(issue["project_id"])].append(issue) + for project_id in project_ids: - issues = workspace_issues.filter(project__id=project_id) + issues = project_dict.get(str(project_id), []) + exporter = EXPORTER_MAPPER.get(provider) if exporter is not None: exporter(header, project_id, issues, files) @@ -393,7 +560,7 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s else: exporter = EXPORTER_MAPPER.get(provider) if exporter is not None: - exporter(header, workspace_id, workspace_issues, files) + exporter(header, workspace_id, issues_data, files) zip_buffer = create_zip_file(files) upload_to_s3(zip_buffer, workspace_id, token_id, slug) diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index 6e8990ad1..4e80f2cc1 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -63,7 +63,7 @@ def forgot_password(first_name, email, uidb64, token, current_site): ) msg.attach_alternative(html_content, "text/html") msg.send() - logging.getLogger("plane").info("Email sent successfully") + logging.getLogger("plane.worker").info("Email sent successfully") return except Exception as e: log_exception(e) diff --git a/apiserver/plane/bgtasks/issue_activities_task.py b/apiserver/plane/bgtasks/issue_activities_task.py index fcd75f8e3..4def8e8ca 100644 --- a/apiserver/plane/bgtasks/issue_activities_task.py +++ b/apiserver/plane/bgtasks/issue_activities_task.py @@ -1650,40 +1650,6 @@ def issue_activity( # Save all the values to database issue_activities_created = IssueActivity.objects.bulk_create(issue_activities) - # Post the updates to segway for integrations and webhooks - if len(issue_activities_created): - for activity in issue_activities_created: - webhook_activity.delay( - event=( - "issue_comment" - if activity.field == "comment" - else "intake_issue" - if intake - else "issue" - ), - event_id=( - activity.issue_comment_id - if activity.field == "comment" - else intake - if intake - else activity.issue_id - ), - verb=activity.verb, - field=( - "description" if activity.field == "comment" else activity.field - ), - old_value=( - activity.old_value if activity.old_value != "" else None - ), - new_value=( - activity.new_value if activity.new_value != "" else None - ), - actor_id=activity.actor_id, - current_site=origin, - slug=activity.workspace.slug, - old_identifier=activity.old_identifier, - new_identifier=activity.new_identifier, - ) if notification: notifications.delay( diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py index 1a0e9ba03..d8267e697 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -53,7 +53,7 @@ def magic_link(email, key, token): ) msg.attach_alternative(html_content, "text/html") msg.send() - logging.getLogger("plane").info("Email sent successfully.") + logging.getLogger("plane.worker").info("Email sent successfully.") return except Exception as e: log_exception(e) diff --git a/apiserver/plane/bgtasks/project_add_user_email_task.py b/apiserver/plane/bgtasks/project_add_user_email_task.py index c8308465a..ab1eb0394 100644 --- a/apiserver/plane/bgtasks/project_add_user_email_task.py +++ b/apiserver/plane/bgtasks/project_add_user_email_task.py @@ -80,7 +80,7 @@ def project_add_user_email(current_site, project_member_id, invitor_id): # Send the email msg.send() # Log the success - logging.getLogger("plane").info("Email sent successfully.") + logging.getLogger("plane.worker").info("Email sent successfully.") return except Exception as e: log_exception(e) diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py index 84ef237ef..179dfa00f 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -76,7 +76,7 @@ def project_invitation(email, project_id, token, current_site, invitor): msg.attach_alternative(html_content, "text/html") msg.send() - logging.getLogger("plane").info("Email sent successfully.") + logging.getLogger("plane.worker").info("Email sent successfully.") return except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist): return diff --git a/apiserver/plane/bgtasks/user_activation_email_task.py b/apiserver/plane/bgtasks/user_activation_email_task.py index 23f0e1d01..492564b3c 100644 --- a/apiserver/plane/bgtasks/user_activation_email_task.py +++ b/apiserver/plane/bgtasks/user_activation_email_task.py @@ -58,7 +58,7 @@ def user_activation_email(current_site, user_id): msg.attach_alternative(html_content, "text/html") msg.send() - logging.getLogger("plane").info("Email sent successfully.") + logging.getLogger("plane.worker").info("Email sent successfully.") return except Exception as e: log_exception(e) diff --git a/apiserver/plane/bgtasks/user_deactivation_email_task.py b/apiserver/plane/bgtasks/user_deactivation_email_task.py index 9425dc324..2595d8055 100644 --- a/apiserver/plane/bgtasks/user_deactivation_email_task.py +++ b/apiserver/plane/bgtasks/user_deactivation_email_task.py @@ -60,7 +60,7 @@ def user_deactivation_email(current_site, user_id): # Attach HTML content msg.attach_alternative(html_content, "text/html") msg.send() - logging.getLogger("plane").info("Email sent successfully.") + logging.getLogger("plane.worker").info("Email sent successfully.") return except Exception as e: log_exception(e) diff --git a/apiserver/plane/bgtasks/webhook_task.py b/apiserver/plane/bgtasks/webhook_task.py index c1ea01a4d..0bcfd2693 100644 --- a/apiserver/plane/bgtasks/webhook_task.py +++ b/apiserver/plane/bgtasks/webhook_task.py @@ -5,6 +5,7 @@ import logging import uuid import requests +from typing import Any, Dict, List, Optional, Union # Third party imports from celery import shared_task @@ -70,150 +71,89 @@ MODEL_MAPPER = { } -def get_model_data(event, event_id, many=False): +logger = logging.getLogger("plane.worker") + + +def get_model_data( + event: str, event_id: Union[str, List[str]], many: bool = False +) -> Dict[str, Any]: + """ + Retrieve and serialize model data based on the event type. + + Args: + event (str): The type of event/model to retrieve data for + event_id (Union[str, List[str]]): The ID or list of IDs of the model instance(s) + many (bool): Whether to retrieve multiple instances + + Returns: + Dict[str, Any]: Serialized model data + + Raises: + ValueError: If serializer is not found for the event + ObjectDoesNotExist: If model instance is not found + """ model = MODEL_MAPPER.get(event) - if many: - queryset = model.objects.filter(pk__in=event_id) - else: - queryset = model.objects.get(pk=event_id) - serializer = SERIALIZER_MAPPER.get(event) - return serializer(queryset, many=many).data + if model is None: + raise ValueError(f"Model not found for event: {event}") - -@shared_task( - bind=True, - autoretry_for=(requests.RequestException,), - retry_backoff=600, - max_retries=5, - retry_jitter=True, -) -def webhook_task(self, webhook, slug, event, event_data, action, current_site): try: - webhook = Webhook.objects.get(id=webhook, workspace__slug=slug) + if many: + queryset = model.objects.filter(pk__in=event_id) + else: + queryset = model.objects.get(pk=event_id) - headers = { - "Content-Type": "application/json", - "User-Agent": "Autopilot", - "X-Plane-Delivery": str(uuid.uuid4()), - "X-Plane-Event": event, - } + serializer = SERIALIZER_MAPPER.get(event) + if serializer is None: + raise ValueError(f"Serializer not found for event: {event}") - # # Your secret key - event_data = ( - json.loads(json.dumps(event_data, cls=DjangoJSONEncoder)) - if event_data is not None - else None - ) - - action = { - "POST": "create", - "PATCH": "update", - "PUT": "update", - "DELETE": "delete", - }.get(action, action) - - payload = { - "event": event, - "action": action, - "webhook_id": str(webhook.id), - "workspace_id": str(webhook.workspace_id), - "data": event_data, - } - - # Use HMAC for generating signature - if webhook.secret_key: - hmac_signature = hmac.new( - webhook.secret_key.encode("utf-8"), - json.dumps(payload).encode("utf-8"), - hashlib.sha256, - ) - signature = hmac_signature.hexdigest() - headers["X-Plane-Signature"] = signature - - # Send the webhook event - response = requests.post(webhook.url, headers=headers, json=payload, timeout=30) - - # Log the webhook request - WebhookLog.objects.create( - workspace_id=str(webhook.workspace_id), - webhook=str(webhook.id), - event_type=str(event), - request_method=str(action), - request_headers=str(headers), - request_body=str(payload), - response_status=str(response.status_code), - response_headers=str(response.headers), - response_body=str(response.text), - retry_count=str(self.request.retries), - ) - - except Webhook.DoesNotExist: - return - except requests.RequestException as e: - # Log the failed webhook request - WebhookLog.objects.create( - workspace_id=str(webhook.workspace_id), - webhook=str(webhook.id), - event_type=str(event), - request_method=str(action), - request_headers=str(headers), - request_body=str(payload), - response_status=500, - response_headers="", - response_body=str(e), - retry_count=str(self.request.retries), - ) - # Retry logic - if self.request.retries >= self.max_retries: - Webhook.objects.filter(pk=webhook.id).update(is_active=False) - if webhook: - # send email for the deactivation of the webhook - send_webhook_deactivation_email( - webhook_id=webhook.id, - receiver_id=webhook.created_by_id, - reason=str(e), - current_site=current_site, - ) - return - raise requests.RequestException() - - except Exception as e: - if settings.DEBUG: - print(e) - log_exception(e) - return + return serializer(queryset, many=many).data + except ObjectDoesNotExist: + raise ObjectDoesNotExist(f"No {event} found with id: {event_id}") @shared_task -def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reason): - # Get email configurations - ( - EMAIL_HOST, - EMAIL_HOST_USER, - EMAIL_HOST_PASSWORD, - EMAIL_PORT, - EMAIL_USE_TLS, - EMAIL_USE_SSL, - EMAIL_FROM, - ) = get_email_configuration() - - receiver = User.objects.get(pk=receiver_id) - webhook = Webhook.objects.get(pk=webhook_id) - subject = "Webhook Deactivated" - message = f"Webhook {webhook.url} has been deactivated due to failed requests." - - # Send the mail - context = { - "email": receiver.email, - "message": message, - "webhook_url": f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}", - } - html_content = render_to_string( - "emails/notifications/webhook-deactivate.html", context - ) - text_content = strip_tags(html_content) +def send_webhook_deactivation_email( + webhook_id: str, receiver_id: str, current_site: str, reason: str +) -> None: + """ + Send an email notification when a webhook is deactivated. + Args: + webhook_id (str): ID of the deactivated webhook + receiver_id (str): ID of the user to receive the notification + current_site (str): Current site URL + reason (str): Reason for webhook deactivation + """ try: + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_USE_SSL, + EMAIL_FROM, + ) = get_email_configuration() + + receiver = User.objects.get(pk=receiver_id) + webhook = Webhook.objects.get(pk=webhook_id) + + # Get the webhook payload + subject = "Webhook Deactivated" + message = f"Webhook {webhook.url} has been deactivated due to failed requests." + + # Send the mail + context = { + "email": receiver.email, + "message": message, + "webhook_url": f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}", + } + html_content = render_to_string( + "emails/notifications/webhook-deactivate.html", context + ) + text_content = strip_tags(html_content) + + # Set the email connection connection = get_connection( host=EMAIL_HOST, port=int(EMAIL_PORT), @@ -223,6 +163,7 @@ def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reaso use_ssl=EMAIL_USE_SSL == "1", ) + # Create the email message msg = EmailMultiAlternatives( subject=subject, body=text_content, @@ -232,11 +173,10 @@ def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reaso ) msg.attach_alternative(html_content, "text/html") msg.send() - logging.getLogger("plane").info("Email sent successfully.") - return + logger.info("Email sent successfully.") except Exception as e: log_exception(e) - return + logger.error(f"Failed to send email: {e}") @shared_task( @@ -247,10 +187,29 @@ def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reaso retry_jitter=True, ) def webhook_send_task( - self, webhook, slug, event, event_data, action, current_site, activity -): + self, + webhook_id: str, + slug: str, + event: str, + event_data: Optional[Dict[str, Any]], + action: str, + current_site: str, + activity: Optional[Dict[str, Any]], +) -> None: + """ + Send webhook notifications to configured endpoints. + + Args: + webhook (str): Webhook ID + slug (str): Workspace slug + event (str): Event type + event_data (Optional[Dict[str, Any]]): Event data to be sent + action (str): HTTP method/action + current_site (str): Current site URL + activity (Optional[Dict[str, Any]]): Activity data + """ try: - webhook = Webhook.objects.get(id=webhook, workspace__slug=slug) + webhook = Webhook.objects.get(id=webhook_id, workspace__slug=slug) headers = { "Content-Type": "application/json", @@ -297,7 +256,12 @@ def webhook_send_task( ) signature = hmac_signature.hexdigest() headers["X-Plane-Signature"] = signature + except Exception as e: + log_exception(e) + logger.error(f"Failed to send webhook: {e}") + return + try: # Send the webhook event response = requests.post(webhook.url, headers=headers, json=payload, timeout=30) @@ -314,7 +278,7 @@ def webhook_send_task( response_body=str(response.text), retry_count=str(self.request.retries), ) - + logger.info(f"Webhook {webhook.id} sent successfully") except requests.RequestException as e: # Log the failed webhook request WebhookLog.objects.create( @@ -329,12 +293,13 @@ def webhook_send_task( response_body=str(e), retry_count=str(self.request.retries), ) + logger.error(f"Webhook {webhook.id} failed with error: {e}") # Retry logic if self.request.retries >= self.max_retries: Webhook.objects.filter(pk=webhook.id).update(is_active=False) if webhook: # send email for the deactivation of the webhook - send_webhook_deactivation_email( + send_webhook_deactivation_email.delay( webhook_id=webhook.id, receiver_id=webhook.created_by_id, reason=str(e), @@ -344,26 +309,50 @@ def webhook_send_task( raise requests.RequestException() except Exception as e: - if settings.DEBUG: - print(e) log_exception(e) return @shared_task def webhook_activity( - event, - verb, - field, - old_value, - new_value, - actor_id, - slug, - current_site, - event_id, - old_identifier, - new_identifier, -): + event: str, + verb: str, + field: Optional[str], + old_value: Any, + new_value: Any, + actor_id: str | uuid.UUID, + slug: str, + current_site: str, + event_id: str | uuid.UUID, + old_identifier: Optional[str], + new_identifier: Optional[str], +) -> None: + """ + Process and send webhook notifications for various activities in the system. + + This task filters relevant webhooks based on the event type and sends notifications + to all active webhooks for the workspace. + + Args: + event (str): Type of event (project, issue, module, cycle, issue_comment) + verb (str): Action performed (created, updated, deleted) + field (Optional[str]): Name of the field that was changed + old_value (Any): Previous value of the field + new_value (Any): New value of the field + actor_id (str | uuid.UUID): ID of the user who performed the action + slug (str): Workspace slug + current_site (str): Current site URL + event_id (str | uuid.UUID): ID of the event object + old_identifier (Optional[str]): Previous identifier if any + new_identifier (Optional[str]): New identifier if any + + Returns: + None + + Note: + The function silently returns on ObjectDoesNotExist exceptions to handle + race conditions where objects might have been deleted. + """ try: webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True) @@ -384,7 +373,7 @@ def webhook_activity( for webhook in webhooks: webhook_send_task.delay( - webhook=webhook.id, + webhook_id=webhook.id, slug=slug, event=event, event_data=( diff --git a/apiserver/plane/bgtasks/work_item_link_task.py b/apiserver/plane/bgtasks/work_item_link_task.py new file mode 100644 index 000000000..1ba48caf9 --- /dev/null +++ b/apiserver/plane/bgtasks/work_item_link_task.py @@ -0,0 +1,177 @@ +# Python imports +import logging + + +# Third party imports +from celery import shared_task +import requests +from bs4 import BeautifulSoup +from urllib.parse import urlparse, urljoin +import base64 +import ipaddress +from typing import Dict, Any +from typing import Optional +from plane.db.models import IssueLink +from plane.utils.exception_logger import log_exception + +logger = logging.getLogger("plane.worker") + + +DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayI+PHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiLz48cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiLz48L3N2Zz4=" # noqa: E501 + +def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]: + """ + Crawls a URL to extract the title and favicon. + + Args: + url (str): The URL to crawl + + Returns: + str: JSON string containing title and base64-encoded favicon + """ + try: + # Prevent access to private IP ranges + parsed = urlparse(url) + + try: + ip = ipaddress.ip_address(parsed.hostname) + if ip.is_private or ip.is_loopback or ip.is_reserved: + raise ValueError("Access to private/internal networks is not allowed") + except ValueError: + # Not an IP address, continue with domain validation + pass + + # Set up headers to mimic a real browser + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" # noqa: E501 + } + + soup = None + title = None + + try: + response = requests.get(url, headers=headers, timeout=1) + + soup = BeautifulSoup(response.content, "html.parser") + title_tag = soup.find("title") + title = title_tag.get_text().strip() if title_tag else None + + except requests.RequestException as e: + logger.warning(f"Failed to fetch HTML for title: {str(e)}") + + # Fetch and encode favicon + favicon_base64 = fetch_and_encode_favicon(headers, soup, url) + + # Prepare result + result = { + "title": title, + "favicon": favicon_base64["favicon_base64"], + "url": url, + "favicon_url": favicon_base64["favicon_url"], + } + + return result + + except Exception as e: + log_exception(e) + return { + "error": f"Unexpected error: {str(e)}", + "title": None, + "favicon": None, + "url": url, + } + + +def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[str]: + """ + Find the favicon URL from HTML soup. + + Args: + soup: BeautifulSoup object + base_url: Base URL for resolving relative paths + + Returns: + str: Absolute URL to favicon or None + """ + + if soup is not None: + # Look for various favicon link tags + favicon_selectors = [ + 'link[rel="icon"]', + 'link[rel="shortcut icon"]', + 'link[rel="apple-touch-icon"]', + 'link[rel="apple-touch-icon-precomposed"]', + ] + + for selector in favicon_selectors: + favicon_tag = soup.select_one(selector) + if favicon_tag and favicon_tag.get("href"): + return urljoin(base_url, favicon_tag["href"]) + + # Fallback to /favicon.ico + parsed_url = urlparse(base_url) + fallback_url = f"{parsed_url.scheme}://{parsed_url.netloc}/favicon.ico" + + # Check if fallback exists + try: + response = requests.head(fallback_url, timeout=2) + if response.status_code == 200: + return fallback_url + except requests.RequestException as e: + log_exception(e) + return None + + return None + + +def fetch_and_encode_favicon( + headers: Dict[str, str], soup: Optional[BeautifulSoup], url: str +) -> Dict[str, Optional[str]]: + """ + Fetch favicon and encode it as base64. + + Args: + favicon_url: URL to the favicon + headers: Request headers + + Returns: + str: Base64 encoded favicon with data URI prefix or None + """ + try: + favicon_url = find_favicon_url(soup, url) + if favicon_url is None: + return { + "favicon_url": None, + "favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}", + } + + response = requests.get(favicon_url, headers=headers, timeout=1) + + # Get content type + content_type = response.headers.get("content-type", "image/x-icon") + + # Convert to base64 + favicon_base64 = base64.b64encode(response.content).decode("utf-8") + + # Return as data URI + return { + "favicon_url": favicon_url, + "favicon_base64": f"data:{content_type};base64,{favicon_base64}", + } + + except Exception as e: + logger.warning(f"Failed to fetch favicon: {e}") + return { + "favicon_url": None, + "favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}", + } + + +@shared_task +def crawl_work_item_link_title(id: str, url: str) -> None: + meta_data = crawl_work_item_link_title_and_favicon(url) + issue_link = IssueLink.objects.get(id=id) + + issue_link.metadata = meta_data + + issue_link.save() diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index a8bd0d7d0..c855a8ce6 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -78,7 +78,7 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter): ) msg.attach_alternative(html_content, "text/html") msg.send() - logging.getLogger("plane").info("Email sent successfully") + logging.getLogger("plane.worker").info("Email sent successfully") return except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist): return diff --git a/apiserver/plane/bgtasks/workspace_seed_task.py b/apiserver/plane/bgtasks/workspace_seed_task.py new file mode 100644 index 000000000..c2fbfb065 --- /dev/null +++ b/apiserver/plane/bgtasks/workspace_seed_task.py @@ -0,0 +1,319 @@ +# Python imports +import os +import json +import time +import uuid +from typing import Dict +import logging + +# Django imports +from django.conf import settings + +# Third party imports +from celery import shared_task + +# Module imports +from plane.db.models import ( + Workspace, + WorkspaceMember, + Project, + ProjectMember, + IssueUserProperty, + State, + Label, + Issue, + IssueLabel, + IssueSequence, + IssueActivity, +) + +logger = logging.getLogger("plane.worker") + + +def read_seed_file(filename): + """ + Read a JSON file from the seed directory. + + Args: + filename (str): Name of the JSON file to read + + Returns: + dict: Contents of the JSON file + """ + file_path = os.path.join(settings.SEED_DIR, "data", filename) + try: + with open(file_path, "r") as file: + return json.load(file) + except FileNotFoundError: + logger.error(f"Seed file {filename} not found in {settings.SEED_DIR}/data") + return None + except json.JSONDecodeError: + logger.error(f"Error decoding JSON from {filename}") + return None + + +def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]: + """Creates a project and associated members for a workspace. + + Creates a new project using the workspace name and sets up all necessary + member associations and user properties. + + Args: + workspace: The workspace to create the project in + + Returns: + A mapping of seed project IDs to actual project IDs + """ + project_seeds = read_seed_file("projects.json") + project_identifier = "".join(ch for ch in workspace.name if ch.isalnum())[:5] + + # Create members + workspace_members = WorkspaceMember.objects.filter(workspace=workspace).values( + "member_id", "role" + ) + + projects_map: Dict[int, uuid.UUID] = {} + + if not project_seeds: + logger.warning( + "Task: workspace_seed_task -> No project seeds found. Skipping project creation." + ) + return projects_map + + for project_seed in project_seeds: + project_id = project_seed.pop("id") + # Remove the name from seed data since we want to use workspace name + project_seed.pop("name", None) + project_seed.pop("identifier", None) + + project = Project.objects.create( + **project_seed, + workspace=workspace, + name=workspace.name, # Use workspace name + identifier=project_identifier, + created_by_id=workspace.created_by_id, + ) + + # Create project members + ProjectMember.objects.bulk_create( + [ + ProjectMember( + project=project, + member_id=workspace_member["member_id"], + role=workspace_member["role"], + workspace_id=workspace.id, + created_by_id=workspace.created_by_id, + ) + for workspace_member in workspace_members + ] + ) + + # Create issue user properties + IssueUserProperty.objects.bulk_create( + [ + IssueUserProperty( + project=project, + user_id=workspace_member["member_id"], + workspace_id=workspace.id, + display_filters={ + "group_by": None, + "order_by": "sort_order", + "type": None, + "sub_issue": True, + "show_empty_groups": True, + "layout": "list", + "calendar_date_range": "", + }, + created_by_id=workspace.created_by_id, + ) + for workspace_member in workspace_members + ] + ) + # update map + projects_map[project_id] = project.id + logger.info(f"Task: workspace_seed_task -> Project {project_id} created") + + return projects_map + + +def create_project_states( + workspace: Workspace, project_map: Dict[int, uuid.UUID] +) -> Dict[int, uuid.UUID]: + """Creates states for each project in the workspace. + + Args: + workspace: The workspace containing the projects + project_map: Mapping of seed project IDs to actual project IDs + + Returns: + A mapping of seed state IDs to actual state IDs + """ + + state_seeds = read_seed_file("states.json") + state_map: Dict[int, uuid.UUID] = {} + + if not state_seeds: + return state_map + + for state_seed in state_seeds: + state_id = state_seed.pop("id") + project_id = state_seed.pop("project_id") + + state = State.objects.create( + **state_seed, + project_id=project_map[project_id], + workspace=workspace, + created_by_id=workspace.created_by_id, + ) + + state_map[state_id] = state.id + logger.info(f"Task: workspace_seed_task -> State {state_id} created") + return state_map + + +def create_project_labels( + workspace: Workspace, project_map: Dict[int, uuid.UUID] +) -> Dict[int, uuid.UUID]: + """Creates labels for each project in the workspace. + + Args: + workspace: The workspace containing the projects + project_map: Mapping of seed project IDs to actual project IDs + + Returns: + A mapping of seed label IDs to actual label IDs + """ + label_seeds = read_seed_file("labels.json") + label_map: Dict[int, uuid.UUID] = {} + + if not label_seeds: + return label_map + + for label_seed in label_seeds: + label_id = label_seed.pop("id") + project_id = label_seed.pop("project_id") + label = Label.objects.create( + **label_seed, + project_id=project_map[project_id], + workspace=workspace, + created_by_id=workspace.created_by_id, + ) + label_map[label_id] = label.id + + logger.info(f"Task: workspace_seed_task -> Label {label_id} created") + return label_map + + +def create_project_issues( + workspace: Workspace, + project_map: Dict[int, uuid.UUID], + states_map: Dict[int, uuid.UUID], + labels_map: Dict[int, uuid.UUID], +) -> None: + """Creates issues and their associated records for each project. + + Creates issues along with their sequences, activities, and label associations. + + Args: + workspace: The workspace containing the projects + project_map: Mapping of seed project IDs to actual project IDs + states_map: Mapping of seed state IDs to actual state IDs + labels_map: Mapping of seed label IDs to actual label IDs + """ + issue_seeds = read_seed_file("issues.json") + + if not issue_seeds: + return + + for issue_seed in issue_seeds: + required_fields = ["id", "labels", "project_id", "state_id"] + # get the values + for field in required_fields: + if field not in issue_seed: + logger.error( + f"Task: workspace_seed_task -> Required field '{field}' missing in issue seed" + ) + continue + + # get the values + issue_id = issue_seed.pop("id") + labels = issue_seed.pop("labels") + project_id = issue_seed.pop("project_id") + state_id = issue_seed.pop("state_id") + + issue = Issue.objects.create( + **issue_seed, + state_id=states_map[state_id], + project_id=project_map[project_id], + workspace=workspace, + created_by_id=workspace.created_by_id, + ) + IssueSequence.objects.create( + issue=issue, + project_id=project_map[project_id], + workspace_id=workspace.id, + created_by_id=workspace.created_by_id, + ) + + IssueActivity.objects.create( + issue=issue, + project_id=project_map[project_id], + workspace_id=workspace.id, + comment="created the issue", + verb="created", + actor_id=workspace.created_by_id, + epoch=time.time(), + ) + + for label_id in labels: + IssueLabel.objects.create( + issue=issue, + label_id=labels_map[label_id], + project_id=project_map[project_id], + workspace_id=workspace.id, + created_by_id=workspace.created_by_id, + ) + + logger.info(f"Task: workspace_seed_task -> Issue {issue_id} created") + return + + +@shared_task +def workspace_seed(workspace_id: uuid.UUID) -> None: + """Seeds a new workspace with initial project data. + + Creates a complete workspace setup including: + - Projects and project members + - Project states + - Project labels + - Issues and their associations + + Args: + workspace_id: ID of the workspace to seed + """ + try: + logger.info(f"Task: workspace_seed_task -> Seeding workspace {workspace_id}") + # Get the workspace + workspace = Workspace.objects.get(id=workspace_id) + + # Create a project with the same name as workspace + project_map = create_project_and_member(workspace) + + # Create project states + state_map = create_project_states(workspace, project_map) + + # Create project labels + label_map = create_project_labels(workspace, project_map) + + # create project issues + create_project_issues(workspace, project_map, state_map, label_map) + + logger.info( + f"Task: workspace_seed_task -> Workspace {workspace_id} seeded successfully" + ) + return + except Exception as e: + logger.error( + f"Task: workspace_seed_task -> Failed to seed workspace {workspace_id}: {str(e)}" + ) + raise e diff --git a/apiserver/plane/db/management/commands/update_deleted_workspace_slug.py b/apiserver/plane/db/management/commands/update_deleted_workspace_slug.py index 3d07e8e34..48600e662 100644 --- a/apiserver/plane/db/management/commands/update_deleted_workspace_slug.py +++ b/apiserver/plane/db/management/commands/update_deleted_workspace_slug.py @@ -5,7 +5,9 @@ from plane.db.models import Workspace class Command(BaseCommand): - help = "Updates the slug of a soft-deleted workspace by appending the epoch timestamp" + help = ( + "Updates the slug of a soft-deleted workspace by appending the epoch timestamp" + ) def add_arguments(self, parser): parser.add_argument( @@ -75,4 +77,4 @@ class Command(BaseCommand): self.style.ERROR( f"Error updating workspace '{workspace.name}': {str(e)}" ) - ) \ No newline at end of file + ) diff --git a/apiserver/plane/db/migrations/0094_auto_20250425_0902.py b/apiserver/plane/db/migrations/0094_auto_20250425_0902.py new file mode 100644 index 000000000..54adb7e22 --- /dev/null +++ b/apiserver/plane/db/migrations/0094_auto_20250425_0902.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.17 on 2025-04-25 09:02 + +from django.db import migrations, models +from plane.db.models.intake import SourceType + +def set_default_source_type(apps, schema_editor): + IntakeIssue = apps.get_model("db", "IntakeIssue") + IntakeIssue.objects.filter(source__iexact="in-app").update(source=SourceType.IN_APP) + +class Migration(migrations.Migration): + dependencies = [ + ('db', '0093_page_moved_to_page_page_moved_to_project_and_more'), + ] + + operations = [ + migrations.RunPython( + set_default_source_type, + migrations.RunPython.noop, + ), + migrations.AddField( + model_name='profile', + name='start_of_the_week', + field=models.PositiveSmallIntegerField(choices=[(0, 'Sunday'), (1, 'Monday'), (2, 'Tuesday'), (3, 'Wednesday'), (4, 'Thursday'), (5, 'Friday'), (6, 'Saturday')], default=0), + ), + ] diff --git a/apiserver/plane/db/migrations/0095_page_external_id_page_external_source.py b/apiserver/plane/db/migrations/0095_page_external_id_page_external_source.py new file mode 100644 index 000000000..eed8acf87 --- /dev/null +++ b/apiserver/plane/db/migrations/0095_page_external_id_page_external_source.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.14 on 2025-05-09 11:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0094_auto_20250425_0902'), + ] + + operations = [ + migrations.AddField( + model_name='page', + name='external_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='page', + name='external_source', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/apiserver/plane/db/migrations/0096_user_is_email_valid_user_masked_at.py b/apiserver/plane/db/migrations/0096_user_is_email_valid_user_masked_at.py new file mode 100644 index 000000000..66635d89d --- /dev/null +++ b/apiserver/plane/db/migrations/0096_user_is_email_valid_user_masked_at.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.20 on 2025-05-21 13:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0095_page_external_id_page_external_source"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="is_email_valid", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="user", + name="masked_at", + field=models.DateTimeField(null=True), + ), + ] diff --git a/apiserver/plane/db/migrations/0097_project_external_id_project_external_source.py b/apiserver/plane/db/migrations/0097_project_external_id_project_external_source.py new file mode 100644 index 000000000..5548f8afd --- /dev/null +++ b/apiserver/plane/db/migrations/0097_project_external_id_project_external_source.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.21 on 2025-06-06 12:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0096_user_is_email_valid_user_masked_at'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='external_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='project', + name='external_source', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 04e5a27f6..3cf46c919 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -82,4 +82,4 @@ from .label import Label from .device import Device, DeviceSession -from .sticky import Sticky \ No newline at end of file +from .sticky import Sticky diff --git a/apiserver/plane/db/models/base.py b/apiserver/plane/db/models/base.py index d0531e881..558c25a40 100644 --- a/apiserver/plane/db/models/base.py +++ b/apiserver/plane/db/models/base.py @@ -18,22 +18,28 @@ class BaseModel(AuditModel): class Meta: abstract = True - def save(self, *args, **kwargs): - user = get_current_user() + def save(self, *args, created_by_id=None, disable_auto_set_user=False, **kwargs): + if not disable_auto_set_user: + # Check if created_by_id is provided + if created_by_id: + self.created_by_id = created_by_id + else: + user = get_current_user() - if user is None or user.is_anonymous: - self.created_by = None - self.updated_by = None - super(BaseModel, self).save(*args, **kwargs) - else: - # Check if the model is being created or updated - if self._state.adding: - # If created only set created_by value: set updated_by to None - self.created_by = user - self.updated_by = None - # If updated only set updated_by value don't touch created_by - self.updated_by = user - super(BaseModel, self).save(*args, **kwargs) + if user is None or user.is_anonymous: + self.created_by = None + self.updated_by = None + else: + # Check if the model is being created or updated + if self._state.adding: + # If creating, set created_by and leave updated_by as None + self.created_by = user + self.updated_by = None + else: + # If updating, set updated_by only + self.updated_by = user + + super(BaseModel, self).save(*args, **kwargs) def __str__(self): return str(self.id) diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index 5f4fb2744..30a641ef8 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -17,6 +17,11 @@ def get_view_props(): class Page(BaseModel): + PRIVATE_ACCESS = 1 + PUBLIC_ACCESS = 0 + + ACCESS_CHOICES = ((PRIVATE_ACCESS, "Private"), (PUBLIC_ACCESS, "Public")) + workspace = models.ForeignKey( "db.Workspace", on_delete=models.CASCADE, related_name="pages" ) @@ -53,6 +58,9 @@ class Page(BaseModel): moved_to_page = models.UUIDField(null=True, blank=True) moved_to_project = models.UUIDField(null=True, blank=True) + external_id = models.CharField(max_length=255, null=True, blank=True) + external_source = models.CharField(max_length=255, null=True, blank=True) + class Meta: verbose_name = "Page" verbose_name_plural = "Pages" @@ -91,9 +99,7 @@ class PageLog(BaseModel): transaction = models.UUIDField(default=uuid.uuid4) 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, verbose_name="Transaction Type" - ) + entity_name = models.CharField(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/project.py b/apiserver/plane/db/models/project.py index c4d097ac8..79a0707d3 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -122,6 +122,9 @@ class Project(BaseModel): # timezone TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES) + # external_id for imports + external_source = models.CharField(max_length=255, null=True, blank=True) + external_id = models.CharField(max_length=255, blank=True, null=True) @property def cover_image_url(self): diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index a7ac5251e..ad6e858ad 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -106,6 +106,12 @@ class User(AbstractBaseUser, PermissionsMixin): max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES ) + # email validation + is_email_valid = models.BooleanField(default=False) + + # masking + masked_at = models.DateTimeField(null=True) + USERNAME_FIELD = "email" REQUIRED_FIELDS = ["username"] @@ -164,6 +170,24 @@ class User(AbstractBaseUser, PermissionsMixin): class Profile(TimeAuditModel): + SUNDAY = 0 + MONDAY = 1 + TUESDAY = 2 + WEDNESDAY = 3 + THURSDAY = 4 + FRIDAY = 5 + SATURDAY = 6 + + START_OF_THE_WEEK_CHOICES = ( + (SUNDAY, "Sunday"), + (MONDAY, "Monday"), + (TUESDAY, "Tuesday"), + (WEDNESDAY, "Wednesday"), + (THURSDAY, "Thursday"), + (FRIDAY, "Friday"), + (SATURDAY, "Saturday"), + ) + id = models.UUIDField( default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True ) @@ -194,6 +218,9 @@ class Profile(TimeAuditModel): mobile_timezone_auto_set = models.BooleanField(default=False) # language language = models.CharField(max_length=255, default="en") + start_of_the_week = models.PositiveSmallIntegerField( + choices=START_OF_THE_WEEK_CHOICES, default=SUNDAY + ) class Meta: verbose_name = "Profile" diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index e1af103f3..7e5103a70 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -153,12 +153,8 @@ class Workspace(BaseModel): return None def delete( - self, - using: Optional[str] = None, - soft: bool = True, - *args: Any, - **kwargs: Any - ): + self, using: Optional[str] = None, soft: bool = True, *args: Any, **kwargs: Any + ): """ Override the delete method to append epoch timestamp to the slug when soft deleting. @@ -172,7 +168,7 @@ class Workspace(BaseModel): result = super().delete(using=using, soft=soft, *args, **kwargs) # If it's a soft delete and the model still exists (not hard deleted) - if soft and hasattr(self, 'deleted_at') and self.deleted_at: + if soft and hasattr(self, "deleted_at") and self.deleted_at: # Use the deleted_at timestamp to update the slug deletion_timestamp: int = int(self.deleted_at.timestamp()) self.slug = f"{self.slug}__{deletion_timestamp}" diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py index 0e2b64fc9..c598acfef 100644 --- a/apiserver/plane/license/api/views/instance.py +++ b/apiserver/plane/license/api/views/instance.py @@ -57,7 +57,7 @@ class InstanceEndpoint(BaseAPIView): POSTHOG_API_KEY, POSTHOG_HOST, UNSPLASH_ACCESS_KEY, - OPENAI_API_KEY, + LLM_API_KEY, IS_INTERCOM_ENABLED, INTERCOM_APP_ID, ) = get_configuration_value( @@ -112,8 +112,8 @@ class InstanceEndpoint(BaseAPIView): "default": os.environ.get("UNSPLASH_ACCESS_KEY", ""), }, { - "key": "OPENAI_API_KEY", - "default": os.environ.get("OPENAI_API_KEY", ""), + "key": "LLM_API_KEY", + "default": os.environ.get("LLM_API_KEY", ""), }, # Intercom settings { @@ -151,7 +151,7 @@ class InstanceEndpoint(BaseAPIView): data["has_unsplash_configured"] = bool(UNSPLASH_ACCESS_KEY) # Open AI settings - data["has_openai_configured"] = bool(OPENAI_API_KEY) + data["has_llm_configured"] = bool(LLM_API_KEY) # File size settings data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880)) diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py index ce6bbf7a0..2e1b6a123 100644 --- a/apiserver/plane/license/management/commands/configure_instance.py +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -157,7 +157,7 @@ class Command(BaseCommand): }, # Deprecated, use LLM_MODEL { - "key": "GPT_ENGINE", + "key": "GPT_ENGINE", "value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), "category": "SMTP", "is_encrypted": False, diff --git a/apiserver/plane/middleware/logger.py b/apiserver/plane/middleware/logger.py index 166de17c2..7481c3992 100644 --- a/apiserver/plane/middleware/logger.py +++ b/apiserver/plane/middleware/logger.py @@ -83,6 +83,32 @@ class APITokenLogMiddleware: self.process_request(request, response, request_body) return response + def _safe_decode_body(self, content): + """ + Safely decodes request/response body content, handling binary data. + Returns None if content is None, or a string representation of the content. + """ + # If the content is None, return None + if content is None: + return None + + # If the content is an empty bytes object, return None + if content == b"": + return None + + # Check if content is binary by looking for common binary file signatures + if ( + content.startswith(b"\x89PNG") + or content.startswith(b"\xff\xd8\xff") + or content.startswith(b"%PDF") + ): + return "[Binary Content]" + + try: + return content.decode("utf-8") + except UnicodeDecodeError: + return "[Could not decode content]" + def process_request(self, request, response, request_body): api_key_header = "X-Api-Key" api_key = request.headers.get(api_key_header) @@ -95,9 +121,13 @@ class APITokenLogMiddleware: method=request.method, query_params=request.META.get("QUERY_STRING", ""), headers=str(request.headers), - body=(request_body.decode("utf-8") if request_body else None), + body=( + self._safe_decode_body(request_body) if request_body else None + ), response_body=( - response.content.decode("utf-8") if response.content else None + self._safe_decode_body(response.content) + if response.content + else None ), response_code=response.status_code, ip_address=get_client_ip(request=request), diff --git a/apiserver/plane/seeds/data/issues.json b/apiserver/plane/seeds/data/issues.json new file mode 100644 index 000000000..ca341304b --- /dev/null +++ b/apiserver/plane/seeds/data/issues.json @@ -0,0 +1,85 @@ +[ + { + "id": 1, + "name": "Welcome to Plane 👋", + "sequence_id": 1, + "description_html": "

Hey there! This demo project is your playground to get hands-on with Plane. We've set this up so you can click around and see how everything works without worrying about breaking anything.

Each work item is designed to make you familiar with the basics of using Plane. Just follow along card by card at your own pace.

First thing to try

  1. Look in the Properties section below where it says State: Todo.

  2. Click on it and change it to Done from the dropdown. Alternatively, you can drag and drop the card to the Done column.

", + "description_stripped": "Hey there! This demo project is your playground to get hands-on with Plane. We've set this up so you can click around and see how everything works without worrying about breaking anything.Each work item is designed to make you familiar with the basics of using Plane. Just follow along card by card at your own pace.First thing to tryLook in the Properties section below where it says State: Todo.Click on it and change it to Done from the dropdown. Alternatively, you can drag and drop the card to the Done column.", + "sort_order": 1000, + "state_id": 3, + "labels": [], + "priority": "none", + "project_id": 1 + }, + { + "id": 2, + "name": "1. Create Projects 🎯", + "sequence_id": 2, + "description_html": "


A Project in Plane is where all your work comes together. Think of it as a base that organizes your work items and everything else your team needs to get things done.

Note: This tutorial is already set up as a Project, and these cards you're reading are work items within it!

We're showing you how to create a new project just so you'll know exactly what to do when you're ready to start your own real one.

  1. Look over at the left sidebar and find where it says Projects.

  2. Hover your mouse there and you'll see a little + icon pop up - go ahead and click it!

  3. A modal opens where you can give your project a name and other details.

  4. Notice the Access type options? Public means anyone (except Guest users) can see and join it, while Private keeps it just for those you invite.

    Tip: You can also quickly create a new project by using the keyboard shortcut P from anywhere in Plane!

", + "sort_order": 2000, + "state_id": 2, + "labels": [2], + "priority": "none", + "project_id": 1 + }, + { + "id": 3, + "name": "2. Invite your team 🤜🤛", + "sequence_id": 3, + "description_html": "

Let's get your teammates on board!

First, you'll need to invite them to your workspace before they can join specific projects:

  1. Click on your workspace name in the top-left corner, then select Settings from the dropdown.

  2. Head over to the Members tab - this is your user management hub. Click Add member on the top right.

  3. Enter your teammate's email address. Select a role for them (Admin, Member or Guest) that determines what they can do in the workspace.

  4. Your team member will get an email invite. Once they've joined your workspace, you can add them to specific projects.

  5. To do this, go to your project's Settings page.

  6. Find the Members section, select your teammate, and assign them a project role - this controls what they can do within just this project.


That's it!

To learn more about user management, see Manage users and roles.

", + "description_stripped": "Let's get your teammates on board!First, you'll need to invite them to your workspace before they can join specific projects:Click on your workspace name in the top-left corner, then select Settings from the dropdown.Head over to the Members tab - this is your user management hub. Click Add member on the top right.Enter your teammate's email address. Select a role for them (Admin, Member or Guest) that determines what they can do in the workspace.Your team member will get an email invite. Once they've joined your workspace, you can add them to specific projects.To do this, go to your project's Settings page.Find the Members section, select your teammate, and assign them a project role - this controls what they can do within just this project.That's it!To learn more about user management, see Manage users and roles.", + "sort_order": 3000, + "state_id": 1, + "labels": [], + "priority": "none", + "project_id": 1 + }, + { + "id": 4, + "name": "3. Create and assign Work Items ✏️", + "sequence_id": 4, + "description_html": "

A work item is the fundamental building block of your project. Think of these as the actionable tasks that move your project forward.

Ready to add something to your project's to-do list? Here's how:

  1. Click the Add work item button in the top-right corner of the Work Items page.

  2. Give your task a clear title and add any details in the description.

  3. Set up the essentials:

    • Assign it to a team member (or yourself!)

    • Choose a priority level

    • Add start and due dates if there's a timeline

Tip: Save time by using the keyboard shortcut C from anywhere in your project to quickly create a new work item!

Want to dive deeper into all the things you can do with work items? Check out our documentation.

", + "description_stripped": "A work item is the fundamental building block of your project. Think of these as the actionable tasks that move your project forward.Ready to add something to your project's to-do list? Here's how:Click the Add work item button in the top-right corner of the Work Items page.Give your task a clear title and add any details in the description.Set up the essentials:Assign it to a team member (or yourself!)Choose a priority levelAdd start and due dates if there's a timelineTip: Save time by using the keyboard shortcut C from anywhere in your project to quickly create a new work item!Want to dive deeper into all the things you can do with work items? Check out our documentation.", + "sort_order": 4000, + "state_id": 1, + "labels": [2], + "priority": "none", + "project_id": 1 + }, + { + "id": 5, + "name": "4. Visualize your work 🔮", + "sequence_id": 5, + "description_html": "

Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!

Switch between layouts

  1. Look at the top toolbar in your project. You'll see several layout icons.

  2. Click any of these icons to instantly switch between layouts.

Tip: Different layouts work best for different needs. Try Board view for tracking progress, Calendar for deadline management, and Gantt for timeline planning! See Layouts for more info.

Filter and display options

Need to focus on specific work?

  1. Click the Filters dropdown in the toolbar. Select criteria and choose which items to show.

  2. Click the Display dropdown to tailor how the information appears in your layout

  3. Created the perfect setup? Save it for later by clicking the the Save View button.

  4. Access saved views anytime from the Views section in your sidebar.

", + "description_stripped": "Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!Switch between layoutsLook at the top toolbar in your project. You'll see several layout icons.Click any of these icons to instantly switch between layouts.Tip: Different layouts work best for different needs. Try Board view for tracking progress, Calendar for deadline management, and Gantt for timeline planning! See Layouts for more info.Filter and display optionsNeed to focus on specific work?Click the Filters dropdown in the toolbar. Select criteria and choose which items to show.Click the Display dropdown to tailor how the information appears in your layoutCreated the perfect setup? Save it for later by clicking the the Save View button.Access saved views anytime from the Views section in your sidebar.", + "sort_order": 5000, + "state_id": 1, + "labels": [], + "priority": "none", + "project_id": 1 + }, + { + "id": 6, + "name": "5. Use Cycles to time box tasks 🗓️", + "sequence_id": 6, + "description_html": "

A Cycle in Plane is like a sprint - a dedicated timeframe where your team focuses on completing specific work items. It helps you break down your project into manageable chunks with clear start and end dates so everyone knows what to work on and when it needs to be done.

Setup Cycles

  1. Go to the Cycles section in your project (you can find it in the left sidebar)

  2. Click the Add cycle button in the top-right corner

  3. Enter details and set the start and end dates for your cycle.

  4. Click Create cycle and you're ready to go!

  5. Add existing work items to the Cycle or create new ones.

Tip: To create a new Cycle quickly, just press Q from anywhere in your project!

Want to learn more?

  • Starting and stopping cycles

  • Transferring work items between cycles

  • Tracking progress with charts

Check out our detailed documentation for everything you need to know!

", + "description_stripped": "A Cycle in Plane is like a sprint - a dedicated timeframe where your team focuses on completing specific work items. It helps you break down your project into manageable chunks with clear start and end dates so everyone knows what to work on and when it needs to be done.Setup CyclesGo to the Cycles section in your project (you can find it in the left sidebar)Click the Add cycle button in the top-right cornerEnter details and set the start and end dates for your cycle.Click Create cycle and you're ready to go!Add existing work items to the Cycle or create new ones.Tip: To create a new Cycle quickly, just press Q from anywhere in your project!Want to learn more?Starting and stopping cyclesTransferring work items between cyclesTracking progress with chartsCheck out our detailed documentation for everything you need to know!", + "sort_order": 6000, + "state_id": 1, + "labels": [2], + "priority": "none", + "project_id": 1 + }, + { + "id": 7, + "name": "6. Customize your settings ⚙️", + "sequence_id": 7, + "description_html": "

Now that you're getting familiar with Plane, let's explore how you can customize settings to make it work just right for you and your team!

Workspace settings

Remember those workspace settings we mentioned when inviting team members? There's a lot more you can do there:

  • Invite and manage workspace members

  • Upgrade plans and manage billing

  • Import data from other tools

  • Export your data

  • Manage integrations

Project Settings

Each project has its own settings where you can:

  • Change project details and visibility

  • Invite specific members to just this project

  • Customize your workflow States (like adding a \"Testing\" state)

  • Create and organize Labels

  • Enable or disable features you need (or don't need)

Your Profile Settings

You can also customize your own personal experience! Click on your profile icon in the top-right corner to find:

  • Profile settings (update your name, photo, etc.)

  • Choose your timezone and preferred language for the interface

  • Email notification preferences (what you want to be alerted about)

  • Appearance settings (light/dark mode)

Taking a few minutes to set things up just the way you like will make your everyday Plane experience much smoother!

Note: Some settings are only available to workspace or project admins. If you don't see certain options, you might need admin access.

", + "description_stripped": "Now that you're getting familiar with Plane, let's explore how you can customize settings to make it work just right for you and your team!Workspace settingsRemember those workspace settings we mentioned when inviting team members? There's a lot more you can do there:Invite and manage workspace membersUpgrade plans and manage billingImport data from other toolsExport your dataManage integrationsProject SettingsEach project has its own settings where you can:Change project details and visibilityInvite specific members to just this projectCustomize your workflow States (like adding a \"Testing\" state)Create and organize LabelsEnable or disable features you need (or don't need)Your Profile SettingsYou can also customize your own personal experience! Click on your profile icon in the top-right corner to find:Profile settings (update your name, photo, etc.)Choose your timezone and preferred language for the interfaceEmail notification preferences (what you want to be alerted about)Appearance settings (light/dark mode)Taking a few minutes to set things up just the way you like will make your everyday Plane experience much smoother!Note: Some settings are only available to workspace or project admins. If you don't see certain options, you might need admin access.", + "sort_order": 7000, + "state_id": 1, + "labels": [], + "priority": "none", + "project_id": 1 + } +] diff --git a/apiserver/plane/seeds/data/labels.json b/apiserver/plane/seeds/data/labels.json new file mode 100644 index 000000000..f7286a69c --- /dev/null +++ b/apiserver/plane/seeds/data/labels.json @@ -0,0 +1,16 @@ +[ + { + "id": 1, + "name": "admin", + "color": "#0693e3", + "sort_order": 85535, + "project_id": 1 + }, + { + "id": 2, + "name": "concepts", + "color": "#9900ef", + "sort_order": 95535, + "project_id": 1 + } +] diff --git a/apiserver/plane/seeds/data/projects.json b/apiserver/plane/seeds/data/projects.json new file mode 100644 index 000000000..1b24b8642 --- /dev/null +++ b/apiserver/plane/seeds/data/projects.json @@ -0,0 +1,17 @@ +[ + { + "id": 1, + "name": "Plane Demo Project", + "identifier": "PDP", + "description": "Welcome to the Plane Demo Project! This project throws you into the driver’s seat of Plane, work management software. Through curated work items, you’ll uncover key features, pick up best practices, and see how Plane can streamline your team’s workflow. Whether you’re a startup hungry to scale or an enterprise sharpening efficiency, this demo is your launchpad to mastering Plane. Jump in and see what it can do!", + "network": 2, + "cover_image": "https://images.unsplash.com/photo-1691230995681-480d86cbc135?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80", + "logo_props": { + "emoji": { + "url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f447.png", + "value": "128071" + }, + "in_use": "emoji" + } + } +] diff --git a/apiserver/plane/seeds/data/states.json b/apiserver/plane/seeds/data/states.json new file mode 100644 index 000000000..5eff65b9d --- /dev/null +++ b/apiserver/plane/seeds/data/states.json @@ -0,0 +1,47 @@ +[ + { + "id": 1, + "name": "Backlog", + "color": "#A3A3A3", + "sequence": 15000, + "group": "backlog", + "default": true, + "project_id": 1 + }, + { + "id": 2, + "name": "Todo", + "color": "#3A3A3A", + "sequence": 25000, + "group": "unstarted", + "default": false, + "project_id": 1 + }, + { + "id": 3, + "name": "In Progress", + "color": "#F59E0B", + "sequence": 35000, + "group": "started", + "default": false, + "project_id": 1 + }, + { + "id": 4, + "name": "Done", + "color": "#16A34A", + "sequence": 45000, + "group": "completed", + "default": false, + "project_id": 1 + }, + { + "id": 5, + "name": "Cancelled", + "color": "#EF4444", + "sequence": 55000, + "group": "cancelled", + "default": false, + "project_id": 1 + } +] diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 444ec0a4f..38d2ac6e0 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -3,7 +3,7 @@ # Python imports import os from urllib.parse import urlparse - +from urllib.parse import urljoin # Third party imports import dj_database_url @@ -13,6 +13,10 @@ from django.core.management.utils import get_random_secret_key from corsheaders.defaults import default_headers +# Module imports +from plane.utils.url import is_valid_url + + BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Secret Key @@ -310,11 +314,35 @@ CSRF_TRUSTED_ORIGINS = cors_allowed_origins CSRF_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None) CSRF_FAILURE_VIEW = "plane.authentication.views.common.csrf_failure" -# Base URLs +###### Base URLs ###### + +# Admin Base URL ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None) +if ADMIN_BASE_URL and not is_valid_url(ADMIN_BASE_URL): + ADMIN_BASE_URL = None +ADMIN_BASE_PATH = os.environ.get("ADMIN_BASE_PATH", "/god-mode/") + +# Space Base URL SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None) -APP_BASE_URL = os.environ.get("APP_BASE_URL") -LIVE_BASE_URL = os.environ.get("LIVE_BASE_URL") +if SPACE_BASE_URL and not is_valid_url(SPACE_BASE_URL): + SPACE_BASE_URL = None +SPACE_BASE_PATH = os.environ.get("SPACE_BASE_PATH", "/spaces/") + +# App Base URL +APP_BASE_URL = os.environ.get("APP_BASE_URL", None) +if APP_BASE_URL and not is_valid_url(APP_BASE_URL): + APP_BASE_URL = None +APP_BASE_PATH = os.environ.get("APP_BASE_PATH", "/") + +# Live Base URL +LIVE_BASE_URL = os.environ.get("LIVE_BASE_URL", None) +if LIVE_BASE_URL and not is_valid_url(LIVE_BASE_URL): + LIVE_BASE_URL = None +LIVE_BASE_PATH = os.environ.get("LIVE_BASE_PATH", "/live/") + +LIVE_URL = urljoin(LIVE_BASE_URL, LIVE_BASE_PATH) if LIVE_BASE_URL else None + +# WEB URL WEB_URL = os.environ.get("WEB_URL") HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60)) @@ -372,9 +400,21 @@ ATTACHMENT_MIME_TYPES = [ "video/x-ms-wmv", # Archives "application/zip", + "application/x-rar", "application/x-rar-compressed", "application/x-tar", "application/gzip", + "application/x-zip", + "application/x-zip-compressed", + "application/x-7z-compressed", + "application/x-compressed", + "application/x-compressed-tar", + "application/x-compressed-tar-gz", + "application/x-compressed-tar-bz2", + "application/x-compressed-tar-zip", + "application/x-compressed-tar-7z", + "application/x-compressed-tar-rar", + "application/x-compressed-tar-zip", # 3D Models "model/gltf-binary", "model/gltf+json", @@ -396,3 +436,6 @@ ATTACHMENT_MIME_TYPES = [ # Gzip "application/x-gzip", ] + +# Seed directory path +SEED_DIR = os.path.join(BASE_DIR, "seeds") diff --git a/apiserver/plane/settings/storage.py b/apiserver/plane/settings/storage.py index a757d12f3..f2be261ad 100644 --- a/apiserver/plane/settings/storage.py +++ b/apiserver/plane/settings/storage.py @@ -32,7 +32,6 @@ class S3Storage(S3Boto3Storage): ) or os.environ.get("MINIO_ENDPOINT_URL") if os.environ.get("USE_MINIO") == "1": - # Determine protocol based on environment variable if os.environ.get("MINIO_ENDPOINT_SSL") == "1": endpoint_protocol = "https" diff --git a/apiserver/plane/space/utils/grouper.py b/apiserver/plane/space/utils/grouper.py index b334999de..4dd956b9f 100644 --- a/apiserver/plane/space/utils/grouper.py +++ b/apiserver/plane/space/utils/grouper.py @@ -135,7 +135,7 @@ def issue_on_results( default=None, output_field=JSONField(), ), - filter=Q(votes__isnull=False,votes__deleted_at__isnull=True), + filter=Q(votes__isnull=False, votes__deleted_at__isnull=True), distinct=True, ), reaction_items=ArrayAgg( @@ -169,7 +169,9 @@ def issue_on_results( default=None, output_field=JSONField(), ), - filter=Q(issue_reactions__isnull=False, issue_reactions__deleted_at__isnull=True), + filter=Q( + issue_reactions__isnull=False, issue_reactions__deleted_at__isnull=True + ), distinct=True, ), ).values(*required_fields, "vote_items", "reaction_items") diff --git a/apiserver/plane/space/views/intake.py b/apiserver/plane/space/views/intake.py index 644a2de3a..83ec354c6 100644 --- a/apiserver/plane/space/views/intake.py +++ b/apiserver/plane/space/views/intake.py @@ -21,6 +21,7 @@ from plane.app.serializers import ( ) from plane.utils.issue_filters import issue_filters from plane.bgtasks.issue_activities_task import issue_activity +from plane.db.models.intake import SourceType class IntakeIssuePublicViewSet(BaseViewSet): @@ -156,7 +157,7 @@ class IntakeIssuePublicViewSet(BaseViewSet): intake_id=intake_id, project_id=project_deploy_board.project_id, issue=issue, - source=request.data.get("source", "IN-APP"), + source=SourceType.IN_APP, ) serializer = IssueStateIntakeSerializer(issue) diff --git a/apiserver/plane/space/views/issue.py b/apiserver/plane/space/views/issue.py index 699253ae5..93aaaa7b9 100644 --- a/apiserver/plane/space/views/issue.py +++ b/apiserver/plane/space/views/issue.py @@ -179,7 +179,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): Q(issue_intake__status=1) | Q(issue_intake__status=-1) | Q(issue_intake__status=2) - | Q(issue_intake__status=True), + | Q(issue_intake__isnull=True), archived_at__isnull=True, is_draft=False, ), @@ -205,7 +205,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): Q(issue_intake__status=1) | Q(issue_intake__status=-1) | Q(issue_intake__status=2) - | Q(issue_intake__status=True), + | Q(issue_intake__isnull=True), archived_at__isnull=True, is_draft=False, ), diff --git a/apiserver/plane/space/views/meta.py b/apiserver/plane/space/views/meta.py index d092e7e58..dc7ecb648 100644 --- a/apiserver/plane/space/views/meta.py +++ b/apiserver/plane/space/views/meta.py @@ -14,9 +14,7 @@ class ProjectMetaDataEndpoint(BaseAPIView): def get(self, request, anchor): try: - deploy_board = DeployBoard.objects.get( - anchor=anchor, entity_name="project" - ) + deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") except DeployBoard.DoesNotExist: return Response( {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND diff --git a/apiserver/plane/tests/README.md b/apiserver/plane/tests/README.md new file mode 100644 index 000000000..df9aba6da --- /dev/null +++ b/apiserver/plane/tests/README.md @@ -0,0 +1,143 @@ +# Plane Tests + +This directory contains tests for the Plane application. The tests are organized using pytest. + +## Test Structure + +Tests are organized into the following categories: + +- **Unit tests**: Test individual functions or classes in isolation. +- **Contract tests**: Test interactions between components and verify API contracts are fulfilled. + - **API tests**: Test the external API endpoints (under `/api/v1/`). + - **App tests**: Test the web application API endpoints (under `/api/`). +- **Smoke tests**: Basic tests to verify that the application runs correctly. + +## API vs App Endpoints + +Plane has two types of API endpoints: + +1. **External API** (`plane.api`): + - Available at `/api/v1/` endpoint + - Uses API key authentication (X-Api-Key header) + - Designed for external API contracts and third-party access + - Tests use the `api_key_client` fixture for authentication + - Test files are in `contract/api/` + +2. **Web App API** (`plane.app`): + - Available at `/api/` endpoint + - Uses session-based authentication (CSRF disabled) + - Designed for the web application frontend + - Tests use the `session_client` fixture for authentication + - Test files are in `contract/app/` + +## Running Tests + +To run all tests: + +```bash +python -m pytest +``` + +To run specific test categories: + +```bash +# Run unit tests +python -m pytest plane/tests/unit/ + +# Run API contract tests +python -m pytest plane/tests/contract/api/ + +# Run App contract tests +python -m pytest plane/tests/contract/app/ + +# Run smoke tests +python -m pytest plane/tests/smoke/ +``` + +For convenience, we also provide a helper script: + +```bash +# Run all tests +./run_tests.py + +# Run only unit tests +./run_tests.py -u + +# Run contract tests with coverage report +./run_tests.py -c -o + +# Run tests in parallel +./run_tests.py -p +``` + +## Fixtures + +The following fixtures are available for testing: + +- `api_client`: Unauthenticated API client +- `create_user`: Creates a test user +- `api_token`: API token for the test user +- `api_key_client`: API client with API key authentication (for external API tests) +- `session_client`: API client with session authentication (for app API tests) +- `plane_server`: Live Django test server for HTTP-based smoke tests + +## Writing Tests + +When writing tests, follow these guidelines: + +1. Place tests in the appropriate directory based on their type. +2. Use the correct client fixture based on the API being tested: + - For external API (`/api/v1/`), use `api_key_client` + - For web app API (`/api/`), use `session_client` + - For smoke tests with real HTTP, use `plane_server` +3. Use the correct URL namespace when reverse-resolving URLs: + - For external API, use `reverse("api:endpoint_name")` + - For web app API, use `reverse("endpoint_name")` +4. Add the `@pytest.mark.django_db` decorator to tests that interact with the database. +5. Add the appropriate markers (`@pytest.mark.contract`, etc.) to categorize tests. + +## Test Fixtures + +Common fixtures are defined in: + +- `conftest.py`: General fixtures for authentication, database access, etc. +- `conftest_external.py`: Fixtures for external services (Redis, Elasticsearch, Celery, MongoDB) +- `factories.py`: Test factories for easy model instance creation + +## Best Practices + +When writing tests, follow these guidelines: + +1. **Use pytest's assert syntax** instead of Django's `self.assert*` methods. +2. **Add markers to categorize tests**: + ```python + @pytest.mark.unit + @pytest.mark.contract + @pytest.mark.smoke + ``` +3. **Use fixtures instead of setUp/tearDown methods** for cleaner, more reusable test code. +4. **Mock external dependencies** with the provided fixtures to avoid external service dependencies. +5. **Write focused tests** that verify one specific behavior or edge case. +6. **Keep test files small and organized** by logical components or endpoints. +7. **Target 90% code coverage** for models, serializers, and business logic. + +## External Dependencies + +Tests for components that interact with external services should: + +1. Use the `mock_redis`, `mock_elasticsearch`, `mock_mongodb`, and `mock_celery` fixtures for unit and most contract tests. +2. For more comprehensive contract tests, use Docker-based test containers (optional). + +## Coverage Reports + +Generate a coverage report with: + +```bash +python -m pytest --cov=plane --cov-report=term --cov-report=html +``` + +This creates an HTML report in the `htmlcov/` directory. + +## Migration from Old Tests + +Some tests are still in the old format in the `api/` directory. These need to be migrated to the new contract test structure in the appropriate directories. \ No newline at end of file diff --git a/apiserver/plane/tests/TESTING_GUIDE.md b/apiserver/plane/tests/TESTING_GUIDE.md new file mode 100644 index 000000000..98f4a1dba --- /dev/null +++ b/apiserver/plane/tests/TESTING_GUIDE.md @@ -0,0 +1,151 @@ +# Testing Guide for Plane + +This guide explains how to write tests for Plane using our pytest-based testing strategy. + +## Test Categories + +We divide tests into three categories: + +1. **Unit Tests**: Testing individual components in isolation. +2. **Contract Tests**: Testing API endpoints and verifying contracts between components. +3. **Smoke Tests**: Basic end-to-end tests for critical flows. + +## Writing Unit Tests + +Unit tests should be placed in the appropriate directory under `tests/unit/` depending on what you're testing: + +- `tests/unit/models/` - For model tests +- `tests/unit/serializers/` - For serializer tests +- `tests/unit/utils/` - For utility function tests + +### Example Unit Test: + +```python +import pytest +from plane.api.serializers import MySerializer + +@pytest.mark.unit +class TestMySerializer: + def test_serializer_valid_data(self): + # Create input data + data = {"field1": "value1", "field2": 42} + + # Initialize the serializer + serializer = MySerializer(data=data) + + # Validate + assert serializer.is_valid() + + # Check validated data + assert serializer.validated_data["field1"] == "value1" + assert serializer.validated_data["field2"] == 42 +``` + +## Writing Contract Tests + +Contract tests should be placed in `tests/contract/api/` or `tests/contract/app/` directories and should test the API endpoints. + +### Example Contract Test: + +```python +import pytest +from django.urls import reverse +from rest_framework import status + +@pytest.mark.contract +class TestMyEndpoint: + @pytest.mark.django_db + def test_my_endpoint_get(self, auth_client): + # Get the URL + url = reverse("my-endpoint") + + # Make request + response = auth_client.get(url) + + # Check response + assert response.status_code == status.HTTP_200_OK + assert "data" in response.data +``` + +## Writing Smoke Tests + +Smoke tests should be placed in `tests/smoke/` directory and use the `plane_server` fixture to test against a real HTTP server. + +### Example Smoke Test: + +```python +import pytest +import requests + +@pytest.mark.smoke +class TestCriticalFlow: + @pytest.mark.django_db + def test_login_flow(self, plane_server, create_user, user_data): + # Get login URL + url = f"{plane_server.url}/api/auth/signin/" + + # Test login + response = requests.post( + url, + json={ + "email": user_data["email"], + "password": user_data["password"] + } + ) + + # Verify + assert response.status_code == 200 + data = response.json() + assert "access_token" in data +``` + +## Useful Fixtures + +Our test setup provides several useful fixtures: + +1. `api_client`: An unauthenticated DRF APIClient +2. `api_key_client`: API client with API key authentication (for external API tests) +3. `session_client`: API client with session authentication (for web app API tests) +4. `create_user`: Creates and returns a test user +5. `mock_redis`: Mocks Redis interactions +6. `mock_elasticsearch`: Mocks Elasticsearch interactions +7. `mock_celery`: Mocks Celery task execution + +## Using Factory Boy + +For more complex test data setup, use the provided factories: + +```python +from plane.tests.factories import UserFactory, WorkspaceFactory + +# Create a user +user = UserFactory() + +# Create a workspace with a specific owner +workspace = WorkspaceFactory(owner=user) + +# Create multiple objects +users = UserFactory.create_batch(5) +``` + +## Running Tests + +Use pytest to run tests: + +```bash +# Run all tests +python -m pytest + +# Run only unit tests with coverage +python -m pytest -m unit --cov=plane +``` + +## Best Practices + +1. **Keep tests small and focused** - Each test should verify one specific behavior. +2. **Use markers** - Always add appropriate markers (`@pytest.mark.unit`, etc.). +3. **Mock external dependencies** - Use the provided mock fixtures. +4. **Use factories** - For complex data setup, use factories. +5. **Don't test the framework** - Focus on testing your business logic, not Django/DRF itself. +6. **Write readable assertions** - Use plain `assert` statements with clear messaging. +7. **Focus on coverage** - Aim for ≥90% code coverage for critical components. \ No newline at end of file diff --git a/apiserver/plane/tests/__init__.py b/apiserver/plane/tests/__init__.py index 0a0e47b0b..73d90cd21 100644 --- a/apiserver/plane/tests/__init__.py +++ b/apiserver/plane/tests/__init__.py @@ -1 +1 @@ -from .api import * +# Test package initialization diff --git a/apiserver/plane/tests/api/base.py b/apiserver/plane/tests/api/base.py deleted file mode 100644 index e3209a281..000000000 --- a/apiserver/plane/tests/api/base.py +++ /dev/null @@ -1,34 +0,0 @@ -# Third party imports -from rest_framework.test import APITestCase, APIClient - -# Module imports -from plane.db.models import User -from plane.app.views.authentication import get_tokens_for_user - - -class BaseAPITest(APITestCase): - def setUp(self): - self.client = APIClient(HTTP_USER_AGENT="plane/test", REMOTE_ADDR="10.10.10.10") - - -class AuthenticatedAPITest(BaseAPITest): - def setUp(self): - super().setUp() - - ## Create Dummy User - self.email = "user@plane.so" - user = User.objects.create(email=self.email) - user.set_password("user@123") - user.save() - - # Set user - self.user = user - - # Set Up User ID - self.user_id = user.id - - access_token, _ = get_tokens_for_user(user) - self.access_token = access_token - - # Set Up Authentication Token - self.client.credentials(HTTP_AUTHORIZATION="Bearer " + access_token) diff --git a/apiserver/plane/tests/api/test_asset.py b/apiserver/plane/tests/api/test_asset.py deleted file mode 100644 index b15d32e40..000000000 --- a/apiserver/plane/tests/api/test_asset.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Tests for File Asset Uploads diff --git a/apiserver/plane/tests/api/test_auth_extended.py b/apiserver/plane/tests/api/test_auth_extended.py deleted file mode 100644 index af6450ef4..000000000 --- a/apiserver/plane/tests/api/test_auth_extended.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Tests for ChangePassword and other Endpoints diff --git a/apiserver/plane/tests/api/test_authentication.py b/apiserver/plane/tests/api/test_authentication.py deleted file mode 100644 index 5d7beabdf..000000000 --- a/apiserver/plane/tests/api/test_authentication.py +++ /dev/null @@ -1,183 +0,0 @@ -# Python import -import json - -# Django imports -from django.urls import reverse - -# Third Party imports -from rest_framework import status -from .base import BaseAPITest - -# Module imports -from plane.db.models import User -from plane.settings.redis import redis_instance - - -class SignInEndpointTests(BaseAPITest): - def setUp(self): - super().setUp() - user = User.objects.create(email="user@plane.so") - user.set_password("user@123") - user.save() - - def test_without_data(self): - url = reverse("sign-in") - response = self.client.post(url, {}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_email_validity(self): - url = reverse("sign-in") - response = self.client.post( - url, {"email": "useremail.com", "password": "user@123"}, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, {"error": "Please provide a valid email address."} - ) - - def test_password_validity(self): - url = reverse("sign-in") - response = self.client.post( - url, {"email": "user@plane.so", "password": "user123"}, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual( - response.data, - { - "error": "Sorry, we could not find a user with the provided credentials. Please try again." - }, - ) - - def test_user_exists(self): - url = reverse("sign-in") - response = self.client.post( - url, {"email": "user@email.so", "password": "user123"}, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual( - response.data, - { - "error": "Sorry, we could not find a user with the provided credentials. Please try again." - }, - ) - - def test_user_login(self): - url = reverse("sign-in") - - response = self.client.post( - url, {"email": "user@plane.so", "password": "user@123"}, format="json" - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data.get("user").get("email"), "user@plane.so") - - -class MagicLinkGenerateEndpointTests(BaseAPITest): - def setUp(self): - super().setUp() - user = User.objects.create(email="user@plane.so") - user.set_password("user@123") - user.save() - - def test_without_data(self): - url = reverse("magic-generate") - response = self.client.post(url, {}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_email_validity(self): - url = reverse("magic-generate") - response = self.client.post(url, {"email": "useremail.com"}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, {"error": "Please provide a valid email address."} - ) - - def test_magic_generate(self): - url = reverse("magic-generate") - - ri = redis_instance() - ri.delete("magic_user@plane.so") - - response = self.client.post(url, {"email": "user@plane.so"}, format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_max_generate_attempt(self): - url = reverse("magic-generate") - - ri = redis_instance() - ri.delete("magic_user@plane.so") - - for _ in range(4): - response = self.client.post(url, {"email": "user@plane.so"}, format="json") - - response = self.client.post(url, {"email": "user@plane.so"}, format="json") - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, {"error": "Max attempts exhausted. Please try again later."} - ) - - -class MagicSignInEndpointTests(BaseAPITest): - def setUp(self): - super().setUp() - user = User.objects.create(email="user@plane.so") - user.set_password("user@123") - user.save() - - def test_without_data(self): - url = reverse("magic-sign-in") - response = self.client.post(url, {}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data, {"error": "User token and key are required"}) - - def test_expired_invalid_magic_link(self): - ri = redis_instance() - ri.delete("magic_user@plane.so") - - url = reverse("magic-sign-in") - response = self.client.post( - url, - {"key": "magic_user@plane.so", "token": "xxxx-xxxxx-xxxx"}, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, {"error": "The magic code/link has expired please try again"} - ) - - def test_invalid_magic_code(self): - ri = redis_instance() - ri.delete("magic_user@plane.so") - ## Create Token - url = reverse("magic-generate") - self.client.post(url, {"email": "user@plane.so"}, format="json") - - url = reverse("magic-sign-in") - response = self.client.post( - url, - {"key": "magic_user@plane.so", "token": "xxxx-xxxxx-xxxx"}, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, {"error": "Your login code was incorrect. Please try again."} - ) - - def test_magic_code_sign_in(self): - ri = redis_instance() - ri.delete("magic_user@plane.so") - ## Create Token - url = reverse("magic-generate") - self.client.post(url, {"email": "user@plane.so"}, format="json") - - # Get the token - user_data = json.loads(ri.get("magic_user@plane.so")) - token = user_data["token"] - - url = reverse("magic-sign-in") - response = self.client.post( - url, {"key": "magic_user@plane.so", "token": token}, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data.get("user").get("email"), "user@plane.so") diff --git a/apiserver/plane/tests/api/test_cycle.py b/apiserver/plane/tests/api/test_cycle.py deleted file mode 100644 index 72b580c99..000000000 --- a/apiserver/plane/tests/api/test_cycle.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Write Test for Cycle Endpoints diff --git a/apiserver/plane/tests/api/test_issue.py b/apiserver/plane/tests/api/test_issue.py deleted file mode 100644 index a45ff36b1..000000000 --- a/apiserver/plane/tests/api/test_issue.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Write Test for Issue Endpoints diff --git a/apiserver/plane/tests/api/test_oauth.py b/apiserver/plane/tests/api/test_oauth.py deleted file mode 100644 index 1e7dac0ef..000000000 --- a/apiserver/plane/tests/api/test_oauth.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Tests for OAuth Authentication Endpoint diff --git a/apiserver/plane/tests/api/test_people.py b/apiserver/plane/tests/api/test_people.py deleted file mode 100644 index 624281a2f..000000000 --- a/apiserver/plane/tests/api/test_people.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Write Test for people Endpoint diff --git a/apiserver/plane/tests/api/test_project.py b/apiserver/plane/tests/api/test_project.py deleted file mode 100644 index 9a7c50f19..000000000 --- a/apiserver/plane/tests/api/test_project.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Write Tests for project endpoints diff --git a/apiserver/plane/tests/api/test_shortcut.py b/apiserver/plane/tests/api/test_shortcut.py deleted file mode 100644 index 5103b5059..000000000 --- a/apiserver/plane/tests/api/test_shortcut.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Write Test for shortcuts diff --git a/apiserver/plane/tests/api/test_state.py b/apiserver/plane/tests/api/test_state.py deleted file mode 100644 index a336d955a..000000000 --- a/apiserver/plane/tests/api/test_state.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Wrote test for state endpoints diff --git a/apiserver/plane/tests/api/test_view.py b/apiserver/plane/tests/api/test_view.py deleted file mode 100644 index c8864f28a..000000000 --- a/apiserver/plane/tests/api/test_view.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Write test for view endpoints diff --git a/apiserver/plane/tests/api/test_workspace.py b/apiserver/plane/tests/api/test_workspace.py deleted file mode 100644 index d63eab2e0..000000000 --- a/apiserver/plane/tests/api/test_workspace.py +++ /dev/null @@ -1,44 +0,0 @@ -# Django imports -from django.urls import reverse - -# Third party import -from rest_framework import status - -# Module imports -from .base import AuthenticatedAPITest -from plane.db.models import Workspace, WorkspaceMember - - -class WorkSpaceCreateReadUpdateDelete(AuthenticatedAPITest): - def setUp(self): - super().setUp() - - def test_create_workspace(self): - url = reverse("workspace") - - # Test with empty data - response = self.client.post(url, {}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - # Test with valid data - response = self.client.post( - url, {"name": "Plane", "slug": "pla-ne"}, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(Workspace.objects.count(), 1) - # Check if the member is created - self.assertEqual(WorkspaceMember.objects.count(), 1) - - # Check other values - workspace = Workspace.objects.get(pk=response.data["id"]) - workspace_member = WorkspaceMember.objects.get( - workspace=workspace, member_id=self.user_id - ) - self.assertEqual(workspace.owner_id, self.user_id) - self.assertEqual(workspace_member.role, 20) - - # Create a already existing workspace - response = self.client.post( - url, {"name": "Plane", "slug": "pla-ne"}, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) diff --git a/apiserver/plane/tests/conftest.py b/apiserver/plane/tests/conftest.py new file mode 100644 index 000000000..832558810 --- /dev/null +++ b/apiserver/plane/tests/conftest.py @@ -0,0 +1,120 @@ +import pytest +from django.conf import settings +from rest_framework.test import APIClient +from pytest_django.fixtures import django_db_setup +from unittest.mock import patch, MagicMock + +from plane.db.models import User +from plane.db.models.api import APIToken + + +@pytest.fixture(scope="session") +def django_db_setup(django_db_setup): + """Set up the Django database for the test session""" + pass + + +@pytest.fixture +def api_client(): + """Return an unauthenticated API client""" + return APIClient() + + +@pytest.fixture +def user_data(): + """Return standard user data for tests""" + return { + "email": "test@plane.so", + "password": "test-password", + "first_name": "Test", + "last_name": "User", + } + + +@pytest.fixture +def create_user(db, user_data): + """Create and return a user instance""" + user = User.objects.create( + email=user_data["email"], + first_name=user_data["first_name"], + last_name=user_data["last_name"], + ) + user.set_password(user_data["password"]) + user.save() + return user + + +@pytest.fixture +def api_token(db, create_user): + """Create and return an API token for testing the external API""" + token = APIToken.objects.create( + user=create_user, + label="Test API Token", + token="test-api-token-12345", + ) + return token + + +@pytest.fixture +def api_key_client(api_client, api_token): + """Return an API key authenticated client for external API testing""" + api_client.credentials(HTTP_X_API_KEY=api_token.token) + return api_client + + +@pytest.fixture +def session_client(api_client, create_user): + """Return a session authenticated API client for app API testing, which is what plane.app uses""" + api_client.force_authenticate(user=create_user) + return api_client + + +@pytest.fixture +def create_bot_user(db): + """Create and return a bot user instance""" + from uuid import uuid4 + + unique_id = uuid4().hex[:8] + user = User.objects.create( + email=f"bot-{unique_id}@plane.so", + username=f"bot_user_{unique_id}", + first_name="Bot", + last_name="User", + is_bot=True, + ) + user.set_password("bot@123") + user.save() + return user + + +@pytest.fixture +def api_token_data(): + """Return sample API token data for testing""" + from django.utils import timezone + from datetime import timedelta + + return { + "label": "Test API Token", + "description": "Test description for API token", + "expired_at": (timezone.now() + timedelta(days=30)).isoformat(), + } + + +@pytest.fixture +def create_api_token_for_user(db, create_user): + """Create and return an API token for a specific user""" + return APIToken.objects.create( + label="Test Token", + description="Test token description", + user=create_user, + user_type=0, + ) + + +@pytest.fixture +def plane_server(live_server): + """ + Renamed version of live_server fixture to avoid name clashes. + Returns a live Django server for testing HTTP requests. + """ + return live_server diff --git a/apiserver/plane/tests/conftest_external.py b/apiserver/plane/tests/conftest_external.py new file mode 100644 index 000000000..d2d6a2df5 --- /dev/null +++ b/apiserver/plane/tests/conftest_external.py @@ -0,0 +1,117 @@ +import pytest +from unittest.mock import MagicMock, patch +from django.conf import settings + + +@pytest.fixture +def mock_redis(): + """ + Mock Redis for testing without actual Redis connection. + + This fixture patches the redis_instance function to return a MagicMock + that behaves like a Redis client. + """ + mock_redis_client = MagicMock() + + # Configure the mock to handle common Redis operations + mock_redis_client.get.return_value = None + mock_redis_client.set.return_value = True + mock_redis_client.delete.return_value = True + mock_redis_client.exists.return_value = 0 + mock_redis_client.ttl.return_value = -1 + + # Start the patch + with patch('plane.settings.redis.redis_instance', return_value=mock_redis_client): + yield mock_redis_client + + +@pytest.fixture +def mock_elasticsearch(): + """ + Mock Elasticsearch for testing without actual ES connection. + + This fixture patches Elasticsearch to return a MagicMock + that behaves like an Elasticsearch client. + """ + mock_es_client = MagicMock() + + # Configure the mock to handle common ES operations + mock_es_client.indices.exists.return_value = True + mock_es_client.indices.create.return_value = {"acknowledged": True} + mock_es_client.search.return_value = {"hits": {"total": {"value": 0}, "hits": []}} + mock_es_client.index.return_value = {"_id": "test_id", "result": "created"} + mock_es_client.update.return_value = {"_id": "test_id", "result": "updated"} + mock_es_client.delete.return_value = {"_id": "test_id", "result": "deleted"} + + # Start the patch + with patch('elasticsearch.Elasticsearch', return_value=mock_es_client): + yield mock_es_client + + +@pytest.fixture +def mock_mongodb(): + """ + Mock MongoDB for testing without actual MongoDB connection. + + This fixture patches PyMongo to return a MagicMock that behaves like a MongoDB client. + """ + # Create mock MongoDB clients and collections + mock_mongo_client = MagicMock() + mock_mongo_db = MagicMock() + mock_mongo_collection = MagicMock() + + # Set up the chain: client -> database -> collection + mock_mongo_client.__getitem__.return_value = mock_mongo_db + mock_mongo_client.get_database.return_value = mock_mongo_db + mock_mongo_db.__getitem__.return_value = mock_mongo_collection + + # Configure common MongoDB collection operations + mock_mongo_collection.find_one.return_value = None + mock_mongo_collection.find.return_value = MagicMock( + __iter__=lambda x: iter([]), + count=lambda: 0 + ) + mock_mongo_collection.insert_one.return_value = MagicMock( + inserted_id="mock_id_123", + acknowledged=True + ) + mock_mongo_collection.insert_many.return_value = MagicMock( + inserted_ids=["mock_id_123", "mock_id_456"], + acknowledged=True + ) + mock_mongo_collection.update_one.return_value = MagicMock( + modified_count=1, + matched_count=1, + acknowledged=True + ) + mock_mongo_collection.update_many.return_value = MagicMock( + modified_count=2, + matched_count=2, + acknowledged=True + ) + mock_mongo_collection.delete_one.return_value = MagicMock( + deleted_count=1, + acknowledged=True + ) + mock_mongo_collection.delete_many.return_value = MagicMock( + deleted_count=2, + acknowledged=True + ) + mock_mongo_collection.count_documents.return_value = 0 + + # Start the patch + with patch('pymongo.MongoClient', return_value=mock_mongo_client): + yield mock_mongo_client + + +@pytest.fixture +def mock_celery(): + """ + Mock Celery for testing without actual task execution. + + This fixture patches Celery's task.delay() to prevent actual task execution. + """ + # Start the patch + with patch('celery.app.task.Task.delay') as mock_delay: + mock_delay.return_value = MagicMock(id="mock-task-id") + yield mock_delay \ No newline at end of file diff --git a/apiserver/plane/tests/api/__init__.py b/apiserver/plane/tests/contract/__init__.py similarity index 100% rename from apiserver/plane/tests/api/__init__.py rename to apiserver/plane/tests/contract/__init__.py diff --git a/apiserver/plane/tests/contract/api/__init__.py b/apiserver/plane/tests/contract/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/tests/contract/app/__init__.py b/apiserver/plane/tests/contract/app/__init__.py new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/apiserver/plane/tests/contract/app/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apiserver/plane/tests/contract/app/test_api_token.py b/apiserver/plane/tests/contract/app/test_api_token.py new file mode 100644 index 000000000..5160788de --- /dev/null +++ b/apiserver/plane/tests/contract/app/test_api_token.py @@ -0,0 +1,372 @@ +import pytest +from datetime import timedelta +from uuid import uuid4 +from django.urls import reverse +from django.utils import timezone +from rest_framework import status + +from plane.db.models import APIToken, User + + +@pytest.mark.contract +class TestApiTokenEndpoint: + """Test cases for ApiTokenEndpoint""" + + # POST /user/api-tokens/ tests + @pytest.mark.django_db + def test_create_api_token_success( + self, session_client, create_user, api_token_data + ): + """Test successful API token creation""" + # Arrange + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens") + + # Act + response = session_client.post(url, api_token_data, format="json") + + # Assert + assert response.status_code == status.HTTP_201_CREATED + assert "token" in response.data + assert response.data["label"] == api_token_data["label"] + assert response.data["description"] == api_token_data["description"] + assert response.data["user_type"] == 0 # Human user + + # Verify token was created in database + token = APIToken.objects.get(pk=response.data["id"]) + assert token.user == create_user + assert token.label == api_token_data["label"] + + @pytest.mark.django_db + def test_create_api_token_for_bot_user( + self, session_client, create_bot_user, api_token_data + ): + """Test API token creation for bot user""" + # Arrange + session_client.force_authenticate(user=create_bot_user) + url = reverse("api-tokens") + + # Act + response = session_client.post(url, api_token_data, format="json") + + # Assert + assert response.status_code == status.HTTP_201_CREATED + assert response.data["user_type"] == 1 # Bot user + + @pytest.mark.django_db + def test_create_api_token_minimal_data(self, session_client, create_user): + """Test API token creation with minimal data""" + # Arrange + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens") + + # Act + response = session_client.post(url, {}, format="json") + + # Assert + assert response.status_code == status.HTTP_201_CREATED + assert "token" in response.data + assert len(response.data["label"]) == 32 # UUID hex length + assert response.data["description"] == "" + + @pytest.mark.django_db + def test_create_api_token_with_expiry(self, session_client, create_user): + """Test API token creation with expiry date""" + # Arrange + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens") + future_date = timezone.now() + timedelta(days=30) + data = {"label": "Expiring Token", "expired_at": future_date.isoformat()} + + # Act + response = session_client.post(url, data, format="json") + + # Assert + assert response.status_code == status.HTTP_201_CREATED + + # Verify expiry date was set + token = APIToken.objects.get(pk=response.data["id"]) + assert token.expired_at is not None + + @pytest.mark.django_db + def test_create_api_token_unauthenticated(self, api_client, api_token_data): + """Test API token creation without authentication""" + # Arrange + url = reverse("api-tokens") + + # Act + response = api_client.post(url, api_token_data, format="json") + + # Assert + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + # GET /user/api-tokens/ tests + @pytest.mark.django_db + def test_get_all_api_tokens(self, session_client, create_user): + """Test retrieving all API tokens for user""" + # Arrange + session_client.force_authenticate(user=create_user) + + # Create multiple tokens + APIToken.objects.create(label="Token 1", user=create_user, user_type=0) + APIToken.objects.create(label="Token 2", user=create_user, user_type=0) + # Create a service token (should be excluded) + APIToken.objects.create( + label="Service Token", user=create_user, user_type=0, is_service=True + ) + url = reverse("api-tokens") + + # Act + response = session_client.get(url) + + # Assert + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 2 # Only non-service tokens + assert all(token["is_service"] is False for token in response.data) + + @pytest.mark.django_db + def test_get_empty_api_tokens_list(self, session_client, create_user): + """Test retrieving API tokens when none exist""" + # Arrange + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens") + + # Act + response = session_client.get(url) + + # Assert + assert response.status_code == status.HTTP_200_OK + assert response.data == [] + + # GET /user/api-tokens// tests + @pytest.mark.django_db + def test_get_specific_api_token( + self, session_client, create_user, create_api_token_for_user + ): + """Test retrieving a specific API token""" + # Arrange + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk}) + + # Act + response = session_client.get(url) + + # Assert + assert response.status_code == status.HTTP_200_OK + assert str(response.data["id"]) == str(create_api_token_for_user.pk) + assert response.data["label"] == create_api_token_for_user.label + assert ( + "token" not in response.data + ) # Token should not be visible in read serializer + + @pytest.mark.django_db + def test_get_nonexistent_api_token(self, session_client, create_user): + """Test retrieving a non-existent API token""" + # Arrange + session_client.force_authenticate(user=create_user) + fake_pk = uuid4() + url = reverse("api-tokens", kwargs={"pk": fake_pk}) + + # Act + response = session_client.get(url) + + # Assert + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_get_other_users_api_token(self, session_client, create_user, db): + """Test retrieving another user's API token (should fail)""" + # Arrange + # Create another user and their token with unique email and username + unique_id = uuid4().hex[:8] + unique_email = f"other-{unique_id}@plane.so" + unique_username = f"other_user_{unique_id}" + other_user = User.objects.create(email=unique_email, username=unique_username) + other_token = APIToken.objects.create( + label="Other Token", user=other_user, user_type=0 + ) + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens", kwargs={"pk": other_token.pk}) + + # Act + response = session_client.get(url) + + # Assert + assert response.status_code == status.HTTP_404_NOT_FOUND + + # DELETE /user/api-tokens// tests + @pytest.mark.django_db + def test_delete_api_token_success( + self, session_client, create_user, create_api_token_for_user + ): + """Test successful API token deletion""" + # Arrange + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk}) + + # Act + response = session_client.delete(url) + + # Assert + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not APIToken.objects.filter(pk=create_api_token_for_user.pk).exists() + + @pytest.mark.django_db + def test_delete_nonexistent_api_token(self, session_client, create_user): + """Test deleting a non-existent API token""" + # Arrange + session_client.force_authenticate(user=create_user) + fake_pk = uuid4() + url = reverse("api-tokens", kwargs={"pk": fake_pk}) + + # Act + response = session_client.delete(url) + + # Assert + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_delete_other_users_api_token(self, session_client, create_user, db): + """Test deleting another user's API token (should fail)""" + # Arrange + # Create another user and their token with unique email and username + unique_id = uuid4().hex[:8] + unique_email = f"delete-other-{unique_id}@plane.so" + unique_username = f"delete_other_user_{unique_id}" + other_user = User.objects.create(email=unique_email, username=unique_username) + other_token = APIToken.objects.create( + label="Other Token", user=other_user, user_type=0 + ) + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens", kwargs={"pk": other_token.pk}) + + # Act + response = session_client.delete(url) + + # Assert + assert response.status_code == status.HTTP_404_NOT_FOUND + # Verify token still exists + assert APIToken.objects.filter(pk=other_token.pk).exists() + + @pytest.mark.django_db + def test_delete_service_api_token_forbidden(self, session_client, create_user): + """Test deleting a service API token (should fail)""" + # Arrange + service_token = APIToken.objects.create( + label="Service Token", user=create_user, user_type=0, is_service=True + ) + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens", kwargs={"pk": service_token.pk}) + + # Act + response = session_client.delete(url) + + # Assert + assert response.status_code == status.HTTP_404_NOT_FOUND + # Verify token still exists + assert APIToken.objects.filter(pk=service_token.pk).exists() + + # PATCH /user/api-tokens// tests + @pytest.mark.django_db + def test_patch_api_token_success( + self, session_client, create_user, create_api_token_for_user + ): + """Test successful API token update""" + # Arrange + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk}) + update_data = { + "label": "Updated Token Label", + "description": "Updated description", + } + + # Act + response = session_client.patch(url, update_data, format="json") + + # Assert + assert response.status_code == status.HTTP_200_OK + assert response.data["label"] == update_data["label"] + assert response.data["description"] == update_data["description"] + + # Verify database was updated + create_api_token_for_user.refresh_from_db() + assert create_api_token_for_user.label == update_data["label"] + assert create_api_token_for_user.description == update_data["description"] + + @pytest.mark.django_db + def test_patch_api_token_partial_update( + self, session_client, create_user, create_api_token_for_user + ): + """Test partial API token update""" + # Arrange + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk}) + original_description = create_api_token_for_user.description + update_data = {"label": "Only Label Updated"} + + # Act + response = session_client.patch(url, update_data, format="json") + + # Assert + assert response.status_code == status.HTTP_200_OK + assert response.data["label"] == update_data["label"] + assert response.data["description"] == original_description + + @pytest.mark.django_db + def test_patch_nonexistent_api_token(self, session_client, create_user): + """Test updating a non-existent API token""" + # Arrange + session_client.force_authenticate(user=create_user) + fake_pk = uuid4() + url = reverse("api-tokens", kwargs={"pk": fake_pk}) + update_data = {"label": "New Label"} + + # Act + response = session_client.patch(url, update_data, format="json") + + # Assert + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_patch_other_users_api_token(self, session_client, create_user, db): + """Test updating another user's API token (should fail)""" + # Arrange + # Create another user and their token with unique email and username + unique_id = uuid4().hex[:8] + unique_email = f"patch-other-{unique_id}@plane.so" + unique_username = f"patch_other_user_{unique_id}" + other_user = User.objects.create(email=unique_email, username=unique_username) + other_token = APIToken.objects.create( + label="Other Token", user=other_user, user_type=0 + ) + session_client.force_authenticate(user=create_user) + url = reverse("api-tokens", kwargs={"pk": other_token.pk}) + update_data = {"label": "Hacked Label"} + + # Act + response = session_client.patch(url, update_data, format="json") + + # Assert + assert response.status_code == status.HTTP_404_NOT_FOUND + + # Verify token was not updated + other_token.refresh_from_db() + assert other_token.label == "Other Token" + + # Authentication tests + @pytest.mark.django_db + def test_all_endpoints_require_authentication(self, api_client): + """Test that all endpoints require authentication""" + # Arrange + endpoints = [ + (reverse("api-tokens"), "get"), + (reverse("api-tokens"), "post"), + (reverse("api-tokens", kwargs={"pk": uuid4()}), "get"), + (reverse("api-tokens", kwargs={"pk": uuid4()}), "patch"), + (reverse("api-tokens", kwargs={"pk": uuid4()}), "delete"), + ] + + # Act & Assert + for url, method in endpoints: + response = getattr(api_client, method)(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/apiserver/plane/tests/contract/app/test_authentication.py b/apiserver/plane/tests/contract/app/test_authentication.py new file mode 100644 index 000000000..0dc548710 --- /dev/null +++ b/apiserver/plane/tests/contract/app/test_authentication.py @@ -0,0 +1,459 @@ +import json +import uuid +import pytest +from django.urls import reverse +from django.utils import timezone +from rest_framework import status +from django.test import Client +from django.core.exceptions import ValidationError +from unittest.mock import patch, MagicMock + +from plane.db.models import User +from plane.settings.redis import redis_instance +from plane.license.models import Instance + + +@pytest.fixture +def setup_instance(db): + """Create and configure an instance for authentication tests""" + instance_id = uuid.uuid4() if not Instance.objects.exists() else Instance.objects.first().id + + # Create or update instance with all required fields + instance, _ = Instance.objects.update_or_create( + id=instance_id, + defaults={ + "instance_name": "Test Instance", + "instance_id": str(uuid.uuid4()), + "current_version": "1.0.0", + "domain": "http://localhost:8000", + "last_checked_at": timezone.now(), + "is_setup_done": True, + } + ) + return instance + + +@pytest.fixture +def django_client(): + """Return a Django test client with User-Agent header for handling redirects""" + client = Client(HTTP_USER_AGENT="Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1") + return client + + +@pytest.mark.contract +class TestMagicLinkGenerate: + """Test magic link generation functionality""" + + @pytest.fixture + def setup_user(self, db): + """Create a test user for magic link tests""" + user = User.objects.create(email="user@plane.so") + user.set_password("user@123") + user.save() + return user + + @pytest.mark.django_db + def test_without_data(self, api_client, setup_user, setup_instance): + """Test magic link generation with empty data""" + url = reverse("magic-generate") + try: + response = api_client.post(url, {}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + except ValidationError: + # If a ValidationError is raised directly, that's also acceptable + # as it indicates the empty email was rejected + assert True + + @pytest.mark.django_db + def test_email_validity(self, api_client, setup_user, setup_instance): + """Test magic link generation with invalid email format""" + url = reverse("magic-generate") + try: + response = api_client.post(url, {"email": "useremail.com"}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "error_code" in response.data # Check for error code in response + except ValidationError: + # If a ValidationError is raised directly, that's also acceptable + # as it indicates the invalid email was rejected + assert True + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_magic_generate(self, mock_magic_link, api_client, setup_user, setup_instance): + """Test successful magic link generation""" + url = reverse("magic-generate") + + ri = redis_instance() + ri.delete("magic_user@plane.so") + + response = api_client.post(url, {"email": "user@plane.so"}, format="json") + assert response.status_code == status.HTTP_200_OK + assert "key" in response.data # Check for key in response + + # Verify the mock was called with the expected arguments + mock_magic_link.assert_called_once() + args = mock_magic_link.call_args[0] + assert args[0] == "user@plane.so" # First arg should be the email + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_max_generate_attempt(self, mock_magic_link, api_client, setup_user, setup_instance): + """Test exceeding maximum magic link generation attempts""" + url = reverse("magic-generate") + + ri = redis_instance() + ri.delete("magic_user@plane.so") + + for _ in range(4): + api_client.post(url, {"email": "user@plane.so"}, format="json") + + response = api_client.post(url, {"email": "user@plane.so"}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "error_code" in response.data # Check for error code in response + + +@pytest.mark.contract +class TestSignInEndpoint: + """Test sign-in functionality""" + + @pytest.fixture + def setup_user(self, db): + """Create a test user for authentication tests""" + user = User.objects.create(email="user@plane.so") + user.set_password("user@123") + user.save() + return user + + @pytest.mark.django_db + def test_without_data(self, django_client, setup_user, setup_instance): + """Test sign-in with empty data""" + url = reverse("sign-in") + response = django_client.post(url, {}, follow=True) + + # Check redirect contains error code + assert "REQUIRED_EMAIL_PASSWORD_SIGN_IN" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_email_validity(self, django_client, setup_user, setup_instance): + """Test sign-in with invalid email format""" + url = reverse("sign-in") + response = django_client.post( + url, {"email": "useremail.com", "password": "user@123"}, follow=True + ) + + # Check redirect contains error code + assert "INVALID_EMAIL_SIGN_IN" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_user_exists(self, django_client, setup_user, setup_instance): + """Test sign-in with non-existent user""" + url = reverse("sign-in") + response = django_client.post( + url, {"email": "user@email.so", "password": "user123"}, follow=True + ) + + # Check redirect contains error code + assert "USER_DOES_NOT_EXIST" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_password_validity(self, django_client, setup_user, setup_instance): + """Test sign-in with incorrect password""" + url = reverse("sign-in") + response = django_client.post( + url, {"email": "user@plane.so", "password": "user123"}, follow=True + ) + + + # Check for the specific authentication error in the URL + redirect_urls = [url for url, _ in response.redirect_chain] + redirect_contents = ' '.join(redirect_urls) + + # The actual error code for invalid password is AUTHENTICATION_FAILED_SIGN_IN + assert "AUTHENTICATION_FAILED_SIGN_IN" in redirect_contents + + @pytest.mark.django_db + def test_user_login(self, django_client, setup_user, setup_instance): + """Test successful sign-in""" + url = reverse("sign-in") + + # First make the request without following redirects + response = django_client.post( + url, {"email": "user@plane.so", "password": "user@123"}, follow=False + ) + + # Check that the initial response is a redirect (302) without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # Now follow just the first redirect to avoid 404s + response = django_client.get(response.url, follow=False) + + # The user should be authenticated regardless of the final page + assert "_auth_user_id" in django_client.session + + @pytest.mark.django_db + def test_next_path_redirection(self, django_client, setup_user, setup_instance): + """Test sign-in with next_path parameter""" + url = reverse("sign-in") + next_path = "workspaces" + + # First make the request without following redirects + response = django_client.post( + url, + {"email": "user@plane.so", "password": "user@123", "next_path": next_path}, + follow=False + ) + + # Check that the initial response is a redirect (302) without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + + # In a real browser, the next_path would be used to build the absolute URL + # Since we're just testing the authentication logic, we won't check for the exact URL structure + # Instead, just verify that we're authenticated + assert "_auth_user_id" in django_client.session + + +@pytest.mark.contract +class TestMagicSignIn: + """Test magic link sign-in functionality""" + + @pytest.fixture + def setup_user(self, db): + """Create a test user for magic sign-in tests""" + user = User.objects.create(email="user@plane.so") + user.set_password("user@123") + user.save() + return user + + @pytest.mark.django_db + def test_without_data(self, django_client, setup_user, setup_instance): + """Test magic link sign-in with empty data""" + url = reverse("magic-sign-in") + response = django_client.post(url, {}, follow=True) + + # Check redirect contains error code + assert "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_expired_invalid_magic_link(self, django_client, setup_user, setup_instance): + """Test magic link sign-in with expired/invalid link""" + ri = redis_instance() + ri.delete("magic_user@plane.so") + + url = reverse("magic-sign-in") + response = django_client.post( + url, + {"email": "user@plane.so", "code": "xxxx-xxxxx-xxxx"}, + follow=False + ) + + # Check that we get a redirect + assert response.status_code == 302 + + # The actual error code is EXPIRED_MAGIC_CODE_SIGN_IN (when key doesn't exist) + # or INVALID_MAGIC_CODE_SIGN_IN (when key exists but code doesn't match) + assert "EXPIRED_MAGIC_CODE_SIGN_IN" in response.url or "INVALID_MAGIC_CODE_SIGN_IN" in response.url + + @pytest.mark.django_db + def test_user_does_not_exist(self, django_client, setup_instance): + """Test magic sign-in with non-existent user""" + url = reverse("magic-sign-in") + response = django_client.post( + url, + {"email": "nonexistent@plane.so", "code": "xxxx-xxxxx-xxxx"}, + follow=True + ) + + # Check redirect contains error code + assert "USER_DOES_NOT_EXIST" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_magic_code_sign_in(self, mock_magic_link, django_client, api_client, setup_user, setup_instance): + """Test successful magic link sign-in process""" + # First generate a magic link token + gen_url = reverse("magic-generate") + response = api_client.post(gen_url, {"email": "user@plane.so"}, format="json") + + # Check that the token generation was successful + assert response.status_code == status.HTTP_200_OK + + # Since we're mocking the magic_link task, we need to manually get the token from Redis + ri = redis_instance() + user_data = json.loads(ri.get("magic_user@plane.so")) + token = user_data["token"] + + # Use Django client to test the redirect flow without following redirects + url = reverse("magic-sign-in") + response = django_client.post( + url, + {"email": "user@plane.so", "code": token}, + follow=False + ) + + # Check that the initial response is a redirect without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # The user should now be authenticated + assert "_auth_user_id" in django_client.session + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_magic_sign_in_with_next_path(self, mock_magic_link, django_client, api_client, setup_user, setup_instance): + """Test magic sign-in with next_path parameter""" + # First generate a magic link token + gen_url = reverse("magic-generate") + response = api_client.post(gen_url, {"email": "user@plane.so"}, format="json") + + # Check that the token generation was successful + assert response.status_code == status.HTTP_200_OK + + # Since we're mocking the magic_link task, we need to manually get the token from Redis + ri = redis_instance() + user_data = json.loads(ri.get("magic_user@plane.so")) + token = user_data["token"] + + # Use Django client to test the redirect flow without following redirects + url = reverse("magic-sign-in") + next_path = "workspaces" + response = django_client.post( + url, + {"email": "user@plane.so", "code": token, "next_path": next_path}, + follow=False + ) + + # Check that the initial response is a redirect without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # Check that the redirect URL contains the next_path + assert next_path in response.url + + # The user should now be authenticated + assert "_auth_user_id" in django_client.session + + +@pytest.mark.contract +class TestMagicSignUp: + """Test magic link sign-up functionality""" + + @pytest.mark.django_db + def test_without_data(self, django_client, setup_instance): + """Test magic link sign-up with empty data""" + url = reverse("magic-sign-up") + response = django_client.post(url, {}, follow=True) + + # Check redirect contains error code + assert "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_user_already_exists(self, django_client, db, setup_instance): + """Test magic sign-up with existing user""" + # Create a user that already exists + User.objects.create(email="existing@plane.so") + + url = reverse("magic-sign-up") + response = django_client.post( + url, + {"email": "existing@plane.so", "code": "xxxx-xxxxx-xxxx"}, + follow=True + ) + + # Check redirect contains error code + assert "USER_ALREADY_EXIST" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_expired_invalid_magic_link(self, django_client, setup_instance): + """Test magic link sign-up with expired/invalid link""" + url = reverse("magic-sign-up") + response = django_client.post( + url, + {"email": "new@plane.so", "code": "xxxx-xxxxx-xxxx"}, + follow=False + ) + + # Check that we get a redirect + assert response.status_code == 302 + + # The actual error code is EXPIRED_MAGIC_CODE_SIGN_UP (when key doesn't exist) + # or INVALID_MAGIC_CODE_SIGN_UP (when key exists but code doesn't match) + assert "EXPIRED_MAGIC_CODE_SIGN_UP" in response.url or "INVALID_MAGIC_CODE_SIGN_UP" in response.url + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_magic_code_sign_up(self, mock_magic_link, django_client, api_client, setup_instance): + """Test successful magic link sign-up process""" + email = "newuser@plane.so" + + # First generate a magic link token + gen_url = reverse("magic-generate") + response = api_client.post(gen_url, {"email": email}, format="json") + + # Check that the token generation was successful + assert response.status_code == status.HTTP_200_OK + + # Since we're mocking the magic_link task, we need to manually get the token from Redis + ri = redis_instance() + user_data = json.loads(ri.get(f"magic_{email}")) + token = user_data["token"] + + # Use Django client to test the redirect flow without following redirects + url = reverse("magic-sign-up") + response = django_client.post( + url, + {"email": email, "code": token}, + follow=False + ) + + # Check that the initial response is a redirect without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # Check if user was created + assert User.objects.filter(email=email).exists() + + # Check if user is authenticated + assert "_auth_user_id" in django_client.session + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_magic_sign_up_with_next_path(self, mock_magic_link, django_client, api_client, setup_instance): + """Test magic sign-up with next_path parameter""" + email = "newuser2@plane.so" + + # First generate a magic link token + gen_url = reverse("magic-generate") + response = api_client.post(gen_url, {"email": email}, format="json") + + # Check that the token generation was successful + assert response.status_code == status.HTTP_200_OK + + # Since we're mocking the magic_link task, we need to manually get the token from Redis + ri = redis_instance() + user_data = json.loads(ri.get(f"magic_{email}")) + token = user_data["token"] + + # Use Django client to test the redirect flow without following redirects + url = reverse("magic-sign-up") + next_path = "onboarding" + response = django_client.post( + url, + {"email": email, "code": token, "next_path": next_path}, + follow=False + ) + + # Check that the initial response is a redirect without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # In a real browser, the next_path would be used to build the absolute URL + # Since we're just testing the authentication logic, we won't check for the exact URL structure + + # Check if user was created + assert User.objects.filter(email=email).exists() + + # Check if user is authenticated + assert "_auth_user_id" in django_client.session \ No newline at end of file diff --git a/apiserver/plane/tests/contract/app/test_workspace_app.py b/apiserver/plane/tests/contract/app/test_workspace_app.py new file mode 100644 index 000000000..71ad1d412 --- /dev/null +++ b/apiserver/plane/tests/contract/app/test_workspace_app.py @@ -0,0 +1,79 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from unittest.mock import patch + +from plane.db.models import Workspace, WorkspaceMember + + +@pytest.mark.contract +class TestWorkspaceAPI: + """Test workspace CRUD operations""" + + @pytest.mark.django_db + def test_create_workspace_empty_data(self, session_client): + """Test creating a workspace with empty data""" + url = reverse("workspace") + + # Test with empty data + response = session_client.post(url, {}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + @patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay") + def test_create_workspace_valid_data(self, mock_workspace_seed, session_client, create_user): + """Test creating a workspace with valid data""" + url = reverse("workspace") + user = create_user # Use the create_user fixture directly as it returns a user object + + # Test with valid data - include all required fields + workspace_data = { + "name": "Plane", + "slug": "pla-ne-test", + "company_name": "Plane Inc." + } + + # Make the request + response = session_client.post(url, workspace_data, format="json") + + # Check response status + assert response.status_code == status.HTTP_201_CREATED + + # Verify workspace was created + assert Workspace.objects.count() == 1 + + # Check if the member is created + assert WorkspaceMember.objects.count() == 1 + + # Check other values + workspace = Workspace.objects.get(slug=workspace_data["slug"]) + workspace_member = WorkspaceMember.objects.filter( + workspace=workspace, member=user + ).first() + assert workspace.owner == user + assert workspace_member.role == 20 + + # Verify the workspace_seed task was called + mock_workspace_seed.assert_called_once_with(response.data["id"]) + + @pytest.mark.django_db + @patch('plane.bgtasks.workspace_seed_task.workspace_seed.delay') + def test_create_duplicate_workspace(self, mock_workspace_seed, session_client): + """Test creating a duplicate workspace""" + url = reverse("workspace") + + # Create first workspace + session_client.post( + url, {"name": "Plane", "slug": "pla-ne"}, format="json" + ) + + # Try to create a workspace with the same slug + response = session_client.post( + url, {"name": "Plane", "slug": "pla-ne"}, format="json" + ) + + # The API returns 400 BAD REQUEST for duplicate slugs, not 409 CONFLICT + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Optionally check the error message to confirm it's related to the duplicate slug + assert "slug" in response.data \ No newline at end of file diff --git a/apiserver/plane/tests/factories.py b/apiserver/plane/tests/factories.py new file mode 100644 index 000000000..8d95773de --- /dev/null +++ b/apiserver/plane/tests/factories.py @@ -0,0 +1,82 @@ +import factory +from uuid import uuid4 +from django.utils import timezone + +from plane.db.models import ( + User, + Workspace, + WorkspaceMember, + Project, + ProjectMember +) + + +class UserFactory(factory.django.DjangoModelFactory): + """Factory for creating User instances""" + class Meta: + model = User + django_get_or_create = ('email',) + + id = factory.LazyFunction(uuid4) + email = factory.Sequence(lambda n: f'user{n}@plane.so') + password = factory.PostGenerationMethodCall('set_password', 'password') + first_name = factory.Sequence(lambda n: f'First{n}') + last_name = factory.Sequence(lambda n: f'Last{n}') + is_active = True + is_superuser = False + is_staff = False + + +class WorkspaceFactory(factory.django.DjangoModelFactory): + """Factory for creating Workspace instances""" + class Meta: + model = Workspace + django_get_or_create = ('slug',) + + id = factory.LazyFunction(uuid4) + name = factory.Sequence(lambda n: f'Workspace {n}') + slug = factory.Sequence(lambda n: f'workspace-{n}') + owner = factory.SubFactory(UserFactory) + created_at = factory.LazyFunction(timezone.now) + updated_at = factory.LazyFunction(timezone.now) + + +class WorkspaceMemberFactory(factory.django.DjangoModelFactory): + """Factory for creating WorkspaceMember instances""" + class Meta: + model = WorkspaceMember + + id = factory.LazyFunction(uuid4) + workspace = factory.SubFactory(WorkspaceFactory) + member = factory.SubFactory(UserFactory) + role = 20 # Admin role by default + created_at = factory.LazyFunction(timezone.now) + updated_at = factory.LazyFunction(timezone.now) + + +class ProjectFactory(factory.django.DjangoModelFactory): + """Factory for creating Project instances""" + class Meta: + model = Project + django_get_or_create = ('name', 'workspace') + + id = factory.LazyFunction(uuid4) + name = factory.Sequence(lambda n: f'Project {n}') + workspace = factory.SubFactory(WorkspaceFactory) + created_by = factory.SelfAttribute('workspace.owner') + updated_by = factory.SelfAttribute('workspace.owner') + created_at = factory.LazyFunction(timezone.now) + updated_at = factory.LazyFunction(timezone.now) + + +class ProjectMemberFactory(factory.django.DjangoModelFactory): + """Factory for creating ProjectMember instances""" + class Meta: + model = ProjectMember + + id = factory.LazyFunction(uuid4) + project = factory.SubFactory(ProjectFactory) + member = factory.SubFactory(UserFactory) + role = 20 # Admin role by default + created_at = factory.LazyFunction(timezone.now) + updated_at = factory.LazyFunction(timezone.now) \ No newline at end of file diff --git a/apiserver/plane/tests/smoke/__init__.py b/apiserver/plane/tests/smoke/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/tests/smoke/test_auth_smoke.py b/apiserver/plane/tests/smoke/test_auth_smoke.py new file mode 100644 index 000000000..4d6de6c35 --- /dev/null +++ b/apiserver/plane/tests/smoke/test_auth_smoke.py @@ -0,0 +1,100 @@ +import pytest +import requests +from django.urls import reverse + + +@pytest.mark.smoke +class TestAuthSmoke: + """Smoke tests for authentication endpoints""" + + @pytest.mark.django_db + def test_login_endpoint_available(self, plane_server, create_user, user_data): + """Test that the login endpoint is available and responds correctly""" + # Get the sign-in URL + relative_url = reverse("sign-in") + url = f"{plane_server.url}{relative_url}" + + # 1. Test bad login - test with wrong password + response = requests.post( + url, + data={ + "email": user_data["email"], + "password": "wrong-password" + } + ) + + # For bad credentials, any of these status codes would be valid + # The test shouldn't be brittle to minor implementation changes + assert response.status_code != 500, "Authentication should not cause server errors" + assert response.status_code != 404, "Authentication endpoint should exist" + + if response.status_code == 200: + # If API returns 200 for failures, check the response body for error indication + if hasattr(response, 'json'): + try: + data = response.json() + # JSON response might indicate error in its structure + assert "error" in data or "error_code" in data or "detail" in data or response.url.endswith("sign-in"), \ + "Error response should contain error details" + except ValueError: + # It's ok if response isn't JSON format + pass + elif response.status_code in [302, 303]: + # If it's a redirect, it should redirect to a login page or error page + redirect_url = response.headers.get('Location', '') + assert "error" in redirect_url or "sign-in" in redirect_url, \ + "Failed login should redirect to login page or error page" + + # 2. Test good login with correct credentials + response = requests.post( + url, + data={ + "email": user_data["email"], + "password": user_data["password"] + }, + allow_redirects=False # Don't follow redirects + ) + + # Successful auth should not be a client error or server error + assert response.status_code not in range(400, 600), \ + f"Authentication with valid credentials failed with status {response.status_code}" + + # Specific validation based on response type + if response.status_code in [302, 303]: + # Redirect-based auth: check that redirect URL doesn't contain error + redirect_url = response.headers.get('Location', '') + assert "error" not in redirect_url and "error_code" not in redirect_url, \ + "Successful login redirect should not contain error parameters" + + elif response.status_code == 200: + # API token-based auth: check for tokens or user session + if hasattr(response, 'json'): + try: + data = response.json() + # If it's a token response + if "access_token" in data: + assert "refresh_token" in data, "JWT auth should return both access and refresh tokens" + # If it's a user session response + elif "user" in data: + assert "is_authenticated" in data and data["is_authenticated"], \ + "User session response should indicate authentication" + # Otherwise it should at least indicate success + else: + assert not any(error_key in data for error_key in ["error", "error_code", "detail"]), \ + "Success response should not contain error keys" + except ValueError: + # Non-JSON is acceptable if it's a redirect or HTML response + pass + + +@pytest.mark.smoke +class TestHealthCheckSmoke: + """Smoke test for health check endpoint""" + + def test_healthcheck_endpoint(self, plane_server): + """Test that the health check endpoint is available and responds correctly""" + # Make a request to the health check endpoint + response = requests.get(f"{plane_server.url}/") + + # Should be OK + assert response.status_code == 200, "Health check endpoint should return 200 OK" \ No newline at end of file diff --git a/apiserver/plane/tests/unit/__init__.py b/apiserver/plane/tests/unit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/tests/unit/models/__init__.py b/apiserver/plane/tests/unit/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/tests/unit/models/test_workspace_model.py b/apiserver/plane/tests/unit/models/test_workspace_model.py new file mode 100644 index 000000000..40380fa0f --- /dev/null +++ b/apiserver/plane/tests/unit/models/test_workspace_model.py @@ -0,0 +1,50 @@ +import pytest +from uuid import uuid4 + +from plane.db.models import Workspace, WorkspaceMember, User + + +@pytest.mark.unit +class TestWorkspaceModel: + """Test the Workspace model""" + + @pytest.mark.django_db + def test_workspace_creation(self, create_user): + """Test creating a workspace""" + # Create a workspace + workspace = Workspace.objects.create( + name="Test Workspace", + slug="test-workspace", + id=uuid4(), + owner=create_user + ) + + # Verify it was created + assert workspace.id is not None + assert workspace.name == "Test Workspace" + assert workspace.slug == "test-workspace" + assert workspace.owner == create_user + + @pytest.mark.django_db + def test_workspace_member_creation(self, create_user): + """Test creating a workspace member""" + # Create a workspace + workspace = Workspace.objects.create( + name="Test Workspace", + slug="test-workspace", + id=uuid4(), + owner=create_user + ) + + # Create a workspace member + workspace_member = WorkspaceMember.objects.create( + workspace=workspace, + member=create_user, + role=20 # Admin role + ) + + # Verify it was created + assert workspace_member.id is not None + assert workspace_member.workspace == workspace + assert workspace_member.member == create_user + assert workspace_member.role == 20 \ No newline at end of file diff --git a/apiserver/plane/tests/unit/serializers/__init__.py b/apiserver/plane/tests/unit/serializers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/tests/unit/serializers/test_issue_recent_visit.py b/apiserver/plane/tests/unit/serializers/test_issue_recent_visit.py new file mode 100644 index 000000000..72d1f3384 --- /dev/null +++ b/apiserver/plane/tests/unit/serializers/test_issue_recent_visit.py @@ -0,0 +1,75 @@ +import pytest + +from plane.db.models import ( + Workspace, + Project, + Issue, + User, + IssueAssignee, + WorkspaceMember, + ProjectMember, +) +from plane.app.serializers.workspace import IssueRecentVisitSerializer +from django.utils import timezone + + +@pytest.mark.unit +class TestIssueRecentVisitSerializer: + """Test the IssueRecentVisitSerializer""" + + def test_issue_recent_visit_serializer_fields(self, db): + """Test that the serializer includes the correct fields""" + + test_user_1 = User.objects.create( + email="test_user_1@example.com", first_name="Test", last_name="User" + ) + + # To test for deleted issue assignee + test_user_2 = User.objects.create( + email="test_user_2@example.com", + first_name="Other", + last_name="User", + username="some user name", + ) + + workspace = Workspace.objects.create( + name="Test Workspace", slug="test-workspace", owner=test_user_1 + ) + + WorkspaceMember.objects.create(member=test_user_2, role=15, workspace=workspace) + + project = Project.objects.create( + name="Test Project", identifier="test-project", workspace=workspace + ) + ProjectMember.objects.create(project=project, member=test_user_2) + + issue = Issue.objects.create( + name="Test Issue", + workspace=workspace, + project=project, + ) + + IssueAssignee.objects.create(issue=issue, assignee=test_user_1, project=project) + + # Deleted issue assignee + IssueAssignee.objects.create( + issue=issue, + assignee=test_user_2, + project=project, + deleted_at=timezone.now(), + ) + + serialized_data = IssueRecentVisitSerializer( + issue, + ).data + + # Check fields are present and correct + assert "name" in serialized_data + assert "assignees" in serialized_data + assert "project_identifier" in serialized_data + + assert serialized_data["name"] == "Test Issue" + assert serialized_data["project_identifier"] == "TEST-PROJECT" + + # Only including non-deleted issue assignees + assert serialized_data["assignees"] == [test_user_1.id] diff --git a/apiserver/plane/tests/unit/serializers/test_workspace.py b/apiserver/plane/tests/unit/serializers/test_workspace.py new file mode 100644 index 000000000..19767a7c6 --- /dev/null +++ b/apiserver/plane/tests/unit/serializers/test_workspace.py @@ -0,0 +1,71 @@ +import pytest +from uuid import uuid4 + +from plane.api.serializers import WorkspaceLiteSerializer +from plane.db.models import Workspace, User + + +@pytest.mark.unit +class TestWorkspaceLiteSerializer: + """Test the WorkspaceLiteSerializer""" + + def test_workspace_lite_serializer_fields(self, db): + """Test that the serializer includes the correct fields""" + # Create a user to be the owner + owner = User.objects.create( + email="test@example.com", + first_name="Test", + last_name="User" + ) + + # Create a workspace with explicit ID to test serialization + workspace_id = uuid4() + workspace = Workspace.objects.create( + name="Test Workspace", + slug="test-workspace", + id=workspace_id, + owner=owner + ) + + # Serialize the workspace + serialized_data = WorkspaceLiteSerializer(workspace).data + + # Check fields are present and correct + assert "name" in serialized_data + assert "slug" in serialized_data + assert "id" in serialized_data + + assert serialized_data["name"] == "Test Workspace" + assert serialized_data["slug"] == "test-workspace" + assert str(serialized_data["id"]) == str(workspace_id) + + def test_workspace_lite_serializer_read_only(self, db): + """Test that the serializer fields are read-only""" + # Create a user to be the owner + owner = User.objects.create( + email="test2@example.com", + first_name="Test", + last_name="User" + ) + + # Create a workspace + workspace = Workspace.objects.create( + name="Test Workspace", + slug="test-workspace", + id=uuid4(), + owner=owner + ) + + # Try to update via serializer + serializer = WorkspaceLiteSerializer( + workspace, + data={"name": "Updated Name", "slug": "updated-slug"} + ) + + # Serializer should be valid (since read-only fields are ignored) + assert serializer.is_valid() + + # Save should not update the read-only fields + updated_workspace = serializer.save() + assert updated_workspace.name == "Test Workspace" + assert updated_workspace.slug == "test-workspace" \ No newline at end of file diff --git a/apiserver/plane/tests/unit/utils/__init__.py b/apiserver/plane/tests/unit/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/tests/unit/utils/test_uuid.py b/apiserver/plane/tests/unit/utils/test_uuid.py new file mode 100644 index 000000000..81403c5be --- /dev/null +++ b/apiserver/plane/tests/unit/utils/test_uuid.py @@ -0,0 +1,49 @@ +import uuid +import pytest +from plane.utils.uuid import is_valid_uuid, convert_uuid_to_integer + + +@pytest.mark.unit +class TestUUIDUtils: + """Test the UUID utilities""" + + def test_is_valid_uuid_with_valid_uuid(self): + """Test is_valid_uuid with a valid UUID""" + # Generate a valid UUID + valid_uuid = str(uuid.uuid4()) + assert is_valid_uuid(valid_uuid) is True + + def test_is_valid_uuid_with_invalid_uuid(self): + """Test is_valid_uuid with invalid UUID strings""" + # Test with different invalid formats + assert is_valid_uuid("not-a-uuid") is False + assert is_valid_uuid("123456789") is False + assert is_valid_uuid("") is False + assert is_valid_uuid("00000000-0000-0000-0000-000000000000") is False # This is a valid UUID but version 1 + + def test_convert_uuid_to_integer(self): + """Test convert_uuid_to_integer function""" + # Create a known UUID + test_uuid = uuid.UUID("f47ac10b-58cc-4372-a567-0e02b2c3d479") + + # Convert to integer + result = convert_uuid_to_integer(test_uuid) + + # Check that the result is an integer + assert isinstance(result, int) + + # Ensure consistent results with the same input + assert convert_uuid_to_integer(test_uuid) == result + + # Different UUIDs should produce different integers + different_uuid = uuid.UUID("550e8400-e29b-41d4-a716-446655440000") + assert convert_uuid_to_integer(different_uuid) != result + + def test_convert_uuid_to_integer_string_input(self): + """Test convert_uuid_to_integer handles string UUID""" + # Test with a UUID string + test_uuid_str = "f47ac10b-58cc-4372-a567-0e02b2c3d479" + test_uuid = uuid.UUID(test_uuid_str) + + # Should get the same result whether passing UUID or string + assert convert_uuid_to_integer(test_uuid) == convert_uuid_to_integer(test_uuid_str) \ No newline at end of file diff --git a/apiserver/plane/urls.py b/apiserver/plane/urls.py index e3870a393..b692306a7 100644 --- a/apiserver/plane/urls.py +++ b/apiserver/plane/urls.py @@ -2,12 +2,10 @@ from django.conf import settings from django.urls import include, path, re_path -from django.views.generic import TemplateView handler404 = "plane.app.views.error_404.custom_404_view" urlpatterns = [ - path("", TemplateView.as_view(template_name="index.html")), path("api/", include("plane.app.urls")), path("api/public/", include("plane.space.urls")), path("api/instances/", include("plane.license.urls")), diff --git a/apiserver/plane/utils/analytics_plot.py b/apiserver/plane/utils/analytics_plot.py index 7527a3524..43c465e7c 100644 --- a/apiserver/plane/utils/analytics_plot.py +++ b/apiserver/plane/utils/analytics_plot.py @@ -140,7 +140,9 @@ def burndown_plot(queryset, slug, project_id, plot_type, cycle_id=None, module_i # Get all dates between the two dates date_range = [ (queryset.start_date + timedelta(days=x)).date() - for x in range((queryset.end_date - queryset.start_date).days + 1) + for x in range( + (queryset.end_date.date() - queryset.start_date.date()).days + 1 + ) ] else: date_range = [] diff --git a/apiserver/plane/utils/build_chart.py b/apiserver/plane/utils/build_chart.py new file mode 100644 index 000000000..be5bb7753 --- /dev/null +++ b/apiserver/plane/utils/build_chart.py @@ -0,0 +1,204 @@ +from typing import Dict, Any, Tuple, Optional, List, Union + + +# Django imports +from django.db.models import ( + Count, + F, + QuerySet, + Aggregate, +) + +from plane.db.models import Issue +from rest_framework.exceptions import ValidationError + + +x_axis_mapper = { + "STATES": "STATES", + "STATE_GROUPS": "STATE_GROUPS", + "LABELS": "LABELS", + "ASSIGNEES": "ASSIGNEES", + "ESTIMATE_POINTS": "ESTIMATE_POINTS", + "CYCLES": "CYCLES", + "MODULES": "MODULES", + "PRIORITY": "PRIORITY", + "START_DATE": "START_DATE", + "TARGET_DATE": "TARGET_DATE", + "CREATED_AT": "CREATED_AT", + "COMPLETED_AT": "COMPLETED_AT", + "CREATED_BY": "CREATED_BY", +} + + +def get_y_axis_filter(y_axis: str) -> Dict[str, Any]: + filter_mapping = { + "WORK_ITEM_COUNT": {"id": F("id")}, + } + return filter_mapping.get(y_axis, {}) + + +def get_x_axis_field() -> Dict[str, Tuple[str, str, Optional[Dict[str, Any]]]]: + return { + "STATES": ("state__id", "state__name", None), + "STATE_GROUPS": ("state__group", "state__group", None), + "LABELS": ( + "labels__id", + "labels__name", + {"label_issue__deleted_at__isnull": True}, + ), + "ASSIGNEES": ( + "assignees__id", + "assignees__display_name", + {"issue_assignee__deleted_at__isnull": True}, + ), + "ESTIMATE_POINTS": ("estimate_point__value", "estimate_point__key", None), + "CYCLES": ( + "issue_cycle__cycle_id", + "issue_cycle__cycle__name", + {"issue_cycle__deleted_at__isnull": True}, + ), + "MODULES": ( + "issue_module__module_id", + "issue_module__module__name", + {"issue_module__deleted_at__isnull": True}, + ), + "PRIORITY": ("priority", "priority", None), + "START_DATE": ("start_date", "start_date", None), + "TARGET_DATE": ("target_date", "target_date", None), + "CREATED_AT": ("created_at__date", "created_at__date", None), + "COMPLETED_AT": ("completed_at__date", "completed_at__date", None), + "CREATED_BY": ("created_by_id", "created_by__display_name", None), + } + + +def process_grouped_data( + data: List[Dict[str, Any]], +) -> Tuple[List[Dict[str, Any]], Dict[str, str]]: + response = {} + schema = {} + + for item in data: + key = item["key"] + if key not in response: + response[key] = { + "key": key if key else "none", + "name": ( + item.get("display_name", key) + if item.get("display_name", key) + else "None" + ), + "count": 0, + } + group_key = str(item["group_key"]) if item["group_key"] else "none" + schema[group_key] = item.get("group_name", item["group_key"]) + schema[group_key] = schema[group_key] if schema[group_key] else "None" + response[key][group_key] = response[key].get(group_key, 0) + item["count"] + response[key]["count"] += item["count"] + + return list(response.values()), schema + + +def build_number_chart_response( + queryset: QuerySet[Issue], + y_axis_filter: Dict[str, Any], + y_axis: str, + aggregate_func: Aggregate, +) -> List[Dict[str, Any]]: + count = ( + queryset.filter(**y_axis_filter).aggregate(total=aggregate_func).get("total", 0) + ) + return [{"key": y_axis, "name": y_axis, "count": count}] + + +def build_grouped_chart_response( + queryset: QuerySet[Issue], + id_field: str, + name_field: str, + group_field: str, + group_name_field: str, + aggregate_func: Aggregate, +) -> Tuple[List[Dict[str, Any]], Dict[str, str]]: + data = ( + queryset.annotate( + key=F(id_field), + group_key=F(group_field), + group_name=F(group_name_field), + display_name=F(name_field) if name_field else F(id_field), + ) + .values("key", "group_key", "group_name", "display_name") + .annotate(count=aggregate_func) + .order_by("-count") + ) + return process_grouped_data(data) + + +def build_simple_chart_response( + queryset: QuerySet, id_field: str, name_field: str, aggregate_func: Aggregate +) -> List[Dict[str, Any]]: + data = ( + queryset.annotate( + key=F(id_field), display_name=F(name_field) if name_field else F(id_field) + ) + .values("key", "display_name") + .annotate(count=aggregate_func) + .order_by("key") + ) + + return [ + { + "key": item["key"] if item["key"] else "None", + "name": item["display_name"] if item["display_name"] else "None", + "count": item["count"], + } + for item in data + ] + + +def build_analytics_chart( + queryset: QuerySet[Issue], + x_axis: str, + group_by: Optional[str] = None, + date_filter: Optional[str] = None, +) -> Dict[str, Union[List[Dict[str, Any]], Dict[str, str]]]: + # Validate x_axis + if x_axis not in x_axis_mapper: + raise ValidationError(f"Invalid x_axis field: {x_axis}") + + # Validate group_by + if group_by and group_by not in x_axis_mapper: + raise ValidationError(f"Invalid group_by field: {group_by}") + + field_mapping = get_x_axis_field() + + id_field, name_field, additional_filter = field_mapping.get( + x_axis, (None, None, {}) + ) + group_field, group_name_field, group_additional_filter = field_mapping.get( + group_by, (None, None, {}) + ) + + # Apply additional filters if they exist + if additional_filter or {}: + queryset = queryset.filter(**additional_filter) + + if group_additional_filter or {}: + queryset = queryset.filter(**group_additional_filter) + + aggregate_func = Count("id", distinct=True) + + if group_field: + response, schema = build_grouped_chart_response( + queryset, + id_field, + name_field, + group_field, + group_name_field, + aggregate_func, + ) + else: + response = build_simple_chart_response( + queryset, id_field, name_field, aggregate_func + ) + schema = {} + + return {"data": response, "schema": schema} diff --git a/apiserver/plane/utils/date_utils.py b/apiserver/plane/utils/date_utils.py new file mode 100644 index 000000000..4225e70b5 --- /dev/null +++ b/apiserver/plane/utils/date_utils.py @@ -0,0 +1,201 @@ +from datetime import datetime, timedelta, date +from django.utils import timezone +from typing import Dict, Optional, List, Union, Tuple, Any + +from plane.db.models import User + + +def get_analytics_date_range( + date_filter: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, +) -> Optional[Dict[str, Dict[str, datetime]]]: + """ + Get date range for analytics with current and previous periods for comparison. + Returns a dictionary with current and previous date ranges. + + Args: + date_filter (str): The type of date filter to apply + start_date (str): Start date for custom range (format: YYYY-MM-DD) + end_date (str): End date for custom range (format: YYYY-MM-DD) + + Returns: + dict: Dictionary containing current and previous date ranges + """ + if not date_filter: + return None + + today = timezone.now().date() + + if date_filter == "yesterday": + yesterday = today - timedelta(days=1) + return { + "current": { + "gte": datetime.combine(yesterday, datetime.min.time()), + "lte": datetime.combine(yesterday, datetime.max.time()), + } + } + elif date_filter == "last_7_days": + return { + "current": { + "gte": datetime.combine(today - timedelta(days=7), datetime.min.time()), + "lte": datetime.combine(today, datetime.max.time()), + }, + "previous": { + "gte": datetime.combine( + today - timedelta(days=14), datetime.min.time() + ), + "lte": datetime.combine(today - timedelta(days=8), datetime.max.time()), + }, + } + elif date_filter == "last_30_days": + return { + "current": { + "gte": datetime.combine( + today - timedelta(days=30), datetime.min.time() + ), + "lte": datetime.combine(today, datetime.max.time()), + }, + "previous": { + "gte": datetime.combine( + today - timedelta(days=60), datetime.min.time() + ), + "lte": datetime.combine( + today - timedelta(days=31), datetime.max.time() + ), + }, + } + elif date_filter == "last_3_months": + return { + "current": { + "gte": datetime.combine( + today - timedelta(days=90), datetime.min.time() + ), + "lte": datetime.combine(today, datetime.max.time()), + }, + "previous": { + "gte": datetime.combine( + today - timedelta(days=180), datetime.min.time() + ), + "lte": datetime.combine( + today - timedelta(days=91), datetime.max.time() + ), + }, + } + elif date_filter == "custom" and start_date and end_date: + try: + start = datetime.strptime(start_date, "%Y-%m-%d").date() + end = datetime.strptime(end_date, "%Y-%m-%d").date() + return { + "current": { + "gte": datetime.combine(start, datetime.min.time()), + "lte": datetime.combine(end, datetime.max.time()), + } + } + except (ValueError, TypeError): + return None + return None + + +def get_chart_period_range( + date_filter: Optional[str] = None, +) -> Optional[Tuple[date, date]]: + """ + Get date range for chart visualization. + Returns a tuple of (start_date, end_date) for the specified period. + + Args: + date_filter (str): The type of date filter to apply. Options are: + - "yesterday": Yesterday's date + - "last_7_days": Last 7 days + - "last_30_days": Last 30 days + - "last_3_months": Last 90 days + Defaults to "last_7_days" if not specified or invalid. + + Returns: + tuple: A tuple containing (start_date, end_date) as date objects + """ + if not date_filter: + return None + + today = timezone.now().date() + period_ranges = { + "yesterday": ( + today - timedelta(days=1), + today - timedelta(days=1), + ), + "last_7_days": (today - timedelta(days=7), today), + "last_30_days": (today - timedelta(days=30), today), + "last_3_months": (today - timedelta(days=90), today), + } + + return period_ranges.get(date_filter, None) + + +def get_analytics_filters( + slug: str, + user: User, + type: str, + date_filter: Optional[str] = None, + project_ids: Optional[Union[str, List[str]]] = None, +) -> Dict[str, Any]: + """ + Get combined project and date filters for analytics endpoints + + Args: + slug: The workspace slug + user: The current user + type: The type of filter ("analytics" or "chart") + date_filter: Optional date filter string + project_ids: Optional list of project IDs or comma-separated string of project IDs + + Returns: + dict: A dictionary containing: + - base_filters: Base filters for the workspace and user + - project_filters: Project-specific filters + - analytics_date_range: Date range filters for analytics comparison + - chart_period_range: Date range for chart visualization + """ + # Get project IDs from request + if project_ids and isinstance(project_ids, str): + project_ids = [str(project_id) for project_id in project_ids.split(",")] + + # Base filters for workspace and user + base_filters = { + "workspace__slug": slug, + "project__project_projectmember__member": user, + "project__project_projectmember__is_active": True, + "project__deleted_at__isnull": True, + "project__archived_at__isnull": True, + } + + # Project filters + project_filters = { + "workspace__slug": slug, + "project_projectmember__member": user, + "project_projectmember__is_active": True, + "deleted_at__isnull": True, + "archived_at__isnull": True, + } + + # Add project IDs to filters if provided + if project_ids: + base_filters["project_id__in"] = project_ids + project_filters["id__in"] = project_ids + + # Initialize date range variables + analytics_date_range = None + chart_period_range = None + + # Get date range filters based on type + if type == "analytics": + analytics_date_range = get_analytics_date_range(date_filter) + elif type == "chart": + chart_period_range = get_chart_period_range(date_filter) + + return { + "base_filters": base_filters, + "project_filters": project_filters, + "analytics_date_range": analytics_date_range, + "chart_period_range": chart_period_range, + } diff --git a/apiserver/plane/utils/host.py b/apiserver/plane/utils/host.py index c4914d7ff..860e19e0e 100644 --- a/apiserver/plane/utils/host.py +++ b/apiserver/plane/utils/host.py @@ -9,7 +9,13 @@ from rest_framework.request import Request # Module imports from plane.utils.ip_address import get_client_ip -def base_host(request: Request | HttpRequest, is_admin: bool = False, is_space: bool = False, is_app: bool = False) -> str: + +def base_host( + request: Request | HttpRequest, + is_admin: bool = False, + is_space: bool = False, + is_app: bool = False, +) -> str: """Utility function to return host / origin from the request""" # Calculate the base origin from request base_origin = settings.WEB_URL or settings.APP_BASE_URL @@ -17,19 +23,35 @@ def base_host(request: Request | HttpRequest, is_admin: bool = False, is_space: if not base_origin: raise ImproperlyConfigured("APP_BASE_URL or WEB_URL is not set") - # Admin redirections + # Admin redirection if is_admin: - if settings.ADMIN_BASE_URL: - return settings.ADMIN_BASE_URL - else: - return base_origin + "/god-mode/" + admin_base_path = getattr(settings, "ADMIN_BASE_PATH", None) + if not isinstance(admin_base_path, str): + admin_base_path = "/god-mode/" + if not admin_base_path.startswith("/"): + admin_base_path = "/" + admin_base_path + if not admin_base_path.endswith("/"): + admin_base_path += "/" - # Space redirections - if is_space: - if settings.SPACE_BASE_URL: - return settings.SPACE_BASE_URL + if settings.ADMIN_BASE_URL: + return settings.ADMIN_BASE_URL + admin_base_path else: - return base_origin + "/spaces/" + return base_origin + admin_base_path + + # Space redirection + if is_space: + space_base_path = getattr(settings, "SPACE_BASE_PATH", None) + if not isinstance(space_base_path, str): + space_base_path = "/spaces/" + if not space_base_path.startswith("/"): + space_base_path = "/" + space_base_path + if not space_base_path.endswith("/"): + space_base_path += "/" + + if settings.SPACE_BASE_URL: + return settings.SPACE_BASE_URL + space_base_path + else: + return base_origin + space_base_path # App Redirection if is_app: diff --git a/apiserver/plane/utils/order_queryset.py b/apiserver/plane/utils/order_queryset.py index 174637b74..9138cb31e 100644 --- a/apiserver/plane/utils/order_queryset.py +++ b/apiserver/plane/utils/order_queryset.py @@ -16,7 +16,7 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"): ], output_field=CharField(), ) - ).order_by("priority_order") + ).order_by("priority_order", "-created_at") order_by_param = ( "priority_order" if order_by_param.startswith("-") else "-priority_order" ) @@ -36,7 +36,7 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"): default=Value(len(state_order)), output_field=CharField(), ) - ).order_by("state_order") + ).order_by("state_order", "-created_at") order_by_param = ( "-state_order" if order_by_param.startswith("-") else "state_order" ) @@ -55,11 +55,18 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"): if order_by_param.startswith("-") else order_by_param ) - ).order_by("-min_values" if order_by_param.startswith("-") else "min_values") + ).order_by( + "-min_values" if order_by_param.startswith("-") else "min_values", + "-created_at", + ) order_by_param = ( "-min_values" if order_by_param.startswith("-") else "min_values" ) else: - issue_queryset = issue_queryset.order_by(order_by_param) + # If the order_by_param is created_at, then don't add the -created_at + if "created_at" in order_by_param: + issue_queryset = issue_queryset.order_by(order_by_param) + else: + issue_queryset = issue_queryset.order_by(order_by_param, "-created_at") order_by_param = order_by_param return issue_queryset, order_by_param diff --git a/apiserver/plane/utils/paginator.py b/apiserver/plane/utils/paginator.py index 6bec093e7..0793d2a30 100644 --- a/apiserver/plane/utils/paginator.py +++ b/apiserver/plane/utils/paginator.py @@ -102,6 +102,7 @@ class OffsetPaginator: max_limit=MAX_LIMIT, max_offset=None, on_results=None, + total_count_queryset=None, ): # Key tuple and remove `-` if descending order by self.key = ( @@ -115,6 +116,7 @@ class OffsetPaginator: self.max_limit = max_limit self.max_offset = max_offset self.on_results = on_results + self.total_count_queryset = total_count_queryset def get_result(self, limit=1000, cursor=None): # offset is page # @@ -138,9 +140,9 @@ class OffsetPaginator: ) # The current page page = cursor.offset - # The offset - offset = cursor.offset * cursor.value - stop = offset + (cursor.value or limit) + 1 + # The offset - use limit instead of cursor.value for consistent pagination + offset = cursor.offset * limit + stop = offset + limit + 1 if self.max_offset is not None and offset >= self.max_offset: raise BadPaginationError("Pagination offset too large") @@ -148,11 +150,23 @@ class OffsetPaginator: raise BadPaginationError("Pagination offset cannot be negative") results = queryset[offset:stop] - if cursor.value != limit: + # Duplicate the queryset so it does not evaluate on any python ops + page_results = queryset[offset:stop].values("id") + + # Only slice from the end if we're going backwards (previous page) + if cursor.value != limit and cursor.is_prev: results = results[-(limit + 1) :] + total_count = ( + self.total_count_queryset.count() + if self.total_count_queryset + else results.count() + ) + + # Check if there are more results available after the current page + # Adjust cursors based on the results for pagination - next_cursor = Cursor(limit, page + 1, False, results.count() > limit) + next_cursor = Cursor(limit, page + 1, False, page_results.count() > limit) # If the page is greater than 0, then set the previous cursor prev_cursor = Cursor(limit, page - 1, True, page > 0) @@ -164,7 +178,7 @@ class OffsetPaginator: results = self.on_results(results) # Count the queryset - count = queryset.count() + count = total_count # Optionally, calculate the total count and max_hits if needed max_hits = math.ceil(count / limit) @@ -196,6 +210,7 @@ class GroupedOffsetPaginator(OffsetPaginator): group_by_field_name, group_by_fields, count_filter, + total_count_queryset=None, *args, **kwargs, ): @@ -404,6 +419,7 @@ class SubGroupedOffsetPaginator(OffsetPaginator): group_by_fields, sub_group_by_fields, count_filter, + total_count_queryset=None, *args, **kwargs, ): @@ -694,6 +710,7 @@ class BasePaginator: sub_group_by_field_name=None, sub_group_by_fields=None, count_filter=None, + total_count_queryset=None, **paginator_kwargs, ): """Paginate the request""" @@ -719,6 +736,8 @@ class BasePaginator: ) paginator_kwargs["sub_group_by_fields"] = sub_group_by_fields + paginator_kwargs["total_count_queryset"] = total_count_queryset + paginator = paginator_cls(**paginator_kwargs) try: diff --git a/apiserver/plane/utils/timezone_converter.py b/apiserver/plane/utils/timezone_converter.py index 40480b4f6..9a66742ed 100644 --- a/apiserver/plane/utils/timezone_converter.py +++ b/apiserver/plane/utils/timezone_converter.py @@ -35,9 +35,7 @@ def user_timezone_converter(queryset, datetime_fields, user_timezone): return queryset_values -def convert_to_utc( - date, project_id, is_start_date=False, is_start_date_end_date_equal=False -): +def convert_to_utc(date, project_id, is_start_date=False): """ Converts a start date string to the project's local timezone at 12:00 AM and then converts it to UTC for storage. @@ -82,10 +80,8 @@ def convert_to_utc( return utc_datetime else: - # 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) + # the cycle end date is the last minute of the day + localized_datetime += timedelta(hours=23, minutes=59, seconds=0) # Convert the localized datetime to UTC utc_datetime = localized_datetime.astimezone(pytz.utc) diff --git a/apiserver/plane/utils/url.py b/apiserver/plane/utils/url.py new file mode 100644 index 000000000..1b4a229a8 --- /dev/null +++ b/apiserver/plane/utils/url.py @@ -0,0 +1,87 @@ +# Python imports +import re +from typing import Optional +from urllib.parse import urlparse, urlunparse + + +def contains_url(value: str) -> bool: + """ + Check if the value contains a URL. + """ + url_pattern = re.compile(r"https?://|www\\.") + return bool(url_pattern.search(value)) + + +def is_valid_url(url: str) -> bool: + """ + Validates whether the given string is a well-formed URL. + + Args: + url (str): The URL string to validate. + + Returns: + bool: True if the URL is valid, False otherwise. + + Example: + >>> is_valid_url("https://example.com") + True + >>> is_valid_url("not a url") + False + """ + try: + result = urlparse(url) + # A valid URL should have at least scheme and netloc + return all([result.scheme, result.netloc]) + except TypeError: + return False + + +def get_url_components(url: str) -> Optional[dict]: + """ + Parses the URL and returns its components if valid. + + Args: + url (str): The URL string to parse. + + Returns: + Optional[dict]: A dictionary with URL components if valid, None otherwise. + + Example: + >>> get_url_components("https://example.com/path?query=1") + {'scheme': 'https', 'netloc': 'example.com', 'path': '/path', 'params': '', 'query': 'query=1', 'fragment': ''} + """ + if not is_valid_url(url): + return None + result = urlparse(url) + return { + "scheme": result.scheme, + "netloc": result.netloc, + "path": result.path, + "params": result.params, + "query": result.query, + "fragment": result.fragment, + } + + +def normalize_url_path(url: str) -> str: + """ + Normalize the path component of a URL by replacing multiple consecutive slashes with a single slash. + + This function preserves the protocol, domain, query parameters, and fragments of the URL, + only modifying the path portion to ensure there are no duplicate slashes. + + Args: + url (str): The input URL string to normalize. + + Returns: + str: The normalized URL with redundant slashes in the path removed. + + Example: + >>> normalize_url_path('https://example.com//foo///bar//baz?x=1#frag') + 'https://example.com/foo/bar/baz?x=1#frag' + """ + parts = urlparse(url) + # Normalize the path + normalized_path = re.sub(r"/+", "/", parts.path) + # Reconstruct the URL + return urlunparse(parts._replace(path=normalized_path)) diff --git a/apiserver/plane/web/urls.py b/apiserver/plane/web/urls.py index 512d4a258..28734ad91 100644 --- a/apiserver/plane/web/urls.py +++ b/apiserver/plane/web/urls.py @@ -1,4 +1,4 @@ from django.urls import path -from django.views.generic import TemplateView +from plane.web.views import robots_txt, health_check -urlpatterns = [path("about/", TemplateView.as_view(template_name="about.html"))] +urlpatterns = [path("robots.txt", robots_txt), path("", health_check)] diff --git a/apiserver/plane/web/views.py b/apiserver/plane/web/views.py index 60f00ef0e..8acb70a77 100644 --- a/apiserver/plane/web/views.py +++ b/apiserver/plane/web/views.py @@ -1 +1,9 @@ -# Create your views here. +from django.http import HttpResponse, JsonResponse + + +def health_check(request): + return JsonResponse({"status": "OK"}) + + +def robots_txt(request): + return HttpResponse("User-agent: *\nDisallow: /", content_type="text/plain") diff --git a/apiserver/pyproject.toml b/apiserver/pyproject.toml index 4292580a8..099d5e36e 100644 --- a/apiserver/pyproject.toml +++ b/apiserver/pyproject.toml @@ -42,7 +42,7 @@ quote-style = "double" indent-style = "space" # Respect magic trailing commas. -skip-magic-trailing-comma = true +# skip-magic-trailing-comma = true # Automatically detect the appropriate line ending. line-ending = "auto" diff --git a/apiserver/pytest.ini b/apiserver/pytest.ini new file mode 100644 index 000000000..e2f194456 --- /dev/null +++ b/apiserver/pytest.ini @@ -0,0 +1,17 @@ +[pytest] +DJANGO_SETTINGS_MODULE = plane.settings.test +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +markers = + unit: Unit tests for models, serializers, and utility functions + contract: Contract tests for API endpoints + smoke: Smoke tests for critical functionality + slow: Tests that are slow and might be skipped in some contexts + +addopts = + --strict-markers + --reuse-db + --nomigrations + -vs \ No newline at end of file diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 6cdb4d8b2..3a12b9bf6 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -1,7 +1,7 @@ # base requirements # django -Django==4.2.21 +Django==4.2.22 # rest framework djangorestframework==3.15.2 # postgres diff --git a/apiserver/requirements/test.txt b/apiserver/requirements/test.txt index 1ffc82d00..66a1ff163 100644 --- a/apiserver/requirements/test.txt +++ b/apiserver/requirements/test.txt @@ -1,4 +1,12 @@ -r base.txt -# test checker -pytest==7.1.2 -coverage==6.5.0 \ No newline at end of file +# test framework +pytest==7.4.0 +pytest-django==4.5.2 +pytest-cov==4.1.0 +pytest-xdist==3.3.1 +pytest-mock==3.11.1 +factory-boy==3.3.0 +freezegun==1.2.2 +coverage==7.2.7 +httpx==0.24.1 +requests==2.32.4 \ No newline at end of file diff --git a/apiserver/run_tests.py b/apiserver/run_tests.py new file mode 100755 index 000000000..f4f0951b1 --- /dev/null +++ b/apiserver/run_tests.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python +import argparse +import subprocess +import sys + + +def main(): + parser = argparse.ArgumentParser(description="Run Plane tests") + parser.add_argument( + "-u", "--unit", + action="store_true", + help="Run unit tests only" + ) + parser.add_argument( + "-c", "--contract", + action="store_true", + help="Run contract tests only" + ) + parser.add_argument( + "-s", "--smoke", + action="store_true", + help="Run smoke tests only" + ) + parser.add_argument( + "-o", "--coverage", + action="store_true", + help="Generate coverage report" + ) + parser.add_argument( + "-p", "--parallel", + action="store_true", + help="Run tests in parallel" + ) + parser.add_argument( + "-v", "--verbose", + action="store_true", + help="Verbose output" + ) + args = parser.parse_args() + + # Build command + cmd = ["python", "-m", "pytest"] + markers = [] + + # Add test markers + if args.unit: + markers.append("unit") + if args.contract: + markers.append("contract") + if args.smoke: + markers.append("smoke") + + # Add markers filter + if markers: + cmd.extend(["-m", " or ".join(markers)]) + + # Add coverage + if args.coverage: + cmd.extend(["--cov=plane", "--cov-report=term", "--cov-report=html"]) + + # Add parallel + if args.parallel: + cmd.extend(["-n", "auto"]) + + # Add verbose + if args.verbose: + cmd.append("-v") + + # Add common flags + cmd.extend(["--reuse-db", "--nomigrations"]) + + # Print command + print(f"Running: {' '.join(cmd)}") + + # Execute command + result = subprocess.run(cmd) + + # Check coverage thresholds if coverage is enabled + if args.coverage: + print("Checking coverage thresholds...") + coverage_cmd = ["python", "-m", "coverage", "report", "--fail-under=90"] + coverage_result = subprocess.run(coverage_cmd) + if coverage_result.returncode != 0: + print("Coverage below threshold (90%)") + sys.exit(coverage_result.returncode) + + sys.exit(result.returncode) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/apiserver/run_tests.sh b/apiserver/run_tests.sh new file mode 100755 index 000000000..7e22479b5 --- /dev/null +++ b/apiserver/run_tests.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +# This is a simple wrapper script that calls the main test runner in the tests directory +exec tests/run_tests.sh "$@" \ No newline at end of file diff --git a/apiserver/templates/about.html b/apiserver/templates/about.html deleted file mode 100644 index 91804cda4..000000000 --- a/apiserver/templates/about.html +++ /dev/null @@ -1,9 +0,0 @@ - -{% extends 'base.html' %} -{% load static %} - - -{% block content %} -

Hello from plane!

-

Made with Django

-{% endblock content %} \ No newline at end of file diff --git a/apiserver/templates/emails/notifications/issue-updates.html b/apiserver/templates/emails/notifications/issue-updates.html index c1a48752f..8ba91c6fe 100644 --- a/apiserver/templates/emails/notifications/issue-updates.html +++ b/apiserver/templates/emails/notifications/issue-updates.html @@ -3,7 +3,7 @@ - Updates on issue + Updates on {{entity_type}} @@ -37,7 +37,7 @@ {% else %}

{{summary}} {% if data|length > 0 %} {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name}} {% else %} {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name}} {% endif %} and others.

{% endif %} {% for update in data %} {% if update.changes.name %} -

The issue title has been updated to {{ issue.name}}

+

The {{entity_type}} title has been updated to {{ issue.name}}

{% endif %} {% if data %}
@@ -209,7 +209,7 @@ {% for actor_comment in comment.actor_comments.new_value %} -
+

{{ actor_comment|safe }}

@@ -224,7 +224,7 @@ {% endif %}
-
View issue
+
View {{entity_type}}
@@ -232,7 +232,7 @@
- This email was sent to {{ receiver.email }}. If you'd rather not receive this kind of email, you can unsubscribe to the issue or manage your email preferences. + This email was sent to {{ receiver.email }}. If you'd rather not receive this kind of email, you can unsubscribe to the {{entity_type}} or manage your email preferences.
diff --git a/apiserver/templates/index.html b/apiserver/templates/index.html deleted file mode 100644 index 630ca66b6..000000000 --- a/apiserver/templates/index.html +++ /dev/null @@ -1,5 +0,0 @@ - {% extends 'base.html' %} {% load static %} {% block content %} -
-

Hello from plane!

-
-{% endblock content %} \ No newline at end of file diff --git a/docker-compose-local.yml b/docker-compose-local.yml index 392c55a11..86457615a 100644 --- a/docker-compose-local.yml +++ b/docker-compose-local.yml @@ -6,6 +6,8 @@ services: - dev_env volumes: - redisdata:/data + ports: + - "6379:6379" plane-mq: image: rabbitmq:3.13.6-management-alpine @@ -26,7 +28,15 @@ services: restart: unless-stopped networks: - dev_env - command: server /export --console-address ":9090" + entrypoint: > + /bin/sh -c " + mkdir -p /export/${AWS_S3_BUCKET_NAME} && + minio server /export --console-address ':9090' & + sleep 5 && + mc alias set myminio http://localhost:9000 ${AWS_ACCESS_KEY_ID} ${AWS_SECRET_ACCESS_KEY} && + mc mb myminio/${AWS_S3_BUCKET_NAME} -p || true + && tail -f /dev/null + " volumes: - uploads:/export env_file: @@ -34,6 +44,9 @@ services: environment: MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID} MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY} + ports: + - "9000:9000" + - "9090:9090" plane-db: image: postgres:15.7-alpine @@ -47,63 +60,65 @@ services: - .env environment: PGDATA: /var/lib/postgresql/data + ports: + - "5432:5432" - web: - build: - context: . - dockerfile: ./web/Dockerfile.dev - restart: unless-stopped - networks: - - dev_env - volumes: - - ./web:/app/web - env_file: - - ./web/.env - depends_on: - - api - - worker + # web: + # build: + # context: . + # dockerfile: ./web/Dockerfile.dev + # restart: unless-stopped + # networks: + # - dev_env + # volumes: + # - ./web:/app/web + # env_file: + # - ./web/.env + # depends_on: + # - api + # - worker - space: - build: - context: . - dockerfile: ./space/Dockerfile.dev - restart: unless-stopped - networks: - - dev_env - volumes: - - ./space:/app/space - depends_on: - - api - - worker - - web + # space: + # build: + # context: . + # dockerfile: ./space/Dockerfile.dev + # restart: unless-stopped + # networks: + # - dev_env + # volumes: + # - ./space:/app/space + # depends_on: + # - api + # - worker + # - web - admin: - build: - context: . - dockerfile: ./admin/Dockerfile.dev - restart: unless-stopped - networks: - - dev_env - volumes: - - ./admin:/app/admin - depends_on: - - api - - worker - - web + # admin: + # build: + # context: . + # dockerfile: ./admin/Dockerfile.dev + # restart: unless-stopped + # networks: + # - dev_env + # volumes: + # - ./admin:/app/admin + # depends_on: + # - api + # - worker + # - web - live: - build: - context: . - dockerfile: ./live/Dockerfile.dev - restart: unless-stopped - networks: - - dev_env - volumes: - - ./live:/app/live - depends_on: - - api - - worker - - web + # live: + # build: + # context: . + # dockerfile: ./live/Dockerfile.dev + # restart: unless-stopped + # networks: + # - dev_env + # volumes: + # - ./live:/app/live + # depends_on: + # - api + # - worker + # - web api: build: @@ -122,6 +137,9 @@ services: depends_on: - plane-db - plane-redis + - plane-mq + ports: + - "8000:8000" worker: build: @@ -179,25 +197,25 @@ services: - plane-db - plane-redis - proxy: - build: - context: ./nginx - dockerfile: Dockerfile.dev - restart: unless-stopped - networks: - - dev_env - ports: - - ${NGINX_PORT}:80 - env_file: - - .env - environment: - FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880} - BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} - depends_on: - - web - - api - - space - - admin + # proxy: + # build: + # context: ./nginx + # dockerfile: Dockerfile.dev + # restart: unless-stopped + # networks: + # - dev_env + # ports: + # - ${NGINX_PORT}:80 + # env_file: + # - .env + # environment: + # FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880} + # BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} + # depends_on: + # - api + # - web + # - space + # - admin volumes: redisdata: diff --git a/live/.env.example b/live/.env.example index 3203db847..064258253 100644 --- a/live/.env.example +++ b/live/.env.example @@ -1,8 +1,13 @@ -API_BASE_URL="http://api:8000" +API_BASE_URL="http://localhost:8000" + +WEB_BASE_URL="http://localhost:3000" + +LIVE_BASE_URL="http://localhost:3100" LIVE_BASE_PATH="/live" -REDIS_URL="redis://plane-redis:6379/" +LIVE_SERVER_SECRET_KEY="secret-key" # If you prefer not to provide a Redis URL, you can set the REDIS_HOST and REDIS_PORT environment variables instead. REDIS_PORT=6379 -REDIS_HOST=plane-redis \ No newline at end of file +REDIS_HOST=localhost +REDIS_URL="redis://localhost:6379/" diff --git a/live/package.json b/live/package.json index ea74f8f7d..7399de761 100644 --- a/live/package.json +++ b/live/package.json @@ -1,6 +1,6 @@ { "name": "live", - "version": "0.26.1", + "version": "0.27.0", "license": "AGPL-3.0", "description": "A realtime collaborative server powers Plane's rich text editor", "main": "./src/server.ts", @@ -57,7 +57,7 @@ "concurrently": "^9.0.1", "nodemon": "^3.1.7", "ts-node": "^10.9.2", - "tsup": "^8.4.0", - "typescript": "5.3.3" + "tsup": "8.4.0", + "typescript": "5.8.3" } } diff --git a/package.json b/package.json index 447a41d4f..73c8697a1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "plane", "description": "Open-source project management that unlocks customer value", "repository": "https://github.com/makeplane/plane.git", - "version": "0.26.1", + "version": "0.27.0", "license": "AGPL-3.0", "private": true, "workspaces": [ @@ -24,13 +24,16 @@ "devDependencies": { "prettier": "latest", "prettier-plugin-tailwindcss": "^0.5.4", - "turbo": "^2.5.0" + "turbo": "^2.5.4" }, "resolutions": { + "brace-expansion": "2.0.2", "nanoid": "3.3.8", "esbuild": "0.25.0", "@babel/helpers": "7.26.10", - "@babel/runtime": "7.26.10" + "@babel/runtime": "7.26.10", + "chokidar": "3.6.0", + "tar-fs": "3.0.9" }, "packageManager": "yarn@1.22.22" } diff --git a/packages/constants/package.json b/packages/constants/package.json index d69253bc7..e007dc10b 100644 --- a/packages/constants/package.json +++ b/packages/constants/package.json @@ -1,6 +1,6 @@ { "name": "@plane/constants", - "version": "0.26.1", + "version": "0.27.0", "private": true, "main": "./src/index.ts", "license": "AGPL-3.0" diff --git a/packages/constants/src/analytics.ts b/packages/constants/src/analytics.ts deleted file mode 100644 index 6c8211ae0..000000000 --- a/packages/constants/src/analytics.ts +++ /dev/null @@ -1,81 +0,0 @@ -// types -import { TXAxisValues, TYAxisValues } from "@plane/types"; - -export const ANALYTICS_TABS = [ - { - key: "scope_and_demand", - i18n_title: "workspace_analytics.tabs.scope_and_demand", - }, - { key: "custom", i18n_title: "workspace_analytics.tabs.custom" }, -]; - -export const ANALYTICS_X_AXIS_VALUES: { value: TXAxisValues; label: string }[] = - [ - { - value: "state_id", - label: "State name", - }, - { - value: "state__group", - label: "State group", - }, - { - value: "priority", - label: "Priority", - }, - { - value: "labels__id", - label: "Label", - }, - { - value: "assignees__id", - label: "Assignee", - }, - { - value: "estimate_point__value", - label: "Estimate point", - }, - { - value: "issue_cycle__cycle_id", - label: "Cycle", - }, - { - value: "issue_module__module_id", - label: "Module", - }, - { - value: "completed_at", - label: "Completed date", - }, - { - value: "target_date", - label: "Due date", - }, - { - value: "start_date", - label: "Start date", - }, - { - value: "created_at", - label: "Created date", - }, - ]; - -export const ANALYTICS_Y_AXIS_VALUES: { value: TYAxisValues; label: string }[] = - [ - { - value: "issue_count", - label: "Work item Count", - }, - { - value: "estimate", - label: "Estimate", - }, - ]; - -export const ANALYTICS_DATE_KEYS = [ - "completed_at", - "target_date", - "start_date", - "created_at", -]; diff --git a/packages/constants/src/analytics/common.ts b/packages/constants/src/analytics/common.ts new file mode 100644 index 000000000..4ac899523 --- /dev/null +++ b/packages/constants/src/analytics/common.ts @@ -0,0 +1,178 @@ +import { TAnalyticsTabsBase } from "@plane/types"; +import { ChartXAxisProperty, ChartYAxisMetric } from "../chart"; + +export interface IInsightField { + key: string; + i18nKey: string; + i18nProps?: { + entity?: string; + entityPlural?: string; + [key: string]: any; + }; +} + +export const ANALYTICS_INSIGHTS_FIELDS: Record = { + overview: [ + { + key: "total_users", + i18nKey: "workspace_analytics.total", + i18nProps: { + entity: "common.users", + }, + }, + { + key: "total_admins", + i18nKey: "workspace_analytics.total", + i18nProps: { + entity: "common.admins", + }, + }, + { + key: "total_members", + i18nKey: "workspace_analytics.total", + i18nProps: { + entity: "common.members", + }, + }, + { + key: "total_guests", + i18nKey: "workspace_analytics.total", + i18nProps: { + entity: "common.guests", + }, + }, + { + key: "total_projects", + i18nKey: "workspace_analytics.total", + i18nProps: { + entity: "common.projects", + }, + }, + { + key: "total_work_items", + i18nKey: "workspace_analytics.total", + i18nProps: { + entity: "common.work_items", + }, + }, + { + key: "total_cycles", + i18nKey: "workspace_analytics.total", + i18nProps: { + entity: "common.cycles", + }, + }, + { + key: "total_intake", + i18nKey: "workspace_analytics.total", + i18nProps: { + entity: "sidebar.intake", + }, + }, + ], + "work-items": [ + { + key: "total_work_items", + i18nKey: "workspace_analytics.total", + }, + { + key: "started_work_items", + i18nKey: "workspace_analytics.started_work_items", + }, + { + key: "backlog_work_items", + i18nKey: "workspace_analytics.backlog_work_items", + }, + { + key: "un_started_work_items", + i18nKey: "workspace_analytics.un_started_work_items", + }, + { + key: "completed_work_items", + i18nKey: "workspace_analytics.completed_work_items", + }, + ], +}; + +export const ANALYTICS_DURATION_FILTER_OPTIONS = [ + { + name: "Yesterday", + value: "yesterday", + }, + { + name: "Last 7 days", + value: "last_7_days", + }, + { + name: "Last 30 days", + value: "last_30_days", + }, + { + name: "Last 3 months", + value: "last_3_months", + }, +]; + +export const ANALYTICS_X_AXIS_VALUES: { value: ChartXAxisProperty; label: string }[] = [ + { + value: ChartXAxisProperty.STATES, + label: "State name", + }, + { + value: ChartXAxisProperty.STATE_GROUPS, + label: "State group", + }, + { + value: ChartXAxisProperty.PRIORITY, + label: "Priority", + }, + { + value: ChartXAxisProperty.LABELS, + label: "Label", + }, + { + value: ChartXAxisProperty.ASSIGNEES, + label: "Assignee", + }, + { + value: ChartXAxisProperty.ESTIMATE_POINTS, + label: "Estimate point", + }, + { + value: ChartXAxisProperty.CYCLES, + label: "Cycle", + }, + { + value: ChartXAxisProperty.MODULES, + label: "Module", + }, + { + value: ChartXAxisProperty.COMPLETED_AT, + label: "Completed date", + }, + { + value: ChartXAxisProperty.TARGET_DATE, + label: "Due date", + }, + { + value: ChartXAxisProperty.START_DATE, + label: "Start date", + }, + { + value: ChartXAxisProperty.CREATED_AT, + label: "Created date", + }, +]; + +export const ANALYTICS_Y_AXIS_VALUES: { value: ChartYAxisMetric; label: string }[] = [ + { + value: ChartYAxisMetric.WORK_ITEM_COUNT, + label: "Work item", + }, + { + value: ChartYAxisMetric.ESTIMATE_POINT_COUNT, + label: "Estimate", + }, +]; + +export const ANALYTICS_V2_DATE_KEYS = ["completed_at", "target_date", "start_date", "created_at"]; diff --git a/packages/constants/src/analytics/index.ts b/packages/constants/src/analytics/index.ts new file mode 100644 index 000000000..3e2372da3 --- /dev/null +++ b/packages/constants/src/analytics/index.ts @@ -0,0 +1 @@ +export * from "./common" \ No newline at end of file diff --git a/packages/constants/src/auth.ts b/packages/constants/src/auth.ts index bcdda31b4..1b6cb9111 100644 --- a/packages/constants/src/auth.ts +++ b/packages/constants/src/auth.ts @@ -69,7 +69,7 @@ export enum EErrorAlertType { export type TAuthErrorInfo = { type: EErrorAlertType; - code: EAdminAuthErrorCodes; + code: EAuthErrorCodes; title: string; message: any; }; @@ -87,6 +87,13 @@ export enum EAdminAuthErrorCodes { ADMIN_USER_DEACTIVATED = "5190", } +export type TAdminAuthErrorInfo = { + type: EErrorAlertType; + code: EAdminAuthErrorCodes; + title: string; + message: any; +}; + export enum EAuthErrorCodes { // Global INSTANCE_NOT_CONFIGURED = "5000", diff --git a/packages/constants/src/chart.ts b/packages/constants/src/chart.ts index bddd0fd38..be736d807 100644 --- a/packages/constants/src/chart.ts +++ b/packages/constants/src/chart.ts @@ -1,2 +1,157 @@ +import { TChartColorScheme } from "@plane/types"; + export const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide"; export const AXIS_LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide"; + + +export enum ChartXAxisProperty { + STATES = "STATES", + STATE_GROUPS = "STATE_GROUPS", + LABELS = "LABELS", + ASSIGNEES = "ASSIGNEES", + ESTIMATE_POINTS = "ESTIMATE_POINTS", + CYCLES = "CYCLES", + MODULES = "MODULES", + PRIORITY = "PRIORITY", + START_DATE = "START_DATE", + TARGET_DATE = "TARGET_DATE", + CREATED_AT = "CREATED_AT", + COMPLETED_AT = "COMPLETED_AT", + CREATED_BY = "CREATED_BY", + WORK_ITEM_TYPES = "WORK_ITEM_TYPES", + PROJECTS = "PROJECTS", + EPICS = "EPICS", +} + +export enum ChartYAxisMetric { + WORK_ITEM_COUNT = "WORK_ITEM_COUNT", + ESTIMATE_POINT_COUNT = "ESTIMATE_POINT_COUNT", + PENDING_WORK_ITEM_COUNT = "PENDING_WORK_ITEM_COUNT", + COMPLETED_WORK_ITEM_COUNT = "COMPLETED_WORK_ITEM_COUNT", + IN_PROGRESS_WORK_ITEM_COUNT = "IN_PROGRESS_WORK_ITEM_COUNT", + WORK_ITEM_DUE_THIS_WEEK_COUNT = "WORK_ITEM_DUE_THIS_WEEK_COUNT", + WORK_ITEM_DUE_TODAY_COUNT = "WORK_ITEM_DUE_TODAY_COUNT", + BLOCKED_WORK_ITEM_COUNT = "BLOCKED_WORK_ITEM_COUNT", +} + + +export enum ChartXAxisDateGrouping { + DAY = "DAY", + WEEK = "WEEK", + MONTH = "MONTH", + YEAR = "YEAR", +} + +export const TO_CAPITALIZE_PROPERTIES: ChartXAxisProperty[] = [ + ChartXAxisProperty.PRIORITY, + ChartXAxisProperty.STATE_GROUPS, +]; + +export const CHART_X_AXIS_DATE_PROPERTIES: ChartXAxisProperty[] = [ + ChartXAxisProperty.START_DATE, + ChartXAxisProperty.TARGET_DATE, + ChartXAxisProperty.CREATED_AT, + ChartXAxisProperty.COMPLETED_AT, +]; + + +export enum EChartModels { + BASIC = "BASIC", + STACKED = "STACKED", + GROUPED = "GROUPED", + MULTI_LINE = "MULTI_LINE", + COMPARISON = "COMPARISON", + PROGRESS = "PROGRESS", +} + +export const CHART_COLOR_PALETTES: { + key: TChartColorScheme; + i18n_label: string; + light: string[]; + dark: string[]; +}[] = [ + { + key: "modern", + i18n_label: "dashboards.widget.color_palettes.modern", + light: [ + "#6172E8", + "#8B6EDB", + "#E05F99", + "#29A383", + "#CB8A37", + "#3AA7C1", + "#F1B24A", + "#E84855", + "#50C799", + "#B35F9E", + ], + dark: [ + "#6B7CDE", + "#8E9DE6", + "#D45D9E", + "#2EAF85", + "#D4A246", + "#29A7C1", + "#B89F6A", + "#D15D64", + "#4ED079", + "#A169A4", + ], + }, + { + key: "horizon", + i18n_label: "dashboards.widget.color_palettes.horizon", + light: [ + "#E76E50", + "#289D90", + "#F3A362", + "#E9C368", + "#264753", + "#8A6FA0", + "#5B9EE5", + "#7CC474", + "#BA7DB5", + "#CF8640", + ], + dark: [ + "#E05A3A", + "#1D8A7E", + "#D98B4D", + "#D1AC50", + "#3A6B7C", + "#7D6297", + "#4D8ACD", + "#569C64", + "#C16A8C", + "#B77436", + ], + }, + { + key: "earthen", + i18n_label: "dashboards.widget.color_palettes.earthen", + light: [ + "#386641", + "#6A994E", + "#A7C957", + "#E97F4E", + "#BC4749", + "#9E2A2B", + "#80CED1", + "#5C3E79", + "#526EAB", + "#6B5B95", + ], + dark: [ + "#497752", + "#7BAA5F", + "#B8DA68", + "#FA905F", + "#CD585A", + "#AF3B3C", + "#91DFE2", + "#6D4F8A", + "#637FBC", + "#7C6CA6", + ], + }, + ]; diff --git a/packages/constants/src/endpoints.ts b/packages/constants/src/endpoints.ts index cd1c08d7a..3f7a4eeee 100644 --- a/packages/constants/src/endpoints.ts +++ b/packages/constants/src/endpoints.ts @@ -1,28 +1,26 @@ export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || ""; -export const API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH || "/"; +export const API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH || ""; export const API_URL = encodeURI(`${API_BASE_URL}${API_BASE_PATH}`); // God Mode Admin App Base Url export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || ""; -export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "/"; +export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || ""; export const GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${ADMIN_BASE_PATH}`); // Publish App Base Url export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || ""; -export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "/"; +export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || ""; export const SITES_URL = encodeURI(`${SPACE_BASE_URL}${SPACE_BASE_PATH}`); // Live App Base Url export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || ""; -export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "/"; +export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || ""; export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}`); // Web App Base Url export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || ""; -export const WEB_BASE_PATH = process.env.NEXT_PUBLIC_WEB_BASE_PATH || "/"; +export const WEB_BASE_PATH = process.env.NEXT_PUBLIC_WEB_BASE_PATH || ""; export const WEB_URL = encodeURI(`${WEB_BASE_URL}${WEB_BASE_PATH}`); // plane website url -export const WEBSITE_URL = - process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so"; +export const WEBSITE_URL = process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so"; // support email -export const SUPPORT_EMAIL = - process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@plane.so"; +export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@plane.so"; // marketing links export const MARKETING_PRICING_PAGE_LINK = "https://plane.so/pricing"; export const MARKETING_CONTACT_US_PAGE_LINK = "https://plane.so/contact"; diff --git a/web/ce/constants/estimates.ts b/packages/constants/src/estimates.ts similarity index 99% rename from web/ce/constants/estimates.ts rename to packages/constants/src/estimates.ts index 2cba8ac83..34e04e562 100644 --- a/web/ce/constants/estimates.ts +++ b/packages/constants/src/estimates.ts @@ -1,4 +1,4 @@ -// types +// plane imports import { TEstimateSystems } from "@plane/types"; export const MAX_ESTIMATE_POINT_INPUT_LENGTH = 20; diff --git a/packages/constants/src/event-tracker.ts b/packages/constants/src/event-tracker.ts deleted file mode 100644 index 4fb1ea15c..000000000 --- a/packages/constants/src/event-tracker.ts +++ /dev/null @@ -1,238 +0,0 @@ -export type IssueEventProps = { - eventName: string; - payload: any; - updates?: any; - path?: string; -}; - -export type EventProps = { - eventName: string; - payload: any; - updates?: any; - path?: string; -}; - -export const getWorkspaceEventPayload = (payload: any) => ({ - workspace_id: payload.id, - created_at: payload.created_at, - updated_at: payload.updated_at, - organization_size: payload.organization_size, - first_time: payload.first_time, - state: payload.state, - element: payload.element, -}); - -export const getProjectEventPayload = (payload: any) => ({ - workspace_id: payload.workspace_id, - project_id: payload.id, - identifier: payload.identifier, - project_visibility: payload.network == 2 ? "Public" : "Private", - changed_properties: payload.changed_properties, - lead_id: payload.project_lead, - created_at: payload.created_at, - updated_at: payload.updated_at, - state: payload.state, - element: payload.element, -}); - -export const getCycleEventPayload = (payload: any) => ({ - workspace_id: payload.workspace_id, - project_id: payload.project, - cycle_id: payload.id, - created_at: payload.created_at, - updated_at: payload.updated_at, - start_date: payload.start_date, - target_date: payload.target_date, - cycle_status: payload.status, - changed_properties: payload.changed_properties, - state: payload.state, - element: payload.element, -}); - -export const getModuleEventPayload = (payload: any) => ({ - workspace_id: payload.workspace_id, - project_id: payload.project, - module_id: payload.id, - created_at: payload.created_at, - updated_at: payload.updated_at, - start_date: payload.start_date, - target_date: payload.target_date, - module_status: payload.status, - lead_id: payload.lead, - changed_properties: payload.changed_properties, - member_ids: payload.members, - state: payload.state, - element: payload.element, -}); - -export const getPageEventPayload = (payload: any) => ({ - workspace_id: payload.workspace_id, - project_id: payload.project, - created_at: payload.created_at, - updated_at: payload.updated_at, - access: payload.access === 0 ? "Public" : "Private", - is_locked: payload.is_locked, - archived_at: payload.archived_at, - created_by: payload.created_by, - state: payload.state, - element: payload.element, -}); - -export const getIssueEventPayload = (props: IssueEventProps) => { - const { eventName, payload, updates, path } = props; - let eventPayload: any = { - issue_id: payload.id, - estimate_point: payload.estimate_point, - link_count: payload.link_count, - target_date: payload.target_date, - is_draft: payload.is_draft, - label_ids: payload.label_ids, - assignee_ids: payload.assignee_ids, - created_at: payload.created_at, - updated_at: payload.updated_at, - sequence_id: payload.sequence_id, - module_ids: payload.module_ids, - sub_issues_count: payload.sub_issues_count, - parent_id: payload.parent_id, - project_id: payload.project_id, - workspace_id: payload.workspace_id, - priority: payload.priority, - state_id: payload.state_id, - start_date: payload.start_date, - attachment_count: payload.attachment_count, - cycle_id: payload.cycle_id, - module_id: payload.module_id, - archived_at: payload.archived_at, - state: payload.state, - view_id: - path?.includes("workspace-views") || path?.includes("views") - ? path.split("/").pop() - : "", - }; - - if (eventName === ISSUE_UPDATED) { - eventPayload = { - ...eventPayload, - ...updates, - updated_from: props.path?.includes("workspace-views") - ? "All views" - : props.path?.includes("cycles") - ? "Cycle" - : props.path?.includes("modules") - ? "Module" - : props.path?.includes("views") - ? "Project view" - : props.path?.includes("inbox") - ? "Inbox" - : props.path?.includes("draft") - ? "Draft" - : "Project", - }; - } - return eventPayload; -}; - -export const getProjectStateEventPayload = (payload: any) => ({ - workspace_id: payload.workspace_id, - project_id: payload.id, - state_id: payload.id, - created_at: payload.created_at, - updated_at: payload.updated_at, - group: payload.group, - color: payload.color, - default: payload.default, - state: payload.state, - element: payload.element, -}); - -// Workspace crud Events -export const WORKSPACE_CREATED = "Workspace created"; -export const WORKSPACE_UPDATED = "Workspace updated"; -export const WORKSPACE_DELETED = "Workspace deleted"; -// Project Events -export const PROJECT_CREATED = "Project created"; -export const PROJECT_UPDATED = "Project updated"; -export const PROJECT_DELETED = "Project deleted"; -// Cycle Events -export const CYCLE_CREATED = "Cycle created"; -export const CYCLE_UPDATED = "Cycle updated"; -export const CYCLE_DELETED = "Cycle deleted"; -export const CYCLE_FAVORITED = "Cycle favorited"; -export const CYCLE_UNFAVORITED = "Cycle unfavorited"; -// Module Events -export const MODULE_CREATED = "Module created"; -export const MODULE_UPDATED = "Module updated"; -export const MODULE_DELETED = "Module deleted"; -export const MODULE_FAVORITED = "Module favorited"; -export const MODULE_UNFAVORITED = "Module unfavorited"; -export const MODULE_LINK_CREATED = "Module link created"; -export const MODULE_LINK_UPDATED = "Module link updated"; -export const MODULE_LINK_DELETED = "Module link deleted"; -// Issue Events -export const ISSUE_CREATED = "Work item created"; -export const ISSUE_UPDATED = "Work item updated"; -export const ISSUE_DELETED = "Work item deleted"; -export const ISSUE_ARCHIVED = "Work item archived"; -export const ISSUE_RESTORED = "Work item restored"; -export const ISSUE_OPENED = "Work item opened"; -// Project State Events -export const STATE_CREATED = "State created"; -export const STATE_UPDATED = "State updated"; -export const STATE_DELETED = "State deleted"; -// Project Page Events -export const PAGE_CREATED = "Page created"; -export const PAGE_UPDATED = "Page updated"; -export const PAGE_DELETED = "Page deleted"; -// Member Events -export const MEMBER_INVITED = "Member invited"; -export const MEMBER_ACCEPTED = "Member accepted"; -export const PROJECT_MEMBER_ADDED = "Project member added"; -export const PROJECT_MEMBER_LEAVE = "Project member leave"; -export const WORKSPACE_MEMBER_LEAVE = "Workspace member leave"; -// Sign-in & Sign-up Events -export const NAVIGATE_TO_SIGNUP = "Navigate to sign-up page"; -export const NAVIGATE_TO_SIGNIN = "Navigate to sign-in page"; -export const CODE_VERIFIED = "Code verified"; -export const SETUP_PASSWORD = "Password setup"; -export const PASSWORD_CREATE_SELECTED = "Password created"; -export const PASSWORD_CREATE_SKIPPED = "Skipped to setup"; -export const SIGN_IN_WITH_PASSWORD = "Sign in with password"; -export const SIGN_UP_WITH_PASSWORD = "Sign up with password"; -export const SIGN_IN_WITH_CODE = "Sign in with magic link"; -export const FORGOT_PASSWORD = "Forgot password clicked"; -export const FORGOT_PASS_LINK = "Forgot password link generated"; -export const NEW_PASS_CREATED = "New password created"; -// Onboarding Events -export const USER_DETAILS = "User details added"; -export const USER_ONBOARDING_COMPLETED = "User onboarding completed"; -// Product Tour Events -export const PRODUCT_TOUR_STARTED = "Product tour started"; -export const PRODUCT_TOUR_COMPLETED = "Product tour completed"; -export const PRODUCT_TOUR_SKIPPED = "Product tour skipped"; -// Dashboard Events -export const CHANGELOG_REDIRECTED = "Changelog redirected"; -export const GITHUB_REDIRECTED = "GitHub redirected"; -// Sidebar Events -export const SIDEBAR_CLICKED = "Sidenav clicked"; -// Global View Events -export const GLOBAL_VIEW_CREATED = "Global view created"; -export const GLOBAL_VIEW_UPDATED = "Global view updated"; -export const GLOBAL_VIEW_DELETED = "Global view deleted"; -export const GLOBAL_VIEW_OPENED = "Global view opened"; -// Notification Events -export const NOTIFICATION_ARCHIVED = "Notification archived"; -export const NOTIFICATION_SNOOZED = "Notification snoozed"; -export const NOTIFICATION_READ = "Notification marked read"; -export const UNREAD_NOTIFICATIONS = "Unread notifications viewed"; -export const NOTIFICATIONS_READ = "All notifications marked read"; -export const SNOOZED_NOTIFICATIONS = "Snoozed notifications viewed"; -export const ARCHIVED_NOTIFICATIONS = "Archived notifications viewed"; -// Groups -export const GROUP_WORKSPACE = "Workspace_metrics"; - -//Elements -export const E_ONBOARDING = "Onboarding"; -export const E_ONBOARDING_STEP_1 = "Onboarding step 1"; -export const E_ONBOARDING_STEP_2 = "Onboarding step 2"; -// Favorites -export const FAVORITE_ADDED = "Favorite added"; diff --git a/packages/constants/src/event-tracker/core.ts b/packages/constants/src/event-tracker/core.ts new file mode 100644 index 000000000..85f2ea6e2 --- /dev/null +++ b/packages/constants/src/event-tracker/core.ts @@ -0,0 +1,258 @@ +export type IssueEventProps = { + eventName: string; + payload: any; + updates?: any; + path?: string; +}; + +export type EventProps = { + eventName: string; + payload: any; + updates?: any; + path?: string; +}; + +export const getWorkspaceEventPayload = (payload: any) => ({ + workspace_id: payload.id, + created_at: payload.created_at, + updated_at: payload.updated_at, + organization_size: payload.organization_size, + first_time: payload.first_time, + state: payload.state, + element: payload.element, +}); + +export const getProjectEventPayload = (payload: any) => ({ + workspace_id: payload.workspace_id, + project_id: payload.id, + identifier: payload.identifier, + project_visibility: payload.network == 2 ? "Public" : "Private", + changed_properties: payload.changed_properties, + lead_id: payload.project_lead, + created_at: payload.created_at, + updated_at: payload.updated_at, + state: payload.state, + element: payload.element, +}); + +export const getCycleEventPayload = (payload: any) => ({ + workspace_id: payload.workspace_id, + project_id: payload.project, + cycle_id: payload.id, + created_at: payload.created_at, + updated_at: payload.updated_at, + start_date: payload.start_date, + target_date: payload.target_date, + cycle_status: payload.status, + changed_properties: payload.changed_properties, + state: payload.state, + element: payload.element, +}); + +export const getModuleEventPayload = (payload: any) => ({ + workspace_id: payload.workspace_id, + project_id: payload.project, + module_id: payload.id, + created_at: payload.created_at, + updated_at: payload.updated_at, + start_date: payload.start_date, + target_date: payload.target_date, + module_status: payload.status, + lead_id: payload.lead, + changed_properties: payload.changed_properties, + member_ids: payload.members, + state: payload.state, + element: payload.element, +}); + +export const getPageEventPayload = (payload: any) => ({ + workspace_id: payload.workspace_id, + project_id: payload.project, + created_at: payload.created_at, + updated_at: payload.updated_at, + access: payload.access === 0 ? "Public" : "Private", + is_locked: payload.is_locked, + archived_at: payload.archived_at, + created_by: payload.created_by, + state: payload.state, + element: payload.element, +}); + +export const getIssueEventPayload = (props: IssueEventProps) => { + const { eventName, payload, updates, path } = props; + let eventPayload: any = { + issue_id: payload.id, + estimate_point: payload.estimate_point, + link_count: payload.link_count, + target_date: payload.target_date, + is_draft: payload.is_draft, + label_ids: payload.label_ids, + assignee_ids: payload.assignee_ids, + created_at: payload.created_at, + updated_at: payload.updated_at, + sequence_id: payload.sequence_id, + module_ids: payload.module_ids, + sub_issues_count: payload.sub_issues_count, + parent_id: payload.parent_id, + project_id: payload.project_id, + workspace_id: payload.workspace_id, + priority: payload.priority, + state_id: payload.state_id, + start_date: payload.start_date, + attachment_count: payload.attachment_count, + cycle_id: payload.cycle_id, + module_id: payload.module_id, + archived_at: payload.archived_at, + state: payload.state, + view_id: path?.includes("workspace-views") || path?.includes("views") ? path.split("/").pop() : "", + }; + + if (eventName === WORK_ITEM_TRACKER_EVENTS.update) { + eventPayload = { + ...eventPayload, + ...updates, + updated_from: props.path?.includes("workspace-views") + ? "All views" + : props.path?.includes("cycles") + ? "Cycle" + : props.path?.includes("modules") + ? "Module" + : props.path?.includes("views") + ? "Project view" + : props.path?.includes("inbox") + ? "Inbox" + : props.path?.includes("draft") + ? "Draft" + : "Project", + }; + } + return eventPayload; +}; + +export const getProjectStateEventPayload = (payload: any) => ({ + workspace_id: payload.workspace_id, + project_id: payload.id, + state_id: payload.id, + created_at: payload.created_at, + updated_at: payload.updated_at, + group: payload.group, + color: payload.color, + default: payload.default, + state: payload.state, + element: payload.element, +}); + +// Dashboard Events +export const GITHUB_REDIRECTED_TRACKER_EVENT = "github_redirected"; +// Groups +export const GROUP_WORKSPACE_TRACKER_EVENT = "workspace_metrics"; + +export const WORKSPACE_TRACKER_EVENTS = { + create: "workspace_created", + update: "workspace_updated", + delete: "workspace_deleted", +}; + +export const PROJECT_TRACKER_EVENTS = { + create: "project_created", + update: "project_updated", + delete: "project_deleted", +}; + +export const CYCLE_TRACKER_EVENTS = { + create: "cycle_created", + update: "cycle_updated", + delete: "cycle_deleted", + favorite: "cycle_favorited", + unfavorite: "cycle_unfavorited", +}; + +export const MODULE_TRACKER_EVENTS = { + create: "module_created", + update: "module_updated", + delete: "module_deleted", + favorite: "module_favorited", + unfavorite: "module_unfavorited", + link: { + create: "module_link_created", + update: "module_link_updated", + delete: "module_link_deleted", + }, +}; + +export const WORK_ITEM_TRACKER_EVENTS = { + create: "work_item_created", + update: "work_item_updated", + delete: "work_item_deleted", + archive: "work_item_archived", + restore: "work_item_restored", +}; + +export const STATE_TRACKER_EVENTS = { + create: "state_created", + update: "state_updated", + delete: "state_deleted", +}; + +export const PROJECT_PAGE_TRACKER_EVENTS = { + create: "project_page_created", + update: "project_page_updated", + delete: "project_page_deleted", +}; + +export const MEMBER_TRACKER_EVENTS = { + invite: "member_invited", + accept: "member_accepted", + project: { + add: "project_member_added", + leave: "project_member_left", + }, + workspace: { + leave: "workspace_member_left", + }, +}; + +export const AUTH_TRACKER_EVENTS = { + navigate: { + sign_up: "navigate_to_sign_up_page", + sign_in: "navigate_to_sign_in_page", + }, + code_verify: "code_verified", + sign_up_with_password: "sign_up_with_password", + sign_in_with_password: "sign_in_with_password", + sign_in_with_code: "sign_in_with_magic_link", + forgot_password: "forgot_password_clicked", +}; + +export const PRODUCT_TOUR_TRACKER_EVENTS = { + start: "product_tour_started", + complete: "product_tour_completed", + skip: "product_tour_skipped", +}; + +export const GLOBAL_VIEW_TOUR_TRACKER_EVENTS = { + create: "global_view_created", + update: "global_view_updated", + delete: "global_view_deleted", + open: "global_view_opened", +}; + +export const NOTIFICATION_TRACKER_EVENTS = { + archive: "notification_archived", + all_marked_read: "all_notifications_marked_read", +}; + +export const USER_TRACKER_EVENTS = { + add_details: "user_details_added", + onboarding_complete: "user_onboarding_completed", +}; + +export const ONBOARDING_TRACKER_EVENTS = { + root: "onboarding", + step_1: "onboarding_step_1", + step_2: "onboarding_step_2", +}; + +export const SIDEBAR_TRACKER_EVENTS = { + click: "sidenav_clicked", +}; diff --git a/packages/constants/src/event-tracker/index.ts b/packages/constants/src/event-tracker/index.ts new file mode 100644 index 000000000..8d119dee8 --- /dev/null +++ b/packages/constants/src/event-tracker/index.ts @@ -0,0 +1 @@ +export * from "./core"; diff --git a/packages/constants/src/file.ts b/packages/constants/src/file.ts index 3fac821fa..9de3b0356 100644 --- a/packages/constants/src/file.ts +++ b/packages/constants/src/file.ts @@ -1 +1,14 @@ export const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + +export const ACCEPTED_AVATAR_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE = { + "image/jpeg": [], + "image/jpg": [], + "image/png": [], + "image/webp": [], +}; +export const ACCEPTED_COVER_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE = { + "image/jpeg": [], + "image/jpg": [], + "image/png": [], + "image/webp": [], +}; diff --git a/packages/constants/src/icon.ts b/packages/constants/src/icon.ts new file mode 100644 index 000000000..3ee66e31e --- /dev/null +++ b/packages/constants/src/icon.ts @@ -0,0 +1,7 @@ +export enum EIconSize { + XS = "xs", + SM = "sm", + MD = "md", + LG = "lg", + XL = "xl", +} diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index f974dd64b..d7ccebd31 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -1,5 +1,4 @@ export * from "./ai"; -export * from "./analytics"; export * from "./auth"; export * from "./chart"; export * from "./endpoints"; @@ -22,7 +21,7 @@ export * from "./module"; export * from "./project"; export * from "./views"; export * from "./themes"; -export * from "./inbox"; +export * from "./intake"; export * from "./profile"; export * from "./workspace-drafts"; export * from "./label"; @@ -32,3 +31,7 @@ export * from "./dashboard"; export * from "./page"; export * from "./emoji"; export * from "./subscription"; +export * from "./settings"; +export * from "./icon"; +export * from "./estimates"; +export * from "./analytics"; diff --git a/packages/constants/src/inbox.ts b/packages/constants/src/intake.ts similarity index 81% rename from packages/constants/src/inbox.ts rename to packages/constants/src/intake.ts index 2d94c1f04..be8c6ffe3 100644 --- a/packages/constants/src/inbox.ts +++ b/packages/constants/src/intake.ts @@ -95,3 +95,32 @@ export const INBOX_ISSUE_SORT_BY_OPTIONS = [ i18n_label: "common.sort.desc", }, ]; + +export enum EPastDurationFilters { + TODAY = "today", + YESTERDAY = "yesterday", + LAST_7_DAYS = "last_7_days", + LAST_30_DAYS = "last_30_days", +} + +export const PAST_DURATION_FILTER_OPTIONS: { + name: string; + value: string; +}[] = [ + { + name: "Today", + value: EPastDurationFilters.TODAY, + }, + { + name: "Yesterday", + value: EPastDurationFilters.YESTERDAY, + }, + { + name: "Last 7 days", + value: EPastDurationFilters.LAST_7_DAYS, + }, + { + name: "Last 30 days", + value: EPastDurationFilters.LAST_30_DAYS, + }, +]; diff --git a/packages/constants/src/issue/common.ts b/packages/constants/src/issue/common.ts index 03634337a..c182cb17e 100644 --- a/packages/constants/src/issue/common.ts +++ b/packages/constants/src/issue/common.ts @@ -1,4 +1,10 @@ -import { TIssueGroupByOptions, TIssueOrderByOptions, IIssueDisplayProperties } from "@plane/types"; +import { + TIssueGroupByOptions, + TIssueOrderByOptions, + IIssueDisplayProperties, + IIssueFilterOptions, + TIssue, +} from "@plane/types"; export const ALL_ISSUES = "All Issues"; @@ -165,6 +171,15 @@ export const ISSUE_DISPLAY_PROPERTIES_KEYS: (keyof IIssueDisplayProperties)[] = "issue_type", ]; +export const SUB_ISSUES_DISPLAY_PROPERTIES_KEYS: (keyof IIssueDisplayProperties)[] = [ + "key", + "assignee", + "start_date", + "due_date", + "priority", + "state", +]; + export const ISSUE_DISPLAY_PROPERTIES: { key: keyof IIssueDisplayProperties; titleTranslationKey: string; @@ -352,3 +367,17 @@ export const SPREADSHEET_PROPERTY_DETAILS: { icon: "LayersIcon", }, }; + +// Map filter keys to their corresponding issue property keys +export const FILTER_TO_ISSUE_MAP: Partial> = { + assignees: "assignee_ids", + created_by: "created_by", + labels: "label_ids", + priority: "priority", + cycle: "cycle_id", + module: "module_ids", + project: "project_id", + state: "state_id", + issue_type: "type_id", + state_group: "state__group", +} as const; diff --git a/packages/constants/src/issue/filter.ts b/packages/constants/src/issue/filter.ts index 687a2bd71..46275d751 100644 --- a/packages/constants/src/issue/filter.ts +++ b/packages/constants/src/issue/filter.ts @@ -1,11 +1,9 @@ -import { - ILayoutDisplayFiltersOptions, - TIssueActivityComment, -} from "@plane/types"; +import { ILayoutDisplayFiltersOptions, TIssueActivityComment } from "@plane/types"; import { TIssueFilterPriorityObject, ISSUE_DISPLAY_PROPERTIES_KEYS, EIssuesStoreType, + SUB_ISSUES_DISPLAY_PROPERTIES_KEYS, } from "./common"; import { TIssueLayout } from "./layout"; @@ -96,23 +94,11 @@ export type TIssueFiltersToDisplayByPageType = { export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = { profile_issues: { list: { - filters: [ - "priority", - "state_group", - "labels", - "start_date", - "target_date", - ], + filters: ["priority", "state_group", "labels", "start_date", "target_date"], display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, display_filters: { group_by: ["state_detail.group", "priority", "project", "labels", null], - order_by: [ - "sort_order", - "-created_at", - "-updated_at", - "start_date", - "-priority", - ], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -121,23 +107,11 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = { }, }, kanban: { - filters: [ - "priority", - "state_group", - "labels", - "start_date", - "target_date", - ], + filters: ["priority", "state_group", "labels", "start_date", "target_date"], display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, display_filters: { group_by: ["state_detail.group", "priority", "project", "labels"], - order_by: [ - "sort_order", - "-created_at", - "-updated_at", - "start_date", - "-priority", - ], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -162,97 +136,8 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = { ], display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, display_filters: { - group_by: [ - "state", - "cycle", - "module", - "state_detail.group", - "priority", - "labels", - "assignees", - "created_by", - null, - ], - order_by: [ - "sort_order", - "-created_at", - "-updated_at", - "start_date", - "-priority", - ], - type: [null, "active", "backlog"], - }, - extra_options: { - access: true, - values: ["show_empty_groups"], - }, - }, - }, - draft_issues: { - list: { - filters: [ - "priority", - "state_group", - "cycle", - "module", - "labels", - "start_date", - "target_date", - "issue_type", - ], - display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, - display_filters: { - group_by: [ - "state_detail.group", - "cycle", - "module", - "priority", - "project", - "labels", - null, - ], - order_by: [ - "sort_order", - "-created_at", - "-updated_at", - "start_date", - "-priority", - ], - type: [null, "active", "backlog"], - }, - extra_options: { - access: true, - values: ["show_empty_groups"], - }, - }, - kanban: { - filters: [ - "priority", - "state_group", - "cycle", - "module", - "labels", - "start_date", - "target_date", - "issue_type", - ], - display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, - display_filters: { - group_by: [ - "state_detail.group", - "cycle", - "module", - "priority", - "project", - "labels", - ], - order_by: [ - "sort_order", - "-created_at", - "-updated_at", - "start_date", - "-priority", - ], + group_by: ["state", "cycle", "module", "priority", "labels", "assignees", "created_by", null], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -323,24 +208,8 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = { ], display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, display_filters: { - group_by: [ - "state", - "priority", - "cycle", - "module", - "labels", - "assignees", - "created_by", - null, - ], - order_by: [ - "sort_order", - "-created_at", - "-updated_at", - "start_date", - "-priority", - "target_date", - ], + group_by: ["state", "priority", "cycle", "module", "labels", "assignees", "created_by", null], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority", "target_date"], type: [null, "active", "backlog"], }, extra_options: { @@ -364,33 +233,9 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = { ], display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, display_filters: { - group_by: [ - "state", - "priority", - "cycle", - "module", - "labels", - "assignees", - "created_by", - ], - sub_group_by: [ - "state", - "priority", - "cycle", - "module", - "labels", - "assignees", - "created_by", - null, - ], - order_by: [ - "sort_order", - "-created_at", - "-updated_at", - "start_date", - "-priority", - "target_date", - ], + group_by: ["state", "priority", "cycle", "module", "labels", "assignees", "created_by"], + sub_group_by: ["state", "priority", "cycle", "module", "labels", "assignees", "created_by", null], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority", "target_date"], type: [null, "active", "backlog"], }, extra_options: { @@ -436,13 +281,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = { ], display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, display_filters: { - order_by: [ - "sort_order", - "-created_at", - "-updated_at", - "start_date", - "-priority", - ], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -466,13 +305,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = { ], display_properties: ["key", "issue_type"], display_filters: { - order_by: [ - "sort_order", - "-created_at", - "-updated_at", - "start_date", - "-priority", - ], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -481,11 +314,23 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = { }, }, }, + sub_work_items: { + list: { + display_properties: SUB_ISSUES_DISPLAY_PROPERTIES_KEYS, + filters: ["priority", "state", "issue_type", "assignees", "start_date", "target_date"], + display_filters: { + order_by: ["-created_at", "-updated_at", "start_date", "-priority"], + group_by: ["state", "priority", "assignees", null], + }, + extra_options: { + access: true, + values: ["sub_issue"], + }, + }, + }, }; -export const ISSUE_STORE_TO_FILTERS_MAP: Partial< - Record -> = { +export const ISSUE_STORE_TO_FILTERS_MAP: Partial> = { [EIssuesStoreType.PROJECT]: ISSUE_DISPLAY_FILTERS_BY_PAGE.issues, }; @@ -496,10 +341,7 @@ export enum EActivityFilterType { export type TActivityFilters = EActivityFilterType; -export const ACTIVITY_FILTER_TYPE_OPTIONS: Record< - TActivityFilters, - { labelTranslationKey: string } -> = { +export const ACTIVITY_FILTER_TYPE_OPTIONS: Record = { [EActivityFilterType.ACTIVITY]: { labelTranslationKey: "common.updates", }, @@ -515,17 +357,12 @@ export type TActivityFilterOption = { onClick: () => void; }; -export const defaultActivityFilters: TActivityFilters[] = [ - EActivityFilterType.ACTIVITY, - EActivityFilterType.COMMENT, -]; +export const defaultActivityFilters: TActivityFilters[] = [EActivityFilterType.ACTIVITY, EActivityFilterType.COMMENT]; export const filterActivityOnSelectedFilters = ( activity: TIssueActivityComment[], filters: TActivityFilters[] ): TIssueActivityComment[] => - activity.filter((activity) => - filters.includes(activity.activity_type as TActivityFilters) - ); + activity.filter((activity) => filters.includes(activity.activity_type as TActivityFilters)); export const ENABLE_ISSUE_DEPENDENCIES = false; diff --git a/packages/constants/src/module.ts b/packages/constants/src/module.ts index 6ce30f0dc..16a332303 100644 --- a/packages/constants/src/module.ts +++ b/packages/constants/src/module.ts @@ -1,9 +1,16 @@ // types -import { - TModuleLayoutOptions, - TModuleOrderByOptions, - TModuleStatus, -} from "@plane/types"; +import { TModuleLayoutOptions, TModuleOrderByOptions, TModuleStatus } from "@plane/types"; + +export const MODULE_STATUS_COLORS: { + [key in TModuleStatus]: string; +} = { + backlog: "#a3a3a2", + planned: "#3f76ff", + paused: "#525252", + completed: "#16a34a", + cancelled: "#ef4444", + "in-progress": "#f39e1f", +}; export const MODULE_STATUS: { i18n_label: string; @@ -15,42 +22,42 @@ export const MODULE_STATUS: { { i18n_label: "project_modules.status.backlog", value: "backlog", - color: "#a3a3a2", + color: MODULE_STATUS_COLORS.backlog, textColor: "text-custom-text-400", bgColor: "bg-custom-background-80", }, { i18n_label: "project_modules.status.planned", value: "planned", - color: "#3f76ff", + color: MODULE_STATUS_COLORS.planned, textColor: "text-blue-500", bgColor: "bg-indigo-50", }, { i18n_label: "project_modules.status.in_progress", value: "in-progress", - color: "#f39e1f", + color: MODULE_STATUS_COLORS["in-progress"], textColor: "text-amber-500", bgColor: "bg-amber-50", }, { i18n_label: "project_modules.status.paused", value: "paused", - color: "#525252", + color: MODULE_STATUS_COLORS.paused, textColor: "text-custom-text-300", bgColor: "bg-custom-background-90", }, { i18n_label: "project_modules.status.completed", value: "completed", - color: "#16a34a", + color: MODULE_STATUS_COLORS.completed, textColor: "text-green-600", bgColor: "bg-green-100", }, { i18n_label: "project_modules.status.cancelled", value: "cancelled", - color: "#ef4444", + color: MODULE_STATUS_COLORS.cancelled, textColor: "text-red-500", bgColor: "bg-red-50", }, diff --git a/packages/constants/src/payment.ts b/packages/constants/src/payment.ts index 6c82b0e30..6fc554a5b 100644 --- a/packages/constants/src/payment.ts +++ b/packages/constants/src/payment.ts @@ -72,23 +72,23 @@ export const PLANE_COMMUNITY_PRODUCTS: Record = { prices: [ { id: `price_yearly_${EProductSubscriptionEnum.BUSINESS}`, - unit_amount: 0, + unit_amount: 15600, recurring: "year", currency: "usd", - workspace_amount: 0, + workspace_amount: 15600, product: EProductSubscriptionEnum.BUSINESS, }, { id: `price_monthly_${EProductSubscriptionEnum.BUSINESS}`, - unit_amount: 0, + unit_amount: 1500, recurring: "month", currency: "usd", - workspace_amount: 0, + workspace_amount: 1500, product: EProductSubscriptionEnum.BUSINESS, }, ], payment_quantity: 1, - is_active: false, + is_active: true, }, [EProductSubscriptionEnum.ENTERPRISE]: { id: EProductSubscriptionEnum.ENTERPRISE, @@ -141,8 +141,8 @@ export const SUBSCRIPTION_REDIRECTION_URLS: Record pathname === "/settings/account/", + }, + security: { + key: "security", + i18n_label: "profile.actions.security", + href: `/settings/account/security`, + highlight: (pathname: string) => pathname === "/settings/account/security/", + }, + activity: { + key: "activity", + i18n_label: "profile.actions.activity", + href: `/settings/account/activity`, + highlight: (pathname: string) => pathname === "/settings/account/activity/", + }, + preferences: { + key: "preferences", + i18n_label: "profile.actions.preferences", + href: `/settings/account/preferences`, + highlight: (pathname: string) => pathname === "/settings/account/preferences", + }, + notifications: { + key: "notifications", + i18n_label: "profile.actions.notifications", + href: `/settings/account/notifications`, + highlight: (pathname: string) => pathname === "/settings/account/notifications/", + }, + "api-tokens": { + key: "api-tokens", + i18n_label: "profile.actions.api-tokens", + href: `/settings/account/api-tokens`, + highlight: (pathname: string) => pathname === "/settings/account/api-tokens/", + }, +}; export const PROFILE_ACTION_LINKS: { key: string; i18n_label: string; href: string; highlight: (pathname: string) => boolean; }[] = [ - { - key: "profile", - i18n_label: "profile.actions.profile", - href: `/profile`, - highlight: (pathname: string) => pathname === "/profile/", - }, - { - key: "security", - i18n_label: "profile.actions.security", - href: `/profile/security`, - highlight: (pathname: string) => pathname === "/profile/security/", - }, - { - key: "activity", - i18n_label: "profile.actions.activity", - href: `/profile/activity`, - highlight: (pathname: string) => pathname === "/profile/activity/", - }, - { - key: "appearance", - i18n_label: "profile.actions.appearance", - href: `/profile/appearance`, - highlight: (pathname: string) => pathname.includes("/profile/appearance"), - }, - { - key: "notifications", - i18n_label: "profile.actions.notifications", - href: `/profile/notifications`, - highlight: (pathname: string) => pathname === "/profile/notifications/", - }, + PROFILE_SETTINGS["profile"], + PROFILE_SETTINGS["security"], + PROFILE_SETTINGS["activity"], + PROFILE_SETTINGS["preferences"], + PROFILE_SETTINGS["notifications"], + PROFILE_SETTINGS["api-tokens"], ]; export const PROFILE_VIEWER_TAB = [ @@ -71,3 +85,70 @@ export const PROFILE_ADMINS_TAB = [ selected: "/activity/", }, ]; + +export const PREFERENCE_OPTIONS: { + id: string; + title: string; + description: string; +}[] = [ + { + id: "theme", + title: "theme", + description: "select_or_customize_your_interface_color_scheme", + }, + { + id: "start_of_week", + title: "First day of the week", + description: "This will change how all calendars in your app look.", + }, +]; + +/** + * @description The start of the week for the user + * @enum {number} + */ +export enum EStartOfTheWeek { + SUNDAY = 0, + MONDAY = 1, + TUESDAY = 2, + WEDNESDAY = 3, + THURSDAY = 4, + FRIDAY = 5, + SATURDAY = 6, +} + +/** + * @description The options for the start of the week + * @type {Array<{value: EStartOfTheWeek, label: string}>} + * @constant + */ +export const START_OF_THE_WEEK_OPTIONS = [ + { + value: EStartOfTheWeek.SUNDAY, + label: "Sunday", + }, + { + value: EStartOfTheWeek.MONDAY, + label: "Monday", + }, + { + value: EStartOfTheWeek.TUESDAY, + label: "Tuesday", + }, + { + value: EStartOfTheWeek.WEDNESDAY, + label: "Wednesday", + }, + { + value: EStartOfTheWeek.THURSDAY, + label: "Thursday", + }, + { + value: EStartOfTheWeek.FRIDAY, + label: "Friday", + }, + { + value: EStartOfTheWeek.SATURDAY, + label: "Saturday", + }, +]; diff --git a/packages/constants/src/project.ts b/packages/constants/src/project.ts index df22641e8..590dbb89e 100644 --- a/packages/constants/src/project.ts +++ b/packages/constants/src/project.ts @@ -149,3 +149,12 @@ export const DEFAULT_PROJECT_FORM_VALUES: Partial = { network: 2, project_lead: null, }; + +export enum EProjectFeatureKey { + WORK_ITEMS = "work_items", + CYCLES = "cycles", + MODULES = "modules", + VIEWS = "views", + PAGES = "pages", + INTAKE = "intake", +} diff --git a/packages/constants/src/settings.ts b/packages/constants/src/settings.ts new file mode 100644 index 000000000..f42374dc7 --- /dev/null +++ b/packages/constants/src/settings.ts @@ -0,0 +1,52 @@ +import { PROFILE_SETTINGS } from "."; +import { WORKSPACE_SETTINGS } from "./workspace"; + +export enum WORKSPACE_SETTINGS_CATEGORY { + ADMINISTRATION = "administration", + FEATURES = "features", + DEVELOPER = "developer", +} + +export enum PROFILE_SETTINGS_CATEGORY { + YOUR_PROFILE = "your profile", + DEVELOPER = "developer", +} + +export enum PROJECT_SETTINGS_CATEGORY { + PROJECTS = "projects", +} + +export const WORKSPACE_SETTINGS_CATEGORIES = [ + WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION, + WORKSPACE_SETTINGS_CATEGORY.FEATURES, + WORKSPACE_SETTINGS_CATEGORY.DEVELOPER, +]; + +export const PROFILE_SETTINGS_CATEGORIES = [ + PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE, + PROFILE_SETTINGS_CATEGORY.DEVELOPER, +]; + +export const PROJECT_SETTINGS_CATEGORIES = [PROJECT_SETTINGS_CATEGORY.PROJECTS]; + +export const GROUPED_WORKSPACE_SETTINGS = { + [WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION]: [ + WORKSPACE_SETTINGS["general"], + WORKSPACE_SETTINGS["members"], + WORKSPACE_SETTINGS["billing-and-plans"], + WORKSPACE_SETTINGS["export"], + ], + [WORKSPACE_SETTINGS_CATEGORY.FEATURES]: [], + [WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"]], +}; + +export const GROUPED_PROFILE_SETTINGS = { + [PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE]: [ + PROFILE_SETTINGS["profile"], + PROFILE_SETTINGS["preferences"], + PROFILE_SETTINGS["notifications"], + PROFILE_SETTINGS["security"], + PROFILE_SETTINGS["activity"], + ], + [PROFILE_SETTINGS_CATEGORY.DEVELOPER]: [PROFILE_SETTINGS["api-tokens"]], +}; diff --git a/packages/constants/src/state.ts b/packages/constants/src/state.ts index fa0f5d277..af7971023 100644 --- a/packages/constants/src/state.ts +++ b/packages/constants/src/state.ts @@ -1,3 +1,5 @@ +"use client" + export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled"; export type TDraggableData = { @@ -77,4 +79,5 @@ export const PROGRESS_STATE_GROUPS_DETAILS = [ }, ]; + export const DISPLAY_WORKFLOW_PRO_CTA = false; diff --git a/packages/constants/src/workspace.ts b/packages/constants/src/workspace.ts index c1c60f392..25c942dfc 100644 --- a/packages/constants/src/workspace.ts +++ b/packages/constants/src/workspace.ts @@ -114,13 +114,6 @@ export const WORKSPACE_SETTINGS = { access: [EUserWorkspaceRoles.ADMIN], highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`, }, - "api-tokens": { - key: "api-tokens", - i18n_label: "workspace_settings.settings.api_tokens.title", - href: `/settings/api-tokens`, - access: [EUserWorkspaceRoles.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens/`, - }, }; export const WORKSPACE_SETTINGS_ACCESS = Object.fromEntries( @@ -139,7 +132,6 @@ export const WORKSPACE_SETTINGS_LINKS: { WORKSPACE_SETTINGS["billing-and-plans"], WORKSPACE_SETTINGS["export"], WORKSPACE_SETTINGS["webhooks"], - WORKSPACE_SETTINGS["api-tokens"], ]; export const ROLE = { diff --git a/packages/decorators/package.json b/packages/decorators/package.json index 92c49b969..566e34827 100644 --- a/packages/decorators/package.json +++ b/packages/decorators/package.json @@ -17,18 +17,18 @@ "lint:errors": "eslint src --ext .ts,.tsx --quiet" }, "dependencies": { - "reflect-metadata": "^0.2.2", - "express": "^4.21.2" + "express": "^4.21.2", + "reflect-metadata": "^0.2.2" }, "devDependencies": { "@plane/eslint-config": "*", - "@types/express": "^4.17.21", - "@types/reflect-metadata": "^0.1.0", "@plane/typescript-config": "*", + "@types/express": "^4.17.21", "@types/node": "^20.14.9", + "@types/reflect-metadata": "^0.1.0", "@types/ws": "^8.5.10", "tsup": "8.4.0", - "typescript": "^5.3.3" + "typescript": "5.8.3" }, "peerDependencies": { "express": ">=4.21.2", diff --git a/packages/editor/package.json b/packages/editor/package.json index 7a14926e6..c511d554f 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor", - "version": "0.26.1", + "version": "0.27.0", "description": "Core Editor that powers Plane", "license": "AGPL-3.0", "private": true, @@ -81,8 +81,8 @@ "@types/react": "^18.3.11", "@types/react-dom": "^18.2.18", "postcss": "^8.4.38", - "tsup": "^8.4.0", - "typescript": "5.3.3" + "tsup": "8.4.0", + "typescript": "5.8.3" }, "keywords": [ "editor", diff --git a/packages/editor/src/ce/constants/utility.ts b/packages/editor/src/ce/constants/utility.ts new file mode 100644 index 000000000..616838a62 --- /dev/null +++ b/packages/editor/src/ce/constants/utility.ts @@ -0,0 +1,14 @@ +import { ExtensionFileSetStorageKey } from "@/plane-editor/types/storage"; + +export const NODE_FILE_MAP: { + [key: string]: { + fileSetName: ExtensionFileSetStorageKey; + }; +} = { + image: { + fileSetName: "deletedImageSet", + }, + imageComponent: { + fileSetName: "deletedImageSet", + }, +}; diff --git a/packages/editor/src/ce/extensions/core/extensions.ts b/packages/editor/src/ce/extensions/core/extensions.ts index d03229133..a72bcc215 100644 --- a/packages/editor/src/ce/extensions/core/extensions.ts +++ b/packages/editor/src/ce/extensions/core/extensions.ts @@ -1,12 +1,13 @@ -import { Extensions } from "@tiptap/core"; +import type { Extensions } from "@tiptap/core"; // types -import { TExtensions } from "@/types"; +import type { IEditorProps } from "@/types"; -type Props = { - disabledExtensions: TExtensions[]; -}; +export type TCoreAdditionalExtensionsProps = Pick< + IEditorProps, + "disabledExtensions" | "flaggedExtensions" | "fileHandler" +>; -export const CoreEditorAdditionalExtensions = (props: Props): Extensions => { +export const CoreEditorAdditionalExtensions = (props: TCoreAdditionalExtensionsProps): Extensions => { const {} = props; return []; }; diff --git a/packages/editor/src/ce/extensions/core/read-only-extensions.ts b/packages/editor/src/ce/extensions/core/read-only-extensions.ts index 398848e31..4f9306da3 100644 --- a/packages/editor/src/ce/extensions/core/read-only-extensions.ts +++ b/packages/editor/src/ce/extensions/core/read-only-extensions.ts @@ -1,12 +1,15 @@ -import { Extensions } from "@tiptap/core"; +import type { Extensions } from "@tiptap/core"; // types -import { TExtensions } from "@/types"; +import type { IReadOnlyEditorProps } from "@/types"; -type Props = { - disabledExtensions: TExtensions[]; -}; +export type TCoreReadOnlyEditorAdditionalExtensionsProps = Pick< + IReadOnlyEditorProps, + "disabledExtensions" | "flaggedExtensions" +>; -export const CoreReadOnlyEditorAdditionalExtensions = (props: Props): Extensions => { +export const CoreReadOnlyEditorAdditionalExtensions = ( + props: TCoreReadOnlyEditorAdditionalExtensionsProps +): Extensions => { const {} = props; return []; }; diff --git a/packages/editor/src/ce/extensions/document-extensions.tsx b/packages/editor/src/ce/extensions/document-extensions.tsx index 445f5e0f8..8815e2d26 100644 --- a/packages/editor/src/ce/extensions/document-extensions.tsx +++ b/packages/editor/src/ce/extensions/document-extensions.tsx @@ -1,37 +1,39 @@ -import { HocuspocusProvider } from "@hocuspocus/provider"; -import { Extensions } from "@tiptap/core"; -import { AnyExtension } from "@tiptap/core"; +import type { HocuspocusProvider } from "@hocuspocus/provider"; +import type { AnyExtension } from "@tiptap/core"; import { SlashCommands } from "@/extensions"; // plane editor types -import { TIssueEmbedConfig } from "@/plane-editor/types"; +import type { TEmbedConfig } from "@/plane-editor/types"; // types -import { TExtensions, TUserDetails } from "@/types"; +import type { IEditorProps, TExtensions, TUserDetails } from "@/types"; -type Props = { - disabledExtensions?: TExtensions[]; - issueEmbedConfig: TIssueEmbedConfig | undefined; - provider: HocuspocusProvider; +export type TDocumentEditorAdditionalExtensionsProps = Pick< + IEditorProps, + "disabledExtensions" | "flaggedExtensions" | "fileHandler" +> & { + embedConfig: TEmbedConfig | undefined; + provider?: HocuspocusProvider; userDetails: TUserDetails; }; -type ExtensionConfig = { - isEnabled: (disabledExtensions: TExtensions[]) => boolean; - getExtension: (props: Props) => AnyExtension; +export type TDocumentEditorAdditionalExtensionsRegistry = { + isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean; + getExtension: (props: TDocumentEditorAdditionalExtensionsProps) => AnyExtension; }; -const extensionRegistry: ExtensionConfig[] = [ +const extensionRegistry: TDocumentEditorAdditionalExtensionsRegistry[] = [ { isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"), - getExtension: () => SlashCommands({}), + getExtension: ({ disabledExtensions, flaggedExtensions }) => + SlashCommands({ disabledExtensions, flaggedExtensions }), }, ]; -export const DocumentEditorAdditionalExtensions = (_props: Props) => { - const { disabledExtensions = [] } = _props; +export const DocumentEditorAdditionalExtensions = (props: TDocumentEditorAdditionalExtensionsProps) => { + const { disabledExtensions, flaggedExtensions } = props; const documentExtensions = extensionRegistry - .filter((config) => config.isEnabled(disabledExtensions)) - .map((config) => config.getExtension(_props)); + .filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions)) + .map((config) => config.getExtension(props)); return documentExtensions; }; diff --git a/packages/editor/src/ce/extensions/rich-text/extensions.tsx b/packages/editor/src/ce/extensions/rich-text/extensions.tsx new file mode 100644 index 000000000..520dfa10e --- /dev/null +++ b/packages/editor/src/ce/extensions/rich-text/extensions.tsx @@ -0,0 +1,42 @@ +import { AnyExtension, Extensions } from "@tiptap/core"; +// extensions +import { SlashCommands } from "@/extensions/slash-commands/root"; +// types +import { IEditorProps, TExtensions } from "@/types"; + +export type TRichTextEditorAdditionalExtensionsProps = Pick< + IEditorProps, + "disabledExtensions" | "flaggedExtensions" | "fileHandler" +>; + +/** + * Registry entry configuration for extensions + */ +export type TRichTextEditorAdditionalExtensionsRegistry = { + /** Determines if the extension should be enabled based on disabled extensions */ + isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean; + /** Returns the extension instance(s) when enabled */ + getExtension: (props: TRichTextEditorAdditionalExtensionsProps) => AnyExtension | undefined; +}; + +const extensionRegistry: TRichTextEditorAdditionalExtensionsRegistry[] = [ + { + isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"), + getExtension: ({ disabledExtensions, flaggedExtensions }) => + SlashCommands({ + disabledExtensions, + flaggedExtensions, + }), + }, +]; + +export const RichTextEditorAdditionalExtensions = (props: TRichTextEditorAdditionalExtensionsProps) => { + const { disabledExtensions, flaggedExtensions } = props; + + const extensions: Extensions = extensionRegistry + .filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions)) + .map((config) => config.getExtension(props)) + .filter((extension): extension is AnyExtension => extension !== undefined); + + return extensions; +}; diff --git a/packages/editor/src/ce/extensions/rich-text/read-only-extensions.tsx b/packages/editor/src/ce/extensions/rich-text/read-only-extensions.tsx new file mode 100644 index 000000000..0b7cbc730 --- /dev/null +++ b/packages/editor/src/ce/extensions/rich-text/read-only-extensions.tsx @@ -0,0 +1,31 @@ +import { AnyExtension, Extensions } from "@tiptap/core"; +// types +import { IReadOnlyEditorProps, TExtensions } from "@/types"; + +export type TRichTextReadOnlyEditorAdditionalExtensionsProps = Pick< + IReadOnlyEditorProps, + "disabledExtensions" | "flaggedExtensions" | "fileHandler" +>; + +/** + * Registry entry configuration for extensions + */ +export type TRichTextReadOnlyEditorAdditionalExtensionsRegistry = { + /** Determines if the extension should be enabled based on disabled extensions */ + isEnabled: (disabledExtensions: TExtensions[]) => boolean; + /** Returns the extension instance(s) when enabled */ + getExtension: (props: TRichTextReadOnlyEditorAdditionalExtensionsProps) => AnyExtension | undefined; +}; + +const extensionRegistry: TRichTextReadOnlyEditorAdditionalExtensionsRegistry[] = []; + +export const RichTextReadOnlyEditorAdditionalExtensions = (props: TRichTextReadOnlyEditorAdditionalExtensionsProps) => { + const { disabledExtensions } = props; + + const extensions: Extensions = extensionRegistry + .filter((config) => config.isEnabled(disabledExtensions)) + .map((config) => config.getExtension(props)) + .filter((extension): extension is AnyExtension => extension !== undefined); + + return extensions; +}; diff --git a/packages/editor/src/ce/extensions/slash-commands.tsx b/packages/editor/src/ce/extensions/slash-commands.tsx index faefa7452..d61d056c8 100644 --- a/packages/editor/src/ce/extensions/slash-commands.tsx +++ b/packages/editor/src/ce/extensions/slash-commands.tsx @@ -1,11 +1,9 @@ // extensions -import { TSlashCommandAdditionalOption } from "@/extensions"; +import type { TSlashCommandAdditionalOption } from "@/extensions"; // types -import { TExtensions } from "@/types"; +import type { IEditorProps } from "@/types"; -type Props = { - disabledExtensions?: TExtensions[]; -}; +type Props = Pick; export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => { const {} = props; diff --git a/packages/editor/src/ce/helpers/parser.ts b/packages/editor/src/ce/helpers/parser.ts new file mode 100644 index 000000000..5b96c9cfd --- /dev/null +++ b/packages/editor/src/ce/helpers/parser.ts @@ -0,0 +1,19 @@ +/** + * @description function to extract all additional assets from HTML content + * @param htmlContent + * @returns {string[]} array of additional asset sources + */ +export const extractAdditionalAssetsFromHTMLContent = (_htmlContent: string): string[] => []; + +/** + * @description function to replace additional assets in HTML content with new IDs + * @param props + * @returns {string} HTML content with replaced additional assets + */ +export const replaceAdditionalAssetsInHTMLContent = (props: { + htmlContent: string; + assetMap: Record; +}): string => { + const { htmlContent } = props; + return htmlContent; +}; diff --git a/packages/editor/src/ce/types/storage.ts b/packages/editor/src/ce/types/storage.ts new file mode 100644 index 000000000..84eee65f9 --- /dev/null +++ b/packages/editor/src/ce/types/storage.ts @@ -0,0 +1,20 @@ +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// extensions +import { type HeadingExtensionStorage } from "@/extensions"; +import { type CustomImageExtensionStorage } from "@/extensions/custom-image/types"; +import { type CustomLinkStorage } from "@/extensions/custom-link"; +import { type ImageExtensionStorage } from "@/extensions/image"; +import { type MentionExtensionStorage } from "@/extensions/mentions"; +import { type UtilityExtensionStorage } from "@/extensions/utility"; + +export type ExtensionStorageMap = { + [CORE_EXTENSIONS.CUSTOM_IMAGE]: CustomImageExtensionStorage; + [CORE_EXTENSIONS.IMAGE]: ImageExtensionStorage; + [CORE_EXTENSIONS.CUSTOM_LINK]: CustomLinkStorage; + [CORE_EXTENSIONS.HEADINGS_LIST]: HeadingExtensionStorage; + [CORE_EXTENSIONS.MENTION]: MentionExtensionStorage; + [CORE_EXTENSIONS.UTILITY]: UtilityExtensionStorage; +}; + +export type ExtensionFileSetStorageKey = Extract; diff --git a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx index 623ec9508..8bbf2e7ce 100644 --- a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx +++ b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx @@ -7,16 +7,17 @@ import { DocumentContentLoader, PageRenderer } from "@/components/editors"; // constants import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config"; // extensions -import { IssueWidget } from "@/extensions"; +import { WorkItemEmbedExtension } from "@/extensions"; // helpers import { getEditorClassNames } from "@/helpers/common"; // hooks import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor"; // types -import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types"; +import { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types"; -const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { +const CollaborativeDocumentEditor: React.FC = (props) => { const { + onChange, onTransaction, aiHandler, bubbleMenuEnabled = true, @@ -27,6 +28,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { editorClassName = "", embedHandler, fileHandler, + flaggedExtensions, forwardedRef, handleEditorReady, id, @@ -39,9 +41,10 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { } = props; const extensions: Extensions = []; + if (embedHandler?.issue) { extensions.push( - IssueWidget({ + WorkItemEmbedExtension({ widgetCallback: embedHandler.issue.widgetCallback, }) ); @@ -55,10 +58,12 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { embedHandler, extensions, fileHandler, + flaggedExtensions, forwardedRef, handleEditorReady, id, mentionHandler, + onChange, onTransaction, placeholder, realtimeConfig, @@ -94,7 +99,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { ); }; -const CollaborativeDocumentEditorWithRef = React.forwardRef( +const CollaborativeDocumentEditorWithRef = React.forwardRef( (props, ref) => ( } /> ) diff --git a/packages/editor/src/core/components/editors/document/page-renderer.tsx b/packages/editor/src/core/components/editors/document/page-renderer.tsx index 0be3c17c1..62613b0a1 100644 --- a/packages/editor/src/core/components/editors/document/page-renderer.tsx +++ b/packages/editor/src/core/components/editors/document/page-renderer.tsx @@ -5,7 +5,7 @@ import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus" // types import { TAIHandler, TDisplayConfig } from "@/types"; -type IPageRenderer = { +type Props = { aiHandler?: TAIHandler; bubbleMenuEnabled: boolean; displayConfig: TDisplayConfig; @@ -15,7 +15,7 @@ type IPageRenderer = { tabIndex?: number; }; -export const PageRenderer = (props: IPageRenderer) => { +export const PageRenderer = (props: Props) => { const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props; return ( diff --git a/packages/editor/src/core/components/editors/document/read-only-editor.tsx b/packages/editor/src/core/components/editors/document/read-only-editor.tsx index 54a1f96e2..8f0d67ddc 100644 --- a/packages/editor/src/core/components/editors/document/read-only-editor.tsx +++ b/packages/editor/src/core/components/editors/document/read-only-editor.tsx @@ -1,5 +1,5 @@ import { Extensions } from "@tiptap/core"; -import { forwardRef, MutableRefObject } from "react"; +import React, { forwardRef, MutableRefObject } from "react"; // plane imports import { cn } from "@plane/utils"; // components @@ -7,36 +7,15 @@ import { PageRenderer } from "@/components/editors"; // constants import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config"; // extensions -import { IssueWidget } from "@/extensions"; +import { WorkItemEmbedExtension } from "@/extensions"; // helpers import { getEditorClassNames } from "@/helpers/common"; // hooks import { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; // types -import { - EditorReadOnlyRefApi, - TDisplayConfig, - TExtensions, - TReadOnlyFileHandler, - TReadOnlyMentionHandler, -} from "@/types"; +import { EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps } from "@/types"; -interface IDocumentReadOnlyEditor { - disabledExtensions: TExtensions[]; - id: string; - initialValue: string; - containerClassName: string; - displayConfig?: TDisplayConfig; - editorClassName?: string; - embedHandler: any; - fileHandler: TReadOnlyFileHandler; - tabIndex?: number; - handleEditorReady?: (value: boolean) => void; - mentionHandler: TReadOnlyMentionHandler; - forwardedRef?: React.MutableRefObject; -} - -const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { +const DocumentReadOnlyEditor: React.FC = (props) => { const { containerClassName, disabledExtensions, @@ -44,6 +23,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { editorClassName = "", embedHandler, fileHandler, + flaggedExtensions, id, forwardedRef, handleEditorReady, @@ -53,7 +33,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { const extensions: Extensions = []; if (embedHandler?.issue) { extensions.push( - IssueWidget({ + WorkItemEmbedExtension({ widgetCallback: embedHandler.issue.widgetCallback, }) ); @@ -64,6 +44,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { editorClassName, extensions, fileHandler, + flaggedExtensions, forwardedRef, handleEditorReady, initialValue, @@ -87,7 +68,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { ); }; -const DocumentReadOnlyEditorWithRef = forwardRef((props, ref) => ( +const DocumentReadOnlyEditorWithRef = forwardRef((props, ref) => ( } /> )); diff --git a/packages/editor/src/core/components/editors/editor-container.tsx b/packages/editor/src/core/components/editors/editor-container.tsx index d0811cd41..3553f07fd 100644 --- a/packages/editor/src/core/components/editors/editor-container.tsx +++ b/packages/editor/src/core/components/editors/editor-container.tsx @@ -4,6 +4,7 @@ import { FC, ReactNode, useRef } from "react"; import { cn } from "@plane/utils"; // constants import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config"; +import { CORE_EXTENSIONS } from "@/constants/extension"; // types import { TDisplayConfig } from "@/types"; // components @@ -36,12 +37,12 @@ export const EditorContainer: FC = (props) => { if ( currentNode.content.size === 0 && // Check if the current node is empty !( - editor.isActive("orderedList") || - editor.isActive("bulletList") || - editor.isActive("taskItem") || - editor.isActive("table") || - editor.isActive("blockquote") || - editor.isActive("codeBlock") + editor.isActive(CORE_EXTENSIONS.ORDERED_LIST) || + editor.isActive(CORE_EXTENSIONS.BULLET_LIST) || + editor.isActive(CORE_EXTENSIONS.TASK_ITEM) || + editor.isActive(CORE_EXTENSIONS.TABLE) || + editor.isActive(CORE_EXTENSIONS.BLOCKQUOTE) || + editor.isActive(CORE_EXTENSIONS.CODE_BLOCK) ) // Check if it's an empty node within an orderedList, bulletList, taskItem, table, quote or code block ) { return; @@ -52,17 +53,14 @@ export const EditorContainer: FC = (props) => { const lastNodePos = editor.state.doc.resolve(Math.max(0, docSize - 2)); const lastNode = lastNodePos.node(); - // Check if the last node is a not paragraph - if (lastNode && lastNode.type.name !== "paragraph") { - // If last node is not a paragraph, insert a new paragraph at the end - const endPosition = editor?.state.doc.content.size; - editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run(); - - // Focus the newly added paragraph for immediate editing - editor - .chain() - .setTextSelection(endPosition + 1) - .run(); + // Check if its last node and add new node + if (lastNode) { + const isLastNodeEmptyParagraph = lastNode.type.name === CORE_EXTENSIONS.PARAGRAPH && lastNode.content.size === 0; + // Only insert a new paragraph if the last node is not an empty paragraph and not a doc node + if (!isLastNodeEmptyParagraph && lastNode.type.name !== "doc") { + const endPosition = editor?.state.doc.content.size; + editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).focus("end").run(); + } } } catch (error) { console.error("An error occurred while handling container click to insert new empty node at bottom:", error); diff --git a/packages/editor/src/core/components/editors/editor-wrapper.tsx b/packages/editor/src/core/components/editors/editor-wrapper.tsx index 9d1297e23..2c1ef52b7 100644 --- a/packages/editor/src/core/components/editors/editor-wrapper.tsx +++ b/packages/editor/src/core/components/editors/editor-wrapper.tsx @@ -26,6 +26,7 @@ export const EditorWrapper: React.FC = (props) => { id, initialValue, fileHandler, + flaggedExtensions, forwardedRef, mentionHandler, onChange, @@ -44,6 +45,7 @@ export const EditorWrapper: React.FC = (props) => { enableHistory: true, extensions, fileHandler, + flaggedExtensions, forwardedRef, id, initialValue, diff --git a/packages/editor/src/core/components/editors/link-view-container.tsx b/packages/editor/src/core/components/editors/link-view-container.tsx index 41263a996..68fa33dde 100644 --- a/packages/editor/src/core/components/editors/link-view-container.tsx +++ b/packages/editor/src/core/components/editors/link-view-container.tsx @@ -12,7 +12,7 @@ interface LinkViewContainerProps { export const LinkViewContainer: FC = ({ editor, containerRef }) => { const [linkViewProps, setLinkViewProps] = useState(); const [isOpen, setIsOpen] = useState(false); - const [virtualElement, setVirtualElement] = useState(null); + const [virtualElement, setVirtualElement] = useState(null); const editorState = useEditorState({ editor, diff --git a/packages/editor/src/core/components/editors/lite-text/editor.tsx b/packages/editor/src/core/components/editors/lite-text/editor.tsx index 849a3c3e2..df89521ae 100644 --- a/packages/editor/src/core/components/editors/lite-text/editor.tsx +++ b/packages/editor/src/core/components/editors/lite-text/editor.tsx @@ -4,23 +4,25 @@ import { EditorWrapper } from "@/components/editors/editor-wrapper"; // extensions import { EnterKeyExtension } from "@/extensions"; // types -import { EditorRefApi, ILiteTextEditor } from "@/types"; +import { EditorRefApi, ILiteTextEditorProps } from "@/types"; -const LiteTextEditor = (props: ILiteTextEditor) => { +const LiteTextEditor: React.FC = (props) => { const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [] } = props; - const extensions = useMemo( - () => [ - ...externalExtensions, - ...(disabledExtensions?.includes("enter-key") ? [] : [EnterKeyExtension(onEnterKeyPress)]), - ], - [externalExtensions, disabledExtensions, onEnterKeyPress] - ); + const extensions = useMemo(() => { + const resolvedExtensions = [...externalExtensions]; + + if (!disabledExtensions?.includes("enter-key")) { + resolvedExtensions.push(EnterKeyExtension(onEnterKeyPress)); + } + + return resolvedExtensions; + }, [externalExtensions, disabledExtensions, onEnterKeyPress]); return ; }; -const LiteTextEditorWithRef = forwardRef((props, ref) => ( +const LiteTextEditorWithRef = forwardRef((props, ref) => ( } /> )); diff --git a/packages/editor/src/core/components/editors/lite-text/read-only-editor.tsx b/packages/editor/src/core/components/editors/lite-text/read-only-editor.tsx index b721c84c5..75e02791d 100644 --- a/packages/editor/src/core/components/editors/lite-text/read-only-editor.tsx +++ b/packages/editor/src/core/components/editors/lite-text/read-only-editor.tsx @@ -2,9 +2,9 @@ import { forwardRef } from "react"; // components import { ReadOnlyEditorWrapper } from "@/components/editors"; // types -import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor } from "@/types"; +import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps } from "@/types"; -const LiteTextReadOnlyEditorWithRef = forwardRef((props, ref) => ( +const LiteTextReadOnlyEditorWithRef = forwardRef((props, ref) => ( } /> )); diff --git a/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx b/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx index 6cd360ac0..b6abd1a6a 100644 --- a/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx +++ b/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx @@ -15,7 +15,9 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => { disabledExtensions, displayConfig = DEFAULT_DISPLAY_CONFIG, editorClassName = "", + extensions, fileHandler, + flaggedExtensions, forwardedRef, id, initialValue, @@ -25,7 +27,9 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => { const editor = useReadOnlyEditor({ disabledExtensions, editorClassName, + extensions, fileHandler, + flaggedExtensions, forwardedRef, initialValue, mentionHandler, diff --git a/packages/editor/src/core/components/editors/rich-text/editor.tsx b/packages/editor/src/core/components/editors/rich-text/editor.tsx index ffcc21da6..8544dcc83 100644 --- a/packages/editor/src/core/components/editors/rich-text/editor.tsx +++ b/packages/editor/src/core/components/editors/rich-text/editor.tsx @@ -3,12 +3,21 @@ import { forwardRef, useCallback } from "react"; import { EditorWrapper } from "@/components/editors"; import { EditorBubbleMenu } from "@/components/menus"; // extensions -import { SideMenuExtension, SlashCommands } from "@/extensions"; +import { SideMenuExtension } from "@/extensions"; +// plane editor imports +import { RichTextEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/extensions"; // types -import { EditorRefApi, IRichTextEditor } from "@/types"; +import { EditorRefApi, IRichTextEditorProps } from "@/types"; -const RichTextEditor = (props: IRichTextEditor) => { - const { disabledExtensions, dragDropEnabled, bubbleMenuEnabled = true, extensions: externalExtensions = [] } = props; +const RichTextEditor: React.FC = (props) => { + const { + bubbleMenuEnabled = true, + disabledExtensions, + dragDropEnabled, + extensions: externalExtensions = [], + fileHandler, + flaggedExtensions, + } = props; const getExtensions = useCallback(() => { const extensions = [ @@ -17,17 +26,15 @@ const RichTextEditor = (props: IRichTextEditor) => { aiEnabled: false, dragDropEnabled: !!dragDropEnabled, }), + ...RichTextEditorAdditionalExtensions({ + disabledExtensions, + fileHandler, + flaggedExtensions, + }), ]; - if (!disabledExtensions?.includes("slash-commands")) { - extensions.push( - SlashCommands({ - disabledExtensions, - }) - ); - } return extensions; - }, [dragDropEnabled, disabledExtensions, externalExtensions]); + }, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler, flaggedExtensions]); return ( @@ -36,7 +43,7 @@ const RichTextEditor = (props: IRichTextEditor) => { ); }; -const RichTextEditorWithRef = forwardRef((props, ref) => ( +const RichTextEditorWithRef = forwardRef((props, ref) => ( } /> )); diff --git a/packages/editor/src/core/components/editors/rich-text/read-only-editor.tsx b/packages/editor/src/core/components/editors/rich-text/read-only-editor.tsx index 8bd7a837a..efad3d6ac 100644 --- a/packages/editor/src/core/components/editors/rich-text/read-only-editor.tsx +++ b/packages/editor/src/core/components/editors/rich-text/read-only-editor.tsx @@ -1,11 +1,32 @@ -import { forwardRef } from "react"; +import { forwardRef, useCallback } from "react"; +// plane editor extensions +import { RichTextReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/read-only-extensions"; // types -import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor } from "@/types"; +import { EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps } from "@/types"; +// local imports import { ReadOnlyEditorWrapper } from "../read-only-editor-wrapper"; -const RichTextReadOnlyEditorWithRef = forwardRef((props, ref) => ( - } /> -)); +const RichTextReadOnlyEditorWithRef = forwardRef((props, ref) => { + const { disabledExtensions, fileHandler, flaggedExtensions } = props; + + const getExtensions = useCallback(() => { + const extensions = RichTextReadOnlyEditorAdditionalExtensions({ + disabledExtensions, + fileHandler, + flaggedExtensions, + }); + + return extensions; + }, [disabledExtensions, fileHandler, flaggedExtensions]); + + return ( + } + /> + ); +}); RichTextReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef"; diff --git a/packages/editor/src/core/components/links/link-edit-view.tsx b/packages/editor/src/core/components/links/link-edit-view.tsx index ad66ce4b4..1e9a62b0e 100644 --- a/packages/editor/src/core/components/links/link-edit-view.tsx +++ b/packages/editor/src/core/components/links/link-edit-view.tsx @@ -51,7 +51,9 @@ export const LinkEditView = ({ viewProps }: LinkEditViewProps) => { if (!hasSubmitted.current && !linkRemoved && initialUrl === "") { try { removeLink(); - } catch (e) {} + } catch (e) { + console.error("Error removing link", e); + } } }, [linkRemoved, initialUrl] diff --git a/packages/editor/src/core/components/menus/block-menu.tsx b/packages/editor/src/core/components/menus/block-menu.tsx index c143abd00..bd86628cb 100644 --- a/packages/editor/src/core/components/menus/block-menu.tsx +++ b/packages/editor/src/core/components/menus/block-menu.tsx @@ -1,7 +1,9 @@ -import { useCallback, useEffect, useRef } from "react"; import { Editor } from "@tiptap/react"; -import tippy, { Instance } from "tippy.js"; import { Copy, LucideIcon, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useRef } from "react"; +import tippy, { Instance } from "tippy.js"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; interface BlockMenuProps { editor: Editor; @@ -102,7 +104,8 @@ export const BlockMenu = (props: BlockMenuProps) => { key: "duplicate", label: "Duplicate", isDisabled: - editor.state.selection.content().content.firstChild?.type.name === "image" || editor.isActive("imageComponent"), + editor.state.selection.content().content.firstChild?.type.name === CORE_EXTENSIONS.IMAGE || + editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE), onClick: (e) => { e.preventDefault(); e.stopPropagation(); diff --git a/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx index 1dd47c5bb..6f582f89c 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx @@ -1,8 +1,10 @@ import { Editor } from "@tiptap/core"; import { Check, Link, Trash2 } from "lucide-react"; import { Dispatch, FC, SetStateAction, useCallback, useRef, useState } from "react"; -// plane utils +// plane imports import { cn } from "@plane/utils"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // helpers import { isValidHttpUrl } from "@/helpers/common"; import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands"; @@ -43,7 +45,7 @@ export const BubbleMenuLinkSelector: FC = (props) => { "h-full flex items-center gap-1 px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors", { "bg-custom-background-80": isOpen, - "text-custom-text-100": editor.isActive("link"), + "text-custom-text-100": editor.isActive(CORE_EXTENSIONS.CUSTOM_LINK), } )} onClick={(e) => { diff --git a/packages/editor/src/core/components/menus/bubble-menu/node-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/node-selector.tsx index 7d1378800..564f7d97c 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/node-selector.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/node-selector.tsx @@ -1,6 +1,6 @@ -import { Dispatch, FC, SetStateAction } from "react"; import { Editor } from "@tiptap/react"; import { Check, ChevronDown } from "lucide-react"; +import { Dispatch, FC, SetStateAction } from "react"; // plane utils import { cn } from "@plane/utils"; // components 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 02eb8d486..a3fa3e2d7 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/root.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/root.tsx @@ -10,6 +10,7 @@ import { BubbleMenuLinkSelector, BubbleMenuNodeSelector, CodeItem, + EditorMenuItem, ItalicItem, StrikeThroughItem, TextAlignItem, @@ -18,10 +19,12 @@ import { } from "@/components/menus"; // constants import { COLORS_LIST } from "@/constants/common"; +import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection"; // local components import { TextAlignmentSelector } from "./alignment-selector"; +import { TEditorCommands } from "@/types"; type EditorBubbleMenuProps = Omit; @@ -30,19 +33,19 @@ export interface EditorStateType { bold: boolean; italic: boolean; underline: boolean; - strike: boolean; + strikethrough: boolean; left: boolean; right: boolean; center: boolean; color: { key: string; label: string; textColor: string; backgroundColor: string } | undefined; backgroundColor: - | { - key: string; - label: string; - textColor: string; - backgroundColor: string; - } - | undefined; + | { + key: string; + label: string; + textColor: string; + backgroundColor: string; + } + | undefined; } export const EditorBubbleMenu: FC = (props: { editor: Editor }) => { @@ -57,8 +60,10 @@ export const EditorBubbleMenu: FC = (props: { editor: Edi bold: BoldItem(props.editor), italic: ItalicItem(props.editor), underline: UnderLineItem(props.editor), - strike: StrikeThroughItem(props.editor), - textAlign: TextAlignItem(props.editor), + strikethrough: StrikeThroughItem(props.editor), + "text-align": TextAlignItem(props.editor), + } satisfies { + [K in TEditorCommands]?: EditorMenuItem; }; const editorState: EditorStateType = useEditorState({ @@ -68,10 +73,10 @@ export const EditorBubbleMenu: FC = (props: { editor: Edi bold: formattingItems.bold.isActive(), italic: formattingItems.italic.isActive(), underline: formattingItems.underline.isActive(), - strike: formattingItems.strike.isActive(), - left: formattingItems.textAlign.isActive({ alignment: "left" }), - right: formattingItems.textAlign.isActive({ alignment: "right" }), - center: formattingItems.textAlign.isActive({ alignment: "center" }), + strikethrough: formattingItems.strikethrough.isActive(), + left: formattingItems["text-align"].isActive({ alignment: "left" }), + right: formattingItems["text-align"].isActive({ alignment: "right" }), + center: formattingItems["text-align"].isActive({ alignment: "center" }), color: COLORS_LIST.find((c) => TextColorItem(editor).isActive({ color: c.key })), backgroundColor: COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive({ color: c.key })), }), @@ -79,7 +84,7 @@ export const EditorBubbleMenu: FC = (props: { editor: Edi const basicFormattingOptions = editorState.code ? [formattingItems.code] - : [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strike]; + : [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strikethrough]; const bubbleMenuProps: EditorBubbleMenuProps = { ...props, @@ -90,8 +95,8 @@ export const EditorBubbleMenu: FC = (props: { editor: Edi if ( empty || !editor.isEditable || - editor.isActive("image") || - editor.isActive("imageComponent") || + editor.isActive(CORE_EXTENSIONS.IMAGE) || + editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE) || isNodeSelection(selection) || isCellSelection(selection) || isSelecting diff --git a/packages/editor/src/core/components/menus/menu-items.ts b/packages/editor/src/core/components/menus/menu-items.ts index 4268ccb6c..c3aa4d414 100644 --- a/packages/editor/src/core/components/menus/menu-items.ts +++ b/packages/editor/src/core/components/menus/menu-items.ts @@ -23,6 +23,8 @@ import { Palette, AlignCenter, } from "lucide-react"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // helpers import { insertHorizontalRule, @@ -35,12 +37,7 @@ import { toggleBold, toggleBulletList, toggleCodeBlock, - toggleHeadingFive, - toggleHeadingFour, - toggleHeadingOne, - toggleHeadingSix, - toggleHeadingThree, - toggleHeadingTwo, + toggleHeading, toggleItalic, toggleOrderedList, toggleStrike, @@ -65,63 +62,49 @@ export type EditorMenuItem = { export const TextItem = (editor: Editor): EditorMenuItem<"text"> => ({ key: "text", name: "Text", - isActive: () => editor.isActive("paragraph"), + isActive: () => editor.isActive(CORE_EXTENSIONS.PARAGRAPH), command: () => setText(editor), icon: CaseSensitive, }); -export const HeadingOneItem = (editor: Editor): EditorMenuItem<"h1"> => ({ - key: "h1", - name: "Heading 1", - isActive: () => editor.isActive("heading", { level: 1 }), - command: () => toggleHeadingOne(editor), - icon: Heading1, +type SupportedHeadingLevels = "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; + +const HeadingItem = ( + editor: Editor, + level: 1 | 2 | 3 | 4 | 5 | 6, + key: T, + name: string, + icon: LucideIcon +): EditorMenuItem => ({ + key, + name, + isActive: () => editor.isActive(CORE_EXTENSIONS.HEADING, { level }), + command: () => toggleHeading(editor, level), + icon, }); -export const HeadingTwoItem = (editor: Editor): EditorMenuItem<"h2"> => ({ - key: "h2", - name: "Heading 2", - isActive: () => editor.isActive("heading", { level: 2 }), - command: () => toggleHeadingTwo(editor), - icon: Heading2, -}); +export const HeadingOneItem = (editor: Editor): EditorMenuItem<"h1"> => + HeadingItem(editor, 1, "h1", "Heading 1", Heading1); -export const HeadingThreeItem = (editor: Editor): EditorMenuItem<"h3"> => ({ - key: "h3", - name: "Heading 3", - isActive: () => editor.isActive("heading", { level: 3 }), - command: () => toggleHeadingThree(editor), - icon: Heading3, -}); +export const HeadingTwoItem = (editor: Editor): EditorMenuItem<"h2"> => + HeadingItem(editor, 2, "h2", "Heading 2", Heading2); -export const HeadingFourItem = (editor: Editor): EditorMenuItem<"h4"> => ({ - key: "h4", - name: "Heading 4", - isActive: () => editor.isActive("heading", { level: 4 }), - command: () => toggleHeadingFour(editor), - icon: Heading4, -}); +export const HeadingThreeItem = (editor: Editor): EditorMenuItem<"h3"> => + HeadingItem(editor, 3, "h3", "Heading 3", Heading3); -export const HeadingFiveItem = (editor: Editor): EditorMenuItem<"h5"> => ({ - key: "h5", - name: "Heading 5", - isActive: () => editor.isActive("heading", { level: 5 }), - command: () => toggleHeadingFive(editor), - icon: Heading5, -}); +export const HeadingFourItem = (editor: Editor): EditorMenuItem<"h4"> => + HeadingItem(editor, 4, "h4", "Heading 4", Heading4); -export const HeadingSixItem = (editor: Editor): EditorMenuItem<"h6"> => ({ - key: "h6", - name: "Heading 6", - isActive: () => editor.isActive("heading", { level: 6 }), - command: () => toggleHeadingSix(editor), - icon: Heading6, -}); +export const HeadingFiveItem = (editor: Editor): EditorMenuItem<"h5"> => + HeadingItem(editor, 5, "h5", "Heading 5", Heading5); + +export const HeadingSixItem = (editor: Editor): EditorMenuItem<"h6"> => + HeadingItem(editor, 6, "h6", "Heading 6", Heading6); export const BoldItem = (editor: Editor): EditorMenuItem<"bold"> => ({ key: "bold", name: "Bold", - isActive: () => editor?.isActive("bold"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.BOLD), command: () => toggleBold(editor), icon: BoldIcon, }); @@ -129,7 +112,7 @@ export const BoldItem = (editor: Editor): EditorMenuItem<"bold"> => ({ export const ItalicItem = (editor: Editor): EditorMenuItem<"italic"> => ({ key: "italic", name: "Italic", - isActive: () => editor?.isActive("italic"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.ITALIC), command: () => toggleItalic(editor), icon: ItalicIcon, }); @@ -137,7 +120,7 @@ export const ItalicItem = (editor: Editor): EditorMenuItem<"italic"> => ({ export const UnderLineItem = (editor: Editor): EditorMenuItem<"underline"> => ({ key: "underline", name: "Underline", - isActive: () => editor?.isActive("underline"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.UNDERLINE), command: () => toggleUnderline(editor), icon: UnderlineIcon, }); @@ -145,7 +128,7 @@ export const UnderLineItem = (editor: Editor): EditorMenuItem<"underline"> => ({ export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough"> => ({ key: "strikethrough", name: "Strikethrough", - isActive: () => editor?.isActive("strike"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.STRIKETHROUGH), command: () => toggleStrike(editor), icon: StrikethroughIcon, }); @@ -153,7 +136,7 @@ export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list"> => ({ key: "bulleted-list", name: "Bulleted list", - isActive: () => editor?.isActive("bulletList"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.BULLET_LIST), command: () => toggleBulletList(editor), icon: ListIcon, }); @@ -161,7 +144,7 @@ export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list"> export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"> => ({ key: "numbered-list", name: "Numbered list", - isActive: () => editor?.isActive("orderedList"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.ORDERED_LIST), command: () => toggleOrderedList(editor), icon: ListOrderedIcon, }); @@ -169,7 +152,7 @@ export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list" export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({ key: "to-do-list", name: "To-do list", - isActive: () => editor.isActive("taskItem"), + isActive: () => editor.isActive(CORE_EXTENSIONS.TASK_ITEM), command: () => toggleTaskList(editor), icon: CheckSquare, }); @@ -177,7 +160,7 @@ export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({ export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({ key: "quote", name: "Quote", - isActive: () => editor?.isActive("blockquote"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.BLOCKQUOTE), command: () => toggleBlockquote(editor), icon: TextQuote, }); @@ -185,7 +168,7 @@ export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({ export const CodeItem = (editor: Editor): EditorMenuItem<"code"> => ({ key: "code", name: "Code", - isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.CODE_INLINE) || editor?.isActive(CORE_EXTENSIONS.CODE_BLOCK), command: () => toggleCodeBlock(editor), icon: CodeIcon, }); @@ -193,7 +176,7 @@ export const CodeItem = (editor: Editor): EditorMenuItem<"code"> => ({ export const TableItem = (editor: Editor): EditorMenuItem<"table"> => ({ key: "table", name: "Table", - isActive: () => editor?.isActive("table"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.TABLE), command: () => insertTableCommand(editor), icon: TableIcon, }); @@ -201,7 +184,7 @@ export const TableItem = (editor: Editor): EditorMenuItem<"table"> => ({ export const ImageItem = (editor: Editor): EditorMenuItem<"image"> => ({ key: "image", name: "Image", - isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.IMAGE) || editor?.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE), command: () => insertImage({ editor, event: "insert", pos: editor.state.selection.from }), icon: ImageIcon, }); @@ -210,7 +193,7 @@ export const HorizontalRuleItem = (editor: Editor) => ({ key: "divider", name: "Divider", - isActive: () => editor?.isActive("horizontalRule"), + isActive: () => editor?.isActive(CORE_EXTENSIONS.HORIZONTAL_RULE), command: () => insertHorizontalRule(editor), icon: MinusSquare, }) as const; @@ -218,7 +201,7 @@ export const HorizontalRuleItem = (editor: Editor) => export const TextColorItem = (editor: Editor): EditorMenuItem<"text-color"> => ({ key: "text-color", name: "Color", - isActive: (props) => editor.isActive("customColor", { color: props?.color }), + isActive: (props) => editor.isActive(CORE_EXTENSIONS.CUSTOM_COLOR, { color: props?.color }), command: (props) => { if (!props) return; toggleTextColor(props.color, editor); @@ -229,7 +212,7 @@ export const TextColorItem = (editor: Editor): EditorMenuItem<"text-color"> => ( export const BackgroundColorItem = (editor: Editor): EditorMenuItem<"background-color"> => ({ key: "background-color", name: "Background color", - isActive: (props) => editor.isActive("customColor", { backgroundColor: props?.color }), + isActive: (props) => editor.isActive(CORE_EXTENSIONS.CUSTOM_COLOR, { backgroundColor: props?.color }), command: (props) => { if (!props) return; toggleBackgroundColor(props.color, editor); diff --git a/packages/editor/src/core/constants/config.ts b/packages/editor/src/core/constants/config.ts index ac6d63dd1..922be9ef9 100644 --- a/packages/editor/src/core/constants/config.ts +++ b/packages/editor/src/core/constants/config.ts @@ -8,5 +8,55 @@ export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = { wideLayout: false, }; -export const ACCEPTED_FILE_MIME_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"]; -export const ACCEPTED_FILE_EXTENSIONS = ACCEPTED_FILE_MIME_TYPES.map((type) => `.${type.split("/")[1]}`); +export const ACCEPTED_IMAGE_MIME_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"]; + +export const ACCEPTED_ATTACHMENT_MIME_TYPES = [ + "image/jpeg", + "image/png", + "image/gif", + "image/svg+xml", + "image/webp", + "image/tiff", + "image/bmp", + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "text/plain", + "application/rtf", + "audio/mpeg", + "audio/wav", + "audio/ogg", + "audio/midi", + "audio/x-midi", + "audio/aac", + "audio/flac", + "audio/x-m4a", + "video/mp4", + "video/mpeg", + "video/ogg", + "video/webm", + "video/quicktime", + "video/x-msvideo", + "video/x-ms-wmv", + "application/zip", + "application/x-rar-compressed", + "application/x-tar", + "application/gzip", + "model/gltf-binary", + "model/gltf+json", + "application/octet-stream", + "font/ttf", + "font/otf", + "font/woff", + "font/woff2", + "text/css", + "text/javascript", + "application/json", + "text/xml", + "text/csv", + "application/xml", +]; diff --git a/packages/editor/src/core/constants/extension.ts b/packages/editor/src/core/constants/extension.ts new file mode 100644 index 000000000..db070cb7b --- /dev/null +++ b/packages/editor/src/core/constants/extension.ts @@ -0,0 +1,44 @@ +export enum CORE_EXTENSIONS { + BLOCKQUOTE = "blockquote", + BOLD = "bold", + BULLET_LIST = "bulletList", + CALLOUT = "calloutComponent", + CHARACTER_COUNT = "characterCount", + CODE_BLOCK = "codeBlock", + CODE_INLINE = "code", + CUSTOM_COLOR = "customColor", + CUSTOM_IMAGE = "imageComponent", + CUSTOM_LINK = "link", + DOCUMENT = "doc", + DROP_CURSOR = "dropCursor", + ENTER_KEY = "enterKey", + GAP_CURSOR = "gapCursor", + HARD_BREAK = "hardBreak", + HEADING = "heading", + HEADINGS_LIST = "headingsList", + HISTORY = "history", + HORIZONTAL_RULE = "horizontalRule", + IMAGE = "image", + ITALIC = "italic", + LIST_ITEM = "listItem", + MARKDOWN_CLIPBOARD = "markdownClipboard", + MENTION = "mention", + ORDERED_LIST = "orderedList", + PARAGRAPH = "paragraph", + PLACEHOLDER = "placeholder", + SIDE_MENU = "editorSideMenu", + SLASH_COMMANDS = "slash-command", + STRIKETHROUGH = "strike", + TABLE = "table", + TABLE_CELL = "tableCell", + TABLE_HEADER = "tableHeader", + TABLE_ROW = "tableRow", + TASK_ITEM = "taskItem", + TASK_LIST = "taskList", + TEXT_ALIGN = "textAlign", + TEXT_STYLE = "textStyle", + TYPOGRAPHY = "typography", + UNDERLINE = "underline", + UTILITY = "utility", + WORK_ITEM_EMBED = "issue-embed-component", +} diff --git a/packages/editor/src/core/constants/meta.ts b/packages/editor/src/core/constants/meta.ts new file mode 100644 index 000000000..66769bb82 --- /dev/null +++ b/packages/editor/src/core/constants/meta.ts @@ -0,0 +1,3 @@ +export enum CORE_EDITOR_META { + SKIP_FILE_DELETION = "skipFileDeletion", +} diff --git a/packages/editor/src/core/extensions/callout/block.tsx b/packages/editor/src/core/extensions/callout/block.tsx index b6c6d7991..662a5ad39 100644 --- a/packages/editor/src/core/extensions/callout/block.tsx +++ b/packages/editor/src/core/extensions/callout/block.tsx @@ -1,5 +1,5 @@ -import React, { useState } from "react"; import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import React, { useState } from "react"; // constants import { COLORS_LIST } from "@/constants/common"; // local components diff --git a/packages/editor/src/core/extensions/callout/extension-config.ts b/packages/editor/src/core/extensions/callout/extension-config.ts index 546311509..e52be72d6 100644 --- a/packages/editor/src/core/extensions/callout/extension-config.ts +++ b/packages/editor/src/core/extensions/callout/extension-config.ts @@ -1,6 +1,8 @@ import { Node, mergeAttributes } from "@tiptap/core"; -import { Node as NodeType } from "@tiptap/pm/model"; import { MarkdownSerializerState } from "@tiptap/pm/markdown"; +import { Node as NodeType } from "@tiptap/pm/model"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // types import { EAttributeNames, TCalloutBlockAttributes } from "./types"; // utils @@ -9,14 +11,14 @@ import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES } from "./utils"; // Extend Tiptap's Commands interface declare module "@tiptap/core" { interface Commands { - calloutComponent: { + [CORE_EXTENSIONS.CALLOUT]: { insertCallout: () => ReturnType; }; } } export const CustomCalloutExtensionConfig = Node.create({ - name: "calloutComponent", + name: CORE_EXTENSIONS.CALLOUT, group: "block", content: "block+", diff --git a/packages/editor/src/core/extensions/callout/logo-selector.tsx b/packages/editor/src/core/extensions/callout/logo-selector.tsx index 8ea47d50d..7a552cd16 100644 --- a/packages/editor/src/core/extensions/callout/logo-selector.tsx +++ b/packages/editor/src/core/extensions/callout/logo-selector.tsx @@ -1,9 +1,6 @@ -// plane helpers -import { convertHexEmojiToDecimal } from "@plane/utils"; -// plane ui +// plane imports import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui"; -// plane utils -import { cn } from "@plane/utils"; +import { cn, convertHexEmojiToDecimal } from "@plane/utils"; // types import { TCalloutBlockAttributes } from "./types"; // utils diff --git a/packages/editor/src/core/extensions/callout/types.ts b/packages/editor/src/core/extensions/callout/types.ts index 17c55d9e5..8e650d873 100644 --- a/packages/editor/src/core/extensions/callout/types.ts +++ b/packages/editor/src/core/extensions/callout/types.ts @@ -20,7 +20,7 @@ export type TCalloutBlockEmojiAttributes = { export type TCalloutBlockAttributes = { [EAttributeNames.LOGO_IN_USE]: "emoji" | "icon"; - [EAttributeNames.BACKGROUND]: string; + [EAttributeNames.BACKGROUND]: string | undefined; [EAttributeNames.BLOCK_TYPE]: "callout-component"; } & TCalloutBlockIconAttributes & TCalloutBlockEmojiAttributes; diff --git a/packages/editor/src/core/extensions/callout/utils.ts b/packages/editor/src/core/extensions/callout/utils.ts index 6568a40e3..3bf07f0a9 100644 --- a/packages/editor/src/core/extensions/callout/utils.ts +++ b/packages/editor/src/core/extensions/callout/utils.ts @@ -1,7 +1,6 @@ -// plane helpers -import { sanitizeHTML } from "@plane/utils"; -// plane ui +// plane imports import { TEmojiLogoProps } from "@plane/ui"; +import { sanitizeHTML } from "@plane/utils"; // types import { EAttributeNames, @@ -12,11 +11,11 @@ import { export const DEFAULT_CALLOUT_BLOCK_ATTRIBUTES: TCalloutBlockAttributes = { "data-logo-in-use": "emoji", - "data-icon-color": null, - "data-icon-name": null, + "data-icon-color": undefined, + "data-icon-name": undefined, "data-emoji-unicode": "128161", "data-emoji-url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png", - "data-background": null, + "data-background": undefined, "data-block-type": "callout-component", }; @@ -32,7 +31,7 @@ export const getStoredLogo = (): TStoredLogoValue => { }; if (typeof window !== "undefined") { - const storedData = sanitizeHTML(localStorage.getItem("editor-calloutComponent-logo")); + const storedData = sanitizeHTML(localStorage.getItem("editor-calloutComponent-logo") ?? ""); if (storedData) { let parsedData: TEmojiLogoProps; try { @@ -69,7 +68,7 @@ export const updateStoredLogo = (value: TEmojiLogoProps): void => { // function to get the stored background color from local storage export const getStoredBackgroundColor = (): string | null => { if (typeof window !== "undefined") { - return sanitizeHTML(localStorage.getItem("editor-calloutComponent-background")); + return sanitizeHTML(localStorage.getItem("editor-calloutComponent-background") ?? ""); } return null; }; diff --git a/packages/editor/src/core/extensions/clipboard.ts b/packages/editor/src/core/extensions/clipboard.ts deleted file mode 100644 index 252f0a113..000000000 --- a/packages/editor/src/core/extensions/clipboard.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Extension } from "@tiptap/core"; -import { Fragment, Node } from "@tiptap/pm/model"; -import { Plugin, PluginKey } from "@tiptap/pm/state"; - -export const MarkdownClipboard = Extension.create({ - name: "markdownClipboard", - - addProseMirrorPlugins() { - return [ - new Plugin({ - key: new PluginKey("markdownClipboard"), - props: { - clipboardTextSerializer: (slice) => { - const markdownSerializer = this.editor.storage.markdown.serializer; - const isTableRow = slice.content.firstChild?.type?.name === "tableRow"; - const nodeSelect = slice.openStart === 0 && slice.openEnd === 0; - - if (nodeSelect) { - return markdownSerializer.serialize(slice.content); - } - - const processTableContent = (tableNode: Node | Fragment) => { - let result = ""; - tableNode.content?.forEach?.((tableRowNode: Node | Fragment) => { - tableRowNode.content?.forEach?.((cell: Node) => { - const cellContent = cell.content ? markdownSerializer.serialize(cell.content) : ""; - result += cellContent + "\n"; - }); - }); - return result; - }; - - if (isTableRow) { - const rowsCount = slice.content?.childCount || 0; - const cellsCount = slice.content?.firstChild?.content?.childCount || 0; - if (rowsCount === 1 || cellsCount === 1) { - return processTableContent(slice.content); - } else { - return markdownSerializer.serialize(slice.content); - } - } - - const traverseToParentOfLeaf = ( - node: Node | null, - parent: Fragment | Node, - depth: number - ): Node | Fragment => { - let currentNode = node; - let currentParent = parent; - let currentDepth = depth; - - while (currentNode && currentDepth > 1 && currentNode.content?.firstChild) { - if (currentNode.content?.childCount > 1) { - if (currentNode.content.firstChild?.type?.name === "listItem") { - return currentParent; - } else { - return currentNode.content; - } - } - - currentParent = currentNode; - currentNode = currentNode.content?.firstChild || null; - currentDepth--; - } - - return currentParent; - }; - - if (slice.content.childCount > 1) { - return markdownSerializer.serialize(slice.content); - } else { - const targetNode = traverseToParentOfLeaf(slice.content.firstChild, slice.content, slice.openStart); - - let currentNode = targetNode; - while (currentNode && currentNode.content && currentNode.childCount === 1 && currentNode.firstChild) { - currentNode = currentNode.firstChild; - } - if (currentNode instanceof Node && currentNode.isText) { - return currentNode.text; - } - - return markdownSerializer.serialize(targetNode); - } - }, - }, - }), - ]; - }, -}); diff --git a/packages/editor/src/core/extensions/code-inline/index.tsx b/packages/editor/src/core/extensions/code-inline/index.tsx index 6e023b6ed..ae320cf6a 100644 --- a/packages/editor/src/core/extensions/code-inline/index.tsx +++ b/packages/editor/src/core/extensions/code-inline/index.tsx @@ -1,4 +1,6 @@ import { Mark, markInputRule, markPasteRule, mergeAttributes } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; export interface CodeOptions { HTMLAttributes: Record; @@ -6,7 +8,7 @@ export interface CodeOptions { declare module "@tiptap/core" { interface Commands { - code: { + [CORE_EXTENSIONS.CODE_INLINE]: { /** * Set a code mark */ @@ -27,7 +29,7 @@ export const inputRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))$/; const pasteRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))/g; export const CustomCodeInlineExtension = Mark.create({ - name: "code", + name: CORE_EXTENSIONS.CODE_INLINE, addOptions() { return { diff --git a/packages/editor/src/core/extensions/code/code-block-node-view.tsx b/packages/editor/src/core/extensions/code/code-block-node-view.tsx index a06d83990..7626031bc 100644 --- a/packages/editor/src/core/extensions/code/code-block-node-view.tsx +++ b/packages/editor/src/core/extensions/code/code-block-node-view.tsx @@ -1,11 +1,11 @@ "use client"; -import { useState } from "react"; import { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { NodeViewWrapper, NodeViewContent } from "@tiptap/react"; import ts from "highlight.js/lib/languages/typescript"; import { common, createLowlight } from "lowlight"; import { CopyIcon, CheckIcon } from "lucide-react"; +import { useState } from "react"; // ui import { Tooltip } from "@plane/ui"; // plane utils @@ -27,7 +27,7 @@ export const CodeBlockComponent: React.FC = ({ node }) await navigator.clipboard.writeText(node.textContent); setCopied(true); setTimeout(() => setCopied(false), 1000); - } catch (error) { + } catch { setCopied(false); } e.preventDefault(); diff --git a/packages/editor/src/core/extensions/code/code-block.ts b/packages/editor/src/core/extensions/code/code-block.ts index b2218ee45..3b07617ca 100644 --- a/packages/editor/src/core/extensions/code/code-block.ts +++ b/packages/editor/src/core/extensions/code/code-block.ts @@ -1,5 +1,7 @@ import { mergeAttributes, Node, textblockTypeInputRule } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; export interface CodeBlockOptions { /** @@ -25,7 +27,7 @@ export interface CodeBlockOptions { declare module "@tiptap/core" { interface Commands { - codeBlock: { + [CORE_EXTENSIONS.CODE_BLOCK]: { /** * Set a code block */ @@ -42,7 +44,7 @@ export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/; export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/; export const CodeBlock = Node.create({ - name: "codeBlock", + name: CORE_EXTENSIONS.CODE_BLOCK, addOptions() { return { @@ -118,7 +120,7 @@ export const CodeBlock = Node.create({ toggleCodeBlock: (attributes) => ({ commands }) => - commands.toggleNode(this.name, "paragraph", attributes), + commands.toggleNode(this.name, CORE_EXTENSIONS.PARAGRAPH, attributes), }; }, @@ -126,7 +128,7 @@ export const CodeBlock = Node.create({ return { "Mod-Alt-c": () => this.editor.commands.toggleCodeBlock(), - // remove code block when at start of document or code block is empty + // remove codeBlock when at start of document or codeBlock is empty Backspace: () => { try { const { empty, $anchor } = this.editor.state.selection; @@ -259,7 +261,7 @@ export const CodeBlock = Node.create({ return false; } - if (this.editor.isActive("code")) { + if (this.editor.isActive(CORE_EXTENSIONS.CODE_INLINE)) { // Check if it's an inline code block event.preventDefault(); const text = event.clipboardData.getData("text/plain"); diff --git a/packages/editor/src/core/extensions/code/lowlight-plugin.ts b/packages/editor/src/core/extensions/code/lowlight-plugin.ts index 5ac30c27e..0b8ed71ad 100644 --- a/packages/editor/src/core/extensions/code/lowlight-plugin.ts +++ b/packages/editor/src/core/extensions/code/lowlight-plugin.ts @@ -88,7 +88,7 @@ export function LowlightPlugin({ throw Error("You should provide an instance of lowlight to use the code-block-lowlight extension"); } - const lowlightPlugin: Plugin = new Plugin({ + const lowlightPlugin: Plugin = new Plugin({ key: new PluginKey("lowlight"), state: { diff --git a/packages/editor/src/core/extensions/core-without-props.ts b/packages/editor/src/core/extensions/core-without-props.ts index ed9f5c1a4..d66cae7bd 100644 --- a/packages/editor/src/core/extensions/core-without-props.ts +++ b/packages/editor/src/core/extensions/core-without-props.ts @@ -3,24 +3,24 @@ import TaskList from "@tiptap/extension-task-list"; import TextStyle from "@tiptap/extension-text-style"; import TiptapUnderline from "@tiptap/extension-underline"; import StarterKit from "@tiptap/starter-kit"; -// extensions // helpers import { isValidHttpUrl } from "@/helpers/common"; +// plane editor imports +import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props"; +// extensions +import { CustomCalloutExtensionConfig } from "./callout/extension-config"; import { CustomCodeBlockExtensionWithoutProps } from "./code/without-props"; import { CustomCodeInlineExtension } from "./code-inline"; +import { CustomColorExtension } from "./custom-color"; +import { CustomImageExtensionConfig } from "./custom-image/extension-config"; import { CustomLinkExtension } from "./custom-link"; import { CustomHorizontalRule } from "./horizontal-rule"; -import { ImageExtensionWithoutProps } from "./image"; -import { CustomImageComponentWithoutProps } from "./image/image-component-without-props"; -import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props"; +import { ImageExtensionConfig } from "./image"; import { CustomMentionExtensionConfig } from "./mentions/extension-config"; import { CustomQuoteExtension } from "./quote"; import { TableHeader, TableCell, TableRow, Table } from "./table"; import { CustomTextAlignExtension } from "./text-align"; -import { CustomCalloutExtensionConfig } from "./callout/extension-config"; -import { CustomColorExtension } from "./custom-color"; -// plane editor extensions -import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props"; +import { WorkItemEmbedExtensionConfig } from "./work-item-embed/extension-config"; export const CoreEditorExtensionsWithoutProps = [ StarterKit.configure({ @@ -72,12 +72,8 @@ export const CoreEditorExtensionsWithoutProps = [ "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", }, }), - ImageExtensionWithoutProps().configure({ - HTMLAttributes: { - class: "rounded-md", - }, - }), - CustomImageComponentWithoutProps(), + ImageExtensionConfig, + CustomImageExtensionConfig, TiptapUnderline, TextStyle, TaskList.configure({ @@ -104,4 +100,4 @@ export const CoreEditorExtensionsWithoutProps = [ ...CoreEditorAdditionalExtensionsWithoutProps, ]; -export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()]; +export const DocumentEditorExtensionsWithoutProps = [WorkItemEmbedExtensionConfig]; diff --git a/packages/editor/src/core/extensions/custom-code-inline.ts b/packages/editor/src/core/extensions/custom-code-inline.ts deleted file mode 100644 index 3b3cfaab1..000000000 --- a/packages/editor/src/core/extensions/custom-code-inline.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Extension } from "@tiptap/core"; -import codemark from "prosemirror-codemark"; - -export const CustomCodeMarkPlugin = Extension.create({ - name: "codemarkPlugin", - addProseMirrorPlugins() { - return codemark({ markType: this.editor.schema.marks.code }); - }, -}); diff --git a/packages/editor/src/core/extensions/custom-color.ts b/packages/editor/src/core/extensions/custom-color.ts index b377099fb..8b516e8ec 100644 --- a/packages/editor/src/core/extensions/custom-color.ts +++ b/packages/editor/src/core/extensions/custom-color.ts @@ -1,10 +1,11 @@ import { Mark, mergeAttributes } from "@tiptap/core"; // constants import { COLORS_LIST } from "@/constants/common"; +import { CORE_EXTENSIONS } from "@/constants/extension"; declare module "@tiptap/core" { interface Commands { - color: { + [CORE_EXTENSIONS.CUSTOM_COLOR]: { /** * Set the text color * @param {string} color The color to set @@ -34,7 +35,7 @@ declare module "@tiptap/core" { } export const CustomColorExtension = Mark.create({ - name: "customColor", + name: CORE_EXTENSIONS.CUSTOM_COLOR, addOptions() { return { diff --git a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx b/packages/editor/src/core/extensions/custom-image/components/block.tsx similarity index 90% rename from packages/editor/src/core/extensions/custom-image/components/image-block.tsx rename to packages/editor/src/core/extensions/custom-image/components/block.tsx index 0cc38f5a4..1ff36abca 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/block.tsx @@ -1,68 +1,42 @@ import { NodeSelection } from "@tiptap/pm/state"; import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react"; -// plane utils +// plane imports import { cn } from "@plane/utils"; -// extensions -import { CustoBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image"; +// local imports +import { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types"; +import { ensurePixelString } from "../utils"; +import type { CustomImageNodeViewProps } from "./node-view"; +import { ImageToolbarRoot } from "./toolbar"; import { ImageUploadStatus } from "./upload-status"; const MIN_SIZE = 100; -type Pixel = `${number}px`; - -type PixelAttribute = Pixel | TDefault; - -export type ImageAttributes = { - src: string | null; - width: PixelAttribute<"35%" | number>; - height: PixelAttribute<"auto" | number>; - aspectRatio: number | null; - id: string | null; -}; - -type Size = { - width: PixelAttribute<"35%">; - height: PixelAttribute<"auto">; - aspectRatio: number | null; -}; - -const ensurePixelString = (value: Pixel | TDefault | number | undefined | null, defaultValue?: TDefault) => { - if (!value || value === defaultValue) { - return defaultValue; - } - - if (typeof value === "number") { - return `${value}px` satisfies Pixel; - } - - return value; -}; - -type CustomImageBlockProps = CustoBaseImageNodeViewProps & { - imageFromFileSystem: string | undefined; - setFailedToLoadImage: (isError: boolean) => void; +type CustomImageBlockProps = CustomImageNodeViewProps & { editorContainer: HTMLDivElement | null; + imageFromFileSystem: string | undefined; setEditorContainer: (editorContainer: HTMLDivElement | null) => void; + setFailedToLoadImage: (isError: boolean) => void; src: string | undefined; }; export const CustomImageBlock: React.FC = (props) => { // props const { - node, - updateAttributes, - setFailedToLoadImage, - imageFromFileSystem, - selected, - getPos, editor, editorContainer, - src: resolvedImageSrc, + extension, + getPos, + imageFromFileSystem, + node, + selected, setEditorContainer, + setFailedToLoadImage, + src: resolvedImageSrc, + updateAttributes, } = props; const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs; // states - const [size, setSize] = useState({ + const [size, setSize] = useState({ width: ensurePixelString(nodeWidth, "35%") ?? "35%", height: ensurePixelString(nodeHeight, "auto") ?? "auto", aspectRatio: nodeAspectRatio || null, @@ -77,7 +51,7 @@ export const CustomImageBlock: React.FC = (props) => { const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false); const updateAttributesSafely = useCallback( - (attributes: Partial, errorMessage: string) => { + (attributes: Partial, errorMessage: string) => { try { updateAttributes(attributes); } catch (error) { @@ -114,7 +88,7 @@ export const CustomImageBlock: React.FC = (props) => { const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE); const initialHeight = initialWidth / aspectRatioCalculated; - const initialComputedSize = { + const initialComputedSize: TCustomImageSize = { width: `${Math.round(initialWidth)}px` satisfies Pixel, height: `${Math.round(initialHeight)}px` satisfies Pixel, aspectRatio: aspectRatioCalculated, @@ -139,7 +113,7 @@ export const CustomImageBlock: React.FC = (props) => { } } setInitialResizeComplete(true); - }, [nodeWidth, updateAttributes, editorContainer, nodeAspectRatio]); + }, [nodeWidth, updateAttributesSafely, editorContainer, nodeAspectRatio, setEditorContainer]); // for real time resizing useLayoutEffect(() => { @@ -168,7 +142,7 @@ export const CustomImageBlock: React.FC = (props) => { const handleResizeEnd = useCallback(() => { setIsResizing(false); updateAttributesSafely(size, "Failed to update attributes at the end of resizing:"); - }, [size, updateAttributes]); + }, [size, updateAttributesSafely]); const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => { e.preventDefault(); @@ -242,7 +216,7 @@ export const CustomImageBlock: React.FC = (props) => { onLoad={handleImageLoad} onError={async (e) => { // for old image extension this command doesn't exist or if the image failed to load for the first time - if (!editor?.commands.restoreImage || hasTriedRestoringImageOnce) { + if (!extension.options.restoreImage || hasTriedRestoringImageOnce) { setFailedToLoadImage(true); return; } @@ -253,7 +227,7 @@ export const CustomImageBlock: React.FC = (props) => { if (!imgNodeSrc) { throw new Error("No source image to restore from"); } - await editor?.commands.restoreImage?.(imgNodeSrc); + await extension.options.restoreImage?.(imgNodeSrc); if (!imageRef.current) { throw new Error("Image reference not found"); } @@ -289,10 +263,10 @@ export const CustomImageBlock: React.FC = (props) => { "absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity" } image={{ - src: resolvedImageSrc, - aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio, - height: size.height, width: size.width, + height: size.height, + aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio, + src: resolvedImageSrc, }} /> )} diff --git a/packages/editor/src/core/extensions/custom-image/components/index.ts b/packages/editor/src/core/extensions/custom-image/components/index.ts deleted file mode 100644 index 9d12c3ecf..000000000 --- a/packages/editor/src/core/extensions/custom-image/components/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./toolbar"; -export * from "./image-block"; -export * from "./image-node"; -export * from "./image-uploader"; diff --git a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx b/packages/editor/src/core/extensions/custom-image/components/node-view.tsx similarity index 66% rename from packages/editor/src/core/extensions/custom-image/components/image-node.tsx rename to packages/editor/src/core/extensions/custom-image/components/node-view.tsx index e525bc6da..74ea2c38c 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/node-view.tsx @@ -1,23 +1,27 @@ import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { useEffect, useRef, useState } from "react"; -// extensions -import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// helpers import { getExtensionStorage } from "@/helpers/get-extension-storage"; +// local imports +import type { CustomImageExtension, TCustomImageAttributes } from "../types"; +import { CustomImageBlock } from "./block"; +import { CustomImageUploader } from "./uploader"; -export type CustoBaseImageNodeViewProps = { +export type CustomImageNodeViewProps = Omit & { + extension: CustomImageExtension; getPos: () => number; editor: Editor; node: NodeViewProps["node"] & { - attrs: ImageAttributes; + attrs: TCustomImageAttributes; }; - updateAttributes: (attrs: Partial) => void; + updateAttributes: (attrs: Partial) => void; selected: boolean; }; -export type CustomImageNodeProps = NodeViewProps & CustoBaseImageNodeViewProps; - -export const CustomImageNode = (props: CustomImageNodeProps) => { - const { getPos, editor, node, updateAttributes, selected } = props; +export const CustomImageNodeView: React.FC = (props) => { + const { editor, extension, node } = props; const { src: imgNodeSrc } = node.attrs; const [isUploaded, setIsUploaded] = useState(false); @@ -47,41 +51,37 @@ export const CustomImageNode = (props: CustomImageNodeProps) => { }, [resolvedSrc]); useEffect(() => { + if (!imgNodeSrc) { + setResolvedSrc(undefined); + return; + } + const getImageSource = async () => { - // @ts-expect-error function not expected here, but will still work and don't remove await - const url: string = await editor?.commands?.getImageSource?.(imgNodeSrc); - setResolvedSrc(url as string); + const url = await extension.options.getImageSource?.(imgNodeSrc); + setResolvedSrc(url); }; getImageSource(); - }, [imgNodeSrc]); + }, [imgNodeSrc, extension.options]); return (
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? ( ) : ( )}
diff --git a/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen.tsx b/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen.tsx index 61ae307bb..1d2e52ca0 100644 --- a/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen.tsx @@ -1,14 +1,14 @@ import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useState, useRef } from "react"; -// plane utils +// plane imports import { cn } from "@plane/utils"; type Props = { image: { - src: string; - height: string; width: string; + height: string; aspectRatio: number; + src: string; }; isOpen: boolean; toggleFullScreenMode: (val: boolean) => void; @@ -189,7 +189,7 @@ export const ImageFullScreenAction: React.FC = (props) => { <>
= (props) => { // subscribe to image upload status const uploadStatus: number | undefined = useEditorState({ editor, - selector: ({ editor }) => editor.storage.imageComponent?.assetsUploadStatus[nodeId], + selector: ({ editor }) => getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.assetsUploadStatus?.[nodeId], }); useEffect(() => { diff --git a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx b/packages/editor/src/core/extensions/custom-image/components/uploader.tsx similarity index 72% rename from packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx rename to packages/editor/src/core/extensions/custom-image/components/uploader.tsx index 0fd0e6dd4..68626084a 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/uploader.tsx @@ -1,24 +1,30 @@ import { ImageIcon } from "lucide-react"; import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react"; -// plane utils +// plane imports import { cn } from "@plane/utils"; // constants -import { ACCEPTED_FILE_EXTENSIONS } from "@/constants/config"; -// extensions -import { CustoBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image"; +import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config"; +import { CORE_EXTENSIONS } from "@/constants/extension"; +// helpers +import { EFileError } from "@/helpers/file"; +import { getExtensionStorage } from "@/helpers/get-extension-storage"; // hooks -import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload"; +import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload"; +// local imports +import { getImageComponentImageFileMap } from "../utils"; +import type { CustomImageNodeViewProps } from "./node-view"; -type CustomImageUploaderProps = CustoBaseImageNodeViewProps & { - maxFileSize: number; - loadImageFromFileSystem: (file: string) => void; +type CustomImageUploaderProps = CustomImageNodeViewProps & { failedToLoadImage: boolean; + loadImageFromFileSystem: (file: string) => void; + maxFileSize: number; setIsUploaded: (isUploaded: boolean) => void; }; export const CustomImageUploader = (props: CustomImageUploaderProps) => { const { editor, + extension, failedToLoadImage, getPos, loadImageFromFileSystem, @@ -41,7 +47,9 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { if (!imageEntityId) return; setIsUploaded(true); // Update the node view's src attribute post upload - updateAttributes({ src: url }); + updateAttributes({ + src: url, + }); imageComponentImageFileMap?.delete(imageEntityId); const pos = getPos(); @@ -51,11 +59,11 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { // only if the cursor is at the current image component, manipulate // the cursor position - if (currentNode && currentNode.type.name === "imageComponent" && currentNode.attrs.src === url) { + if (currentNode && currentNode.type.name === node.type.name && currentNode.attrs.src === url) { // control cursor position after upload const nextNode = editor.state.doc.nodeAt(pos + 1); - if (nextNode && nextNode.type.name === "paragraph") { + if (nextNode && nextNode.type.name === CORE_EXTENSIONS.PARAGRAPH) { // If there is a paragraph node after the image component, move the focus to the next node editor.commands.setTextSelection(pos + 1); } else { @@ -65,20 +73,41 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { } } }, + // eslint-disable-next-line react-hooks/exhaustive-deps [imageComponentImageFileMap, imageEntityId, updateAttributes, getPos] ); + + const uploadImageEditorCommand = useCallback( + async (file: File) => await extension.options.uploadImage?.(imageEntityId ?? "", file), + [extension.options, imageEntityId] + ); + + const handleProgressStatus = useCallback( + (isUploading: boolean) => { + getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).uploadInProgress = isUploading; + }, + [editor] + ); + + const handleInvalidFile = useCallback((_error: EFileError, _file: File, message: string) => { + alert(message); + }, []); + // hooks - const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ - blockId: imageEntityId ?? "", - editor, - loadImageFromFileSystem, + const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({ + acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES, + editorCommand: uploadImageEditorCommand, + handleProgressStatus, + loadFileFromFileSystem: loadImageFromFileSystem, maxFileSize, + onInvalidFile: handleInvalidFile, onUpload, }); + const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ editor, - maxFileSize, pos: getPos(), + type: "image", uploader: uploadFile, }); @@ -101,7 +130,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { imageComponentImageFileMap?.set(imageEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true }); } } - }, [meta, uploadFile, imageComponentImageFileMap]); + }, [meta, uploadFile, imageComponentImageFileMap, imageEntityId]); const onFileChange = useCallback( async (e: ChangeEvent) => { @@ -110,11 +139,11 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { if (!filesList) { return; } - await uploadFirstImageAndInsertRemaining({ + await uploadFirstFileAndInsertRemaining({ editor, filesList, - maxFileSize, pos: getPos(), + type: "image", uploader: uploadFile, }); }, @@ -136,7 +165,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { } return "Add an image"; - }, [draggedInside, failedToLoadImage, isImageBeingUploaded]); + }, [draggedInside, failedToLoadImage, isImageBeingUploaded, editor.isEditable]); return (
{ ref={fileInputRef} hidden type="file" - accept={ACCEPTED_FILE_EXTENSIONS.join(",")} + accept={ACCEPTED_IMAGE_MIME_TYPES.join(",")} onChange={onFileChange} multiple /> diff --git a/packages/editor/src/core/extensions/custom-image/custom-image.ts b/packages/editor/src/core/extensions/custom-image/custom-image.ts deleted file mode 100644 index 4f1b3c8db..000000000 --- a/packages/editor/src/core/extensions/custom-image/custom-image.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { Editor, mergeAttributes } from "@tiptap/core"; -import { Image } from "@tiptap/extension-image"; -import { ReactNodeViewRenderer } from "@tiptap/react"; -import { v4 as uuidv4 } from "uuid"; -// extensions -import { CustomImageNode } from "@/extensions/custom-image"; -// helpers -import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary"; -// plugins -import { TrackImageDeletionPlugin, TrackImageRestorationPlugin, isFileValid } from "@/plugins/image"; -// types -import { TFileHandler } from "@/types"; - -export type InsertImageComponentProps = { - file?: File; - pos?: number; - event: "insert" | "drop"; -}; - -declare module "@tiptap/core" { - interface Commands { - imageComponent: { - insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType; - uploadImage: (blockId: string, file: File) => () => Promise | undefined; - updateAssetsUploadStatus?: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void; - getImageSource?: (path: string) => () => Promise; - restoreImage: (src: string) => () => Promise; - }; - } -} - -export const getImageComponentImageFileMap = (editor: Editor) => - (editor.storage.imageComponent as UploadImageExtensionStorage | undefined)?.fileMap; - -export interface UploadImageExtensionStorage { - assetsUploadStatus: TFileHandler["assetsUploadStatus"]; - fileMap: Map; - deletedImageSet: Map; - uploadInProgress: boolean; - maxFileSize: number; -} - -export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean }; - -export const CustomImageExtension = (props: TFileHandler) => { - const { - assetsUploadStatus, - getAssetSrc, - upload, - delete: deleteImageFn, - restore: restoreImageFn, - validation: { maxFileSize }, - } = props; - - return Image.extend, UploadImageExtensionStorage>({ - name: "imageComponent", - selectable: true, - group: "block", - atom: true, - draggable: true, - - addAttributes() { - return { - ...this.parent?.(), - width: { - default: "35%", - }, - src: { - default: null, - }, - height: { - default: "auto", - }, - ["id"]: { - default: null, - }, - aspectRatio: { - default: null, - }, - }; - }, - - parseHTML() { - return [ - { - tag: "image-component", - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return ["image-component", mergeAttributes(HTMLAttributes)]; - }, - - addKeyboardShortcuts() { - return { - ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), - ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name), - }; - }, - - addProseMirrorPlugins() { - return [ - TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name), - TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name), - ]; - }, - - onCreate(this) { - const imageSources = new Set(); - this.editor.state.doc.descendants((node) => { - if (node.type.name === this.name) { - if (!node.attrs.src?.startsWith("http")) return; - imageSources.add(node.attrs.src); - } - }); - imageSources.forEach(async (src) => { - try { - await restoreImageFn(src); - } catch (error) { - console.error("Error restoring image: ", error); - } - }); - }, - - addStorage() { - return { - fileMap: new Map(), - deletedImageSet: new Map(), - uploadInProgress: false, - maxFileSize, - // escape markdown for images - markdown: { - serialize() {}, - }, - assetsUploadStatus, - }; - }, - - addCommands() { - return { - insertImageComponent: - (props) => - ({ commands }) => { - // Early return if there's an invalid file being dropped - if ( - props?.file && - !isFileValid({ - file: props.file, - maxFileSize, - }) - ) { - return false; - } - - // generate a unique id for the image to keep track of dropped - // files' file data - const fileId = uuidv4(); - - const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor); - - if (imageComponentImageFileMap) { - if (props?.event === "drop" && props.file) { - imageComponentImageFileMap.set(fileId, { - file: props.file, - event: props.event, - }); - } else if (props.event === "insert") { - imageComponentImageFileMap.set(fileId, { - event: props.event, - hasOpenedFileInputOnce: false, - }); - } - } - - const attributes = { - id: fileId, - }; - - if (props.pos) { - return commands.insertContentAt(props.pos, { - type: this.name, - attrs: attributes, - }); - } - return commands.insertContent({ - type: this.name, - attrs: attributes, - }); - }, - uploadImage: (blockId, file) => async () => { - const fileUrl = await upload(blockId, file); - return fileUrl; - }, - updateAssetsUploadStatus: (updatedStatus) => () => { - this.storage.assetsUploadStatus = updatedStatus; - }, - getImageSource: (path) => async () => await getAssetSrc(path), - restoreImage: (src) => async () => { - await restoreImageFn(src); - }, - }; - }, - - addNodeView() { - return ReactNodeViewRenderer(CustomImageNode); - }, - }); -}; diff --git a/packages/editor/src/core/extensions/custom-image/extension-config.ts b/packages/editor/src/core/extensions/custom-image/extension-config.ts new file mode 100644 index 000000000..56714f533 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/extension-config.ts @@ -0,0 +1,47 @@ +import { mergeAttributes } from "@tiptap/core"; +import { Image as BaseImageExtension } from "@tiptap/extension-image"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// local imports +import { type CustomImageExtension, ECustomImageAttributeNames, type InsertImageComponentProps } from "./types"; +import { DEFAULT_CUSTOM_IMAGE_ATTRIBUTES } from "./utils"; + +declare module "@tiptap/core" { + interface Commands { + [CORE_EXTENSIONS.CUSTOM_IMAGE]: { + insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType; + }; + } +} + +export const CustomImageExtensionConfig: CustomImageExtension = BaseImageExtension.extend({ + name: CORE_EXTENSIONS.CUSTOM_IMAGE, + group: "block", + atom: true, + + addAttributes() { + const attributes = { + ...this.parent?.(), + ...Object.values(ECustomImageAttributeNames).reduce((acc, value) => { + acc[value] = { + default: DEFAULT_CUSTOM_IMAGE_ATTRIBUTES[value], + }; + return acc; + }, {}), + }; + + return attributes; + }, + + parseHTML() { + return [ + { + tag: "image-component", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["image-component", mergeAttributes(HTMLAttributes)]; + }, +}); diff --git a/packages/editor/src/core/extensions/custom-image/extension.ts b/packages/editor/src/core/extensions/custom-image/extension.ts new file mode 100644 index 000000000..ec795da84 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/extension.ts @@ -0,0 +1,121 @@ +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { v4 as uuidv4 } from "uuid"; +// constants +import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config"; +// helpers +import { isFileValid } from "@/helpers/file"; +import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary"; +// types +import type { TFileHandler, TReadOnlyFileHandler } from "@/types"; +// local imports +import { CustomImageNodeView } from "./components/node-view"; +import { CustomImageExtensionConfig } from "./extension-config"; +import { getImageComponentImageFileMap } from "./utils"; + +type Props = { + fileHandler: TFileHandler | TReadOnlyFileHandler; + isEditable: boolean; +}; + +export const CustomImageExtension = (props: Props) => { + const { fileHandler, isEditable } = props; + // derived values + const { getAssetSrc, restore: restoreImageFn } = fileHandler; + + return CustomImageExtensionConfig.extend({ + selectable: isEditable, + draggable: isEditable, + + addOptions() { + const upload = "upload" in fileHandler ? fileHandler.upload : undefined; + + return { + ...this.parent?.(), + getImageSource: getAssetSrc, + restoreImage: restoreImageFn, + uploadImage: upload, + }; + }, + + addStorage() { + const maxFileSize = "validation" in fileHandler ? fileHandler.validation?.maxFileSize : 0; + + return { + fileMap: new Map(), + deletedImageSet: new Map(), + maxFileSize, + // escape markdown for images + markdown: { + serialize() {}, + }, + }; + }, + + addCommands() { + return { + insertImageComponent: + (props) => + ({ commands }) => { + // Early return if there's an invalid file being dropped + if ( + props?.file && + !isFileValid({ + acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES, + file: props.file, + maxFileSize: this.storage.maxFileSize, + onError: (_error, message) => alert(message), + }) + ) { + return false; + } + + // generate a unique id for the image to keep track of dropped + // files' file data + const fileId = uuidv4(); + + const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor); + + if (imageComponentImageFileMap) { + if (props?.event === "drop" && props.file) { + imageComponentImageFileMap.set(fileId, { + file: props.file, + event: props.event, + }); + } else if (props.event === "insert") { + imageComponentImageFileMap.set(fileId, { + event: props.event, + hasOpenedFileInputOnce: false, + }); + } + } + + const attributes = { + id: fileId, + }; + + if (props.pos) { + return commands.insertContentAt(props.pos, { + type: this.name, + attrs: attributes, + }); + } + return commands.insertContent({ + type: this.name, + attrs: attributes, + }); + }, + }; + }, + + addKeyboardShortcuts() { + return { + ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), + ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name), + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(CustomImageNodeView); + }, + }); +}; diff --git a/packages/editor/src/core/extensions/custom-image/index.ts b/packages/editor/src/core/extensions/custom-image/index.ts deleted file mode 100644 index de2bb3878..000000000 --- a/packages/editor/src/core/extensions/custom-image/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./components"; -export * from "./custom-image"; -export * from "./read-only-custom-image"; diff --git a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts deleted file mode 100644 index 0d8a7cc55..000000000 --- a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { mergeAttributes } from "@tiptap/core"; -import { Image } from "@tiptap/extension-image"; -import { ReactNodeViewRenderer } from "@tiptap/react"; -// components -import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image"; -// types -import { TReadOnlyFileHandler } from "@/types"; - -export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => { - const { getAssetSrc, restore: restoreImageFn } = props; - - return Image.extend, UploadImageExtensionStorage>({ - name: "imageComponent", - selectable: false, - group: "block", - atom: true, - draggable: false, - - addAttributes() { - return { - ...this.parent?.(), - width: { - default: "35%", - }, - src: { - default: null, - }, - height: { - default: "auto", - }, - ["id"]: { - default: null, - }, - aspectRatio: { - default: null, - }, - }; - }, - - parseHTML() { - return [ - { - tag: "image-component", - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return ["image-component", mergeAttributes(HTMLAttributes)]; - }, - - addStorage() { - return { - fileMap: new Map(), - deletedImageSet: new Map(), - uploadInProgress: false, - maxFileSize: 0, - // escape markdown for images - markdown: { - serialize() {}, - }, - assetsUploadStatus: {}, - }; - }, - - addCommands() { - return { - getImageSource: (path: string) => async () => await getAssetSrc(path), - restoreImage: (src) => async () => { - await restoreImageFn(src); - }, - }; - }, - - addNodeView() { - return ReactNodeViewRenderer(CustomImageNode); - }, - }); -}; diff --git a/packages/editor/src/core/extensions/custom-image/types.ts b/packages/editor/src/core/extensions/custom-image/types.ts new file mode 100644 index 000000000..675d8a221 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/types.ts @@ -0,0 +1,51 @@ +import type { Node } from "@tiptap/core"; +// types +import type { TFileHandler } from "@/types"; + +export enum ECustomImageAttributeNames { + ID = "id", + WIDTH = "width", + HEIGHT = "height", + ASPECT_RATIO = "aspectRatio", + SOURCE = "src", +} + +export type Pixel = `${number}px`; + +export type PixelAttribute = Pixel | TDefault; + +export type TCustomImageSize = { + width: PixelAttribute<"35%">; + height: PixelAttribute<"auto">; + aspectRatio: number | null; +}; + +export type TCustomImageAttributes = { + [ECustomImageAttributeNames.ID]: string | null; + [ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null; + [ECustomImageAttributeNames.HEIGHT]: PixelAttribute<"auto" | number> | null; + [ECustomImageAttributeNames.ASPECT_RATIO]: number | null; + [ECustomImageAttributeNames.SOURCE]: string | null; +}; + +export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean }; + +export type InsertImageComponentProps = { + file?: File; + pos?: number; + event: "insert" | "drop"; +}; + +export type CustomImageExtensionOptions = { + getImageSource: TFileHandler["getAssetSrc"]; + restoreImage: TFileHandler["restore"]; + uploadImage?: TFileHandler["upload"]; +}; + +export type CustomImageExtensionStorage = { + fileMap: Map; + deletedImageSet: Map; + maxFileSize: number; +}; + +export type CustomImageExtension = Node; diff --git a/packages/editor/src/core/extensions/custom-image/utils.ts b/packages/editor/src/core/extensions/custom-image/utils.ts new file mode 100644 index 000000000..0711e094f --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/utils.ts @@ -0,0 +1,33 @@ +import type { Editor } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// helpers +import { getExtensionStorage } from "@/helpers/get-extension-storage"; +// local imports +import { ECustomImageAttributeNames, type Pixel, type TCustomImageAttributes } from "./types"; + +export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = { + [ECustomImageAttributeNames.SOURCE]: null, + [ECustomImageAttributeNames.ID]: null, + [ECustomImageAttributeNames.WIDTH]: "35%", + [ECustomImageAttributeNames.HEIGHT]: "auto", + [ECustomImageAttributeNames.ASPECT_RATIO]: null, +}; + +export const getImageComponentImageFileMap = (editor: Editor) => + getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap; + +export const ensurePixelString = ( + value: Pixel | TDefault | number | undefined | null, + defaultValue?: TDefault +) => { + if (!value || value === defaultValue) { + return defaultValue; + } + + if (typeof value === "number") { + return `${value}px` satisfies Pixel; + } + + return value; +}; diff --git a/packages/editor/src/core/extensions/custom-link/extension.tsx b/packages/editor/src/core/extensions/custom-link/extension.tsx index 27c1bb598..182afc9f8 100644 --- a/packages/editor/src/core/extensions/custom-link/extension.tsx +++ b/packages/editor/src/core/extensions/custom-link/extension.tsx @@ -1,6 +1,9 @@ import { Mark, markPasteRule, mergeAttributes, PasteRuleMatch } from "@tiptap/core"; import { Plugin } from "@tiptap/pm/state"; import { find, registerCustomProtocol, reset } from "linkifyjs"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// local imports import { autolink } from "./helpers/autolink"; import { clickHandler } from "./helpers/clickHandler"; import { pasteHandler } from "./helpers/pasteHandler"; @@ -46,7 +49,7 @@ export interface LinkOptions { declare module "@tiptap/core" { interface Commands { - link: { + [CORE_EXTENSIONS.CUSTOM_LINK]: { /** * Set a link mark */ @@ -79,7 +82,7 @@ export type CustomLinkStorage = { }; export const CustomLinkExtension = Mark.create({ - name: "link", + name: CORE_EXTENSIONS.CUSTOM_LINK, priority: 1000, diff --git a/packages/editor/src/core/extensions/custom-link/helpers/clickHandler.ts b/packages/editor/src/core/extensions/custom-link/helpers/clickHandler.ts index 1b084d1ac..72906bc94 100644 --- a/packages/editor/src/core/extensions/custom-link/helpers/clickHandler.ts +++ b/packages/editor/src/core/extensions/custom-link/helpers/clickHandler.ts @@ -16,7 +16,7 @@ export function clickHandler(options: ClickHandlerOptions): Plugin { } let a = event.target as HTMLElement; - const els = []; + const els: HTMLElement[] = []; while (a?.nodeName !== "DIV") { els.push(a); diff --git a/packages/editor/src/core/extensions/custom-list-keymap/list-helpers.ts b/packages/editor/src/core/extensions/custom-list-keymap/list-helpers.ts index 7d4cad17e..547f9f17e 100644 --- a/packages/editor/src/core/extensions/custom-list-keymap/list-helpers.ts +++ b/packages/editor/src/core/extensions/custom-list-keymap/list-helpers.ts @@ -1,12 +1,14 @@ import { Editor, getNodeType, getNodeAtPosition, isAtEndOfNode, isAtStartOfNode, isNodeActive } from "@tiptap/core"; import { Node, NodeType } from "@tiptap/pm/model"; import { EditorState } from "@tiptap/pm/state"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => { const { $from } = state.selection; const nodeType = getNodeType(typeOrName, state.schema); - let currentNode = null; + let currentNode: Node | null = null; let currentDepth = $from.depth; let currentPos = $from.pos; let targetDepth: number | null = null; @@ -72,7 +74,11 @@ const getPrevListDepth = (typeOrName: string, state: EditorState) => { // Traverse up the document structure from the adjusted position for (let d = resolvedPos.depth; d > 0; d--) { const node = resolvedPos.node(d); - if (node.type.name === "bulletList" || node.type.name === "orderedList" || node.type.name === "taskList") { + if ( + [CORE_EXTENSIONS.BULLET_LIST, CORE_EXTENSIONS.ORDERED_LIST, CORE_EXTENSIONS.TASK_LIST].includes( + node.type.name as CORE_EXTENSIONS + ) + ) { // Increment depth for each list ancestor found depth++; } @@ -309,12 +315,12 @@ const isCurrentParagraphASibling = (state: EditorState): boolean => { // Ensure we're in a paragraph and the parent is a list item. if ( - currentParagraphNode.type.name === "paragraph" && - (listItemNode.type.name === "listItem" || listItemNode.type.name === "taskItem") + currentParagraphNode.type.name === CORE_EXTENSIONS.PARAGRAPH && + [CORE_EXTENSIONS.LIST_ITEM, CORE_EXTENSIONS.TASK_ITEM].includes(listItemNode.type.name as CORE_EXTENSIONS) ) { let paragraphNodesCount = 0; listItemNode.forEach((child) => { - if (child.type.name === "paragraph") { + if (child.type.name === CORE_EXTENSIONS.PARAGRAPH) { paragraphNodesCount++; } }); diff --git a/packages/editor/src/core/extensions/custom-list-keymap/list-keymap.ts b/packages/editor/src/core/extensions/custom-list-keymap/list-keymap.ts index 2a17838fd..576888f55 100644 --- a/packages/editor/src/core/extensions/custom-list-keymap/list-keymap.ts +++ b/packages/editor/src/core/extensions/custom-list-keymap/list-keymap.ts @@ -1,4 +1,6 @@ import { Extension } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions import { handleBackspace, handleDelete } from "@/extensions/custom-list-keymap/list-helpers"; @@ -31,10 +33,10 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) => addKeyboardShortcuts() { return { Tab: () => { - if (this.editor.isActive("listItem") || this.editor.isActive("taskItem")) { - if (this.editor.commands.sinkListItem("listItem")) { + if (this.editor.isActive(CORE_EXTENSIONS.LIST_ITEM) || this.editor.isActive(CORE_EXTENSIONS.TASK_ITEM)) { + if (this.editor.commands.sinkListItem(CORE_EXTENSIONS.LIST_ITEM)) { return true; - } else if (this.editor.commands.sinkListItem("taskItem")) { + } else if (this.editor.commands.sinkListItem(CORE_EXTENSIONS.TASK_ITEM)) { return true; } return true; @@ -46,9 +48,9 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) => return true; }, "Shift-Tab": () => { - if (this.editor.commands.liftListItem("listItem")) { + if (this.editor.commands.liftListItem(CORE_EXTENSIONS.LIST_ITEM)) { return true; - } else if (this.editor.commands.liftListItem("taskItem")) { + } else if (this.editor.commands.liftListItem(CORE_EXTENSIONS.TASK_ITEM)) { return true; } // if tabIndex is set, we don't want to handle Tab key diff --git a/packages/editor/src/core/extensions/drop.tsx b/packages/editor/src/core/extensions/drop.tsx deleted file mode 100644 index 0d578770a..000000000 --- a/packages/editor/src/core/extensions/drop.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { Extension, Editor } from "@tiptap/core"; -import { Plugin, PluginKey } from "@tiptap/pm/state"; -import { EditorView } from "@tiptap/pm/view"; - -export const DropHandlerExtension = Extension.create({ - name: "dropHandler", - priority: 1000, - - addProseMirrorPlugins() { - const editor = this.editor; - return [ - new Plugin({ - key: new PluginKey("drop-handler-plugin"), - props: { - handlePaste: (view: EditorView, event: ClipboardEvent) => { - if ( - editor.isEditable && - event.clipboardData && - event.clipboardData.files && - event.clipboardData.files.length > 0 - ) { - event.preventDefault(); - const files = Array.from(event.clipboardData.files); - const imageFiles = files.filter((file) => file.type.startsWith("image")); - - if (imageFiles.length > 0) { - const pos = view.state.selection.from; - insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" }); - } - return true; - } - return false; - }, - handleDrop: (view: EditorView, event: DragEvent, _slice: any, moved: boolean) => { - if ( - editor.isEditable && - !moved && - event.dataTransfer && - event.dataTransfer.files && - event.dataTransfer.files.length > 0 - ) { - event.preventDefault(); - const files = Array.from(event.dataTransfer.files); - const imageFiles = files.filter((file) => file.type.startsWith("image")); - - if (imageFiles.length > 0) { - const coordinates = view.posAtCoords({ - left: event.clientX, - top: event.clientY, - }); - - if (coordinates) { - const pos = coordinates.pos; - insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" }); - } - return true; - } - } - return false; - }, - }, - }), - ]; - }, -}); -export const insertImagesSafely = async ({ - editor, - files, - initialPos, - event, -}: { - editor: Editor; - files: File[]; - initialPos: number; - event: "insert" | "drop"; -}) => { - let pos = initialPos; - - for (const file of files) { - // safe insertion - const docSize = editor.state.doc.content.size; - pos = Math.min(pos, docSize); - - try { - // Insert the image at the current position - editor.commands.insertImageComponent({ file, pos, event }); - } catch (error) { - console.error(`Error while ${event}ing image:`, error); - } - - // Move to the next position - pos += 1; - } -}; diff --git a/packages/editor/src/core/extensions/enter-key-extension.tsx b/packages/editor/src/core/extensions/enter-key.ts similarity index 53% rename from packages/editor/src/core/extensions/enter-key-extension.tsx rename to packages/editor/src/core/extensions/enter-key.ts index d67ceb78b..65119425f 100644 --- a/packages/editor/src/core/extensions/enter-key-extension.tsx +++ b/packages/editor/src/core/extensions/enter-key.ts @@ -1,16 +1,19 @@ import { Extension } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// helpers +import { getExtensionStorage } from "@/helpers/get-extension-storage"; export const EnterKeyExtension = (onEnterKeyPress?: () => void) => Extension.create({ - name: "enterKey", + name: CORE_EXTENSIONS.ENTER_KEY, addKeyboardShortcuts(this) { return { Enter: () => { - if (!this.editor.storage.mentionsOpen) { - if (onEnterKeyPress) { - onEnterKeyPress(); - } + const isMentionOpen = getExtensionStorage(this.editor, CORE_EXTENSIONS.MENTION)?.mentionsOpen; + if (!isMentionOpen) { + onEnterKeyPress?.(); return true; } return false; @@ -18,8 +21,8 @@ export const EnterKeyExtension = (onEnterKeyPress?: () => void) => "Shift-Enter": ({ editor }) => editor.commands.first(({ commands }) => [ () => commands.newlineInCode(), - () => commands.splitListItem("listItem"), - () => commands.splitListItem("taskItem"), + () => commands.splitListItem(CORE_EXTENSIONS.LIST_ITEM), + () => commands.splitListItem(CORE_EXTENSIONS.TASK_ITEM), () => commands.createParagraphNear(), () => commands.liftEmptyBlock(), () => commands.splitBlock(), diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.ts similarity index 75% rename from packages/editor/src/core/extensions/extensions.tsx rename to packages/editor/src/core/extensions/extensions.ts index ff200cd32..cc8882005 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.ts @@ -7,49 +7,58 @@ import TextStyle from "@tiptap/extension-text-style"; import TiptapUnderline from "@tiptap/extension-underline"; import StarterKit from "@tiptap/starter-kit"; import { Markdown } from "tiptap-markdown"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions import { CustomCalloutExtension, CustomCodeBlockExtension, CustomCodeInlineExtension, - CustomCodeMarkPlugin, CustomColorExtension, CustomHorizontalRule, - CustomImageExtension, CustomKeymap, CustomLinkExtension, CustomMentionExtension, CustomQuoteExtension, CustomTextAlignExtension, CustomTypographyExtension, - DropHandlerExtension, ImageExtension, ListKeymap, Table, TableCell, TableHeader, TableRow, - MarkdownClipboard, + UtilityExtension, } from "@/extensions"; // helpers import { isValidHttpUrl } from "@/helpers/common"; +import { getExtensionStorage } from "@/helpers/get-extension-storage"; // plane editor extensions import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions"; // types -import { TExtensions, TFileHandler, TMentionHandler } from "@/types"; +import type { IEditorProps } from "@/types"; +// local imports +import { CustomImageExtension } from "./custom-image/extension"; -type TArguments = { - disabledExtensions: TExtensions[]; +type TArguments = Pick< + IEditorProps, + "disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler" | "placeholder" | "tabIndex" +> & { enableHistory: boolean; - fileHandler: TFileHandler; - mentionHandler: TMentionHandler; - placeholder?: string | ((isFocused: boolean, value: string) => string); - tabIndex?: number; editable: boolean; }; export const CoreEditorExtensions = (args: TArguments): Extensions => { - const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex } = args; + const { + disabledExtensions, + enableHistory, + fileHandler, + flaggedExtensions, + mentionHandler, + placeholder, + tabIndex, + editable, + } = args; const extensions = [ StarterKit.configure({ @@ -89,7 +98,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { ...(enableHistory ? {} : { history: false }), }), CustomQuoteExtension, - DropHandlerExtension, CustomHorizontalRule.configure({ HTMLAttributes: { class: "py-4 border-custom-border-400", @@ -127,7 +135,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { class: "", }, }), - CustomCodeMarkPlugin, CustomCodeInlineExtension, Markdown.configure({ html: true, @@ -135,7 +142,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { transformPastedText: true, breaks: true, }), - MarkdownClipboard, Table, TableHeader, TableCell, @@ -145,15 +151,17 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { placeholder: ({ editor, node }) => { if (!editor.isEditable) return ""; - if (node.type.name === "heading") return `Heading ${node.attrs.level}`; + if (node.type.name === CORE_EXTENSIONS.HEADING) return `Heading ${node.attrs.level}`; - if (editor.storage.imageComponent?.uploadInProgress) return ""; + const isUploadInProgress = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress; + + if (isUploadInProgress) return ""; const shouldHidePlaceholder = - editor.isActive("table") || - editor.isActive("codeBlock") || - editor.isActive("image") || - editor.isActive("imageComponent"); + editor.isActive(CORE_EXTENSIONS.TABLE) || + editor.isActive(CORE_EXTENSIONS.CODE_BLOCK) || + editor.isActive(CORE_EXTENSIONS.IMAGE) || + editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE); if (shouldHidePlaceholder) return ""; @@ -169,20 +177,28 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { CharacterCount, CustomTextAlignExtension, CustomCalloutExtension, + UtilityExtension({ + disabledExtensions, + fileHandler, + isEditable: editable, + }), CustomColorExtension, ...CoreEditorAdditionalExtensions({ disabledExtensions, + flaggedExtensions, + fileHandler, }), ]; if (!disabledExtensions.includes("image")) { extensions.push( - ImageExtension(fileHandler).configure({ - HTMLAttributes: { - class: "rounded-md", - }, + ImageExtension({ + fileHandler, }), - CustomImageExtension(fileHandler) + CustomImageExtension({ + fileHandler, + isEditable: editable, + }) ); } diff --git a/packages/editor/src/core/extensions/headers.ts b/packages/editor/src/core/extensions/headings-list.ts similarity index 86% rename from packages/editor/src/core/extensions/headers.ts rename to packages/editor/src/core/extensions/headings-list.ts index 958cf6ca3..51a9aeedc 100644 --- a/packages/editor/src/core/extensions/headers.ts +++ b/packages/editor/src/core/extensions/headings-list.ts @@ -1,5 +1,7 @@ import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; export interface IMarking { type: "heading"; @@ -12,8 +14,8 @@ export type HeadingExtensionStorage = { headings: IMarking[]; }; -export const HeadingListExtension = Extension.create({ - name: "headingList", +export const HeadingListExtension = Extension.create({ + name: CORE_EXTENSIONS.HEADINGS_LIST, addStorage() { return { diff --git a/packages/editor/src/core/extensions/horizontal-rule.ts b/packages/editor/src/core/extensions/horizontal-rule.ts index b9be1a314..99a5dacc3 100644 --- a/packages/editor/src/core/extensions/horizontal-rule.ts +++ b/packages/editor/src/core/extensions/horizontal-rule.ts @@ -1,5 +1,7 @@ import { isNodeSelection, mergeAttributes, Node, nodeInputRule } from "@tiptap/core"; import { NodeSelection, TextSelection } from "@tiptap/pm/state"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; export interface HorizontalRuleOptions { HTMLAttributes: Record; @@ -7,7 +9,7 @@ export interface HorizontalRuleOptions { declare module "@tiptap/core" { interface Commands { - horizontalRule: { + [CORE_EXTENSIONS.HORIZONTAL_RULE]: { /** * Add a horizontal rule */ @@ -17,7 +19,7 @@ declare module "@tiptap/core" { } export const CustomHorizontalRule = Node.create({ - name: "horizontalRule", + name: CORE_EXTENSIONS.HORIZONTAL_RULE, addOptions() { return { diff --git a/packages/editor/src/core/extensions/image/extension-config.tsx b/packages/editor/src/core/extensions/image/extension-config.tsx new file mode 100644 index 000000000..6dbad2d24 --- /dev/null +++ b/packages/editor/src/core/extensions/image/extension-config.tsx @@ -0,0 +1,24 @@ +import { Image as BaseImageExtension } from "@tiptap/extension-image"; +// local imports +import { CustomImageExtensionOptions } from "../custom-image/types"; +import { ImageExtensionStorage } from "./extension"; + +export const ImageExtensionConfig = BaseImageExtension.extend< + Pick, + ImageExtensionStorage +>({ + addAttributes() { + return { + ...this.parent?.(), + width: { + default: "35%", + }, + height: { + default: null, + }, + aspectRatio: { + default: null, + }, + }; + }, +}); diff --git a/packages/editor/src/core/extensions/image/extension.tsx b/packages/editor/src/core/extensions/image/extension.tsx index 6766b4d0c..80cf7c182 100644 --- a/packages/editor/src/core/extensions/image/extension.tsx +++ b/packages/editor/src/core/extensions/image/extension.tsx @@ -1,23 +1,33 @@ -import ImageExt from "@tiptap/extension-image"; import { ReactNodeViewRenderer } from "@tiptap/react"; -// extensions -import { CustomImageNode } from "@/extensions"; // helpers import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary"; -// plugins -import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image"; // types -import { TFileHandler } from "@/types"; +import type { TFileHandler, TReadOnlyFileHandler } from "@/types"; +// local imports +import { CustomImageNodeView } from "../custom-image/components/node-view"; +import { ImageExtensionConfig } from "./extension-config"; -export const ImageExtension = (fileHandler: TFileHandler) => { - const { - getAssetSrc, - delete: deleteImageFn, - restore: restoreImageFn, - validation: { maxFileSize }, - } = fileHandler; +export type ImageExtensionStorage = { + deletedImageSet: Map; +}; + +type Props = { + fileHandler: TFileHandler | TReadOnlyFileHandler; +}; + +export const ImageExtension = (props: Props) => { + const { fileHandler } = props; + // derived values + const { getAssetSrc } = fileHandler; + + return ImageExtensionConfig.extend({ + addOptions() { + return { + ...this.parent?.(), + getImageSource: getAssetSrc, + }; + }, - return ImageExt.extend({ addKeyboardShortcuts() { return { ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), @@ -25,64 +35,19 @@ export const ImageExtension = (fileHandler: TFileHandler) => { }; }, - addProseMirrorPlugins() { - return [ - TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name), - TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name), - ]; - }, - - onCreate(this) { - const imageSources = new Set(); - this.editor.state.doc.descendants((node) => { - if (node.type.name === this.name) { - if (!node.attrs.src?.startsWith("http")) return; - - imageSources.add(node.attrs.src); - } - }); - imageSources.forEach(async (src) => { - try { - await restoreImageFn(src); - } catch (error) { - console.error("Error restoring image: ", error); - } - }); - }, - // storage to keep track of image states Map addStorage() { + const maxFileSize = "validation" in fileHandler ? fileHandler.validation?.maxFileSize : 0; + return { deletedImageSet: new Map(), - uploadInProgress: false, maxFileSize, }; }, - addAttributes() { - return { - ...this.parent?.(), - width: { - default: "35%", - }, - height: { - default: null, - }, - aspectRatio: { - default: null, - }, - }; - }, - - addCommands() { - return { - getImageSource: (path: string) => async () => await getAssetSrc(path), - }; - }, - // render custom image node addNodeView() { - return ReactNodeViewRenderer(CustomImageNode); + return ReactNodeViewRenderer(CustomImageNodeView); }, }); }; diff --git a/packages/editor/src/core/extensions/image/image-component-without-props.tsx b/packages/editor/src/core/extensions/image/image-component-without-props.tsx deleted file mode 100644 index c17bcc559..000000000 --- a/packages/editor/src/core/extensions/image/image-component-without-props.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { mergeAttributes } from "@tiptap/core"; -import { Image } from "@tiptap/extension-image"; -// extensions -import { ImageExtensionStorage } from "@/plugins/image"; - -export const CustomImageComponentWithoutProps = () => - Image.extend, ImageExtensionStorage>({ - name: "imageComponent", - selectable: true, - group: "block", - atom: true, - draggable: true, - - addAttributes() { - return { - ...this.parent?.(), - width: { - default: "35%", - }, - src: { - default: null, - }, - height: { - default: "auto", - }, - ["id"]: { - default: null, - }, - aspectRatio: { - default: null, - }, - }; - }, - - parseHTML() { - return [ - { - tag: "image-component", - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return ["image-component", mergeAttributes(HTMLAttributes)]; - }, - - addStorage() { - return { - fileMap: new Map(), - deletedImageSet: new Map(), - uploadInProgress: false, - maxFileSize: 0, - assetsUploadStatus: {}, - }; - }, - }); - -export default CustomImageComponentWithoutProps; diff --git a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx b/packages/editor/src/core/extensions/image/image-extension-without-props.tsx deleted file mode 100644 index bb6c5b4ad..000000000 --- a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import ImageExt from "@tiptap/extension-image"; - -export const ImageExtensionWithoutProps = () => - ImageExt.extend({ - addAttributes() { - return { - ...this.parent?.(), - width: { - default: "35%", - }, - height: { - default: null, - }, - aspectRatio: { - default: null, - }, - }; - }, - }); diff --git a/packages/editor/src/core/extensions/image/index.ts b/packages/editor/src/core/extensions/image/index.ts index 9c7dc65d7..02b5a53d6 100644 --- a/packages/editor/src/core/extensions/image/index.ts +++ b/packages/editor/src/core/extensions/image/index.ts @@ -1,3 +1,2 @@ export * from "./extension"; -export * from "./image-extension-without-props"; -export * from "./read-only-image"; +export * from "./extension-config"; diff --git a/packages/editor/src/core/extensions/image/read-only-image.tsx b/packages/editor/src/core/extensions/image/read-only-image.tsx deleted file mode 100644 index a65607803..000000000 --- a/packages/editor/src/core/extensions/image/read-only-image.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import Image from "@tiptap/extension-image"; -import { ReactNodeViewRenderer } from "@tiptap/react"; -// extensions -import { CustomImageNode } from "@/extensions"; -// types -import { TReadOnlyFileHandler } from "@/types"; - -export const ReadOnlyImageExtension = (props: TReadOnlyFileHandler) => { - const { getAssetSrc } = props; - - return Image.extend({ - addAttributes() { - return { - ...this.parent?.(), - width: { - default: "35%", - }, - height: { - default: null, - }, - aspectRatio: { - default: null, - }, - }; - }, - - addCommands() { - return { - getImageSource: (path: string) => async () => await getAssetSrc(path), - }; - }, - - addNodeView() { - return ReactNodeViewRenderer(CustomImageNode); - }, - }); -}; diff --git a/packages/editor/src/core/extensions/index.ts b/packages/editor/src/core/extensions/index.ts index e98607585..c3a8e5d5c 100644 --- a/packages/editor/src/core/extensions/index.ts +++ b/packages/editor/src/core/extensions/index.ts @@ -1,26 +1,23 @@ export * from "./callout"; export * from "./code"; export * from "./code-inline"; -export * from "./custom-image"; export * from "./custom-link"; export * from "./custom-list-keymap"; export * from "./image"; -export * from "./issue-embed"; export * from "./mentions"; export * from "./slash-commands"; export * from "./table"; export * from "./typography"; +export * from "./work-item-embed"; export * from "./core-without-props"; -export * from "./custom-code-inline"; export * from "./custom-color"; -export * from "./drop"; -export * from "./enter-key-extension"; +export * from "./enter-key"; export * from "./extensions"; -export * from "./headers"; +export * from "./headings-list"; export * from "./horizontal-rule"; export * from "./keymap"; export * from "./quote"; export * from "./read-only-extensions"; export * from "./side-menu"; export * from "./text-align"; -export * from "./clipboard"; +export * from "./utility"; diff --git a/packages/editor/src/core/extensions/issue-embed/index.ts b/packages/editor/src/core/extensions/issue-embed/index.ts deleted file mode 100644 index f47619a03..000000000 --- a/packages/editor/src/core/extensions/issue-embed/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./widget-node"; -export * from "./issue-embed-without-props"; diff --git a/packages/editor/src/core/extensions/issue-embed/issue-embed-without-props.ts b/packages/editor/src/core/extensions/issue-embed/issue-embed-without-props.ts deleted file mode 100644 index bef366cba..000000000 --- a/packages/editor/src/core/extensions/issue-embed/issue-embed-without-props.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { mergeAttributes, Node } from "@tiptap/core"; - -export const IssueWidgetWithoutProps = () => - Node.create({ - name: "issue-embed-component", - group: "block", - atom: true, - selectable: true, - draggable: true, - - addAttributes() { - return { - entity_identifier: { - default: undefined, - }, - project_identifier: { - default: undefined, - }, - workspace_identifier: { - default: undefined, - }, - id: { - default: undefined, - }, - entity_name: { - default: undefined, - }, - }; - }, - - parseHTML() { - return [ - { - tag: "issue-embed-component", - }, - ]; - }, - renderHTML({ HTMLAttributes }) { - return ["issue-embed-component", mergeAttributes(HTMLAttributes)]; - }, - }); diff --git a/packages/editor/src/core/extensions/issue-embed/widget-node.tsx b/packages/editor/src/core/extensions/issue-embed/widget-node.tsx deleted file mode 100644 index a216ab6d9..000000000 --- a/packages/editor/src/core/extensions/issue-embed/widget-node.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { mergeAttributes, Node } from "@tiptap/core"; -import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"; - -type Props = { - widgetCallback: ({ - issueId, - projectId, - workspaceSlug, - }: { - issueId: string; - projectId: string | undefined; - workspaceSlug: string | undefined; - }) => React.ReactNode; -}; - -export const IssueWidget = (props: Props) => - Node.create({ - name: "issue-embed-component", - group: "block", - atom: true, - selectable: true, - draggable: true, - - addAttributes() { - return { - entity_identifier: { - default: undefined, - }, - project_identifier: { - default: undefined, - }, - workspace_identifier: { - default: undefined, - }, - id: { - default: undefined, - }, - entity_name: { - default: undefined, - }, - }; - }, - - addNodeView() { - return ReactNodeViewRenderer((issueProps: any) => ( - - {props.widgetCallback({ - issueId: issueProps.node.attrs.entity_identifier, - projectId: issueProps.node.attrs.project_identifier, - workspaceSlug: issueProps.node.attrs.workspace_identifier, - })} - - )); - }, - - parseHTML() { - return [ - { - tag: "issue-embed-component", - }, - ]; - }, - renderHTML({ HTMLAttributes }) { - return ["issue-embed-component", mergeAttributes(HTMLAttributes)]; - }, - }); diff --git a/packages/editor/src/core/extensions/keymap.tsx b/packages/editor/src/core/extensions/keymap.ts similarity index 92% rename from packages/editor/src/core/extensions/keymap.tsx rename to packages/editor/src/core/extensions/keymap.ts index 81d60e34f..a4961bb96 100644 --- a/packages/editor/src/core/extensions/keymap.tsx +++ b/packages/editor/src/core/extensions/keymap.ts @@ -2,11 +2,13 @@ import { Extension } from "@tiptap/core"; import { NodeType } from "@tiptap/pm/model"; import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; import { canJoin } from "@tiptap/pm/transform"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; declare module "@tiptap/core" { // eslint-disable-next-line no-unused-vars interface Commands { - customkeymap: { + customKeymap: { /** * Select text between node boundaries */ @@ -59,7 +61,7 @@ function autoJoin(tr: Transaction, newTr: Transaction, nodeTypes: NodeType[]) { } export const CustomKeymap = Extension.create({ - name: "CustomKeymap", + name: "customKeymap", addCommands() { return { @@ -87,9 +89,9 @@ export const CustomKeymap = Extension.create({ const newTr = newState.tr; const joinableNodes = [ - newState.schema.nodes["orderedList"], - newState.schema.nodes["taskList"], - newState.schema.nodes["bulletList"], + newState.schema.nodes[CORE_EXTENSIONS.ORDERED_LIST], + newState.schema.nodes[CORE_EXTENSIONS.TASK_LIST], + newState.schema.nodes[CORE_EXTENSIONS.BULLET_LIST], ]; let joined = false; diff --git a/packages/editor/src/core/extensions/mentions/mention-node-view.tsx b/packages/editor/src/core/extensions/mentions/mention-node-view.tsx index 006336fbb..aac00de88 100644 --- a/packages/editor/src/core/extensions/mentions/mention-node-view.tsx +++ b/packages/editor/src/core/extensions/mentions/mention-node-view.tsx @@ -18,7 +18,7 @@ export const MentionNodeView = (props: Props) => { return ( {(extension.options as TMentionExtensionOptions).renderComponent({ - entity_identifier: attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER], + entity_identifier: attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER] ?? "", entity_name: attrs[EMentionComponentAttributeNames.ENTITY_NAME] ?? "user_mention", })} diff --git a/packages/editor/src/core/extensions/mentions/mentions-list-dropdown.tsx b/packages/editor/src/core/extensions/mentions/mentions-list-dropdown.tsx index 4f09ed2ae..da11d0f99 100644 --- a/packages/editor/src/core/extensions/mentions/mentions-list-dropdown.tsx +++ b/packages/editor/src/core/extensions/mentions/mentions-list-dropdown.tsx @@ -1,7 +1,7 @@ "use client"; -import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; import { Editor } from "@tiptap/react"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; import { v4 as uuidv4 } from "uuid"; // plane utils import { cn } from "@plane/utils"; @@ -61,7 +61,9 @@ export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps sections, selectedIndex, }); - setSelectedIndex(newIndex); + if (newIndex) { + setSelectedIndex(newIndex); + } }, })); @@ -79,7 +81,9 @@ export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps setIsLoading(true); try { const sectionsResponse = await searchCallback?.(query); - setSections(sectionsResponse); + if (sectionsResponse) { + setSections(sectionsResponse); + } } catch (error) { console.error("Failed to fetch suggestions:", error); } finally { diff --git a/packages/editor/src/core/extensions/mentions/utils.ts b/packages/editor/src/core/extensions/mentions/utils.ts index e8e7ed4b7..5a7550c83 100644 --- a/packages/editor/src/core/extensions/mentions/utils.ts +++ b/packages/editor/src/core/extensions/mentions/utils.ts @@ -1,7 +1,7 @@ import { Editor } from "@tiptap/core"; -import { SuggestionOptions } from "@tiptap/suggestion"; import { ReactRenderer } from "@tiptap/react"; -import tippy from "tippy.js"; +import { SuggestionOptions } from "@tiptap/suggestion"; +import tippy, { Instance } from "tippy.js"; // helpers import { CommandListInstance } from "@/helpers/tippy"; // types @@ -15,7 +15,7 @@ export const renderMentionsDropdown = () => { const { searchCallback } = props; let component: ReactRenderer | null = null; - let popup: any | null = null; + let popup: Instance | null = null; return { onStart: (props: { editor: Editor; clientRect: DOMRect }) => { diff --git a/packages/editor/src/core/extensions/quote.tsx b/packages/editor/src/core/extensions/quote.ts similarity index 85% rename from packages/editor/src/core/extensions/quote.tsx rename to packages/editor/src/core/extensions/quote.ts index 4ae81ffe4..99a6c10f0 100644 --- a/packages/editor/src/core/extensions/quote.tsx +++ b/packages/editor/src/core/extensions/quote.ts @@ -1,4 +1,6 @@ import Blockquote from "@tiptap/extension-blockquote"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; export const CustomQuoteExtension = Blockquote.extend({ addKeyboardShortcuts() { @@ -10,7 +12,7 @@ export const CustomQuoteExtension = Blockquote.extend({ if (!parent) return false; - if (parent.type.name !== "blockquote") { + if (parent.type.name !== CORE_EXTENSIONS.BLOCKQUOTE) { return false; } if ($from.pos !== $to.pos) return false; diff --git a/packages/editor/src/core/extensions/read-only-extensions.tsx b/packages/editor/src/core/extensions/read-only-extensions.ts similarity index 84% rename from packages/editor/src/core/extensions/read-only-extensions.tsx rename to packages/editor/src/core/extensions/read-only-extensions.ts index 3881c548b..c99b02312 100644 --- a/packages/editor/src/core/extensions/read-only-extensions.tsx +++ b/packages/editor/src/core/extensions/read-only-extensions.ts @@ -12,7 +12,6 @@ import { CustomHorizontalRule, CustomLinkExtension, CustomTypographyExtension, - ReadOnlyImageExtension, CustomCodeBlockExtension, CustomCodeInlineExtension, TableHeader, @@ -20,27 +19,25 @@ import { TableRow, Table, CustomMentionExtension, - CustomReadOnlyImageExtension, CustomTextAlignExtension, CustomCalloutReadOnlyExtension, CustomColorExtension, - MarkdownClipboard, + UtilityExtension, + ImageExtension, } from "@/extensions"; // helpers import { isValidHttpUrl } from "@/helpers/common"; // plane editor extensions import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions"; // types -import { TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types"; +import type { IReadOnlyEditorProps } from "@/types"; +// local imports +import { CustomImageExtension } from "./custom-image/extension"; -type Props = { - disabledExtensions: TExtensions[]; - fileHandler: TReadOnlyFileHandler; - mentionHandler: TReadOnlyMentionHandler; -}; +type Props = Pick; export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { - const { disabledExtensions, fileHandler, mentionHandler } = props; + const { disabledExtensions, fileHandler, flaggedExtensions, mentionHandler } = props; const extensions = [ StarterKit.configure({ @@ -117,7 +114,6 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { html: true, transformCopiedText: false, }), - MarkdownClipboard, Table, TableHeader, TableCell, @@ -127,19 +123,26 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { CustomColorExtension, CustomTextAlignExtension, CustomCalloutReadOnlyExtension, + UtilityExtension({ + disabledExtensions, + fileHandler, + isEditable: false, + }), ...CoreReadOnlyEditorAdditionalExtensions({ disabledExtensions, + flaggedExtensions, }), ]; if (!disabledExtensions.includes("image")) { extensions.push( - ReadOnlyImageExtension(fileHandler).configure({ - HTMLAttributes: { - class: "rounded-md", - }, + ImageExtension({ + fileHandler, }), - CustomReadOnlyImageExtension(fileHandler) + CustomImageExtension({ + fileHandler, + isEditable: false, + }) ); } diff --git a/packages/editor/src/core/extensions/side-menu.tsx b/packages/editor/src/core/extensions/side-menu.ts similarity index 97% rename from packages/editor/src/core/extensions/side-menu.tsx rename to packages/editor/src/core/extensions/side-menu.ts index 5f11286b5..34e3c45e5 100644 --- a/packages/editor/src/core/extensions/side-menu.tsx +++ b/packages/editor/src/core/extensions/side-menu.ts @@ -1,6 +1,8 @@ import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { EditorView } from "@tiptap/pm/view"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // plugins import { AIHandlePlugin } from "@/plugins/ai-handle"; import { DragHandlePlugin, nodeDOMAtCoords } from "@/plugins/drag-handle"; @@ -33,7 +35,7 @@ export const SideMenuExtension = (props: Props) => { const { aiEnabled, dragDropEnabled } = props; return Extension.create({ - name: "editorSideMenu", + name: CORE_EXTENSIONS.SIDE_MENU, addProseMirrorPlugins() { return [ SideMenu({ diff --git a/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx b/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx index 9fcc733ae..d3ca4856e 100644 --- a/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx +++ b/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx @@ -26,22 +26,17 @@ import { toggleBulletList, toggleOrderedList, toggleTaskList, - toggleHeadingOne, - toggleHeadingTwo, - toggleHeadingThree, - toggleHeadingFour, - toggleHeadingFive, - toggleHeadingSix, + toggleHeading, toggleTextColor, toggleBackgroundColor, insertImage, insertCallout, setText, } from "@/helpers/editor-commands"; -// types -import { CommandProps, ISlashCommandItem, TSlashCommandSectionKeys } from "@/types"; // plane editor extensions import { coreEditorAdditionalSlashCommandOptions } from "@/plane-editor/extensions"; +// types +import { CommandProps, ISlashCommandItem, TSlashCommandSectionKeys } from "@/types"; // local types import { TExtensionProps, TSlashCommandAdditionalOption } from "./root"; @@ -54,7 +49,7 @@ export type TSlashCommandSection = { export const getSlashCommandFilteredSections = (args: TExtensionProps) => ({ query }: { query: string }): TSlashCommandSection[] => { - const { additionalOptions: externalAdditionalOptions, disabledExtensions } = args; + const { additionalOptions: externalAdditionalOptions, disabledExtensions, flaggedExtensions } = args; const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [ { key: "general", @@ -75,7 +70,7 @@ export const getSlashCommandFilteredSections = description: "Big section heading.", searchTerms: ["title", "big", "large"], icon: , - command: ({ editor, range }) => toggleHeadingOne(editor, range), + command: ({ editor, range }) => toggleHeading(editor, 1, range), }, { commandKey: "h2", @@ -84,7 +79,7 @@ export const getSlashCommandFilteredSections = description: "Medium section heading.", searchTerms: ["subtitle", "medium"], icon: , - command: ({ editor, range }) => toggleHeadingTwo(editor, range), + command: ({ editor, range }) => toggleHeading(editor, 2, range), }, { commandKey: "h3", @@ -93,7 +88,7 @@ export const getSlashCommandFilteredSections = description: "Small section heading.", searchTerms: ["subtitle", "small"], icon: , - command: ({ editor, range }) => toggleHeadingThree(editor, range), + command: ({ editor, range }) => toggleHeading(editor, 3, range), }, { commandKey: "h4", @@ -102,7 +97,7 @@ export const getSlashCommandFilteredSections = description: "Small section heading.", searchTerms: ["subtitle", "small"], icon: , - command: ({ editor, range }) => toggleHeadingFour(editor, range), + command: ({ editor, range }) => toggleHeading(editor, 4, range), }, { commandKey: "h5", @@ -111,7 +106,7 @@ export const getSlashCommandFilteredSections = description: "Small section heading.", searchTerms: ["subtitle", "small"], icon: , - command: ({ editor, range }) => toggleHeadingFive(editor, range), + command: ({ editor, range }) => toggleHeading(editor, 5, range), }, { commandKey: "h6", @@ -120,7 +115,7 @@ export const getSlashCommandFilteredSections = description: "Small section heading.", searchTerms: ["subtitle", "small"], icon: , - command: ({ editor, range }) => toggleHeadingSix(editor, range), + command: ({ editor, range }) => toggleHeading(editor, 6, range), }, { commandKey: "to-do-list", @@ -295,6 +290,7 @@ export const getSlashCommandFilteredSections = ...(externalAdditionalOptions ?? []), ...coreEditorAdditionalSlashCommandOptions({ disabledExtensions, + flaggedExtensions, }), ]?.forEach((item) => { const sectionToPushTo = SLASH_COMMAND_SECTIONS.find((s) => s.key === item.section) ?? SLASH_COMMAND_SECTIONS[0]; diff --git a/packages/editor/src/core/extensions/slash-commands/command-menu.tsx b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx index 4ecd3f8fa..9d85266f2 100644 --- a/packages/editor/src/core/extensions/slash-commands/command-menu.tsx +++ b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx @@ -1,15 +1,16 @@ -import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; import { Editor } from "@tiptap/core"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; // helpers import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy"; // components +import { ISlashCommandItem } from "@/types"; import { TSlashCommandSection } from "./command-items-list"; import { CommandMenuItem } from "./command-menu-item"; export type SlashCommandsMenuProps = { editor: Editor; items: TSlashCommandSection[]; - command: any; + command: (item: ISlashCommandItem) => void; }; export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref) => { @@ -103,7 +104,9 @@ export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref) sections, selectedIndex, }); - setSelectedIndex(newIndex); + if (newIndex) { + setSelectedIndex(newIndex); + } }, })); diff --git a/packages/editor/src/core/extensions/slash-commands/root.tsx b/packages/editor/src/core/extensions/slash-commands/root.tsx index c0c078a2d..3a29e932c 100644 --- a/packages/editor/src/core/extensions/slash-commands/root.tsx +++ b/packages/editor/src/core/extensions/slash-commands/root.tsx @@ -1,11 +1,13 @@ import { Editor, Range, Extension } from "@tiptap/core"; import { ReactRenderer } from "@tiptap/react"; import Suggestion, { SuggestionOptions } from "@tiptap/suggestion"; -import tippy from "tippy.js"; +import tippy, { Instance } from "tippy.js"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // helpers import { CommandListInstance } from "@/helpers/tippy"; // types -import { ISlashCommandItem, TEditorCommands, TExtensions, TSlashCommandSectionKeys } from "@/types"; +import { IEditorProps, ISlashCommandItem, TEditorCommands, TSlashCommandSectionKeys } from "@/types"; // components import { getSlashCommandFilteredSections } from "./command-items-list"; import { SlashCommandsMenu, SlashCommandsMenuProps } from "./command-menu"; @@ -20,7 +22,7 @@ export type TSlashCommandAdditionalOption = ISlashCommandItem & { }; const Command = Extension.create({ - name: "slash-command", + name: CORE_EXTENSIONS.SLASH_COMMANDS, addOptions() { return { suggestion: { @@ -34,11 +36,11 @@ const Command = Extension.create({ const parentNode = selection.$from.node(selection.$from.depth); const blockType = parentNode.type.name; - if (blockType === "codeBlock") { + if (blockType === CORE_EXTENSIONS.CODE_BLOCK) { return false; } - if (editor.isActive("table")) { + if (editor.isActive(CORE_EXTENSIONS.TABLE)) { return false; } @@ -59,7 +61,7 @@ const Command = Extension.create({ const renderItems = () => { let component: ReactRenderer | null = null; - let popup: any | null = null; + let popup: Instance | null = null; return { onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { component = new ReactRenderer(SlashCommandsMenu, { @@ -104,9 +106,8 @@ const renderItems = () => { }; }; -export type TExtensionProps = { +export type TExtensionProps = Pick & { additionalOptions?: TSlashCommandAdditionalOption[]; - disabledExtensions?: TExtensions[]; }; export const SlashCommands = (props: TExtensionProps) => diff --git a/packages/editor/src/core/extensions/table/table-cell/table-cell.ts b/packages/editor/src/core/extensions/table/table-cell.ts similarity index 91% rename from packages/editor/src/core/extensions/table/table-cell/table-cell.ts rename to packages/editor/src/core/extensions/table/table-cell.ts index 403bd3f02..2ba06845a 100644 --- a/packages/editor/src/core/extensions/table/table-cell/table-cell.ts +++ b/packages/editor/src/core/extensions/table/table-cell.ts @@ -1,11 +1,12 @@ import { mergeAttributes, Node } from "@tiptap/core"; - +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; export interface TableCellOptions { HTMLAttributes: Record; } export const TableCell = Node.create({ - name: "tableCell", + name: CORE_EXTENSIONS.TABLE_CELL, addOptions() { return { diff --git a/packages/editor/src/core/extensions/table/table-cell/index.ts b/packages/editor/src/core/extensions/table/table-cell/index.ts deleted file mode 100644 index 68a25a9c3..000000000 --- a/packages/editor/src/core/extensions/table/table-cell/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TableCell } from "./table-cell"; diff --git a/packages/editor/src/core/extensions/table/table-header/table-header.ts b/packages/editor/src/core/extensions/table/table-header.ts similarity index 90% rename from packages/editor/src/core/extensions/table/table-header/table-header.ts rename to packages/editor/src/core/extensions/table/table-header.ts index bd994f467..491889eef 100644 --- a/packages/editor/src/core/extensions/table/table-header/table-header.ts +++ b/packages/editor/src/core/extensions/table/table-header.ts @@ -1,11 +1,12 @@ import { mergeAttributes, Node } from "@tiptap/core"; - +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; export interface TableHeaderOptions { HTMLAttributes: Record; } export const TableHeader = Node.create({ - name: "tableHeader", + name: CORE_EXTENSIONS.TABLE_HEADER, addOptions() { return { diff --git a/packages/editor/src/core/extensions/table/table-header/index.ts b/packages/editor/src/core/extensions/table/table-header/index.ts deleted file mode 100644 index 290f37d0b..000000000 --- a/packages/editor/src/core/extensions/table/table-header/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TableHeader } from "./table-header"; diff --git a/packages/editor/src/core/extensions/table/table-row/table-row.ts b/packages/editor/src/core/extensions/table/table-row.ts similarity index 88% rename from packages/editor/src/core/extensions/table/table-row/table-row.ts rename to packages/editor/src/core/extensions/table/table-row.ts index f961c0582..48f95a41c 100644 --- a/packages/editor/src/core/extensions/table/table-row/table-row.ts +++ b/packages/editor/src/core/extensions/table/table-row.ts @@ -1,11 +1,13 @@ import { mergeAttributes, Node } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; export interface TableRowOptions { HTMLAttributes: Record; } export const TableRow = Node.create({ - name: "tableRow", + name: CORE_EXTENSIONS.TABLE_ROW, addOptions() { return { diff --git a/packages/editor/src/core/extensions/table/table-row/index.ts b/packages/editor/src/core/extensions/table/table-row/index.ts deleted file mode 100644 index 24dafb7e0..000000000 --- a/packages/editor/src/core/extensions/table/table-row/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TableRow } from "./table-row"; diff --git a/packages/editor/src/core/extensions/table/table/table-controls.ts b/packages/editor/src/core/extensions/table/table/table-controls.ts index 052922579..d499b1b6a 100644 --- a/packages/editor/src/core/extensions/table/table/table-controls.ts +++ b/packages/editor/src/core/extensions/table/table/table-controls.ts @@ -1,6 +1,8 @@ import { findParentNode } from "@tiptap/core"; -import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; +import { Plugin, PluginKey, TextSelection, Transaction } from "@tiptap/pm/state"; import { DecorationSet, Decoration } from "@tiptap/pm/view"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; const key = new PluginKey("tableControls"); @@ -17,16 +19,14 @@ export function tableControls() { }, props: { handleTripleClickOn(view, pos, node, nodePos, event, direct) { - if (node.type.name === 'tableCell') { + if (node.type.name === CORE_EXTENSIONS.TABLE_CELL) { event.preventDefault(); const $pos = view.state.doc.resolve(pos); const line = $pos.parent; const linePos = $pos.start(); const start = linePos; const end = linePos + line.nodeSize - 1; - const tr = view.state.tr.setSelection( - TextSelection.create(view.state.doc, start, end) - ); + const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, start, end)); view.dispatch(tr); return true; } @@ -52,12 +52,12 @@ export function tableControls() { if (!pos || pos.pos < 0 || pos.pos > view.state.doc.content.size) return; - const table = findParentNode((node) => node.type.name === "table")( - TextSelection.create(view.state.doc, pos.pos) - ); - const cell = findParentNode((node) => node.type.name === "tableCell" || node.type.name === "tableHeader")( + const table = findParentNode((node) => node.type.name === CORE_EXTENSIONS.TABLE)( TextSelection.create(view.state.doc, pos.pos) ); + const cell = findParentNode((node) => + [CORE_EXTENSIONS.TABLE_CELL, CORE_EXTENSIONS.TABLE_HEADER].includes(node.type.name as CORE_EXTENSIONS) + )(TextSelection.create(view.state.doc, pos.pos)); if (!table || !cell) return; @@ -112,7 +112,7 @@ class TableControlsState { }; } - apply(tr: any) { + apply(tr: Transaction) { const actions = tr.getMeta(key); if (actions?.setHoveredTable !== undefined) { diff --git a/packages/editor/src/core/extensions/table/table/table-view.tsx b/packages/editor/src/core/extensions/table/table/table-view.tsx index 2a4802126..f78d964ed 100644 --- a/packages/editor/src/core/extensions/table/table/table-view.tsx +++ b/packages/editor/src/core/extensions/table/table/table-view.tsx @@ -1,12 +1,12 @@ -import { h } from "jsx-dom-cjs"; -import { Node as ProseMirrorNode, ResolvedPos } from "@tiptap/pm/model"; -import { Decoration, NodeView } from "@tiptap/pm/view"; -import tippy, { Instance, Props } from "tippy.js"; - import { Editor } from "@tiptap/core"; +import { Node as ProseMirrorNode, ResolvedPos } from "@tiptap/pm/model"; import { CellSelection, TableMap, updateColumnsOnResize } from "@tiptap/pm/tables"; - +import { Decoration, NodeView } from "@tiptap/pm/view"; +import { h } from "jsx-dom-cjs"; import { icons } from "src/core/extensions/table/table/icons"; +import tippy, { Instance, Props } from "tippy.js"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; type ToolboxItem = { label: string; @@ -30,10 +30,10 @@ export function updateColumns( if (!row) return; for (let i = 0, col = 0; i < row.childCount; i += 1) { - const { colspan, colwidth } = row.child(i).attrs; + const { colspan, colWidth } = row.child(i).attrs; for (let j = 0; j < colspan; j += 1, col += 1) { - const hasWidth = overrideCol === col ? overrideValue : colwidth && colwidth[j]; + const hasWidth = overrideCol === col ? overrideValue : colWidth && colWidth[j]; const cssWidth = hasWidth ? `${hasWidth}px` : ""; totalWidth += hasWidth || cellMinWidth; @@ -85,7 +85,7 @@ function setCellsBackgroundColor(editor: Editor, color: { backgroundColor: strin return editor .chain() .focus() - .updateAttributes("tableCell", { + .updateAttributes(CORE_EXTENSIONS.TABLE_CELL, { background: color.backgroundColor, textColor: color.textColor, }) @@ -104,12 +104,12 @@ function setTableRowBackgroundColor(editor: Editor, color: { backgroundColor: st // Find the depth of the table row node let rowDepth = hoveredCell.depth; - while (rowDepth > 0 && hoveredCell.node(rowDepth).type.name !== "tableRow") { + while (rowDepth > 0 && hoveredCell.node(rowDepth).type.name !== CORE_EXTENSIONS.TABLE_ROW) { rowDepth--; } // If we couldn't find a tableRow node, we can't set the background color - if (hoveredCell.node(rowDepth).type.name !== "tableRow") { + if (hoveredCell.node(rowDepth).type.name !== CORE_EXTENSIONS.TABLE_ROW) { return false; } diff --git a/packages/editor/src/core/extensions/table/table/table.ts b/packages/editor/src/core/extensions/table/table/table.ts index fd775d211..4810706b3 100644 --- a/packages/editor/src/core/extensions/table/table/table.ts +++ b/packages/editor/src/core/extensions/table/table/table.ts @@ -19,11 +19,14 @@ import { toggleHeader, toggleHeaderCell, } from "@tiptap/pm/tables"; - -import { tableControls } from "@/extensions/table/table/table-controls"; -import { TableView } from "@/extensions/table/table/table-view"; -import { createTable } from "@/extensions/table/table/utilities/create-table"; -import { deleteTableWhenAllCellsSelected } from "@/extensions/table/table/utilities/delete-table-when-all-cells-selected"; +import { Decoration } from "@tiptap/pm/view"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// local imports +import { tableControls } from "./table-controls"; +import { TableView } from "./table-view"; +import { createTable } from "./utilities/create-table"; +import { deleteTableWhenAllCellsSelected } from "./utilities/delete-table-when-all-cells-selected"; import { insertLineAboveTableAction } from "./utilities/insert-line-above-table-action"; import { insertLineBelowTableAction } from "./utilities/insert-line-below-table-action"; @@ -38,7 +41,7 @@ export interface TableOptions { declare module "@tiptap/core" { interface Commands { - table: { + [CORE_EXTENSIONS.TABLE]: { insertTable: (options?: { rows?: number; cols?: number; @@ -79,7 +82,7 @@ declare module "@tiptap/core" { } export const Table = Node.create({ - name: "table", + name: CORE_EXTENSIONS.TABLE, addOptions() { return { @@ -219,8 +222,8 @@ export const Table = Node.create({ addKeyboardShortcuts() { return { Tab: () => { - if (this.editor.isActive("table")) { - if (this.editor.isActive("listItem") || this.editor.isActive("taskItem")) { + if (this.editor.isActive(CORE_EXTENSIONS.TABLE)) { + if (this.editor.isActive(CORE_EXTENSIONS.LIST_ITEM) || this.editor.isActive(CORE_EXTENSIONS.TASK_ITEM)) { return false; } if (this.editor.commands.goToNextCell()) { @@ -249,7 +252,7 @@ export const Table = Node.create({ return ({ editor, getPos, node, decorations }) => { const { cellMinWidth } = this.options; - return new TableView(node, cellMinWidth, decorations as any, editor, getPos as () => number); + return new TableView(node, cellMinWidth, decorations as Decoration[], editor, getPos as () => number); }; }, diff --git a/packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts b/packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts index 53388fbf2..5c84b8617 100644 --- a/packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts @@ -1,4 +1,6 @@ import { findParentNodeClosestToPos, KeyboardShortcutCommand } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection"; @@ -10,14 +12,17 @@ export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ edito } let cellCount = 0; - const table = findParentNodeClosestToPos(selection.ranges[0].$from, (node) => node.type.name === "table"); + const table = findParentNodeClosestToPos( + selection.ranges[0].$from, + (node) => node.type.name === CORE_EXTENSIONS.TABLE + ); table?.node.descendants((node) => { - if (node.type.name === "table") { + if (node.type.name === CORE_EXTENSIONS.TABLE) { return false; } - if (["tableCell", "tableHeader"].includes(node.type.name)) { + if ([CORE_EXTENSIONS.TABLE_CELL, CORE_EXTENSIONS.TABLE_HEADER].includes(node.type.name as CORE_EXTENSIONS)) { cellCount += 1; } }); diff --git a/packages/editor/src/core/extensions/table/table/utilities/insert-line-above-table-action.ts b/packages/editor/src/core/extensions/table/table/utilities/insert-line-above-table-action.ts index ca5ed3d7e..35c2ee3c7 100644 --- a/packages/editor/src/core/extensions/table/table/utilities/insert-line-above-table-action.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/insert-line-above-table-action.ts @@ -1,17 +1,19 @@ import { KeyboardShortcutCommand } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // helpers import { findParentNodeOfType } from "@/helpers/common"; export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor }) => { // Check if the current selection or the closest node is a table - if (!editor.isActive("table")) return false; + if (!editor.isActive(CORE_EXTENSIONS.TABLE)) return false; try { // Get the current selection const { selection } = editor.state; // Find the table node and its position - const tableNode = findParentNodeOfType(selection, "table"); + const tableNode = findParentNodeOfType(selection, CORE_EXTENSIONS.TABLE); if (!tableNode) return false; const tablePos = tableNode.pos; @@ -39,7 +41,7 @@ export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor }) const prevNode = editor.state.doc.nodeAt(prevNodePos - 1); - if (prevNode && prevNode.type.name === "paragraph") { + if (prevNode && prevNode.type.name === CORE_EXTENSIONS.PARAGRAPH) { // If there's a paragraph before the table, move the cursor to the end of that paragraph const endOfParagraphPos = tablePos - prevNode.nodeSize; editor.chain().setTextSelection(endOfParagraphPos).run(); diff --git a/packages/editor/src/core/extensions/table/table/utilities/insert-line-below-table-action.ts b/packages/editor/src/core/extensions/table/table/utilities/insert-line-below-table-action.ts index 7edca9f30..6c26e22a2 100644 --- a/packages/editor/src/core/extensions/table/table/utilities/insert-line-below-table-action.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/insert-line-below-table-action.ts @@ -1,17 +1,19 @@ import { KeyboardShortcutCommand } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // helpers import { findParentNodeOfType } from "@/helpers/common"; export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor }) => { // Check if the current selection or the closest node is a table - if (!editor.isActive("table")) return false; + if (!editor.isActive(CORE_EXTENSIONS.TABLE)) return false; try { // Get the current selection const { selection } = editor.state; // Find the table node and its position - const tableNode = findParentNodeOfType(selection, "table"); + const tableNode = findParentNodeOfType(selection, CORE_EXTENSIONS.TABLE); if (!tableNode) return false; const tablePos = tableNode.pos; @@ -31,13 +33,13 @@ export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor }) // Check for an existing node immediately after the table const nextNode = editor.state.doc.nodeAt(nextNodePos); - if (nextNode && nextNode.type.name === "paragraph") { + if (nextNode && nextNode.type.name === CORE_EXTENSIONS.PARAGRAPH) { // If the next node is an paragraph, move the cursor there const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1; editor.chain().setTextSelection(endOfParagraphPos).run(); } else if (!nextNode) { // If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there - editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run(); + editor.chain().insertContentAt(nextNodePos, { type: CORE_EXTENSIONS.PARAGRAPH }).run(); editor .chain() .setTextSelection(nextNodePos + 1) diff --git a/packages/editor/src/core/extensions/typography/index.ts b/packages/editor/src/core/extensions/typography/index.ts index 6b736953b..32ffea6a2 100644 --- a/packages/editor/src/core/extensions/typography/index.ts +++ b/packages/editor/src/core/extensions/typography/index.ts @@ -1,4 +1,6 @@ import { Extension, InputRule } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; import { TypographyOptions, emDash, @@ -23,7 +25,7 @@ import { } from "./inputRules"; export const CustomTypographyExtension = Extension.create({ - name: "typography", + name: CORE_EXTENSIONS.TYPOGRAPHY, addInputRules() { const rules: InputRule[] = []; diff --git a/packages/editor/src/core/extensions/utility.ts b/packages/editor/src/core/extensions/utility.ts new file mode 100644 index 000000000..758c74241 --- /dev/null +++ b/packages/editor/src/core/extensions/utility.ts @@ -0,0 +1,74 @@ +import { Extension } from "@tiptap/core"; +// prosemirror plugins +import codemark from "prosemirror-codemark"; +// helpers +import { restorePublicImages } from "@/helpers/image-helpers"; +// plugins +import { DropHandlerPlugin } from "@/plugins/drop"; +import { FilePlugins } from "@/plugins/file/root"; +import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard"; +// types +import type { IEditorProps, TFileHandler, TReadOnlyFileHandler } from "@/types"; + +declare module "@tiptap/core" { + interface Commands { + utility: { + updateAssetsUploadStatus: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void; + }; + } +} + +export interface UtilityExtensionStorage { + assetsUploadStatus: TFileHandler["assetsUploadStatus"]; + uploadInProgress: boolean; +} + +type Props = Pick & { + fileHandler: TFileHandler | TReadOnlyFileHandler; + isEditable: boolean; +}; + +export const UtilityExtension = (props: Props) => { + const { disabledExtensions, fileHandler, isEditable } = props; + const { restore } = fileHandler; + + return Extension.create, UtilityExtensionStorage>({ + name: "utility", + priority: 1000, + + addProseMirrorPlugins() { + return [ + ...FilePlugins({ + editor: this.editor, + isEditable, + fileHandler, + }), + ...codemark({ markType: this.editor.schema.marks.code }), + MarkdownClipboardPlugin(this.editor), + DropHandlerPlugin({ + disabledExtensions, + editor: this.editor, + }), + ]; + }, + + onCreate() { + restorePublicImages(this.editor, restore); + }, + + addStorage() { + return { + assetsUploadStatus: isEditable && "assetsUploadStatus" in fileHandler ? fileHandler.assetsUploadStatus : {}, + uploadInProgress: false, + }; + }, + + addCommands() { + return { + updateAssetsUploadStatus: (updatedStatus) => () => { + this.storage.assetsUploadStatus = updatedStatus; + }, + }; + }, + }); +}; diff --git a/packages/editor/src/core/extensions/work-item-embed/extension-config.ts b/packages/editor/src/core/extensions/work-item-embed/extension-config.ts new file mode 100644 index 000000000..0ea25c770 --- /dev/null +++ b/packages/editor/src/core/extensions/work-item-embed/extension-config.ts @@ -0,0 +1,43 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; + +export const WorkItemEmbedExtensionConfig = Node.create({ + name: CORE_EXTENSIONS.WORK_ITEM_EMBED, + group: "block", + atom: true, + selectable: true, + draggable: true, + + addAttributes() { + return { + entity_identifier: { + default: undefined, + }, + project_identifier: { + default: undefined, + }, + workspace_identifier: { + default: undefined, + }, + id: { + default: undefined, + }, + entity_name: { + default: undefined, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "issue-embed-component", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["issue-embed-component", mergeAttributes(HTMLAttributes)]; + }, +}); diff --git a/packages/editor/src/core/extensions/work-item-embed/extension.tsx b/packages/editor/src/core/extensions/work-item-embed/extension.tsx new file mode 100644 index 000000000..64e655a40 --- /dev/null +++ b/packages/editor/src/core/extensions/work-item-embed/extension.tsx @@ -0,0 +1,30 @@ +import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"; +// local imports +import { WorkItemEmbedExtensionConfig } from "./extension-config"; + +type Props = { + widgetCallback: ({ + issueId, + projectId, + workspaceSlug, + }: { + issueId: string; + projectId: string | undefined; + workspaceSlug: string | undefined; + }) => React.ReactNode; +}; + +export const WorkItemEmbedExtension = (props: Props) => + WorkItemEmbedExtensionConfig.extend({ + addNodeView() { + return ReactNodeViewRenderer((issueProps: any) => ( + + {props.widgetCallback({ + issueId: issueProps.node.attrs.entity_identifier, + projectId: issueProps.node.attrs.project_identifier, + workspaceSlug: issueProps.node.attrs.workspace_identifier, + })} + + )); + }, + }); diff --git a/packages/editor/src/core/extensions/work-item-embed/index.ts b/packages/editor/src/core/extensions/work-item-embed/index.ts new file mode 100644 index 000000000..2ce32da8b --- /dev/null +++ b/packages/editor/src/core/extensions/work-item-embed/index.ts @@ -0,0 +1 @@ +export * from "./extension"; diff --git a/packages/editor/src/core/helpers/common.ts b/packages/editor/src/core/helpers/common.ts index 36075caf2..e694e1e85 100644 --- a/packages/editor/src/core/helpers/common.ts +++ b/packages/editor/src/core/helpers/common.ts @@ -1,6 +1,8 @@ import { EditorState, Selection } from "@tiptap/pm/state"; -// plane utils +// plane imports import { cn } from "@plane/utils"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; interface EditorClassNames { noBorder?: boolean; @@ -38,11 +40,10 @@ export const findTableAncestor = (node: Node | null): HTMLTableElement | null => return node as HTMLTableElement; }; -export const getTrimmedHTML = (html: string) => { - html = html.replace(/^(

<\/p>)+/, ""); - html = html.replace(/(

<\/p>)+$/, ""); - return html; -}; +export const getTrimmedHTML = (html: string) => + html + .replace(/^(?:

<\/p>)+/g, "") // Remove from beginning + .replace(/(?:

<\/p>)+$/g, ""); // Remove from end export const isValidHttpUrl = (string: string): { isValid: boolean; url: string } => { // List of potentially dangerous protocols to block @@ -68,7 +69,7 @@ export const isValidHttpUrl = (string: string): { isValid: boolean; url: string url: string, }; } - } catch (_) { + } catch { // Original string wasn't a valid URL - that's okay, we'll try with https } @@ -80,7 +81,7 @@ export const isValidHttpUrl = (string: string): { isValid: boolean; url: string isValid: true, url: urlWithHttps, }; - } catch (_) { + } catch { return { isValid: false, url: string, @@ -92,7 +93,7 @@ export const getParagraphCount = (editorState: EditorState | undefined) => { if (!editorState) return 0; let paragraphCount = 0; editorState.doc.descendants((node) => { - if (node.type.name === "paragraph" && node.content.size > 0) paragraphCount++; + if (node.type.name === CORE_EXTENSIONS.PARAGRAPH && node.content.size > 0) paragraphCount++; }); return paragraphCount; }; diff --git a/packages/editor/src/core/helpers/editor-commands.ts b/packages/editor/src/core/helpers/editor-commands.ts index 39796ac24..415a42bb3 100644 --- a/packages/editor/src/core/helpers/editor-commands.ts +++ b/packages/editor/src/core/helpers/editor-commands.ts @@ -1,50 +1,21 @@ import { Editor, Range } from "@tiptap/core"; -// types -import { InsertImageComponentProps } from "@/extensions"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text"; +import type { InsertImageComponentProps } from "@/extensions/custom-image/types"; // helpers import { findTableAncestor } from "@/helpers/common"; export const setText = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).setNode("paragraph").run(); - else editor.chain().focus().setNode("paragraph").run(); + if (range) editor.chain().focus().deleteRange(range).setNode(CORE_EXTENSIONS.PARAGRAPH).run(); + else editor.chain().focus().setNode(CORE_EXTENSIONS.PARAGRAPH).run(); }; -export const toggleHeadingOne = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run(); +export const toggleHeading = (editor: Editor, level: 1 | 2 | 3 | 4 | 5 | 6, range?: Range) => { + if (range) editor.chain().focus().deleteRange(range).setNode(CORE_EXTENSIONS.HEADING, { level }).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(); + else editor.chain().focus().toggleHeading({ level }).run(); }; export const toggleBold = (editor: Editor, range?: Range) => { @@ -69,7 +40,7 @@ export const toggleUnderline = (editor: Editor, range?: Range) => { export const toggleCodeBlock = (editor: Editor, range?: Range) => { try { // if it's a code block, replace it with the code with paragraphs - if (editor.isActive("codeBlock")) { + if (editor.isActive(CORE_EXTENSIONS.CODE_BLOCK)) { replaceCodeWithText(editor); return; } @@ -78,12 +49,12 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => { const text = editor.state.doc.textBetween(from, to, "\n"); const isMultiline = text.includes("\n"); - // if the selection is not a range i.e. empty, then simply convert it into a code block + // if the selection is not a range i.e. empty, then simply convert it into a codeBlock if (editor.state.selection.empty) { editor.chain().focus().toggleCodeBlock().run(); } else if (isMultiline) { // if the selection is multiline, then also replace the text content with - // a code block + // a codeBlock editor.chain().focus().deleteRange({ from, to }).insertContentAt(from, `\`\`\`\n${text}\n\`\`\``).run(); } else { // if the selection is single line, then simply convert it into inline @@ -206,6 +177,7 @@ export const insertHorizontalRule = (editor: Editor, range?: Range) => { if (range) editor.chain().focus().deleteRange(range).setHorizontalRule().run(); else editor.chain().focus().setHorizontalRule().run(); }; + export const insertCallout = (editor: Editor, range?: Range) => { if (range) editor.chain().focus().deleteRange(range).insertCallout().run(); else editor.chain().focus().insertCallout().run(); diff --git a/packages/editor/src/core/helpers/file.ts b/packages/editor/src/core/helpers/file.ts new file mode 100644 index 000000000..33d3c7d78 --- /dev/null +++ b/packages/editor/src/core/helpers/file.ts @@ -0,0 +1,36 @@ +export enum EFileError { + INVALID_FILE_TYPE = "INVALID_FILE_TYPE", + FILE_SIZE_TOO_LARGE = "FILE_SIZE_TOO_LARGE", + NO_FILE_SELECTED = "NO_FILE_SELECTED", +} + +type TArgs = { + acceptedMimeTypes: string[]; + file: File; + maxFileSize: number; + onError: (error: EFileError, message: string) => void; +}; + +export const isFileValid = (args: TArgs): boolean => { + const { acceptedMimeTypes, file, maxFileSize, onError } = args; + + if (!file) { + onError(EFileError.NO_FILE_SELECTED, "No file selected. Please select a file to upload."); + return false; + } + + if (!acceptedMimeTypes.includes(file.type)) { + onError(EFileError.INVALID_FILE_TYPE, "Invalid file type."); + return false; + } + + if (file.size > maxFileSize) { + onError( + EFileError.FILE_SIZE_TOO_LARGE, + `File size too large. Please select a file smaller than ${maxFileSize / 1024 / 1024}MB.` + ); + return false; + } + + return true; +}; diff --git a/packages/editor/src/core/helpers/get-extension-storage.ts b/packages/editor/src/core/helpers/get-extension-storage.ts index 0107f8425..86db93e18 100644 --- a/packages/editor/src/core/helpers/get-extension-storage.ts +++ b/packages/editor/src/core/helpers/get-extension-storage.ts @@ -1,23 +1,8 @@ import { Editor } from "@tiptap/core"; -import { - CustomLinkStorage, - HeadingExtensionStorage, - MentionExtensionStorage, - UploadImageExtensionStorage, -} from "@/extensions"; -import { ImageExtensionStorage } from "@/plugins/image"; +// plane editor types +import { ExtensionStorageMap } from "@/plane-editor/types/storage"; -type ExtensionNames = "imageComponent" | "image" | "link" | "headingList" | "mention"; - -interface ExtensionStorageMap { - imageComponent: UploadImageExtensionStorage; - image: ImageExtensionStorage; - link: CustomLinkStorage; - headingList: HeadingExtensionStorage; - mention: MentionExtensionStorage; -} - -export const getExtensionStorage = ( +export const getExtensionStorage = ( editor: Editor, extensionName: K ): ExtensionStorageMap[K] => editor.storage[extensionName]; diff --git a/packages/editor/src/core/helpers/image-helpers.ts b/packages/editor/src/core/helpers/image-helpers.ts new file mode 100644 index 000000000..9fcb877f9 --- /dev/null +++ b/packages/editor/src/core/helpers/image-helpers.ts @@ -0,0 +1,32 @@ +import { Editor } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// types +import { TFileHandler } from "@/types"; + +/** + * Finds all public image nodes in the document and restores them using the provided restore function + * + * Never remove this onCreate hook, it's a hack to restore old public + * images, since they don't give error if they've been deleted as they are + * rendered directly from image source instead of going through the + * apiserver + */ +export const restorePublicImages = (editor: Editor, restoreImageFn: TFileHandler["restore"]) => { + const imageSources = new Set(); + editor.state.doc.descendants((node) => { + if ([CORE_EXTENSIONS.IMAGE, CORE_EXTENSIONS.CUSTOM_IMAGE].includes(node.type.name as CORE_EXTENSIONS)) { + if (!node.attrs.src?.startsWith("http")) return; + + imageSources.add(node.attrs.src); + } + }); + + imageSources.forEach(async (src) => { + try { + await restoreImageFn(src); + } catch (error) { + console.error("Error restoring image: ", error); + } + }); +}; diff --git a/packages/editor/src/core/helpers/insert-empty-paragraph-at-node-boundary.ts b/packages/editor/src/core/helpers/insert-empty-paragraph-at-node-boundary.ts index ffad88d4e..b9449b494 100644 --- a/packages/editor/src/core/helpers/insert-empty-paragraph-at-node-boundary.ts +++ b/packages/editor/src/core/helpers/insert-empty-paragraph-at-node-boundary.ts @@ -1,5 +1,7 @@ import { KeyboardShortcutCommand } from "@tiptap/core"; import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; type Direction = "up" | "down"; @@ -39,13 +41,13 @@ export const insertEmptyParagraphAtNodeBoundaries: ( if (insertPosUp === 0) { // If at the very start of the document, insert a new paragraph at the start - editor.chain().insertContentAt(insertPosUp, { type: "paragraph" }).run(); + editor.chain().insertContentAt(insertPosUp, { type: CORE_EXTENSIONS.PARAGRAPH }).run(); editor.chain().setTextSelection(insertPosUp).run(); // Set the cursor to the new paragraph } else { // Otherwise, check the node immediately before the target node const prevNode = doc.nodeAt(insertPosUp - 1); - if (prevNode && prevNode.type.name === "paragraph") { + if (prevNode && prevNode.type.name === CORE_EXTENSIONS.PARAGRAPH) { // If the previous node is a paragraph, move the cursor there editor .chain() @@ -67,13 +69,13 @@ export const insertEmptyParagraphAtNodeBoundaries: ( // Check the node immediately after the target node const nextNode = doc.nodeAt(insertPosDown); - if (nextNode && nextNode.type.name === "paragraph") { + if (nextNode && nextNode.type.name === CORE_EXTENSIONS.PARAGRAPH) { // If the next node is a paragraph, move the cursor to the end of it const endOfParagraphPos = insertPosDown + nextNode.nodeSize - 1; editor.chain().setTextSelection(endOfParagraphPos).run(); } else if (!nextNode) { // If there is no next node (end of document), insert a new paragraph - editor.chain().insertContentAt(insertPosDown, { type: "paragraph" }).run(); + editor.chain().insertContentAt(insertPosDown, { type: CORE_EXTENSIONS.PARAGRAPH }).run(); editor .chain() .setTextSelection(insertPosDown + 1) diff --git a/packages/editor/src/core/helpers/parser.ts b/packages/editor/src/core/helpers/parser.ts new file mode 100644 index 000000000..13b105323 --- /dev/null +++ b/packages/editor/src/core/helpers/parser.ts @@ -0,0 +1,95 @@ +// plane imports +import { TDocumentPayload, TDuplicateAssetData, TDuplicateAssetResponse } from "@plane/types"; +import { TEditorAssetType } from "@plane/types/src/enums"; +// plane web imports +import { + extractAdditionalAssetsFromHTMLContent, + replaceAdditionalAssetsInHTMLContent, +} from "@/plane-editor/helpers/parser"; +// local imports +import { convertHTMLDocumentToAllFormats } from "./yjs-utils"; + +/** + * @description function to extract all assets from HTML content + * @param htmlContent + * @returns {string[]} array of asset sources + */ +const extractAssetsFromHTMLContent = (htmlContent: string): string[] => { + // create a DOM parser + const parser = new DOMParser(); + // parse the HTML string into a DOM document + const doc = parser.parseFromString(htmlContent, "text/html"); + // collect all unique asset sources + const assetSources = new Set(); + // extract sources from image components + const imageComponents = doc.querySelectorAll("image-component"); + imageComponents.forEach((component) => { + const src = component.getAttribute("src"); + if (src) assetSources.add(src); + }); + const additionalAssetIds = extractAdditionalAssetsFromHTMLContent(htmlContent); + return [...Array.from(assetSources), ...additionalAssetIds]; +}; + +/** + * @description function to replace assets in HTML content with new IDs + * @param props + * @returns {string} HTML content with replaced assets + */ +const replaceAssetsInHTMLContent = (props: { htmlContent: string; assetMap: Record }): string => { + const { htmlContent, assetMap } = props; + // create a DOM parser + const parser = new DOMParser(); + // parse the HTML string into a DOM document + const doc = parser.parseFromString(htmlContent, "text/html"); + // replace sources in image components + const imageComponents = doc.querySelectorAll("image-component"); + imageComponents.forEach((component) => { + const oldSrc = component.getAttribute("src"); + if (oldSrc && assetMap[oldSrc]) { + component.setAttribute("src", assetMap[oldSrc]); + } + }); + // replace additional sources + const replacedHTMLContent = replaceAdditionalAssetsInHTMLContent({ + htmlContent: doc.body.innerHTML, + assetMap, + }); + return replacedHTMLContent; +}; + +export const getEditorContentWithReplacedAssets = async (props: { + descriptionHTML: string; + entityId: string; + entityType: TEditorAssetType; + projectId: string | undefined; + variant: "rich" | "document"; + duplicateAssetService: (params: TDuplicateAssetData) => Promise; +}): Promise => { + const { descriptionHTML, entityId, entityType, projectId, variant, duplicateAssetService } = props; + let replacedDescription = descriptionHTML; + // step 1: extract image assets from the description + const assetIds = extractAssetsFromHTMLContent(descriptionHTML); + if (assetIds.length !== 0) { + // step 2: duplicate the image assets + const duplicateAssetsResponse = await duplicateAssetService({ + entity_id: entityId, + entity_type: entityType, + project_id: projectId, + asset_ids: assetIds, + }); + if (Object.keys(duplicateAssetsResponse ?? {}).length > 0) { + // step 3: replace the image assets in the description + replacedDescription = replaceAssetsInHTMLContent({ + htmlContent: descriptionHTML, + assetMap: duplicateAssetsResponse, + }); + } + } + // step 4: convert the description to the document payload + const documentPayload = convertHTMLDocumentToAllFormats({ + document_html: replacedDescription, + variant, + }); + return documentPayload; +}; diff --git a/packages/editor/src/core/helpers/yjs-utils.ts b/packages/editor/src/core/helpers/yjs-utils.ts index dce75fd1f..d61711127 100644 --- a/packages/editor/src/core/helpers/yjs-utils.ts +++ b/packages/editor/src/core/helpers/yjs-utils.ts @@ -3,6 +3,7 @@ import { generateHTML, generateJSON } from "@tiptap/html"; import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror"; import * as Y from "yjs"; // extensions +import { TDocumentPayload } from "@plane/types"; import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps, @@ -140,3 +141,50 @@ export const getAllDocumentFormatsFromDocumentEditorBinaryData = ( contentHTML, }; }; + +type TConvertHTMLDocumentToAllFormatsArgs = { + document_html: string; + variant: "rich" | "document"; +}; + +/** + * @description Converts HTML content to all supported document formats (JSON, HTML, and binary) + * @param {TConvertHTMLDocumentToAllFormatsArgs} args - Arguments containing HTML content and variant type + * @param {string} args.document_html - The HTML content to convert + * @param {"rich" | "document"} args.variant - The type of editor variant to use for conversion + * @returns {TDocumentPayload} Object containing the document in all supported formats + * @throws {Error} If an invalid variant is provided + */ +export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllFormatsArgs): TDocumentPayload => { + const { document_html, variant } = args; + + let allFormats: TDocumentPayload; + + if (variant === "rich") { + // Convert HTML to binary format for rich text editor + const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html); + // Generate all document formats from the binary data + const { contentBinaryEncoded, contentHTML, contentJSON } = + getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary); + allFormats = { + description: contentJSON, + description_html: contentHTML, + description_binary: contentBinaryEncoded, + }; + } else if (variant === "document") { + // Convert HTML to binary format for document editor + const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html); + // Generate all document formats from the binary data + 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/packages/editor/src/core/hooks/use-collaborative-editor.ts b/packages/editor/src/core/hooks/use-collaborative-editor.ts index 4abf7d6d1..9c436dff2 100644 --- a/packages/editor/src/core/hooks/use-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-collaborative-editor.ts @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useState } from "react"; import { HocuspocusProvider } from "@hocuspocus/provider"; import Collaboration from "@tiptap/extension-collaboration"; +import { useEffect, useMemo, useState } from "react"; import { IndexeddbPersistence } from "y-indexeddb"; // extensions import { HeadingListExtension, SideMenuExtension } from "@/extensions"; @@ -9,18 +9,20 @@ import { useEditor } from "@/hooks/use-editor"; // plane editor extensions import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions"; // types -import { TCollaborativeEditorProps } from "@/types"; +import { TCollaborativeEditorHookProps } from "@/types"; -export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { +export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) => { const { + onChange, onTransaction, disabledExtensions, editable, - editorClassName, + editorClassName = "", editorProps = {}, embedHandler, - extensions, + extensions = [], fileHandler, + flaggedExtensions, forwardedRef, handleEditorReady, id, @@ -89,18 +91,22 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { Collaboration.configure({ document: provider.document, }), - ...(extensions ?? []), + ...extensions, ...DocumentEditorAdditionalExtensions({ disabledExtensions, - issueEmbedConfig: embedHandler?.issue, + embedConfig: embedHandler, + fileHandler, + flaggedExtensions, provider, userDetails: user, }), ], fileHandler, + flaggedExtensions, forwardedRef, handleEditorReady, mentionHandler, + onChange, onTransaction, placeholder, provider, diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index cf9d04d83..4c1b93d84 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -1,62 +1,35 @@ -import { HocuspocusProvider } from "@hocuspocus/provider"; import { DOMSerializer } from "@tiptap/pm/model"; -import { EditorProps } from "@tiptap/pm/view"; -import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react"; -import { useImperativeHandle, MutableRefObject, useEffect } from "react"; +import { useEditor as useTiptapEditor } from "@tiptap/react"; +import { useImperativeHandle, useEffect } from "react"; import * as Y from "yjs"; // components import { getEditorMenuItems } from "@/components/menus"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +import { CORE_EDITOR_META } from "@/constants/meta"; // extensions import { CoreEditorExtensions } from "@/extensions"; // helpers import { getParagraphCount } from "@/helpers/common"; +import { getExtensionStorage } from "@/helpers/get-extension-storage"; import { insertContentAtSavedSelection } from "@/helpers/insert-content-at-cursor-position"; import { IMarking, scrollSummary, scrollToNodeViaDOMCoordinates } from "@/helpers/scroll-to-node"; // props import { CoreEditorProps } from "@/props"; // types -import type { - TDocumentEventsServer, - EditorRefApi, - TEditorCommands, - TFileHandler, - TExtensions, - TMentionHandler, -} from "@/types"; +import type { TDocumentEventsServer, TEditorCommands, TEditorHookProps } from "@/types"; -export interface CustomEditorProps { - editable: boolean; - editorClassName: string; - editorProps?: EditorProps; - enableHistory: boolean; - disabledExtensions: TExtensions[]; - extensions?: Extensions; - fileHandler: TFileHandler; - forwardedRef?: MutableRefObject; - handleEditorReady?: (value: boolean) => void; - id?: string; - initialValue?: string; - mentionHandler: TMentionHandler; - onChange?: (json: object, html: string) => void; - onTransaction?: () => void; - autofocus?: boolean; - placeholder?: string | ((isFocused: boolean, value: string) => string); - provider?: HocuspocusProvider; - tabIndex?: number; - // undefined when prop is not passed, null if intentionally passed to stop - // swr syncing - value?: string | null | undefined; -} - -export const useEditor = (props: CustomEditorProps) => { +export const useEditor = (props: TEditorHookProps) => { const { + autofocus = false, disabledExtensions, editable = true, - editorClassName, + editorClassName = "", editorProps = {}, enableHistory, extensions = [], fileHandler, + flaggedExtensions, forwardedRef, handleEditorReady, id = "", @@ -65,10 +38,9 @@ export const useEditor = (props: CustomEditorProps) => { onChange, onTransaction, placeholder, + provider, tabIndex, value, - provider, - autofocus = false, } = props; const editor = useTiptapEditor( @@ -77,6 +49,7 @@ export const useEditor = (props: CustomEditorProps) => { immediatelyRender: false, shouldRerenderOnTransaction: false, autofocus, + parseOptions: { preserveWhitespace: true }, editorProps: { ...CoreEditorProps({ editorClassName, @@ -89,6 +62,7 @@ export const useEditor = (props: CustomEditorProps) => { disabledExtensions, enableHistory, fileHandler, + flaggedExtensions, mentionHandler, placeholder, tabIndex, @@ -111,16 +85,19 @@ export const useEditor = (props: CustomEditorProps) => { // value is null when intentionally passed where syncing is not yet // supported and value is undefined when the data from swr is not populated if (value == null) return; - if (editor && !editor.isDestroyed && !editor.storage.imageComponent?.uploadInProgress) { - try { - editor.commands.setContent(value, false, { preserveWhitespace: "full" }); - if (editor.state.selection) { - const docLength = editor.state.doc.content.size; - const relativePosition = Math.min(editor.state.selection.from, docLength - 1); - editor.commands.setTextSelection(relativePosition); + if (editor) { + const isUploadInProgress = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress; + if (!editor.isDestroyed && !isUploadInProgress) { + try { + editor.commands.setContent(value, false, { preserveWhitespace: true }); + if (editor.state.selection) { + const docLength = editor.state.doc.content.size; + const relativePosition = Math.min(editor.state.selection.from, docLength - 1); + editor.commands.setTextSelection(relativePosition); + } + } catch (error) { + console.error("Error syncing editor content with external value:", error); } - } catch (error) { - console.error("Error syncing editor content with external value:", error); } } }, [editor, value, id]); @@ -143,10 +120,10 @@ export const useEditor = (props: CustomEditorProps) => { }, getCurrentCursorPosition: () => editor?.state.selection.from, clearEditor: (emitUpdate = false) => { - editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run(); + editor?.chain().setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true).clearContent(emitUpdate).run(); }, setEditorValue: (content: string, emitUpdate = false) => { - editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: "full" }); + editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: true }); }, setEditorValueAtCursorPosition: (content: string) => { if (editor?.state.selection) { @@ -179,7 +156,10 @@ export const useEditor = (props: CustomEditorProps) => { onHeadingChange: (callback: (headings: IMarking[]) => void) => { // Subscribe to update event emitted from headers extension editor?.on("update", () => { - callback(editor?.storage.headingList.headings); + const headings = getExtensionStorage(editor, CORE_EXTENSIONS.HEADINGS_LIST)?.headings; + if (headings) { + callback(headings); + } }); // Return a function to unsubscribe to the continuous transactions of // the editor on unmounting the component that has subscribed to this @@ -188,7 +168,7 @@ export const useEditor = (props: CustomEditorProps) => { editor?.off("update"); }; }, - getHeadings: () => editor?.storage.headingList.headings, + getHeadings: () => (editor ? getExtensionStorage(editor, CORE_EXTENSIONS.HEADINGS_LIST)?.headings : []), onStateChange: (callback: () => void) => { // Subscribe to editor state changes editor?.on("transaction", () => { @@ -221,7 +201,8 @@ export const useEditor = (props: CustomEditorProps) => { if (!editor) return; scrollSummary(editor, marking); }, - isEditorReadyToDiscard: () => editor?.storage.imageComponent?.uploadInProgress === false, + isEditorReadyToDiscard: () => + !!editor && getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress === false, setFocusAtPosition: (position: number) => { if (!editor || editor.isDestroyed) { console.error("Editor reference is not available or has been destroyed."); @@ -232,7 +213,7 @@ export const useEditor = (props: CustomEditorProps) => { const safePosition = Math.max(0, Math.min(position, docSize)); editor .chain() - .insertContentAt(safePosition, [{ type: "paragraph" }]) + .insertContentAt(safePosition, [{ type: CORE_EXTENSIONS.PARAGRAPH }]) .focus() .run(); } catch (error) { diff --git a/packages/editor/src/core/hooks/use-file-upload.ts b/packages/editor/src/core/hooks/use-file-upload.ts index e57c811a0..dce48cca5 100644 --- a/packages/editor/src/core/hooks/use-file-upload.ts +++ b/packages/editor/src/core/hooks/use-file-upload.ts @@ -1,87 +1,102 @@ -import { DragEvent, useCallback, useEffect, useState } from "react"; import { Editor } from "@tiptap/core"; -// extensions -import { insertImagesSafely } from "@/extensions/drop"; +import { DragEvent, useCallback, useEffect, useState } from "react"; +// helpers +import { EFileError, isFileValid } from "@/helpers/file"; // plugins -import { isFileValid } from "@/plugins/image"; +import { insertFilesSafely } from "@/plugins/drop"; +// types +import { TEditorCommands } from "@/types"; type TUploaderArgs = { - blockId: string; - editor: Editor; - loadImageFromFileSystem: (file: string) => void; + acceptedMimeTypes: string[]; + editorCommand: (file: File) => Promise; + handleProgressStatus?: (isUploading: boolean) => void; + loadFileFromFileSystem?: (file: string) => void; maxFileSize: number; - onUpload: (url: string) => void; + onInvalidFile: (error: EFileError, file: File, message: string) => void; + onUpload: (url: string, file: File) => void; }; export const useUploader = (args: TUploaderArgs) => { - const { blockId, editor, loadImageFromFileSystem, maxFileSize, onUpload } = args; + const { + acceptedMimeTypes, + editorCommand, + handleProgressStatus, + loadFileFromFileSystem, + maxFileSize, + onInvalidFile, + onUpload, + } = args; // states - const [uploading, setUploading] = useState(false); + const [isUploading, setIsUploading] = useState(false); const uploadFile = useCallback( async (file: File) => { - const setImageUploadInProgress = (isUploading: boolean) => { - if (editor.storage.imageComponent) { - editor.storage.imageComponent.uploadInProgress = isUploading; - } - }; - setImageUploadInProgress(true); - setUploading(true); - const fileNameTrimmed = trimFileName(file.name); - const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type }); + handleProgressStatus?.(true); + setIsUploading(true); const isValid = isFileValid({ - file: fileWithTrimmedName, + acceptedMimeTypes, + file, maxFileSize, + onError: (error, message) => onInvalidFile(error, file, message), }); if (!isValid) { - setImageUploadInProgress(false); + handleProgressStatus?.(false); + setIsUploading(false); return; } try { - const reader = new FileReader(); - reader.onload = () => { - if (reader.result) { - loadImageFromFileSystem(reader.result as string); - } else { - console.error("Failed to read the file: reader.result is null"); - } - }; - reader.onerror = () => { - console.error("Error reading file"); - }; - reader.readAsDataURL(fileWithTrimmedName); - // @ts-expect-error - TODO: fix typings, and don't remove await from - // here for now - const url: string = await editor?.commands.uploadImage(blockId, fileWithTrimmedName); + if (loadFileFromFileSystem) { + const reader = new FileReader(); + reader.onload = () => { + if (reader.result) { + loadFileFromFileSystem(reader.result as string); + } else { + console.error("Failed to read the file: reader.result is null"); + } + }; + reader.onerror = () => { + console.error("Error reading file"); + }; + reader.readAsDataURL(file); + } + const url = await editorCommand(file); if (!url) { - throw new Error("Something went wrong while uploading the image"); + throw new Error("Something went wrong while uploading the file."); } - onUpload(url); - } catch (errPayload: any) { - console.log(errPayload); + onUpload(url, file); + } catch (errPayload) { const error = errPayload?.response?.data?.error || "Something went wrong"; console.error(error); } finally { - setImageUploadInProgress(false); - setUploading(false); + handleProgressStatus?.(false); + setIsUploading(false); } }, - [onUpload] + [ + acceptedMimeTypes, + editorCommand, + handleProgressStatus, + loadFileFromFileSystem, + maxFileSize, + onInvalidFile, + onUpload, + ] ); - return { uploading, uploadFile }; + return { isUploading, uploadFile }; }; type TDropzoneArgs = { editor: Editor; - maxFileSize: number; pos: number; + type: Extract; uploader: (file: File) => Promise; }; export const useDropZone = (args: TDropzoneArgs) => { - const { editor, maxFileSize, pos, uploader } = args; + const { editor, pos, type, uploader } = args; // states const [isDragging, setIsDragging] = useState(false); const [draggedInside, setDraggedInside] = useState(false); @@ -108,87 +123,65 @@ export const useDropZone = (args: TDropzoneArgs) => { async (e: DragEvent) => { e.preventDefault(); setDraggedInside(false); - if (e.dataTransfer.files.length === 0 || !editor.isEditable) { + const filesList = e.dataTransfer.files; + + if (filesList.length === 0 || !editor.isEditable) { return; } - const filesList = e.dataTransfer.files; - await uploadFirstImageAndInsertRemaining({ + + await uploadFirstFileAndInsertRemaining({ editor, filesList, - maxFileSize, pos, + type, uploader, }); }, - [uploader, editor, pos] + [editor, pos, type, uploader] ); + const onDragEnter = useCallback(() => setDraggedInside(true), []); + const onDragLeave = useCallback(() => setDraggedInside(false), []); - const onDragEnter = () => { - setDraggedInside(true); + return { + isDragging, + draggedInside, + onDragEnter, + onDragLeave, + onDrop, }; - - const onDragLeave = () => { - setDraggedInside(false); - }; - - return { isDragging, draggedInside, onDragEnter, onDragLeave, onDrop }; }; -function trimFileName(fileName: string, maxLength = 100) { - if (fileName.length > maxLength) { - const extension = fileName.split(".").pop(); - const nameWithoutExtension = fileName.slice(0, -(extension?.length ?? 0 + 1)); - const allowedNameLength = maxLength - (extension?.length ?? 0) - 1; // -1 for the dot - return `${nameWithoutExtension.slice(0, allowedNameLength)}.${extension}`; - } - - return fileName; -} - -type TMultipleImagesArgs = { +type TMultipleFileArgs = { editor: Editor; filesList: FileList; - maxFileSize: number; pos: number; + type: Extract; uploader: (file: File) => Promise; }; -// Upload the first image and insert the remaining images for uploading multiple image -// post insertion of image-component -export async function uploadFirstImageAndInsertRemaining(args: TMultipleImagesArgs) { - const { editor, filesList, maxFileSize, pos, uploader } = args; - const filteredFiles: File[] = []; - for (let i = 0; i < filesList.length; i += 1) { - const item = filesList.item(i); - if ( - item && - item.type.indexOf("image") !== -1 && - isFileValid({ - file: item, - maxFileSize, - }) - ) { - filteredFiles.push(item); - } - } - if (filteredFiles.length !== filesList.length) { - console.warn("Some files were not images and have been ignored."); - } - if (filteredFiles.length === 0) { - console.error("No image files found to upload"); +// Upload the first file and insert the remaining ones for uploading multiple files +export const uploadFirstFileAndInsertRemaining = async (args: TMultipleFileArgs) => { + const { editor, filesList, pos, type, uploader } = args; + const filesArray = Array.from(filesList); + if (filesArray.length === 0) { + console.error("No files found to upload."); return; } - // Upload the first image - const firstFile = filteredFiles[0]; + // Upload the first file + const firstFile = filesArray[0]; uploader(firstFile); - - // Insert the remaining images - const remainingFiles = filteredFiles.slice(1); - + // Insert the remaining files + const remainingFiles = filesArray.slice(1); if (remainingFiles.length > 0) { const docSize = editor.state.doc.content.size; - const posOfNextImageToBeInserted = Math.min(pos + 1, docSize); - insertImagesSafely({ editor, files: remainingFiles, initialPos: posOfNextImageToBeInserted, event: "drop" }); + const posOfNextFileToBeInserted = Math.min(pos + 1, docSize); + insertFilesSafely({ + editor, + files: remainingFiles, + initialPos: posOfNextFileToBeInserted, + event: "drop", + type, + }); } -} +}; diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index b50b56b02..d259470ac 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -1,8 +1,8 @@ -import { HocuspocusProvider } from "@hocuspocus/provider"; -import { EditorProps } from "@tiptap/pm/view"; -import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react"; -import { useImperativeHandle, MutableRefObject, useEffect } from "react"; +import { useEditor as useTiptapEditor } from "@tiptap/react"; +import { useImperativeHandle, useEffect } from "react"; import * as Y from "yjs"; +// constants +import { CORE_EDITOR_META } from "@/constants/meta"; // extensions import { CoreReadOnlyEditorExtensions } from "@/extensions"; // helpers @@ -11,31 +11,19 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node"; // props import { CoreReadOnlyEditorProps } from "@/props"; // types -import type { EditorReadOnlyRefApi, TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types"; +import type { TReadOnlyEditorHookProps } from "@/types"; -interface CustomReadOnlyEditorProps { - disabledExtensions: TExtensions[]; - editorClassName: string; - editorProps?: EditorProps; - extensions?: Extensions; - forwardedRef?: MutableRefObject; - initialValue?: string; - fileHandler: TReadOnlyFileHandler; - handleEditorReady?: (value: boolean) => void; - mentionHandler: TReadOnlyMentionHandler; - provider?: HocuspocusProvider; -} - -export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { +export const useReadOnlyEditor = (props: TReadOnlyEditorHookProps) => { const { disabledExtensions, - initialValue, - editorClassName, - forwardedRef, - extensions = [], + editorClassName = "", editorProps = {}, + extensions = [], fileHandler, + flaggedExtensions, + forwardedRef, handleEditorReady, + initialValue, mentionHandler, provider, } = props; @@ -45,6 +33,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { immediatelyRender: true, shouldRerenderOnTransaction: false, content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "

", + parseOptions: { preserveWhitespace: true }, editorProps: { ...CoreReadOnlyEditorProps({ editorClassName, @@ -57,8 +46,9 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { extensions: [ ...CoreReadOnlyEditorExtensions({ disabledExtensions, - mentionHandler, fileHandler, + flaggedExtensions, + mentionHandler, }), ...extensions, ], @@ -70,15 +60,15 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { // for syncing swr data on tab refocus etc useEffect(() => { if (initialValue === null || initialValue === undefined) return; - if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" }); + if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: true }); }, [editor, initialValue]); useImperativeHandle(forwardedRef, () => ({ clearEditor: (emitUpdate = false) => { - editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run(); + editor?.chain().setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true).clearContent(emitUpdate).run(); }, setEditorValue: (content: string, emitUpdate = false) => { - editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: "full" }); + editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: true }); }, getMarkDown: (): string => { const markdownOutput = editor?.storage.markdown.getMarkdown(); diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index e71c38b30..4a534bc4c 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -1,7 +1,8 @@ import { Fragment, Slice, Node, Schema } from "@tiptap/pm/model"; import { NodeSelection } from "@tiptap/pm/state"; -// @ts-expect-error __serializeForClipboard's is not exported -import { __serializeForClipboard, EditorView } from "@tiptap/pm/view"; +import { EditorView } from "@tiptap/pm/view"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions"; @@ -10,10 +11,10 @@ const verticalEllipsisIcon = const generalSelectors = [ "li", - "p:not(:first-child)", + "p.editor-paragraph-block:not(:first-child)", ".code-block", "blockquote", - "h1, h2, h3, h4, h5, h6", + "h1.editor-heading-block, h2.editor-heading-block, h3.editor-heading-block, h4.editor-heading-block, h5.editor-heading-block, h6.editor-heading-block", "[data-type=horizontalRule]", ".table-wrapper", ".issue-embed", @@ -132,7 +133,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp let listType = ""; let isDragging = false; let lastClientY = 0; - let scrollAnimationFrame = null; + let scrollAnimationFrame: number | null = null; let isDraggedOutsideWindow: "top" | "bottom" | boolean = false; let isMouseInsideWhileDragging = false; let currentScrollSpeed = 0; @@ -142,8 +143,10 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp }; const handleDragStart = (event: DragEvent, view: EditorView) => { - const { listType: listTypeFromDragStart } = handleNodeSelection(event, view, true, options); - listType = listTypeFromDragStart; + const { listType: listTypeFromDragStart } = handleNodeSelection(event, view, true, options) ?? {}; + if (listTypeFromDragStart) { + listType = listTypeFromDragStart; + } isDragging = true; lastClientY = event.clientY; scroll(); @@ -297,7 +300,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp // Traverse up the document tree to find if we're inside a list item for (let i = resolvedPos.depth; i > 0; i--) { - if (resolvedPos.node(i).type.name === "listItem") { + if (resolvedPos.node(i).type.name === CORE_EXTENSIONS.LIST_ITEM) { isDroppedInsideList = true; dropDepth = i; break; @@ -305,7 +308,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp } // Handle nested list items and task items - if (droppedNode.type.name === "listItem") { + if (droppedNode.type.name === CORE_EXTENSIONS.LIST_ITEM) { let slice = view.state.selection.content(); let newFragment = slice.content; @@ -348,8 +351,8 @@ function flattenListStructure(fragment: Fragment, schema: Schema): Fragment { (node.content.firstChild.type === schema.nodes.bulletList || node.content.firstChild.type === schema.nodes.orderedList) ) { - const sublist = node.content.firstChild; - const flattened = flattenListStructure(sublist.content, schema); + const subList = node.content.firstChild; + const flattened = flattenListStructure(subList.content, schema); flattened.forEach((subNode) => result.push(subNode)); } } @@ -376,7 +379,7 @@ const handleNodeSelection = ( let draggedNodePos = nodePosAtDOM(node, view, options); if (draggedNodePos == null || draggedNodePos < 0) return; - // Handle blockquotes separately + // Handle blockquote separately if (node.matches("blockquote")) { draggedNodePos = nodePosAtDOMForBlockQuotes(node, view); if (draggedNodePos === null || draggedNodePos === undefined) return; @@ -385,7 +388,10 @@ const handleNodeSelection = ( const $pos = view.state.doc.resolve(draggedNodePos); // If it's a nested list item or task item, move up to the item level - if (($pos.parent.type.name === "listItem" || $pos.parent.type.name === "taskItem") && $pos.depth > 1) { + if ( + [CORE_EXTENSIONS.LIST_ITEM, CORE_EXTENSIONS.TASK_ITEM].includes($pos.parent.type.name as CORE_EXTENSIONS) && + $pos.depth > 1 + ) { draggedNodePos = $pos.before($pos.depth); } } @@ -403,14 +409,16 @@ const handleNodeSelection = ( // Additional logic for drag start if (event instanceof DragEvent && !event.dataTransfer) return; - if (nodeSelection.node.type.name === "listItem" || nodeSelection.node.type.name === "taskItem") { + if ( + [CORE_EXTENSIONS.LIST_ITEM, CORE_EXTENSIONS.TASK_ITEM].includes(nodeSelection.node.type.name as CORE_EXTENSIONS) + ) { listType = node.closest("ol, ul")?.tagName || ""; } const slice = view.state.selection.content(); - const { dom, text } = __serializeForClipboard(view, slice); + const { dom, text } = view.serializeForClipboard(slice); - if (event instanceof DragEvent) { + if (event instanceof DragEvent && event.dataTransfer) { event.dataTransfer.clearData(); event.dataTransfer.setData("text/html", dom.innerHTML); event.dataTransfer.setData("text/plain", text); diff --git a/packages/editor/src/core/plugins/drop.ts b/packages/editor/src/core/plugins/drop.ts new file mode 100644 index 000000000..ad8070924 --- /dev/null +++ b/packages/editor/src/core/plugins/drop.ts @@ -0,0 +1,129 @@ +import { Editor } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +// constants +import { ACCEPTED_ATTACHMENT_MIME_TYPES, ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config"; +// types +import { TEditorCommands, TExtensions } from "@/types"; + +type Props = { + disabledExtensions?: TExtensions[]; + editor: Editor; +}; + +export const DropHandlerPlugin = (props: Props): Plugin => { + const { disabledExtensions, editor } = props; + + return new Plugin({ + key: new PluginKey("drop-handler-plugin"), + props: { + handlePaste: (view, event) => { + if ( + editor.isEditable && + event.clipboardData && + event.clipboardData.files && + event.clipboardData.files.length > 0 + ) { + event.preventDefault(); + const files = Array.from(event.clipboardData.files); + const acceptedFiles = files.filter( + (f) => ACCEPTED_IMAGE_MIME_TYPES.includes(f.type) || ACCEPTED_ATTACHMENT_MIME_TYPES.includes(f.type) + ); + + if (acceptedFiles.length) { + const pos = view.state.selection.from; + insertFilesSafely({ + disabledExtensions, + editor, + files: acceptedFiles, + initialPos: pos, + event: "drop", + }); + } + return true; + } + return false; + }, + handleDrop: (view, event, _slice, moved) => { + if ( + editor.isEditable && + !moved && + event.dataTransfer && + event.dataTransfer.files && + event.dataTransfer.files.length > 0 + ) { + event.preventDefault(); + const files = Array.from(event.dataTransfer.files); + const acceptedFiles = files.filter( + (f) => ACCEPTED_IMAGE_MIME_TYPES.includes(f.type) || ACCEPTED_ATTACHMENT_MIME_TYPES.includes(f.type) + ); + + if (acceptedFiles.length) { + const coordinates = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + + if (coordinates) { + const pos = coordinates.pos; + insertFilesSafely({ + disabledExtensions, + editor, + files: acceptedFiles, + initialPos: pos, + event: "drop", + }); + } + return true; + } + } + return false; + }, + }, + }); +}; + +type InsertFilesSafelyArgs = { + disabledExtensions?: TExtensions[]; + editor: Editor; + event: "insert" | "drop"; + files: File[]; + initialPos: number; + type?: Extract; +}; + +export const insertFilesSafely = async (args: InsertFilesSafelyArgs) => { + const { disabledExtensions, editor, event, files, initialPos, type } = args; + let pos = initialPos; + + for (const file of files) { + // safe insertion + const docSize = editor.state.doc.content.size; + pos = Math.min(pos, docSize); + + let fileType: "image" | "attachment" | null = null; + + try { + if (type) { + if (["image", "attachment"].includes(type)) fileType = type; + else throw new Error("Wrong file type passed"); + } else { + if (ACCEPTED_IMAGE_MIME_TYPES.includes(file.type)) fileType = "image"; + else if (ACCEPTED_ATTACHMENT_MIME_TYPES.includes(file.type)) fileType = "attachment"; + } + // insert file depending on the type at the current position + if (fileType === "image" && !disabledExtensions?.includes("image")) { + editor.commands.insertImageComponent({ + file, + pos, + event, + }); + } else if (fileType === "attachment") { + } + } catch (error) { + console.error(`Error while ${event}ing file:`, error); + } + + // Move to the next position + pos += 1; + } +}; diff --git a/packages/editor/src/core/plugins/file/delete.ts b/packages/editor/src/core/plugins/file/delete.ts new file mode 100644 index 000000000..ac69b1819 --- /dev/null +++ b/packages/editor/src/core/plugins/file/delete.ts @@ -0,0 +1,69 @@ +import { Editor } from "@tiptap/core"; +import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; +// constants +import { CORE_EDITOR_META } from "@/constants/meta"; +// plane editor imports +import { NODE_FILE_MAP } from "@/plane-editor/constants/utility"; +// types +import { TFileHandler } from "@/types"; +// local imports +import { TFileNode } from "./types"; + +const DELETE_PLUGIN_KEY = new PluginKey("delete-utility"); + +export const TrackFileDeletionPlugin = (editor: Editor, deleteHandler: TFileHandler["delete"]): Plugin => + new Plugin({ + key: DELETE_PLUGIN_KEY, + appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { + const newFileSources: { + [nodeType: string]: Set | undefined; + } = {}; + if (!transactions.some((tr) => tr.docChanged)) return null; + + newState.doc.descendants((node) => { + const nodeType = node.type.name; + const nodeFileSetDetails = NODE_FILE_MAP[nodeType]; + if (nodeFileSetDetails) { + if (newFileSources[nodeType]) { + newFileSources[nodeType].add(node.attrs.src); + } else { + newFileSources[nodeType] = new Set([node.attrs.src]); + } + } + }); + + transactions.forEach((transaction) => { + // if the transaction has meta of skipFileDeletion set to true, then return (like while clearing the editor content programmatically) + if (transaction.getMeta(CORE_EDITOR_META.SKIP_FILE_DELETION)) return; + + const removedFiles: TFileNode[] = []; + + // iterate through all the nodes in the old state + oldState.doc.descendants((node) => { + const nodeType = node.type.name; + const isAValidNode = NODE_FILE_MAP[nodeType]; + // if the node doesn't match, then return as no point in checking + if (!isAValidNode) return; + // Check if the node has been deleted or replaced + if (!newFileSources[nodeType]?.has(node.attrs.src)) { + removedFiles.push(node as TFileNode); + } + }); + + removedFiles.forEach(async (node) => { + const nodeType = node.type.name; + const src = node.attrs.src; + const nodeFileSetDetails = NODE_FILE_MAP[nodeType]; + if (!nodeFileSetDetails || !src) return; + try { + editor.storage[nodeType][nodeFileSetDetails.fileSetName]?.set(src, true); + await deleteHandler(src); + } catch (error) { + console.error("Error deleting file via delete utility plugin:", error); + } + }); + }); + + return null; + }, + }); diff --git a/packages/editor/src/core/plugins/file/restore.ts b/packages/editor/src/core/plugins/file/restore.ts new file mode 100644 index 000000000..04a4c295c --- /dev/null +++ b/packages/editor/src/core/plugins/file/restore.ts @@ -0,0 +1,72 @@ +import { Editor } from "@tiptap/core"; +import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// plane editor imports +import { NODE_FILE_MAP } from "@/plane-editor/constants/utility"; +// types +import { TFileHandler } from "@/types"; +// local imports +import { TFileNode } from "./types"; + +const RESTORE_PLUGIN_KEY = new PluginKey("restore-utility"); + +export const TrackFileRestorationPlugin = (editor: Editor, restoreHandler: TFileHandler["restore"]): Plugin => + new Plugin({ + key: RESTORE_PLUGIN_KEY, + appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { + if (!transactions.some((tr) => tr.docChanged)) return null; + + const oldFileSources: { + [key: string]: Set | undefined; + } = {}; + oldState.doc.descendants((node) => { + const nodeType = node.type.name; + const nodeFileSetDetails = NODE_FILE_MAP[nodeType]; + if (nodeFileSetDetails) { + if (oldFileSources[nodeType]) { + oldFileSources[nodeType].add(node.attrs.src); + } else { + oldFileSources[nodeType] = new Set([node.attrs.src]); + } + } + }); + + transactions.forEach(() => { + const addedFiles: TFileNode[] = []; + + newState.doc.descendants((node, pos) => { + const nodeType = node.type.name; + const isAValidNode = NODE_FILE_MAP[nodeType]; + // if the node doesn't match, then return as no point in checking + if (!isAValidNode) return; + if (pos < 0 || pos > newState.doc.content.size) return; + if (oldFileSources[nodeType]?.has(node.attrs.src)) return; + // if the src is just a id (private bucket), then we don't need to handle restore from here but + // only while it fails to load + if (nodeType === CORE_EXTENSIONS.CUSTOM_IMAGE && !node.attrs.src?.startsWith("http")) return; + addedFiles.push(node as TFileNode); + }); + + addedFiles.forEach(async (node) => { + const nodeType = node.type.name; + const src = node.attrs.src; + const nodeFileSetDetails = NODE_FILE_MAP[nodeType]; + const extensionFileSetStorage = editor.storage[nodeType]?.[nodeFileSetDetails.fileSetName]; + const wasDeleted = extensionFileSetStorage?.get(src); + if (!nodeFileSetDetails || !src) return; + if (wasDeleted === undefined) { + extensionFileSetStorage?.set(src, false); + } else if (wasDeleted === true) { + try { + await restoreHandler(src); + extensionFileSetStorage?.set(src, false); + } catch (error) { + console.error("Error restoring file via restore utility plugin:", error); + } + } + }); + }); + return null; + }, + }); diff --git a/packages/editor/src/core/plugins/file/root.ts b/packages/editor/src/core/plugins/file/root.ts new file mode 100644 index 000000000..693ac6964 --- /dev/null +++ b/packages/editor/src/core/plugins/file/root.ts @@ -0,0 +1,22 @@ +import { Editor } from "@tiptap/core"; +import { Plugin } from "@tiptap/pm/state"; +// types +import { TFileHandler, TReadOnlyFileHandler } from "@/types"; +// local imports +import { TrackFileDeletionPlugin } from "./delete"; +import { TrackFileRestorationPlugin } from "./restore"; + +type TArgs = { + editor: Editor; + fileHandler: TFileHandler | TReadOnlyFileHandler; + isEditable: boolean; +}; + +export const FilePlugins = (args: TArgs): Plugin[] => { + const { editor, fileHandler, isEditable } = args; + + return [ + ...(isEditable && "delete" in fileHandler ? [TrackFileDeletionPlugin(editor, fileHandler.delete)] : []), + TrackFileRestorationPlugin(editor, fileHandler.restore), + ]; +}; diff --git a/packages/editor/src/core/plugins/file/types.ts b/packages/editor/src/core/plugins/file/types.ts new file mode 100644 index 000000000..164d12ae7 --- /dev/null +++ b/packages/editor/src/core/plugins/file/types.ts @@ -0,0 +1,8 @@ +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; + +export type TFileNode = ProseMirrorNode & { + attrs: { + src: string; + id: string; + }; +}; diff --git a/packages/editor/src/core/plugins/image/constants.ts b/packages/editor/src/core/plugins/image/constants.ts deleted file mode 100644 index 72fae6710..000000000 --- a/packages/editor/src/core/plugins/image/constants.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { PluginKey } from "@tiptap/pm/state"; - -export const uploadKey = new PluginKey("upload-image"); -export const deleteKey = new PluginKey("delete-image"); -export const restoreKey = new PluginKey("restore-image"); - -export const IMAGE_NODE_TYPE = "image"; diff --git a/packages/editor/src/core/plugins/image/delete-image.ts b/packages/editor/src/core/plugins/image/delete-image.ts deleted file mode 100644 index bcede7707..000000000 --- a/packages/editor/src/core/plugins/image/delete-image.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Editor } from "@tiptap/core"; -import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; -// plugins -import { type ImageNode } from "@/plugins/image"; -// types -import { DeleteImage } from "@/types"; - -export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImage, nodeType: string): Plugin => - new Plugin({ - key: new PluginKey(`delete-${nodeType}`), - appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { - const newImageSources = new Set(); - newState.doc.descendants((node) => { - if (node.type.name === nodeType) { - newImageSources.add(node.attrs.src); - } - }); - - transactions.forEach((transaction) => { - // if the transaction has meta of skipImageDeletion get to true, then return (like while clearing the editor content programatically) - if (transaction.getMeta("skipImageDeletion")) return; - // transaction could be a selection - if (!transaction.docChanged) return; - - const removedImages: ImageNode[] = []; - - // iterate through all the nodes in the old state - oldState.doc.descendants((oldNode) => { - // if the node is not an image, then return as no point in checking - if (oldNode.type.name !== nodeType) return; - - // Check if the node has been deleted or replaced - if (!newImageSources.has(oldNode.attrs.src)) { - removedImages.push(oldNode as ImageNode); - } - }); - - removedImages.forEach(async (node) => { - const src = node.attrs.src; - editor.storage[nodeType].deletedImageSet.set(src, true); - await onNodeDeleted(src, deleteImage); - }); - }); - - return null; - }, - }); - -async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise { - if (!src) return; - try { - await deleteImage(src); - } catch (error) { - console.error("Error deleting image: ", error); - } -} diff --git a/packages/editor/src/core/plugins/image/index.ts b/packages/editor/src/core/plugins/image/index.ts deleted file mode 100644 index dfb787873..000000000 --- a/packages/editor/src/core/plugins/image/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./types"; -export * from "./utils"; -export * from "./constants"; -export * from "./delete-image"; -export * from "./restore-image"; diff --git a/packages/editor/src/core/plugins/image/restore-image.ts b/packages/editor/src/core/plugins/image/restore-image.ts deleted file mode 100644 index 4eecf01d7..000000000 --- a/packages/editor/src/core/plugins/image/restore-image.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Editor } from "@tiptap/core"; -import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; -// plugins -import { ImageNode } from "@/plugins/image"; -// types -import { RestoreImage } from "@/types"; - -export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: RestoreImage, nodeType: string): Plugin => - new Plugin({ - key: new PluginKey(`restore-${nodeType}`), - appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { - const oldImageSources = new Set(); - oldState.doc.descendants((node) => { - if (node.type.name === nodeType) { - oldImageSources.add(node.attrs.src); - } - }); - - transactions.forEach((transaction) => { - if (!transaction.docChanged) return; - - const addedImages: ImageNode[] = []; - - newState.doc.descendants((node, pos) => { - if (node.type.name !== nodeType) return; - if (pos < 0 || pos > newState.doc.content.size) return; - if (oldImageSources.has(node.attrs.src)) return; - // if the src is just a id (private bucket), then we don't need to handle restore from here but - // only while it fails to load - if (!node.attrs.src?.startsWith("http")) return; - addedImages.push(node as ImageNode); - }); - - addedImages.forEach(async (image) => { - const src = image.attrs.src; - const wasDeleted = editor.storage[nodeType].deletedImageSet.get(src); - if (wasDeleted === undefined) { - editor.storage[nodeType].deletedImageSet.set(src, false); - } else if (wasDeleted === true) { - try { - await onNodeRestored(src, restoreImage); - editor.storage[nodeType].deletedImageSet.set(src, false); - } catch (error) { - console.error("Error restoring image: ", error); - } - } - }); - }); - return null; - }, - }); - -async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise { - if (!src) return; - try { - await restoreImage(src); - } catch (error) { - console.error("Error restoring image: ", error); - throw error; - } -} diff --git a/packages/editor/src/core/plugins/image/types/image-node.ts b/packages/editor/src/core/plugins/image/types/image-node.ts deleted file mode 100644 index 67afc8315..000000000 --- a/packages/editor/src/core/plugins/image/types/image-node.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Node as ProseMirrorNode } from "@tiptap/pm/model"; - -export interface ImageNode extends ProseMirrorNode { - attrs: { - src: string; - id: string; - }; -} - -export type ImageExtensionStorage = { - deletedImageSet: Map; - uploadInProgress: boolean; -}; diff --git a/packages/editor/src/core/plugins/image/types/index.ts b/packages/editor/src/core/plugins/image/types/index.ts deleted file mode 100644 index 2fddf3bf6..000000000 --- a/packages/editor/src/core/plugins/image/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./image-node"; diff --git a/packages/editor/src/core/plugins/image/utils/index.ts b/packages/editor/src/core/plugins/image/utils/index.ts deleted file mode 100644 index 08d377a83..000000000 --- a/packages/editor/src/core/plugins/image/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./validate-file"; diff --git a/packages/editor/src/core/plugins/image/utils/validate-file.ts b/packages/editor/src/core/plugins/image/utils/validate-file.ts deleted file mode 100644 index 703bb2bf0..000000000 --- a/packages/editor/src/core/plugins/image/utils/validate-file.ts +++ /dev/null @@ -1,28 +0,0 @@ -// constants -import { ACCEPTED_FILE_MIME_TYPES } from "@/constants/config"; - -type TArgs = { - file: File; - maxFileSize: number; -}; - -export const isFileValid = (args: TArgs): boolean => { - const { file, maxFileSize } = args; - - if (!file) { - alert("No file selected. Please select a file to upload."); - return false; - } - - if (!ACCEPTED_FILE_MIME_TYPES.includes(file.type)) { - alert("Invalid file type. Please select a JPEG, JPG, PNG, WEBP or GIF file."); - return false; - } - - if (file.size > maxFileSize) { - alert(`File size too large. Please select a file smaller than ${maxFileSize / 1024 / 1024}MB.`); - return false; - } - - return true; -}; diff --git a/packages/editor/src/core/plugins/markdown-clipboard.ts b/packages/editor/src/core/plugins/markdown-clipboard.ts new file mode 100644 index 000000000..78f649b23 --- /dev/null +++ b/packages/editor/src/core/plugins/markdown-clipboard.ts @@ -0,0 +1,80 @@ +import { Editor } from "@tiptap/core"; +import { Fragment, Node } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; + +export const MarkdownClipboardPlugin = (editor: Editor): Plugin => + new Plugin({ + key: new PluginKey("markdownClipboard"), + props: { + clipboardTextSerializer: (slice) => { + const markdownSerializer = editor.storage.markdown.serializer; + const isTableRow = slice.content.firstChild?.type?.name === CORE_EXTENSIONS.TABLE_ROW; + const nodeSelect = slice.openStart === 0 && slice.openEnd === 0; + + if (nodeSelect) { + return markdownSerializer.serialize(slice.content); + } + + const processTableContent = (tableNode: Node | Fragment) => { + let result = ""; + tableNode.content?.forEach?.((tableRowNode: Node | Fragment) => { + tableRowNode.content?.forEach?.((cell: Node) => { + const cellContent = cell.content ? markdownSerializer.serialize(cell.content) : ""; + result += cellContent + "\n"; + }); + }); + return result; + }; + + if (isTableRow) { + const rowsCount = slice.content?.childCount || 0; + const cellsCount = slice.content?.firstChild?.content?.childCount || 0; + if (rowsCount === 1 || cellsCount === 1) { + return processTableContent(slice.content); + } else { + return markdownSerializer.serialize(slice.content); + } + } + + const traverseToParentOfLeaf = (node: Node | null, parent: Fragment | Node, depth: number): Node | Fragment => { + let currentNode = node; + let currentParent = parent; + let currentDepth = depth; + + while (currentNode && currentDepth > 1 && currentNode.content?.firstChild) { + if (currentNode.content?.childCount > 1) { + if (currentNode.content.firstChild?.type?.name === CORE_EXTENSIONS.LIST_ITEM) { + return currentParent; + } else { + return currentNode.content; + } + } + + currentParent = currentNode; + currentNode = currentNode.content?.firstChild || null; + currentDepth--; + } + + return currentParent; + }; + + if (slice.content.childCount > 1) { + return markdownSerializer.serialize(slice.content); + } else { + const targetNode = traverseToParentOfLeaf(slice.content.firstChild, slice.content, slice.openStart); + + let currentNode = targetNode; + while (currentNode && currentNode.content && currentNode.childCount === 1 && currentNode.firstChild) { + currentNode = currentNode.firstChild; + } + if (currentNode instanceof Node && currentNode.isText) { + return currentNode.text; + } + + return markdownSerializer.serialize(targetNode); + } + }, + }, + }); diff --git a/packages/editor/src/core/types/collaboration.ts b/packages/editor/src/core/types/collaboration.ts index 82e2f81f9..8921e8f05 100644 --- a/packages/editor/src/core/types/collaboration.ts +++ b/packages/editor/src/core/types/collaboration.ts @@ -1,50 +1,4 @@ -import { Extensions } from "@tiptap/core"; -import { EditorProps } from "@tiptap/pm/view"; -// plane editor types -import { TEmbedConfig } from "@/plane-editor/types"; -// types -import { - EditorReadOnlyRefApi, - EditorRefApi, - TExtensions, - TFileHandler, - TMentionHandler, - TReadOnlyFileHandler, - TReadOnlyMentionHandler, - TRealtimeConfig, - TUserDetails, -} from "@/types"; - export type TServerHandler = { onConnect?: () => void; onServerError?: () => void; }; - -type TCollaborativeEditorHookProps = { - disabledExtensions: TExtensions[]; - editable?: boolean; - editorClassName: string; - editorProps?: EditorProps; - extensions?: Extensions; - handleEditorReady?: (value: boolean) => void; - id: string; - realtimeConfig: TRealtimeConfig; - serverHandler?: TServerHandler; - user: TUserDetails; -}; - -export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & { - onTransaction?: () => void; - embedHandler?: TEmbedConfig; - fileHandler: TFileHandler; - forwardedRef?: React.MutableRefObject; - mentionHandler: TMentionHandler; - placeholder?: string | ((isFocused: boolean, value: string) => string); - tabIndex?: number; -}; - -export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & { - fileHandler: TReadOnlyFileHandler; - forwardedRef?: React.MutableRefObject; - mentionHandler: TReadOnlyMentionHandler; -}; diff --git a/packages/editor/src/core/types/config.ts b/packages/editor/src/core/types/config.ts index 4c91fec5d..60ccfa841 100644 --- a/packages/editor/src/core/types/config.ts +++ b/packages/editor/src/core/types/config.ts @@ -1,19 +1,21 @@ -import { DeleteImage, RestoreImage, UploadImage } from "@/types"; +// plane imports +import { TWebhookConnectionQueryParams } from "@plane/types"; export type TReadOnlyFileHandler = { + checkIfAssetExists: (assetId: string) => Promise; getAssetSrc: (path: string) => Promise; - restore: RestoreImage; + restore: (assetSrc: string) => Promise; }; export type TFileHandler = TReadOnlyFileHandler & { assetsUploadStatus: Record; // blockId => progress percentage cancel: () => void; - delete: DeleteImage; - upload: UploadImage; + delete: (assetSrc: string) => Promise; + upload: (blockId: string, file: File) => Promise; validation: { /** * @description max file size in bytes - * @example enter 5242880( 5* 1024 * 1024) for 5MB + * @example enter 5242880(5 * 1024 * 1024) for 5MB */ maxFileSize: number; }; @@ -31,3 +33,15 @@ export type TDisplayConfig = { lineSpacing?: TEditorLineSpacing; wideLayout?: boolean; }; + +export type TUserDetails = { + color: string; + id: string; + name: string; + cookie?: string; +}; + +export type TRealtimeConfig = { + url: string; + queryParams: TWebhookConnectionQueryParams; +}; diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index 647f52e7d..cf3d7d2c7 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -1,13 +1,11 @@ -import { Extensions, JSONContent } from "@tiptap/core"; -import { Selection } from "@tiptap/pm/state"; -// plane types -import { TWebhookConnectionQueryParams } from "@plane/types"; +import type { Extensions, JSONContent } from "@tiptap/core"; +import type { Selection } from "@tiptap/pm/state"; // extension types -import { TTextAlign } from "@/extensions"; +import type { TTextAlign } from "@/extensions"; // helpers -import { IMarking } from "@/helpers/scroll-to-node"; +import type { IMarking } from "@/helpers/scroll-to-node"; // types -import { +import type { TAIHandler, TDisplayConfig, TDocumentEventEmitter, @@ -18,7 +16,9 @@ import { TMentionHandler, TReadOnlyFileHandler, TReadOnlyMentionHandler, + TRealtimeConfig, TServerHandler, + TUserDetails, } from "@/types"; export type TEditorCommands = @@ -114,89 +114,70 @@ export interface EditorRefApi extends EditorReadOnlyRefApi { // editor props export interface IEditorProps { + autofocus?: boolean; + bubbleMenuEnabled?: boolean; containerClassName?: string; displayConfig?: TDisplayConfig; disabledExtensions: TExtensions[]; editorClassName?: string; + extensions?: Extensions; + flaggedExtensions: TExtensions[]; fileHandler: TFileHandler; forwardedRef?: React.MutableRefObject; + handleEditorReady?: (value: boolean) => void; id: string; initialValue: string; mentionHandler: TMentionHandler; onChange?: (json: object, html: string) => void; - onTransaction?: () => void; - handleEditorReady?: (value: boolean) => void; - autofocus?: boolean; onEnterKeyPress?: (e?: any) => void; + onTransaction?: () => void; placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; value?: string | null; - bubbleMenuEnabled?: boolean; } -export interface ILiteTextEditor extends IEditorProps { - extensions?: Extensions; -} -export interface IRichTextEditor extends IEditorProps { - extensions?: Extensions; + +export type ILiteTextEditorProps = IEditorProps; +export interface IRichTextEditorProps extends IEditorProps { dragDropEnabled?: boolean; } -export interface ICollaborativeDocumentEditor - extends Omit { +export interface ICollaborativeDocumentEditorProps + extends Omit { aiHandler?: TAIHandler; - bubbleMenuEnabled?: boolean; editable: boolean; embedHandler: TEmbedConfig; - handleEditorReady?: (value: boolean) => void; - id: string; realtimeConfig: TRealtimeConfig; serverHandler?: TServerHandler; user: TUserDetails; } // read only editor props -export interface IReadOnlyEditorProps { - containerClassName?: string; - disabledExtensions: TExtensions[]; - displayConfig?: TDisplayConfig; - editorClassName?: string; +export interface IReadOnlyEditorProps + extends Pick< + IEditorProps, + | "containerClassName" + | "disabledExtensions" + | "flaggedExtensions" + | "displayConfig" + | "editorClassName" + | "extensions" + | "handleEditorReady" + | "id" + | "initialValue" + > { fileHandler: TReadOnlyFileHandler; forwardedRef?: React.MutableRefObject; - id: string; - initialValue: string; mentionHandler: TReadOnlyMentionHandler; } -export type ILiteTextReadOnlyEditor = IReadOnlyEditorProps; +export type ILiteTextReadOnlyEditorProps = IReadOnlyEditorProps; -export type IRichTextReadOnlyEditor = IReadOnlyEditorProps; +export type IRichTextReadOnlyEditorProps = IReadOnlyEditorProps; -export interface ICollaborativeDocumentReadOnlyEditor extends Omit { +export interface IDocumentReadOnlyEditorProps extends IReadOnlyEditorProps { embedHandler: TEmbedConfig; - handleEditorReady?: (value: boolean) => void; - id: string; - realtimeConfig: TRealtimeConfig; - serverHandler?: TServerHandler; - user: TUserDetails; } -export interface IDocumentReadOnlyEditor extends IReadOnlyEditorProps { - embedHandler: TEmbedConfig; - handleEditorReady?: (value: boolean) => void; -} - -export type TUserDetails = { - color: string; - id: string; - name: string; - cookie?: string; -}; - -export type TRealtimeConfig = { - url: string; - queryParams: TWebhookConnectionQueryParams; -}; - export interface EditorEvents { beforeCreate: never; create: never; diff --git a/packages/editor/src/core/types/hook.ts b/packages/editor/src/core/types/hook.ts new file mode 100644 index 000000000..2224935ca --- /dev/null +++ b/packages/editor/src/core/types/hook.ts @@ -0,0 +1,50 @@ +import type { HocuspocusProvider } from "@hocuspocus/provider"; +import type { EditorProps } from "@tiptap/pm/view"; +// local imports +import type { ICollaborativeDocumentEditorProps, IEditorProps, IReadOnlyEditorProps } from "./editor"; + +type TCoreHookProps = Pick< + IEditorProps, + "disabledExtensions" | "editorClassName" | "extensions" | "flaggedExtensions" | "handleEditorReady" +> & { + editorProps?: EditorProps; +}; + +export type TEditorHookProps = TCoreHookProps & + Pick< + IEditorProps, + | "autofocus" + | "fileHandler" + | "forwardedRef" + | "id" + | "mentionHandler" + | "onChange" + | "onTransaction" + | "placeholder" + | "tabIndex" + | "value" + > & { + editable: boolean; + enableHistory: boolean; + initialValue?: string; + provider?: HocuspocusProvider; + }; + +export type TCollaborativeEditorHookProps = TCoreHookProps & + Pick< + TEditorHookProps, + | "editable" + | "fileHandler" + | "forwardedRef" + | "id" + | "mentionHandler" + | "onChange" + | "onTransaction" + | "placeholder" + | "tabIndex" + > & + Pick; + +export type TReadOnlyEditorHookProps = TCoreHookProps & + Pick & + Pick; diff --git a/packages/editor/src/core/types/image.ts b/packages/editor/src/core/types/image.ts deleted file mode 100644 index ca6f76fb1..000000000 --- a/packages/editor/src/core/types/image.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; - -export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise; - -export type UploadImage = (blockId: string, file: File) => Promise; diff --git a/packages/editor/src/core/types/index.ts b/packages/editor/src/core/types/index.ts index e99a74b28..619fa0c78 100644 --- a/packages/editor/src/core/types/index.ts +++ b/packages/editor/src/core/types/index.ts @@ -4,7 +4,7 @@ export * from "./config"; export * from "./editor"; export * from "./embed"; export * from "./extensions"; -export * from "./image"; +export * from "./hook"; export * from "./mention"; export * from "./slash-commands-suggestion"; export * from "@/plane-editor/types"; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index a2a9afaf9..fec933f91 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -36,5 +36,4 @@ export { type IMarking, useEditorMarkings } from "@/hooks/use-editor-markings"; export { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; // types -export type { CustomEditorProps } from "@/hooks/use-editor"; export * from "@/types"; diff --git a/packages/editor/src/styles/variables.css b/packages/editor/src/styles/variables.css index 44690cf52..6d6e2d9b6 100644 --- a/packages/editor/src/styles/variables.css +++ b/packages/editor/src/styles/variables.css @@ -233,7 +233,9 @@ padding-left: var(--normal-content-margin); padding-right: var(--normal-content-margin); } +} +@container page-content-container (max-width: 930px) { .page-summary-container { display: none; } diff --git a/packages/eslint-config/library.js b/packages/eslint-config/library.js index 790364230..b868b35a4 100644 --- a/packages/eslint-config/library.js +++ b/packages/eslint-config/library.js @@ -5,7 +5,7 @@ const project = resolve(process.cwd(), "tsconfig.json"); /** @type {import("eslint").Linter.Config} */ module.exports = { extends: ["prettier", "plugin:@typescript-eslint/recommended"], - plugins: ["react", "@typescript-eslint", "import"], + plugins: ["react", "react-hooks", "@typescript-eslint", "import"], globals: { React: true, JSX: true, @@ -38,7 +38,7 @@ module.exports = { "react/self-closing-comp": ["error", { component: true, html: true }], "react/jsx-boolean-value": "error", "react/jsx-no-duplicate-props": "error", - // "react-hooks/exhaustive-deps": "warn", + "react-hooks/exhaustive-deps": "warn", "@typescript-eslint/no-unused-expressions": "warn", "@typescript-eslint/no-unused-vars": ["warn"], "@typescript-eslint/no-explicit-any": "warn", diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index f3a01921b..0e7e2382b 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -1,7 +1,7 @@ { "name": "@plane/eslint-config", "private": true, - "version": "0.26.1", + "version": "0.27.0", "license": "AGPL-3.0", "files": [ "library.js", @@ -17,6 +17,7 @@ "eslint-config-turbo": "^1.12.4", "eslint-plugin-import": "^2.29.1", "eslint-plugin-react": "^7.33.2", - "typescript": "5.3.3" + "eslint-plugin-react-hooks": "^5.2.0", + "typescript": "5.8.3" } } diff --git a/packages/hooks/package.json b/packages/hooks/package.json index b095c36c6..81484513d 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -1,6 +1,6 @@ { "name": "@plane/hooks", - "version": "0.26.1", + "version": "0.27.0", "license": "AGPL-3.0", "description": "React hooks that are shared across multiple apps internally", "private": true, @@ -22,7 +22,7 @@ "@plane/eslint-config": "*", "@types/node": "^22.5.4", "@types/react": "^18.3.11", - "tsup": "^8.4.0", - "typescript": "^5.3.3" + "tsup": "8.4.0", + "typescript": "5.8.3" } } diff --git a/packages/i18n/package.json b/packages/i18n/package.json index ce9073a65..d4faaf017 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@plane/i18n", - "version": "0.26.1", + "version": "0.27.0", "license": "AGPL-3.0", "description": "I18n shared across multiple apps internally", "private": true, @@ -17,6 +17,6 @@ "devDependencies": { "@plane/eslint-config": "*", "@types/node": "^22.5.4", - "typescript": "^5.3.3" + "typescript": "5.8.3" } } diff --git a/packages/i18n/src/constants/language.ts b/packages/i18n/src/constants/language.ts index d3d3a887a..4dc0408bd 100644 --- a/packages/i18n/src/constants/language.ts +++ b/packages/i18n/src/constants/language.ts @@ -24,4 +24,14 @@ export const SUPPORTED_LANGUAGES: ILanguageOption[] = [ { label: "Türkçe", value: "tr-TR" }, ]; +/** + * Enum for translation file names + * These are the JSON files that contain translations each category + */ +export enum ETranslationFiles { + TRANSLATIONS = "translations", + ACCESSIBILITY = "accessibility", + EDITOR = "editor", +} + export const LANGUAGE_STORAGE_KEY = "userLanguage"; diff --git a/packages/i18n/src/locales/cs/accessibility.json b/packages/i18n/src/locales/cs/accessibility.json new file mode 100644 index 000000000..676c2d442 --- /dev/null +++ b/packages/i18n/src/locales/cs/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Logo pracovního prostoru", + "open_workspace_switcher": "Otevřít přepínač pracovního prostoru", + "open_user_menu": "Otevřít uživatelské menu", + "open_command_palette": "Otevřít paletu příkazů", + "open_extended_sidebar": "Otevřít rozšířený postranní panel", + "close_extended_sidebar": "Zavřít rozšířený postranní panel", + "create_favorites_folder": "Vytvořit složku oblíbených", + "open_folder": "Otevřít složku", + "close_folder": "Zavřít složku", + "open_favorites_menu": "Otevřít menu oblíbených", + "close_favorites_menu": "Zavřít menu oblíbených", + "enter_folder_name": "Zadejte název složky", + "create_new_project": "Vytvořit nový projekt", + "open_projects_menu": "Otevřít menu projektů", + "close_projects_menu": "Zavřít menu projektů", + "toggle_quick_actions_menu": "Přepnout menu rychlých akcí", + "open_project_menu": "Otevřít menu projektu", + "close_project_menu": "Zavřít menu projektu", + "collapse_sidebar": "Sbalit postranní panel", + "expand_sidebar": "Rozbalit postranní panel", + "edition_badge": "Otevřít modal placených plánů" + }, + "auth_forms": { + "clear_email": "Vymazat e-mail", + "show_password": "Zobrazit heslo", + "hide_password": "Skrýt heslo", + "close_alert": "Zavřít upozornění", + "close_popover": "Zavřít vyskakovací okno" + } + } +} diff --git a/packages/i18n/src/locales/cs/editor.json b/packages/i18n/src/locales/cs/editor.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/i18n/src/locales/cs/editor.json @@ -0,0 +1 @@ +{} diff --git a/packages/i18n/src/locales/cs/translations.json b/packages/i18n/src/locales/cs/translations.json index 4c002389b..396ca03e5 100644 --- a/packages/i18n/src/locales/cs/translations.json +++ b/packages/i18n/src/locales/cs/translations.json @@ -316,6 +316,8 @@ "failed_to_remove_project_from_favorites": "Nepodařilo se odstranit projekt z oblíbených. Zkuste to prosím znovu.", "project_created_successfully": "Projekt úspěšně vytvořen", "project_created_successfully_description": "Projekt byl úspěšně vytvořen. Nyní můžete začít přidávat pracovní položky.", + "project_name_already_taken": "Název projektu už je zabraný.", + "project_identifier_already_taken": "Identifikátor projektu už je zabraný.", "project_cover_image_alt": "Úvodní obrázek projektu", "name_is_required": "Název je povinný", "title_should_be_less_than_255_characters": "Název by měl být kratší než 255 znaků", @@ -365,12 +367,12 @@ "work_management": "Správa práce", "projects_and_issues": "Projekty a pracovní položky", "projects_and_issues_description": "Aktivujte nebo deaktivujte tyto funkce v projektu.", - "cycles_description": "Časově ohraničte práci podle potřeby a měňte frekvenci mezi obdobími.", - "modules_description": "Seskupujte práci do podobných podprojektů s vlastními vedoucími a přiřazenými osobami.", - "views_description": "Uložte řazení, filtry a zobrazovací možnosti pro pozdější použití nebo sdílení.", - "pages_description": "Pište cokoli, jako obvykle.", - "intake_description": "Zůstaňte v obraze u pracovních položek, které odebíráte. Aktivujte toto pro zasílání oznámení.", - "time_tracking_description": "Sledujte čas strávený na pracovních položkách a projektech.", + "cycles_description": "Časově vymezte práci podle projektu a podle potřeby upravte období. Jeden cyklus může trvat 2 týdny, další jen 1 týden.", + "modules_description": "Organizujte práci do podprojektů s vyhrazenými vedoucími a přiřazenými osobami.", + "views_description": "Uložte vlastní řazení, filtry a možnosti zobrazení nebo je sdílejte se svým týmem.", + "pages_description": "Vytvářejte a upravujte volně strukturovaný obsah – poznámky, dokumenty, cokoli.", + "intake_description": "Umožněte nečlenům sdílet chyby, zpětnou vazbu a návrhy, aniž by narušili váš pracovní postup.", + "time_tracking_description": "Zaznamenávejte čas strávený na pracovních položkách a projektech.", "work_management_description": "Spravujte svou práci a projekty snadno.", "documentation": "Dokumentace", "message_support": "Kontaktovat podporu", @@ -500,7 +502,6 @@ "export": "Exportovat", "member": "{count, plural, one{# člen} few{# členové} other{# členů}}", "new_password_must_be_different_from_old_password": "Nové heslo musí být odlišné od starého hesla", - "project_view": { "sort_by": { "created_at": "Vytvořeno dne", @@ -508,12 +509,10 @@ "name": "Název" } }, - "toast": { "success": "Úspěch!", "error": "Chyba!" }, - "links": { "toasts": { "created": { @@ -542,7 +541,6 @@ } } }, - "home": { "empty": { "quickstart_guide": "Váš průvodce rychlým startem", @@ -610,7 +608,6 @@ "title": "Domů", "star_us_on_github": "Ohodnoťte nás na GitHubu" }, - "link": { "modal": { "url": { @@ -624,7 +621,6 @@ } } }, - "common": { "all": "Vše", "states": "Stavy", @@ -752,7 +748,8 @@ "message": "Něco se pokazilo. Zkuste to prosím znovu." }, "required": "Toto pole je povinné", - "entity_required": "{entity} je povinná" + "entity_required": "{entity} je povinná", + "restricted_entity": "{entity} je omezen" }, "update_link": "Aktualizovat odkaz", "attach": "Připojit", @@ -853,6 +850,7 @@ "live": "Živě", "change_history": "Historie změn", "coming_soon": "Již brzy", + "member": "Člen", "members": "Členové", "you": "Vy", "upgrade_cta": { @@ -868,22 +866,35 @@ "pending": "Čekající", "invite": "Pozvat", "view": "Pohled", - "deactivated_user": "Deaktivovaný uživatel" + "deactivated_user": "Deaktivovaný uživatel", + "apply": "Použít", + "applying": "Používání", + "users": "Uživatelé", + "admins": "Administrátoři", + "guests": "Hosté", + "on_track": "Na správné cestě", + "off_track": "Mimo plán", + "at_risk": "V ohrožení", + "timeline": "Časová osa", + "completion": "Dokončení", + "upcoming": "Nadcházející", + "completed": "Dokončeno", + "in_progress": "Probíhá", + "planned": "Plánováno", + "paused": "Pozastaveno", + "no_of": "Počet {entity}" }, - "chart": { "x_axis": "Osa X", "y_axis": "Osa Y", "metric": "Metrika" }, - "form": { "title": { "required": "Název je povinný", "max_length": "Název by měl být kratší než {length} znaků" } }, - "entity": { "grouping_title": "Seskupení {entity}", "priority": "Priorita {entity}", @@ -907,7 +918,6 @@ "failed": "Chyba při přidávání {entity}" } }, - "epic": { "all": "Všechny epiky", "label": "{count, plural, one {Epik} other {Epiky}}", @@ -925,7 +935,6 @@ "required": "Název epiku je povinný." } }, - "issue": { "label": "{count, plural, one {Pracovní položka} few {Pracovní položky} other {Pracovních položek}}", "all": "Všechny pracovní položky", @@ -1088,11 +1097,12 @@ "select": { "error": "Vyberte alespoň jednu pracovní položku", "empty": "Nevybrány žádné pracovní položky", - "add_selected": "Přidat vybrané pracovní položky" + "add_selected": "Přidat vybrané pracovní položky", + "select_all": "Vybrat vše", + "deselect_all": "Zrušit výběr všeho" }, "open_in_full_screen": "Otevřít pracovní položku na celou obrazovku" }, - "attachment": { "error": "Soubor nelze připojit. Zkuste to prosím znovu.", "only_one_file_allowed": "Je možné nahrát pouze jeden soubor najednou.", @@ -1100,7 +1110,6 @@ "drag_and_drop": "Přetáhněte soubor kamkoli pro nahrání", "delete": "Smazat přílohu" }, - "label": { "select": "Vybrat štítek", "create": { @@ -1110,7 +1119,6 @@ "type": "Zadejte pro vytvoření nového štítku" } }, - "sub_work_item": { "update": { "success": "Podřízená pracovní položka úspěšně aktualizována", @@ -1119,9 +1127,20 @@ "remove": { "success": "Podřízená pracovní položka úspěšně odebrána", "error": "Chyba při odebírání podřízené položky" + }, + "empty_state": { + "sub_list_filters": { + "title": "Nemáte podřízené pracovní položky, které odpovídají použitým filtrům.", + "description": "Chcete-li zobrazit všechny podřízené pracovní položky, odstraňte všechny použité filtry.", + "action": "Odstranit filtry" + }, + "list_filters": { + "title": "Nemáte pracovní položky, které odpovídají použitým filtrům.", + "description": "Chcete-li zobrazit všechny pracovní položky, odstraňte všechny použité filtry.", + "action": "Odstranit filtry" + } } }, - "view": { "label": "{count, plural, one {Pohled} few {Pohledy} other {Pohledů}}", "create": { @@ -1131,7 +1150,6 @@ "label": "Aktualizovat pohled" } }, - "inbox_issue": { "status": { "pending": { @@ -1217,7 +1235,6 @@ } } }, - "workspace_creation": { "heading": "Vytvořte si pracovní prostor", "subheading": "Pro používání Plane musíte vytvořit nebo se připojit k pracovnímu prostoru.", @@ -1269,7 +1286,6 @@ } } }, - "workspace_dashboard": { "empty_state": { "general": { @@ -1285,7 +1301,6 @@ } } }, - "workspace_analytics": { "label": "Analytika", "page_label": "{workspace} - Analytika", @@ -1317,20 +1332,38 @@ "custom": "Vlastní analytika" }, "empty_state": { - "general": { - "title": "Sledujte pokrok, vytížení a alokace. Identifikujte trendy, odstraňte překážky a zrychlete práci", - "description": "Sledujte rozsah vs. poptávku, odhady a rozsah. Zjistěte výkonnost členů a týmů, zajistěte včasné dokončení projektů.", - "primary_button": { - "text": "Začněte první projekt", - "comic": { - "title": "Analytika funguje nejlépe s Cykly + Moduly", - "description": "Nejprve časově ohraničte práci do Cyklů a seskupte položky přesahující cyklus do Modulů. Najdete je v levém menu." - } - } + "customized_insights": { + "description": "Pracovní položky přiřazené vám, rozdělené podle stavu, se zde zobrazí.", + "title": "Zatím žádná data" + }, + "created_vs_resolved": { + "description": "Pracovní položky vytvořené a vyřešené v průběhu času se zde zobrazí.", + "title": "Zatím žádná data" + }, + "project_insights": { + "title": "Zatím žádná data", + "description": "Pracovní položky přiřazené vám, rozdělené podle stavu, se zde zobrazí." } - } + }, + "created_vs_resolved": "Vytvořeno vs Vyřešeno", + "customized_insights": "Přizpůsobené přehledy", + "backlog_work_items": "Backlog {entity}", + "active_projects": "Aktivní projekty", + "trend_on_charts": "Trend na grafech", + "all_projects": "Všechny projekty", + "summary_of_projects": "Souhrn projektů", + "project_insights": "Přehled projektu", + "started_work_items": "Zahájené {entity}", + "total_work_items": "Celkový počet {entity}", + "total_projects": "Celkový počet projektů", + "total_admins": "Celkový počet administrátorů", + "total_users": "Celkový počet uživatelů", + "total_intake": "Celkový příjem", + "un_started_work_items": "Nezahájené {entity}", + "total_guests": "Celkový počet hostů", + "completed_work_items": "Dokončené {entity}", + "total": "Celkový počet {entity}" }, - "workspace_projects": { "label": "{count, plural, one {Projekt} few {Projekty} other {Projektů}}", "create": { @@ -1405,7 +1438,6 @@ } } }, - "workspace_views": { "add_view": "Přidat pohled", "empty_state": { @@ -1440,7 +1472,6 @@ } } }, - "workspace_settings": { "label": "Nastavení pracovního prostoru", "page_label": "{workspace} - Obecná nastavení", @@ -1622,7 +1653,6 @@ } } }, - "profile": { "label": "Profil", "page_label": "Vaše práce", @@ -1685,7 +1715,6 @@ } } }, - "project_settings": { "general": { "enter_project_id": "Zadejte ID projektu", @@ -1831,7 +1860,6 @@ "auto_close_status": "Stav automatického uzavření" } }, - "empty_state": { "labels": { "title": "Zatím žádné štítky", @@ -1844,7 +1872,6 @@ } } }, - "project_cycles": { "add_cycle": "Přidat cyklus", "more_details": "Více detailů", @@ -1970,7 +1997,6 @@ } } }, - "project_issues": { "empty_state": { "no_issues": { @@ -1999,7 +2025,6 @@ } } }, - "project_module": { "add_module": "Přidat modul", "update_module": "Aktualizovat modul", @@ -2053,7 +2078,6 @@ } } }, - "project_views": { "empty_state": { "general": { @@ -2073,7 +2097,6 @@ } } }, - "project_page": { "empty_state": { "general": { @@ -2103,7 +2126,6 @@ } } }, - "command_k": { "empty_state": { "search": { @@ -2111,7 +2133,6 @@ } } }, - "issue_relation": { "empty_state": { "search": { @@ -2122,7 +2143,6 @@ } } }, - "issue_comment": { "empty_state": { "general": { @@ -2131,7 +2151,6 @@ } } }, - "notification": { "label": "Schránka", "page_label": "{workspace} - Schránka", @@ -2188,7 +2207,6 @@ "custom": "Vlastní" } }, - "active_cycle": { "empty_state": { "progress": { @@ -2208,7 +2226,6 @@ } } }, - "disabled_project": { "empty_state": { "inbox": { @@ -2271,7 +2288,6 @@ } } }, - "stickies": { "title": "Vaše poznámky", "placeholder": "kliknutím začněte psát", @@ -2329,7 +2345,6 @@ } } }, - "role_details": { "guest": { "title": "Host", @@ -2344,7 +2359,6 @@ "description": "Má všechna oprávnění v prostoru." } }, - "user_roles": { "product_or_project_manager": "Produktový/Projektový manažer", "development_or_engineering": "Vývoj/Inženýrství", @@ -2357,7 +2371,6 @@ "human_resources": "Lidské zdroje", "other": "Jiné" }, - "importer": { "github": { "title": "GitHub", @@ -2368,7 +2381,6 @@ "description": "Importujte položky a epiky z Jira." } }, - "exporter": { "csv": { "title": "CSV", @@ -2397,7 +2409,6 @@ "created": "Vytvořeno", "subscribed": "Odebíráno" }, - "themes": { "theme_options": { "system_preference": { @@ -2443,20 +2454,21 @@ "manual": "Ručně" } }, - "cycle": { "label": "{count, plural, one {Cyklus} few {Cykly} other {Cyklů}}", "no_cycle": "Žádný cyklus" }, - "module": { "label": "{count, plural, one {Modul} few {Moduly} other {Modulů}}", "no_module": "Žádný modul" }, - "description_versions": { "last_edited_by": "Naposledy upraveno uživatelem", "previously_edited_by": "Dříve upraveno uživatelem", "edited_by": "Upraveno uživatelem" + }, + "self_hosted_maintenance_message": { + "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane se nespustil. To může být způsobeno tím, že se jeden nebo více služeb Plane nepodařilo spustit.", + "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Vyberte View Logs z setup.sh a Docker logů, abyste si byli jisti." } -} +} \ No newline at end of file diff --git a/packages/i18n/src/locales/de/accessibility.json b/packages/i18n/src/locales/de/accessibility.json new file mode 100644 index 000000000..edf90970f --- /dev/null +++ b/packages/i18n/src/locales/de/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Arbeitsbereich-Logo", + "open_workspace_switcher": "Arbeitsbereich-Umschalter öffnen", + "open_user_menu": "Benutzermenü öffnen", + "open_command_palette": "Befehlspalette öffnen", + "open_extended_sidebar": "Erweiterte Seitenleiste öffnen", + "close_extended_sidebar": "Erweiterte Seitenleiste schließen", + "create_favorites_folder": "Favoriten-Ordner erstellen", + "open_folder": "Ordner öffnen", + "close_folder": "Ordner schließen", + "open_favorites_menu": "Favoriten-Menü öffnen", + "close_favorites_menu": "Favoriten-Menü schließen", + "enter_folder_name": "Ordnername eingeben", + "create_new_project": "Neues Projekt erstellen", + "open_projects_menu": "Projekt-Menü öffnen", + "close_projects_menu": "Projekt-Menü schließen", + "toggle_quick_actions_menu": "Schnellaktionen-Menü umschalten", + "open_project_menu": "Projekt-Menü öffnen", + "close_project_menu": "Projekt-Menü schließen", + "collapse_sidebar": "Seitenleiste einklappen", + "expand_sidebar": "Seitenleiste ausklappen", + "edition_badge": "Modal für kostenpflichtige Pläne öffnen" + }, + "auth_forms": { + "clear_email": "E-Mail löschen", + "show_password": "Passwort anzeigen", + "hide_password": "Passwort verbergen", + "close_alert": "Warnung schließen", + "close_popover": "Popover schließen" + } + } +} diff --git a/packages/i18n/src/locales/de/editor.json b/packages/i18n/src/locales/de/editor.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/i18n/src/locales/de/editor.json @@ -0,0 +1 @@ +{} diff --git a/packages/i18n/src/locales/de/translations.json b/packages/i18n/src/locales/de/translations.json index 6629d0796..1b6e4778e 100644 --- a/packages/i18n/src/locales/de/translations.json +++ b/packages/i18n/src/locales/de/translations.json @@ -316,6 +316,8 @@ "failed_to_remove_project_from_favorites": "Projekt konnte nicht aus den Favoriten entfernt werden. Bitte versuchen Sie es erneut.", "project_created_successfully": "Projekt erfolgreich erstellt", "project_created_successfully_description": "Das Projekt wurde erfolgreich erstellt. Sie können nun Arbeitselemente hinzufügen.", + "project_name_already_taken": "Der Projektname ist bereits vergeben.", + "project_identifier_already_taken": "Der Projekt-Identifier ist bereits vergeben.", "project_cover_image_alt": "Titelbild des Projekts", "name_is_required": "Name ist erforderlich", "title_should_be_less_than_255_characters": "Der Titel sollte weniger als 255 Zeichen enthalten", @@ -365,11 +367,11 @@ "work_management": "Arbeitsverwaltung", "projects_and_issues": "Projekte und Arbeitselemente", "projects_and_issues_description": "Aktivieren oder deaktivieren Sie diese Funktionen im Projekt.", - "cycles_description": "Begrenzen Sie die Arbeit zeitlich bei Bedarf und ändern Sie die Häufigkeit zwischen Zeiträumen.", - "modules_description": "Gruppieren Sie die Arbeit in ähnliche Unterprojekte mit eigenen Leitern und zugewiesenen Personen.", - "views_description": "Speichern Sie Sortierungen, Filter und Anzeigeoptionen für die spätere Verwendung oder zum Teilen.", - "pages_description": "Schreiben Sie alles Mögliche, wie gewohnt.", - "intake_description": "Bleiben Sie über abonnierte Arbeitselemente informiert. Aktivieren Sie diese Option, um Benachrichtigungen zu erhalten.", + "cycles_description": "Zeitlich begrenzen Sie die Arbeit pro Projekt und passen Sie den Zeitraum bei Bedarf an. Ein Zyklus kann 2 Wochen dauern, der nächste nur 1 Woche.", + "modules_description": "Organisieren Sie die Arbeit in Unterprojekte mit eigenen Leitern und Zuständigen.", + "views_description": "Speichern Sie benutzerdefinierte Sortierungen, Filter und Anzeigeoptionen oder teilen Sie sie mit Ihrem Team.", + "pages_description": "Erstellen und bearbeiten Sie frei formulierte Inhalte – Notizen, Dokumente, alles Mögliche.", + "intake_description": "Erlauben Sie Nicht-Mitgliedern, Bugs, Feedback und Vorschläge zu teilen – ohne Ihren Arbeitsablauf zu stören.", "time_tracking_description": "Erfassen Sie die auf Arbeitselemente und Projekte verwendete Zeit.", "work_management_description": "Verwalten Sie Ihre Arbeit und Projekte mühelos.", "documentation": "Dokumentation", @@ -500,7 +502,6 @@ "export": "Exportieren", "member": "{count, plural, one{# Mitglied} few{# Mitglieder} other{# Mitglieder}}", "new_password_must_be_different_from_old_password": "Das neue Passwort muss von dem alten Passwort abweichen", - "project_view": { "sort_by": { "created_at": "Erstellt am", @@ -747,7 +748,8 @@ "message": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut." }, "required": "Dieses Feld ist erforderlich", - "entity_required": "{entity} ist erforderlich" + "entity_required": "{entity} ist erforderlich", + "restricted_entity": "{entity} ist eingeschränkt" }, "update_link": "Link aktualisieren", "attach": "Anhängen", @@ -848,6 +850,7 @@ "live": "Live", "change_history": "Änderungsverlauf", "coming_soon": "Demnächst verfügbar", + "member": "Mitglied", "members": "Mitglieder", "you": "Sie", "upgrade_cta": { @@ -863,7 +866,23 @@ "pending": "Ausstehend", "invite": "Einladen", "view": "Ansicht", - "deactivated_user": "Deaktivierter Benutzer" + "deactivated_user": "Deaktivierter Benutzer", + "apply": "Anwenden", + "applying": "Wird angewendet", + "users": "Benutzer", + "admins": "Administratoren", + "guests": "Gäste", + "on_track": "Im Plan", + "off_track": "Außer Plan", + "at_risk": "Gefährdet", + "timeline": "Zeitleiste", + "completion": "Fertigstellung", + "upcoming": "Bevorstehend", + "completed": "Abgeschlossen", + "in_progress": "In Bearbeitung", + "planned": "Geplant", + "paused": "Pausiert", + "no_of": "Anzahl {entity}" }, "chart": { "x_axis": "X-Achse", @@ -1078,7 +1097,9 @@ "select": { "error": "Wählen Sie mindestens ein Arbeitselement aus", "empty": "Keine Arbeitselemente ausgewählt", - "add_selected": "Ausgewählte Arbeitselemente hinzufügen" + "add_selected": "Ausgewählte Arbeitselemente hinzufügen", + "select_all": "Alle auswählen", + "deselect_all": "Alle abwählen" }, "open_in_full_screen": "Arbeitselement im Vollbild öffnen" }, @@ -1106,6 +1127,18 @@ "remove": { "success": "Untergeordnetes Arbeitselement erfolgreich entfernt", "error": "Fehler beim Entfernen des untergeordneten Elements" + }, + "empty_state": { + "sub_list_filters": { + "title": "Sie haben keine untergeordneten Arbeitselemente, die den von Ihnen angewendeten Filtern entsprechen.", + "description": "Um alle untergeordneten Arbeitselemente anzuzeigen, entfernen Sie alle angewendeten Filter.", + "action": "Filter entfernen" + }, + "list_filters": { + "title": "Sie haben keine Arbeitselemente, die den von Ihnen angewendeten Filtern entsprechen.", + "description": "Um alle Arbeitselemente anzuzeigen, entfernen Sie alle angewendeten Filter.", + "action": "Filter entfernen" + } } }, "view": { @@ -1299,18 +1332,37 @@ "custom": "Benutzerdefinierte Analysen" }, "empty_state": { - "general": { - "title": "Verfolgen Sie Fortschritt, Auslastung und Zuordnungen. Erkennen Sie Trends, entfernen Sie Blocker und beschleunigen Sie die Arbeit", - "description": "Behalten Sie Umfang vs. Nachfrage, Schätzungen und Umfang im Blick. Verfolgen Sie die Leistung von Mitgliedern und Teams, um sicherzustellen, dass Projekte pünktlich abgeschlossen werden.", - "primary_button": { - "text": "Erstes Projekt starten", - "comic": { - "title": "Analysen funktionieren am besten mit Zyklen + Modulen", - "description": "Begrenzen Sie zuerst Arbeit zeitlich in Zyklen und gruppieren Sie die übergreifenden Elemente in Module. Sie finden sie im linken Menü." - } - } + "customized_insights": { + "description": "Ihnen zugewiesene Arbeitselemente, aufgeschlüsselt nach Status, werden hier angezeigt.", + "title": "Noch keine Daten" + }, + "created_vs_resolved": { + "description": "Im Laufe der Zeit erstellte und gelöste Arbeitselemente werden hier angezeigt.", + "title": "Noch keine Daten" + }, + "project_insights": { + "title": "Noch keine Daten", + "description": "Ihnen zugewiesene Arbeitselemente, aufgeschlüsselt nach Status, werden hier angezeigt." } - } + }, + "created_vs_resolved": "Erstellt vs Gelöst", + "customized_insights": "Individuelle Einblicke", + "backlog_work_items": "Backlog-{entity}", + "active_projects": "Aktive Projekte", + "trend_on_charts": "Trend in Diagrammen", + "all_projects": "Alle Projekte", + "summary_of_projects": "Projektübersicht", + "project_insights": "Projekteinblicke", + "started_work_items": "Begonnene {entity}", + "total_work_items": "Gesamte {entity}", + "total_projects": "Gesamtprojekte", + "total_admins": "Gesamtanzahl der Admins", + "total_users": "Gesamtanzahl der Benutzer", + "total_intake": "Gesamteinnahmen", + "un_started_work_items": "Nicht begonnene {entity}", + "total_guests": "Gesamtanzahl der Gäste", + "completed_work_items": "Abgeschlossene {entity}", + "total": "Gesamte {entity}" }, "workspace_projects": { "label": "{count, plural, one {Projekt} few {Projekte} other {Projekte}}", @@ -2401,20 +2453,21 @@ "manual": "Manuell" } }, - "cycle": { "label": "{count, plural, one {Zyklus} few {Zyklen} other {Zyklen}}", "no_cycle": "Kein Zyklus" }, - "module": { "label": "{count, plural, one {Modul} few {Module} other {Module}}", "no_module": "Kein Modul" }, - "description_versions": { "last_edited_by": "Zuletzt bearbeitet von", "previously_edited_by": "Zuvor bearbeitet von", "edited_by": "Bearbeitet von" + }, + "self_hosted_maintenance_message": { + "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane ist nicht gestartet. Dies könnte daran liegen, dass einer oder mehrere Plane-Services nicht starten konnten.", + "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Wählen Sie View Logs aus setup.sh und Docker-Logs, um sicherzugehen." } } diff --git a/packages/i18n/src/locales/en/accessibility.json b/packages/i18n/src/locales/en/accessibility.json new file mode 100644 index 000000000..86660d640 --- /dev/null +++ b/packages/i18n/src/locales/en/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Workspace logo", + "open_workspace_switcher": "Open workspace switcher", + "open_user_menu": "Open user menu", + "open_command_palette": "Open command palette", + "open_extended_sidebar": "Open extended sidebar", + "close_extended_sidebar": "Close extended sidebar", + "create_favorites_folder": "Create favorites folder", + "open_folder": "Open folder", + "close_folder": "Close folder", + "open_favorites_menu": "Open favorites menu", + "close_favorites_menu": "Close favorites menu", + "enter_folder_name": "Enter folder name", + "create_new_project": "Create new project", + "open_projects_menu": "Open projects menu", + "close_projects_menu": "Close projects menu", + "toggle_quick_actions_menu": "Toggle quick actions menu", + "open_project_menu": "Open project menu", + "close_project_menu": "Close project menu", + "collapse_sidebar": "Collapse sidebar", + "expand_sidebar": "Expand sidebar", + "edition_badge": "Open paid plans' modal" + }, + "auth_forms": { + "clear_email": "Clear email", + "show_password": "Show password", + "hide_password": "Hide password", + "close_alert": "Close alert", + "close_popover": "Close popover" + } + } +} diff --git a/packages/i18n/src/locales/en/editor.json b/packages/i18n/src/locales/en/editor.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/i18n/src/locales/en/editor.json @@ -0,0 +1 @@ +{} diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index 002b4f89f..fafed9c77 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -43,7 +43,8 @@ "your_account": "Your account", "security": "Security", "activity": "Activity", - "appearance": "Appearance", + "preferences": "Preferences", + "language_and_time": "Language & Time", "notifications": "Notifications", "workspaces": "Workspaces", "create_workspace": "Create workspace", @@ -56,6 +57,10 @@ "something_went_wrong_please_try_again": "Something went wrong. Please try again.", "load_more": "Load more", "select_or_customize_your_interface_color_scheme": "Select or customize your interface color scheme.", + "timezone_setting": "Current timezone setting.", + "language_setting": "Choose the language used in the user interface.", + "settings_moved_to_preferences": "Timezone & Language settings have been moved to preferences.", + "go_to_preferences": "Go to preferences", "theme": "Theme", "system_preference": "System preference", "light": "Light", @@ -148,6 +153,8 @@ "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 work items to it.", + "project_name_already_taken": "The project name is already taken.", + "project_identifier_already_taken": "The project identifier is already taken.", "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", @@ -197,12 +204,12 @@ "work_management": "Work management", "projects_and_issues": "Projects and work items", "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 Work items you are subscribed to. Enable this to get notified.", - "time_tracking_description": "Track time spent on work items and projects.", + "cycles_description": "Timebox work per project and adjust the time period as needed. One cycle can be 2 weeks, the next 1 week.", + "modules_description": "Organize work into sub-projects with dedicated leads and assignees.", + "views_description": "Save custom sorts, filters, and display options or share them with your team.", + "pages_description": "Create and edit free-form content; notes, docs, anything.", + "intake_description": "Let non-members share bugs, feedback, and suggestions; without disrupting your workflow.", + "time_tracking_description": "Log time spent on work items and projects.", "work_management_description": "Manage your work and projects with ease.", "documentation": "Documentation", "message_support": "Message support", @@ -334,7 +341,8 @@ "new_password_must_be_different_from_old_password": "New password must be different from old password", "edited": "edited", "bot": "Bot", - + "settings_description": "Manage your account, workspace, and project preferences all in one place. Switch between tabs to easily configure.", + "back_to_workspace": "Back to workspace", "project_view": { "sort_by": { "created_at": "Created at", @@ -342,12 +350,10 @@ "name": "Name" } }, - "toast": { "success": "Success!", "error": "Error!" }, - "links": { "toasts": { "created": { @@ -376,7 +382,6 @@ } } }, - "home": { "empty": { "quickstart_guide": "Your quickstart guide", @@ -444,7 +449,6 @@ "title": "Home", "star_us_on_github": "Star us on GitHub" }, - "link": { "modal": { "url": { @@ -458,7 +462,6 @@ } } }, - "common": { "all": "All", "states": "States", @@ -475,6 +478,9 @@ "modules": "Modules", "labels": "Labels", "label": "Label", + "admins": "Admins", + "users": "Users", + "guests": "Guests", "assignees": "Assignees", "assignee": "Assignee", "created_by": "Created by", @@ -586,7 +592,8 @@ "message": "Something went wrong. Please try again." }, "required": "This field is required", - "entity_required": "{entity} is required" + "entity_required": "{entity} is required", + "restricted_entity": "{entity} is restricted" }, "update_link": "Update link", "attach": "Attach", @@ -610,6 +617,16 @@ "quarter": "Quarter", "press_for_commands": "Press '/' for commands", "click_to_add_description": "Click to add description", + "on_track": "On-Track", + "off_track": "Off-Track", + "at_risk": "At risk", + "timeline": "Timeline", + "completion": "Completion", + "upcoming": "Upcoming", + "completed": "Completed", + "in_progress": "In progress", + "planned": "Planned", + "paused": "Paused", "search": { "label": "Search", "placeholder": "Type to search", @@ -688,6 +705,7 @@ "live": "Live", "change_history": "Change History", "coming_soon": "Coming soon", + "member": "Member", "members": "Members", "you": "You", "upgrade_cta": { @@ -703,22 +721,23 @@ "pending": "Pending", "invite": "Invite", "view": "View", - "deactivated_user": "Deactivated user" + "deactivated_user": "Deactivated user", + "apply": "Apply", + "applying": "Applying", + "overview": "Overview", + "no_of": "No. of {entity}" }, - "chart": { "x_axis": "X-axis", "y_axis": "Y-axis", "metric": "Metric" }, - "form": { "title": { "required": "Title is required", "max_length": "Title should be less than {length} characters" } }, - "entity": { "grouping_title": "{entity} Grouping", "priority": "{entity} Priority", @@ -742,7 +761,6 @@ "failed": "Error adding {entity}" } }, - "epic": { "all": "All Epics", "label": "{count, plural, one {Epic} other {Epics}}", @@ -760,7 +778,6 @@ "required": "Epic title is required." } }, - "issue": { "label": "{count, plural, one {Work item} other {Work items}}", "all": "All Work items", @@ -923,11 +940,12 @@ "select": { "error": "Please select at least one work item", "empty": "No work items selected", - "add_selected": "Add selected work items" + "add_selected": "Add selected work items", + "select_all": "Select all", + "deselect_all": "Deselect all" }, "open_in_full_screen": "Open work item in full screen" }, - "attachment": { "error": "File could not be attached. Try uploading again.", "only_one_file_allowed": "Only one file can be uploaded at a time.", @@ -935,7 +953,6 @@ "drag_and_drop": "Drag and drop anywhere to upload", "delete": "Delete attachment" }, - "label": { "select": "Select label", "create": { @@ -945,7 +962,6 @@ "type": "Type to add a new label" } }, - "sub_work_item": { "update": { "success": "Sub-work item updated successfully", @@ -954,9 +970,20 @@ "remove": { "success": "Sub-work item removed successfully", "error": "Error removing sub-work item" + }, + "empty_state": { + "sub_list_filters": { + "title": "You don't have sub-work items that match the filters you've applied.", + "description": "To see all sub-work items, clear all applied filters.", + "action": "Clear filters" + }, + "list_filters": { + "title": "You don't have work items that match the filters you've applied.", + "description": "To see all work items, clear all applied filters.", + "action": "Clear filters" + } } }, - "view": { "label": "{count, plural, one {View} other {Views}}", "create": { @@ -966,7 +993,6 @@ "label": "Update View" } }, - "inbox_issue": { "status": { "pending": { @@ -1052,7 +1078,6 @@ } } }, - "workspace_creation": { "heading": "Create your workspace", "subheading": "To start using Plane, you need to create or join a workspace.", @@ -1104,7 +1129,6 @@ } } }, - "workspace_dashboard": { "empty_state": { "general": { @@ -1120,7 +1144,6 @@ } } }, - "workspace_analytics": { "label": "Analytics", "page_label": "{workspace} - Analytics", @@ -1151,21 +1174,33 @@ "scope_and_demand": "Scope and Demand", "custom": "Custom Analytics" }, + "total": "Total {entity}", + "started_work_items": "Started {entity}", + "backlog_work_items": "Backlog {entity}", + "un_started_work_items": "Unstarted {entity}", + "completed_work_items": "Completed {entity}", + "project_insights": "Project Insights", + "summary_of_projects": "Summary of Projects", + "all_projects": "All Projects", + "trend_on_charts": "Trend on charts", + "active_projects": "Active Projects", + "customized_insights": "Customized Insights", + "created_vs_resolved": "Created vs Resolved", "empty_state": { - "general": { - "title": "Track progress, workloads, and allocations. Spot trends, remove blockers, and move work faster", - "description": "See scope versus demand, estimates, and scope creep. Get performance by team members and teams, and make sure your project runs on time.", - "primary_button": { - "text": "Start your first project", - "comic": { - "title": "Analytics works best with Cycles + Modules", - "description": "First, timebox your work items into Cycles and, if you can, group work items that span more than a cycle into Modules. Check out both on the left nav." - } - } + "project_insights": { + "title": "No data yet", + "description": "Work items assigned to you, broken down by state, will show up here." + }, + "created_vs_resolved": { + "title": "No data yet", + "description": "Work items created and resolved over time will show up here." + }, + "customized_insights": { + "title": "No data yet", + "description": "Work items assigned to you, broken down by state, will show up here." } } }, - "workspace_projects": { "label": "{count, plural, one {Project} other {Projects}}", "create": { @@ -1240,7 +1275,6 @@ } } }, - "workspace_views": { "add_view": "Add view", "empty_state": { @@ -1275,7 +1309,28 @@ } } }, - + "account_settings": { + "profile": {}, + "preferences": { + "heading": "Preferences", + "description": "Customize your app experience the way you work" + }, + "notifications": { + "heading": "Email notifications", + "description": "Stay in the loop on Work items you are subscribed to. Enable this to get notified." + }, + "security": { + "heading": "Security" + }, + "api_tokens": { + "heading": "Personal Access Tokens", + "description": "Generate secure API tokens to integrate your data with external systems and applications." + }, + "activity": { + "heading": "Activity", + "description": "Track your recent actions and changes across all projects and work items." + } + }, "workspace_settings": { "label": "Workspace settings", "page_label": "{workspace} - General settings", @@ -1342,16 +1397,22 @@ } }, "billing_and_plans": { + "heading": "Billing & Plans", + "description": "Choose your plan, manage subscriptions, and easily upgrade as your needs grow.", "title": "Billing & Plans", "current_plan": "Current plan", "free_plan": "You are currently using the free plan", "view_plans": "View plans" }, "exports": { + "heading": "Exports", + "description": "Export your project data in various formats and access your export history with download links.", "title": "Exports", "exporting": "Exporting", "previous_exports": "Previous exports", "export_separate_files": "Export the data into separate files", + "exporting_projects": "Exporting project", + "format": "Format", "modal": { "title": "Export to", "toasts": { @@ -1367,6 +1428,8 @@ } }, "webhooks": { + "heading": "Webhooks", + "description": "Automate notifications to external services when project events occur.", "title": "Webhooks", "add_webhook": "Add webhook", "modal": { @@ -1418,29 +1481,29 @@ } }, "api_tokens": { - "title": "API Tokens", - "add_token": "Add API token", + "title": "Personal Access Tokens", + "add_token": "Add personal access token", "create_token": "Create token", "never_expires": "Never expires", "generate_token": "Generate token", "generating": "Generating", "delete": { - "title": "Delete API token", + "title": "Delete personal access token", "description": "Any application using this token will no longer have the access to Plane data. This action cannot be undone.", "success": { "title": "Success!", - "message": "The API token has been successfully deleted" + "message": "The token has been successfully deleted" }, "error": { "title": "Error!", - "message": "The API token could not be deleted" + "message": "The token could not be deleted" } } } }, "empty_state": { "api_tokens": { - "title": "No API tokens created", + "title": "No personal access tokens created", "description": "Plane APIs can be used to integrate your data in Plane with any external system. Create a token to get started." }, "webhooks": { @@ -1457,7 +1520,6 @@ } } }, - "profile": { "label": "Profile", "page_label": "Your work", @@ -1491,8 +1553,9 @@ "profile": "Profile", "security": "Security", "activity": "Activity", - "appearance": "Appearance", - "notifications": "Notifications" + "preferences": "Preferences", + "notifications": "Notifications", + "api-tokens": "Personal Access Tokens" }, "tabs": { "summary": "Summary", @@ -1520,7 +1583,6 @@ } } }, - "project_settings": { "general": { "enter_project_id": "Enter project ID", @@ -1555,6 +1617,8 @@ } }, "states": { + "heading": "States", + "description": "Define and customize workflow states to track the progress of your work items.", "describe_this_state_for_your_members": "Describe this state for your members.", "empty_state": { "title": "No states available for the {groupKey} group", @@ -1562,6 +1626,8 @@ } }, "labels": { + "heading": "Labels", + "description": "Create custom labels to categorize and organize your work items", "label_title": "Label title", "label_title_is_required": "Label title is required", "label_max_char": "Label name should not exceed 255 characters", @@ -1570,9 +1636,11 @@ } }, "estimates": { + "heading": "Estimates", + "description": "Set up estimation systems to track and communicate the effort required for each work item.", "label": "Estimates", "title": "Enable estimates for my project", - "description": "They help you in communicating complexity and workload of the team.", + "enable_description": "They help you in communicating complexity and workload of the team.", "no_estimate": "No estimate", "new": "New estimate system", "create": { @@ -1654,6 +1722,8 @@ }, "automations": { "label": "Automations", + "heading": "Automations", + "description": "Configure automated actions to streamline your project management workflow and reduce manual tasks.", "auto-archive": { "title": "Auto-archive closed work items", "description": "Plane will auto archive work items that have been completed or canceled.", @@ -1666,7 +1736,6 @@ "auto_close_status": "Auto-close status" } }, - "empty_state": { "labels": { "title": "No labels yet", @@ -1679,7 +1748,6 @@ } } }, - "project_cycles": { "add_cycle": "Add cycle", "more_details": "More details", @@ -1805,7 +1873,6 @@ } } }, - "project_issues": { "empty_state": { "no_issues": { @@ -1834,7 +1901,6 @@ } } }, - "project_module": { "add_module": "Add Module", "update_module": "Update Module", @@ -1888,7 +1954,6 @@ } } }, - "project_views": { "empty_state": { "general": { @@ -1908,7 +1973,6 @@ } } }, - "project_page": { "empty_state": { "general": { @@ -1938,7 +2002,6 @@ } } }, - "command_k": { "empty_state": { "search": { @@ -1946,7 +2009,6 @@ } } }, - "issue_relation": { "empty_state": { "search": { @@ -1957,7 +2019,6 @@ } } }, - "issue_comment": { "empty_state": { "general": { @@ -1966,7 +2027,6 @@ } } }, - "notification": { "label": "Inbox", "page_label": "{workspace} - Inbox", @@ -2023,7 +2083,6 @@ "custom": "Custom" } }, - "active_cycle": { "empty_state": { "progress": { @@ -2043,7 +2102,6 @@ } } }, - "disabled_project": { "empty_state": { "inbox": { @@ -2106,7 +2164,6 @@ } } }, - "stickies": { "title": "Your stickies", "placeholder": "click to type here", @@ -2164,7 +2221,6 @@ } } }, - "role_details": { "guest": { "title": "Guest", @@ -2179,7 +2235,6 @@ "description": "All permissions set to true within the workspace." } }, - "user_roles": { "product_or_project_manager": "Product / Project Manager", "development_or_engineering": "Development / Engineering", @@ -2192,7 +2247,6 @@ "human_resources": "Human / Resources", "other": "Other" }, - "importer": { "github": { "title": "Github", @@ -2203,7 +2257,6 @@ "description": "Import work items and epics from Jira projects and epics." } }, - "exporter": { "csv": { "title": "CSV", @@ -2232,7 +2285,6 @@ "created": "Created", "subscribed": "Subscribed" }, - "themes": { "theme_options": { "system_preference": { @@ -2278,20 +2330,21 @@ "manual": "Manual" } }, - "cycle": { "label": "{count, plural, one {Cycle} other {Cycles}}", "no_cycle": "No cycle" }, - "module": { "label": "{count, plural, one {Module} other {Modules}}", "no_module": "No module" }, - "description_versions": { "last_edited_by": "Last edited by", "previously_edited_by": "Previously edited by", "edited_by": "Edited by" + }, + "self_hosted_maintenance_message": { + "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane didn't start up. This could be because one or more Plane services failed to start.", + "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Choose View Logs from setup.sh and Docker logs to be sure." } -} +} \ No newline at end of file diff --git a/packages/i18n/src/locales/es/accessibility.json b/packages/i18n/src/locales/es/accessibility.json new file mode 100644 index 000000000..4d957f5a9 --- /dev/null +++ b/packages/i18n/src/locales/es/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Logo del espacio de trabajo", + "open_workspace_switcher": "Abrir cambiador de espacio de trabajo", + "open_user_menu": "Abrir menú de usuario", + "open_command_palette": "Abrir paleta de comandos", + "open_extended_sidebar": "Abrir barra lateral extendida", + "close_extended_sidebar": "Cerrar barra lateral extendida", + "create_favorites_folder": "Crear carpeta de favoritos", + "open_folder": "Abrir carpeta", + "close_folder": "Cerrar carpeta", + "open_favorites_menu": "Abrir menú de favoritos", + "close_favorites_menu": "Cerrar menú de favoritos", + "enter_folder_name": "Ingresar nombre de carpeta", + "create_new_project": "Crear nuevo proyecto", + "open_projects_menu": "Abrir menú de proyectos", + "close_projects_menu": "Cerrar menú de proyectos", + "toggle_quick_actions_menu": "Alternar menú de acciones rápidas", + "open_project_menu": "Abrir menú de proyecto", + "close_project_menu": "Cerrar menú de proyecto", + "collapse_sidebar": "Colapsar barra lateral", + "expand_sidebar": "Expandir barra lateral", + "edition_badge": "Abrir modal de planes de pago" + }, + "auth_forms": { + "clear_email": "Limpiar correo electrónico", + "show_password": "Mostrar contraseña", + "hide_password": "Ocultar contraseña", + "close_alert": "Cerrar alerta", + "close_popover": "Cerrar ventana emergente" + } + } +} diff --git a/packages/i18n/src/locales/es/editor.json b/packages/i18n/src/locales/es/editor.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/i18n/src/locales/es/editor.json @@ -0,0 +1 @@ +{} diff --git a/packages/i18n/src/locales/es/translations.json b/packages/i18n/src/locales/es/translations.json index 20f6da592..49ca53ea1 100644 --- a/packages/i18n/src/locales/es/translations.json +++ b/packages/i18n/src/locales/es/translations.json @@ -18,7 +18,6 @@ "pro": "Pro", "upgrade": "Mejorar" }, - "auth": { "common": { "email": { @@ -168,7 +167,6 @@ } } }, - "submit": "Enviar", "cancel": "Cancelar", "loading": "Cargando", @@ -320,6 +318,8 @@ "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 exitosamente", "project_created_successfully_description": "Proyecto creado exitosamente. Ahora puedes comenzar a agregar elementos de trabajo.", + "project_name_already_taken": "El nombre del proyecto ya está en uso.", + "project_identifier_already_taken": "El identificador del proyecto ya está en uso.", "project_cover_image_alt": "Imagen de portada del proyecto", "name_is_required": "El nombre es requerido", "title_should_be_less_than_255_characters": "El título debe tener menos de 255 caracteres", @@ -369,12 +369,12 @@ "work_management": "Gestión del trabajo", "projects_and_issues": "Proyectos y elementos de trabajo", "projects_and_issues_description": "Activa o desactiva estos en este proyecto.", - "cycles_description": "Organiza el trabajo en períodos de tiempo según lo consideres conveniente 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 escribirías cualquier cosa.", - "intake_description": "Mantente al tanto de los elementos de trabajo a los que estás suscrito. Activa esto para recibir notificaciones.", - "time_tracking_description": "Rastrea el tiempo dedicado a elementos de trabajo y proyectos.", + "cycles_description": "Organiza el trabajo por proyecto en períodos de tiempo y ajusta la duración según sea necesario. Un ciclo puede ser de 2 semanas y el siguiente de 1 semana.", + "modules_description": "Organiza el trabajo en subproyectos con líderes y responsables dedicados.", + "views_description": "Guarda ordenamientos, filtros y opciones de visualización personalizadas o compártelos con tu equipo.", + "pages_description": "Crea y edita contenido libre; notas, documentos, lo que sea.", + "intake_description": "Permite que personas ajenas al equipo compartan errores, comentarios y sugerencias sin interrumpir tu flujo de trabajo.", + "time_tracking_description": "Registra el tiempo dedicado a elementos de trabajo y proyectos.", "work_management_description": "Gestiona tu trabajo y proyectos con facilidad.", "documentation": "Documentación", "message_support": "Mensaje al soporte", @@ -506,7 +506,6 @@ "new_password_must_be_different_from_old_password": "La nueva contraseña debe ser diferente a la contraseña anterior", "edited": "Modificado", "bot": "Bot", - "project_view": { "sort_by": { "created_at": "Creado el", @@ -514,12 +513,10 @@ "name": "Nombre" } }, - "toast": { "success": "¡Éxito!", "error": "¡Error!" }, - "links": { "toasts": { "created": { @@ -548,7 +545,6 @@ } } }, - "home": { "empty": { "quickstart_guide": "Guía de inicio rápido", @@ -616,7 +612,6 @@ "title": "Inicio", "star_us_on_github": "Danos una estrella en GitHub" }, - "link": { "modal": { "url": { @@ -630,7 +625,6 @@ } } }, - "common": { "all": "Todo", "states": "Estados", @@ -758,7 +752,8 @@ "message": "Algo salió mal. Por favor, inténtalo de nuevo." }, "required": "Este campo es obligatorio", - "entity_required": "{entity} es obligatorio" + "entity_required": "{entity} es obligatorio", + "restricted_entity": "{entity} está restringido" }, "update_link": "Actualizar enlace", "attach": "Adjuntar", @@ -858,6 +853,7 @@ "live": "En vivo", "change_history": "Historial de cambios", "coming_soon": "Próximamente", + "member": "Miembro", "members": "Miembros", "you": "Tú", "upgrade_cta": { @@ -873,22 +869,35 @@ "pending": "Pendiente", "invite": "Invitar", "view": "Ver", - "deactivated_user": "Usuario desactivado" + "deactivated_user": "Usuario desactivado", + "apply": "Aplicar", + "applying": "Aplicando", + "users": "Usuarios", + "admins": "Administradores", + "guests": "Invitados", + "on_track": "En camino", + "off_track": "Fuera de camino", + "at_risk": "En riesgo", + "timeline": "Cronograma", + "completion": "Finalización", + "upcoming": "Próximo", + "completed": "Completado", + "in_progress": "En progreso", + "planned": "Planificado", + "paused": "Pausado", + "no_of": "N.º de {entity}" }, - "chart": { "x_axis": "Eje X", "y_axis": "Eje Y", "metric": "Métrica" }, - "form": { "title": { "required": "El título es obligatorio", "max_length": "El título debe tener menos de {length} caracteres" } }, - "entity": { "grouping_title": "Agrupación de {entity}", "priority": "Prioridad de {entity}", @@ -912,7 +921,6 @@ "failed": "Error al agregar {entity}" } }, - "epic": { "all": "Todos los Epics", "label": "{count, plural, one {Epic} other {Epics}}", @@ -930,7 +938,6 @@ "required": "El título del epic es obligatorio." } }, - "issue": { "label": "{count, plural, one {Elemento de trabajo} other {Elementos de trabajo}}", "all": "Todos los elementos de trabajo", @@ -1093,11 +1100,12 @@ "select": { "error": "Por favor selecciona al menos un elemento de trabajo", "empty": "No hay elementos de trabajo seleccionados", - "add_selected": "Agregar elementos seleccionados" + "add_selected": "Agregar elementos seleccionados", + "select_all": "Seleccionar todo", + "deselect_all": "Deseleccionar todo" }, "open_in_full_screen": "Abrir elemento de trabajo en pantalla completa" }, - "attachment": { "error": "No se pudo adjuntar el archivo. Intenta subirlo de nuevo.", "only_one_file_allowed": "Solo se puede subir un archivo a la vez.", @@ -1105,7 +1113,6 @@ "drag_and_drop": "Arrastra y suelta en cualquier lugar para subir", "delete": "Eliminar archivo adjunto" }, - "label": { "select": "Seleccionar etiqueta", "create": { @@ -1115,7 +1122,6 @@ "type": "Escribe para agregar una nueva etiqueta" } }, - "sub_work_item": { "update": { "success": "Sub-elemento actualizado correctamente", @@ -1124,9 +1130,20 @@ "remove": { "success": "Sub-elemento eliminado correctamente", "error": "Error al eliminar el sub-elemento" + }, + "empty_state": { + "sub_list_filters": { + "title": "No tienes sub-elementos de trabajo que coincidan con los filtros que has aplicado.", + "description": "Para ver todos los sub-elementos de trabajo, elimina todos los filtros aplicados.", + "action": "Eliminar filtros" + }, + "list_filters": { + "title": "No tienes elementos de trabajo que coincidan con los filtros que has aplicado.", + "description": "Para ver todos los elementos de trabajo, elimina todos los filtros aplicados.", + "action": "Eliminar filtros" + } } }, - "view": { "label": "{count, plural, one {Vista} other {Vistas}}", "create": { @@ -1136,7 +1153,6 @@ "label": "Actualizar vista" } }, - "inbox_issue": { "status": { "pending": { @@ -1222,7 +1238,6 @@ } } }, - "workspace_creation": { "heading": "Crea tu espacio de trabajo", "subheading": "Para comenzar a usar Plane, necesitas crear o unirte a un espacio de trabajo.", @@ -1274,7 +1289,6 @@ } } }, - "workspace_dashboard": { "empty_state": { "general": { @@ -1290,7 +1304,6 @@ } } }, - "workspace_analytics": { "label": "Análisis", "page_label": "{workspace} - Análisis", @@ -1322,20 +1335,38 @@ "custom": "Análisis Personalizado" }, "empty_state": { - "general": { - "title": "Rastrea el progreso, cargas de trabajo y asignaciones. Identifica tendencias, elimina bloqueos y mueve el trabajo más rápido", - "description": "Observa el alcance versus la demanda, estimaciones y el aumento del alcance. Obtén el rendimiento por miembros del equipo y equipos, y asegúrate de que tu proyecto se ejecute a tiempo.", - "primary_button": { - "text": "Inicia tu primer proyecto", - "comic": { - "title": "El análisis funciona mejor con Ciclos + Módulos", - "description": "Primero, organiza tus elementos de trabajo en Ciclos y, si puedes, agrupa los elementos de trabajo que abarcan más de un ciclo en Módulos. Revisa ambos en la navegación izquierda." - } - } + "customized_insights": { + "description": "Los elementos de trabajo asignados a ti, desglosados por estado, aparecerán aquí.", + "title": "Aún no hay datos" + }, + "created_vs_resolved": { + "description": "Los elementos de trabajo creados y resueltos con el tiempo aparecerán aquí.", + "title": "Aún no hay datos" + }, + "project_insights": { + "title": "Aún no hay datos", + "description": "Los elementos de trabajo asignados a ti, desglosados por estado, aparecerán aquí." } - } + }, + "created_vs_resolved": "Creado vs Resuelto", + "customized_insights": "Información personalizada", + "backlog_work_items": "{entity} en backlog", + "active_projects": "Proyectos activos", + "trend_on_charts": "Tendencia en gráficos", + "all_projects": "Todos los proyectos", + "summary_of_projects": "Resumen de proyectos", + "project_insights": "Información del proyecto", + "started_work_items": "{entity} iniciados", + "total_work_items": "Total de {entity}", + "total_projects": "Total de proyectos", + "total_admins": "Total de administradores", + "total_users": "Total de usuarios", + "total_intake": "Ingreso total", + "un_started_work_items": "{entity} no iniciados", + "total_guests": "Total de invitados", + "completed_work_items": "{entity} completados", + "total": "Total de {entity}" }, - "workspace_projects": { "label": "{count, plural, one {Proyecto} other {Proyectos}}", "create": { @@ -1409,7 +1440,6 @@ } } }, - "workspace_views": { "add_view": "Agregar vista", "empty_state": { @@ -1444,7 +1474,6 @@ } } }, - "workspace_settings": { "label": "Configuración del espacio de trabajo", "page_label": "{workspace} - Configuración general", @@ -1626,7 +1655,6 @@ } } }, - "profile": { "label": "Perfil", "page_label": "Tu trabajo", @@ -1689,7 +1717,6 @@ } } }, - "project_settings": { "general": { "enter_project_id": "Ingresa el ID del proyecto", @@ -1835,7 +1862,6 @@ "auto_close_status": "Estado de cierre automático" } }, - "empty_state": { "labels": { "title": "Aún no hay etiquetas", @@ -1848,7 +1874,6 @@ } } }, - "project_cycles": { "add_cycle": "Agregar ciclo", "more_details": "Más detalles", @@ -1974,7 +1999,6 @@ } } }, - "project_issues": { "empty_state": { "no_issues": { @@ -2003,7 +2027,6 @@ } } }, - "project_module": { "add_module": "Agregar Módulo", "update_module": "Actualizar Módulo", @@ -2057,7 +2080,6 @@ } } }, - "project_views": { "empty_state": { "general": { @@ -2077,7 +2099,6 @@ } } }, - "project_page": { "empty_state": { "general": { @@ -2107,7 +2128,6 @@ } } }, - "command_k": { "empty_state": { "search": { @@ -2115,7 +2135,6 @@ } } }, - "issue_relation": { "empty_state": { "search": { @@ -2126,7 +2145,6 @@ } } }, - "issue_comment": { "empty_state": { "general": { @@ -2135,7 +2153,6 @@ } } }, - "notification": { "label": "Bandeja de entrada", "page_label": "{workspace} - Bandeja de entrada", @@ -2192,7 +2209,6 @@ "custom": "Personalizado" } }, - "active_cycle": { "empty_state": { "progress": { @@ -2212,7 +2228,6 @@ } } }, - "disabled_project": { "empty_state": { "inbox": { @@ -2275,7 +2290,6 @@ } } }, - "stickies": { "title": "Tus notas adhesivas", "placeholder": "haz clic para escribir aquí", @@ -2333,7 +2347,6 @@ } } }, - "role_details": { "guest": { "title": "Invitado", @@ -2348,7 +2361,6 @@ "description": "Todos los permisos establecidos como verdaderos dentro del espacio de trabajo." } }, - "user_roles": { "product_or_project_manager": "Gerente de Producto / Proyecto", "development_or_engineering": "Desarrollo / Ingeniería", @@ -2361,7 +2373,6 @@ "human_resources": "Recursos Humanos", "other": "Otro" }, - "importer": { "github": { "title": "GitHub", @@ -2372,7 +2383,6 @@ "description": "Importa elementos de trabajo y epics desde proyectos y epics de Jira." } }, - "exporter": { "csv": { "title": "CSV", @@ -2401,7 +2411,6 @@ "created": "Creados", "subscribed": "Suscritos" }, - "themes": { "theme_options": { "system_preference": { @@ -2447,20 +2456,21 @@ "manual": "Manual" } }, - "cycle": { "label": "{count, plural, one {Ciclo} other {Ciclos}}", "no_cycle": "Sin ciclo" }, - "module": { "label": "{count, plural, one {Módulo} other {Módulos}}", "no_module": "Sin módulo" }, - "description_versions": { "last_edited_by": "Última edición por", "previously_edited_by": "Editado anteriormente por", "edited_by": "Editado por" + }, + "self_hosted_maintenance_message": { + "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane no se inició. Esto podría deberse a que uno o más servicios de Plane fallaron al iniciar.", + "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Selecciona View Logs desde setup.sh y los logs de Docker para estar seguro." } -} +} \ No newline at end of file diff --git a/packages/i18n/src/locales/fr/accessibility.json b/packages/i18n/src/locales/fr/accessibility.json new file mode 100644 index 000000000..435247c58 --- /dev/null +++ b/packages/i18n/src/locales/fr/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Logo de l'espace de travail", + "open_workspace_switcher": "Ouvrir le sélecteur d'espace de travail", + "open_user_menu": "Ouvrir le menu utilisateur", + "open_command_palette": "Ouvrir la palette de commandes", + "open_extended_sidebar": "Ouvrir la barre latérale étendue", + "close_extended_sidebar": "Fermer la barre latérale étendue", + "create_favorites_folder": "Créer un dossier de favoris", + "open_folder": "Ouvrir le dossier", + "close_folder": "Fermer le dossier", + "open_favorites_menu": "Ouvrir le menu des favoris", + "close_favorites_menu": "Fermer le menu des favoris", + "enter_folder_name": "Saisir le nom du dossier", + "create_new_project": "Créer un nouveau projet", + "open_projects_menu": "Ouvrir le menu des projets", + "close_projects_menu": "Fermer le menu des projets", + "toggle_quick_actions_menu": "Basculer le menu d'actions rapides", + "open_project_menu": "Ouvrir le menu du projet", + "close_project_menu": "Fermer le menu du projet", + "collapse_sidebar": "Réduire la barre latérale", + "expand_sidebar": "Étendre la barre latérale", + "edition_badge": "Ouvrir le modal des plans payants" + }, + "auth_forms": { + "clear_email": "Effacer l'e-mail", + "show_password": "Afficher le mot de passe", + "hide_password": "Masquer le mot de passe", + "close_alert": "Fermer l'alerte", + "close_popover": "Fermer la fenêtre contextuelle" + } + } +} diff --git a/packages/i18n/src/locales/fr/editor.json b/packages/i18n/src/locales/fr/editor.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/i18n/src/locales/fr/editor.json @@ -0,0 +1 @@ +{} diff --git a/packages/i18n/src/locales/fr/translations.json b/packages/i18n/src/locales/fr/translations.json index f307949a0..e42db2c52 100644 --- a/packages/i18n/src/locales/fr/translations.json +++ b/packages/i18n/src/locales/fr/translations.json @@ -18,7 +18,6 @@ "pro": "Pro", "upgrade": "Mettre à niveau" }, - "auth": { "common": { "email": { @@ -168,7 +167,6 @@ } } }, - "submit": "Soumettre", "cancel": "Annuler", "loading": "Chargement", @@ -318,6 +316,8 @@ "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 commencer à ajouter des éléments de travail.", + "project_name_already_taken": "Le nom du projet est déjà pris.", + "project_identifier_already_taken": "L’identifiant du projet est déjà pris.", "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 faire moins de 255 caractères", @@ -367,12 +367,12 @@ "work_management": "Gestion du travail", "projects_and_issues": "Projets et éléments de travail", "projects_and_issues_description": "Activez ou désactivez ces éléments pour ce projet.", - "cycles_description": "Planifiez le travail comme vous le souhaitez par projet et changez la fréquence d'une période à l'autre.", - "modules_description": "Regroupez le travail en configurations de type sous-projet avec leurs propres responsables et assignés.", - "views_description": "Enregistrez les tris, filtres et options d'affichage pour plus tard ou partagez-les.", - "pages_description": "Écrivez n'importe quoi comme vous écrivez n'importe quoi.", - "intake_description": "Restez informé des éléments de travail auxquels vous êtes abonné. Activez ceci pour être notifié.", - "time_tracking_description": "Suivez le temps passé sur les éléments de travail et les projets.", + "cycles_description": "Planifiez le travail par projet dans un cadre temporel et ajustez la période au besoin. Un cycle peut durer 2 semaines, le suivant 1 semaine.", + "modules_description": "Organisez le travail en sous-projets avec des responsables et des personnes assignées dédiés.", + "views_description": "Enregistrez des tris, filtres et options d'affichage personnalisés ou partagez-les avec votre équipe.", + "pages_description": "Créez et modifiez du contenu libre : notes, documents, tout ce que vous voulez.", + "intake_description": "Permettez aux non-membres de partager des bugs, des retours et des suggestions, sans perturber votre flux de travail.", + "time_tracking_description": "Enregistrez le temps passé sur les éléments de travail et les projets.", "work_management_description": "Gérez votre travail et vos projets facilement.", "documentation": "Documentation", "message_support": "Contacter le support", @@ -504,7 +504,6 @@ "new_password_must_be_different_from_old_password": "Le nouveau mot de passe doit être différent du mot de passe précédent", "edited": "Modifié", "bot": "Bot", - "project_view": { "sort_by": { "created_at": "Créé le", @@ -512,12 +511,10 @@ "name": "Nom" } }, - "toast": { "success": "Succès !", "error": "Erreur !" }, - "links": { "toasts": { "created": { @@ -546,7 +543,6 @@ } } }, - "home": { "empty": { "quickstart_guide": "Guide de démarrage rapide", @@ -614,7 +610,6 @@ "title": "Accueil", "star_us_on_github": "Donnez-nous une étoile sur GitHub" }, - "link": { "modal": { "url": { @@ -628,7 +623,6 @@ } } }, - "common": { "all": "Tout", "states": "États", @@ -756,7 +750,8 @@ "message": "Quelque chose s'est mal passé. Veuillez réessayer." }, "required": "Ce champ est obligatoire", - "entity_required": "{entity} est requis" + "entity_required": "{entity} est requis", + "restricted_entity": "{entity} est restreint" }, "update_link": "Mettre à jour le lien", "attach": "Joindre", @@ -856,6 +851,7 @@ "live": "En direct", "change_history": "Historique des modifications", "coming_soon": "À venir", + "member": "Membre", "members": "Membres", "you": "Vous", "upgrade_cta": { @@ -871,22 +867,35 @@ "pending": "En attente", "invite": "Inviter", "view": "Afficher", - "deactivated_user": "Utilisateur désactivé" + "deactivated_user": "Utilisateur désactivé", + "apply": "Appliquer", + "applying": "Application", + "users": "Utilisateurs", + "admins": "Administrateurs", + "guests": "Invités", + "on_track": "Sur la bonne voie", + "off_track": "Hors de la bonne voie", + "at_risk": "À risque", + "timeline": "Chronologie", + "completion": "Achèvement", + "upcoming": "À venir", + "completed": "Terminé", + "in_progress": "En cours", + "planned": "Planifié", + "paused": "En pause", + "no_of": "Nº de {entity}" }, - "chart": { "x_axis": "Axe X", "y_axis": "Axe Y", "metric": "Métrique" }, - "form": { "title": { "required": "Le titre est requis", "max_length": "Le titre doit contenir moins de {length} caractères" } }, - "entity": { "grouping_title": "Regroupement {entity}", "priority": "Priorité {entity}", @@ -910,7 +919,6 @@ "failed": "Erreur lors de l'ajout de {entity}" } }, - "epic": { "all": "Tous les Epics", "label": "{count, plural, one {Epic} other {Epics}}", @@ -928,7 +936,6 @@ "required": "Le titre de l'Epic est requis." } }, - "issue": { "label": "{count, plural, one {Élément de travail} other {Éléments de travail}}", "all": "Tous les éléments de travail", @@ -1091,11 +1098,12 @@ "select": { "error": "Veuillez sélectionner au moins un élément de travail", "empty": "Aucun élément de travail sélectionné", - "add_selected": "Ajouter les éléments de travail sélectionnés" + "add_selected": "Ajouter les éléments de travail sélectionnés", + "select_all": "Sélectionner tout", + "deselect_all": "Tout désélectionner" }, "open_in_full_screen": "Ouvrir l'élément de travail en plein écran" }, - "attachment": { "error": "Le fichier n'a pas pu être joint. Essayez de le télécharger à nouveau.", "only_one_file_allowed": "Un seul fichier peut être téléchargé à la fois.", @@ -1103,7 +1111,6 @@ "drag_and_drop": "Glissez-déposez n'importe où pour télécharger", "delete": "Supprimer la pièce jointe" }, - "label": { "select": "Sélectionner une étiquette", "create": { @@ -1113,7 +1120,6 @@ "type": "Tapez pour ajouter une nouvelle étiquette" } }, - "sub_work_item": { "update": { "success": "Sous-élément de travail mis à jour avec succès", @@ -1122,9 +1128,20 @@ "remove": { "success": "Sous-élément de travail supprimé avec succès", "error": "Erreur lors de la suppression du sous-élément de travail" + }, + "empty_state": { + "sub_list_filters": { + "title": "Vous n'avez pas de sous-éléments de travail qui correspondent aux filtres que vous avez appliqués.", + "description": "Pour voir tous les sous-éléments de travail, effacer tous les filtres appliqués.", + "action": "Effacer les filtres" + }, + "list_filters": { + "title": "Vous n'avez pas d'éléments de travail qui correspondent aux filtres que vous avez appliqués.", + "description": "Pour voir tous les éléments de travail, effacer tous les filtres appliqués.", + "action": "Effacer les filtres" + } } }, - "view": { "label": "{count, plural, one {Vue} other {Vues}}", "create": { @@ -1134,7 +1151,6 @@ "label": "Mettre à jour la vue" } }, - "inbox_issue": { "status": { "pending": { @@ -1220,7 +1236,6 @@ } } }, - "workspace_creation": { "heading": "Créez votre espace de travail", "subheading": "Pour commencer à utiliser Plane, vous devez créer ou rejoindre un espace de travail.", @@ -1272,7 +1287,6 @@ } } }, - "workspace_dashboard": { "empty_state": { "general": { @@ -1288,7 +1302,6 @@ } } }, - "workspace_analytics": { "label": "Analytique", "page_label": "{workspace} - Analytique", @@ -1320,20 +1333,38 @@ "custom": "Analytique Personnalisée" }, "empty_state": { - "general": { - "title": "Suivez les progrès, les charges de travail et les allocations. Repérez les tendances, supprimez les blocages et accélérez le travail", - "description": "Visualisez la portée par rapport à la demande, les estimations et l'augmentation de la portée. Obtenez les performances par membres de l'équipe et équipes, et assurez-vous que votre projet se déroule dans les délais.", - "primary_button": { - "text": "Commencez votre premier projet", - "comic": { - "title": "L'analytique fonctionne mieux avec les Cycles + Modules", - "description": "D'abord, planifiez vos éléments de travail dans des Cycles et, si possible, regroupez les éléments de travail qui s'étendent sur plus d'un cycle dans des Modules. Consultez les deux dans la navigation de gauche." - } - } + "customized_insights": { + "description": "Les éléments de travail qui vous sont assignés, répartis par état, s'afficheront ici.", + "title": "Pas encore de données" + }, + "created_vs_resolved": { + "description": "Les éléments de travail créés et résolus au fil du temps s'afficheront ici.", + "title": "Pas encore de données" + }, + "project_insights": { + "title": "Pas encore de données", + "description": "Les éléments de travail qui vous sont assignés, répartis par état, s'afficheront ici." } - } + }, + "created_vs_resolved": "Créé vs Résolu", + "customized_insights": "Informations personnalisées", + "backlog_work_items": "{entity} en backlog", + "active_projects": "Projets actifs", + "trend_on_charts": "Tendance sur les graphiques", + "all_projects": "Tous les projets", + "summary_of_projects": "Résumé des projets", + "project_insights": "Aperçus du projet", + "started_work_items": "{entity} commencés", + "total_work_items": "Total des {entity}", + "total_projects": "Total des projets", + "total_admins": "Total des administrateurs", + "total_users": "Nombre total d'utilisateurs", + "total_intake": "Revenu total", + "un_started_work_items": "{entity} non commencés", + "total_guests": "Nombre total d'invités", + "completed_work_items": "{entity} terminés", + "total": "Total des {entity}" }, - "workspace_projects": { "label": "{count, plural, one {Projet} other {Projets}}", "create": { @@ -1407,7 +1438,6 @@ } } }, - "workspace_views": { "add_view": "Ajouter une vue", "empty_state": { @@ -1442,7 +1472,6 @@ } } }, - "workspace_settings": { "label": "Paramètres de l'espace de travail", "page_label": "{workspace} - Paramètres généraux", @@ -1624,7 +1653,6 @@ } } }, - "profile": { "label": "Profil", "page_label": "Votre travail", @@ -1687,7 +1715,6 @@ } } }, - "project_settings": { "general": { "enter_project_id": "Saisissez l'ID du projet", @@ -1833,7 +1860,6 @@ "auto_close_status": "Statut de fermeture automatique" } }, - "empty_state": { "labels": { "title": "Pas encore d'étiquettes", @@ -1846,7 +1872,6 @@ } } }, - "project_cycles": { "add_cycle": "Ajouter un cycle", "more_details": "Plus de détails", @@ -1972,7 +1997,6 @@ } } }, - "project_issues": { "empty_state": { "no_issues": { @@ -2001,7 +2025,6 @@ } } }, - "project_module": { "add_module": "Ajouter un module", "update_module": "Mettre à jour le module", @@ -2055,7 +2078,6 @@ } } }, - "project_views": { "empty_state": { "general": { @@ -2075,7 +2097,6 @@ } } }, - "project_page": { "empty_state": { "general": { @@ -2105,7 +2126,6 @@ } } }, - "command_k": { "empty_state": { "search": { @@ -2113,7 +2133,6 @@ } } }, - "issue_relation": { "empty_state": { "search": { @@ -2124,7 +2143,6 @@ } } }, - "issue_comment": { "empty_state": { "general": { @@ -2133,7 +2151,6 @@ } } }, - "notification": { "label": "Boîte de réception", "page_label": "{workspace} - Boîte de réception", @@ -2190,7 +2207,6 @@ "custom": "Personnalisé" } }, - "active_cycle": { "empty_state": { "progress": { @@ -2210,7 +2226,6 @@ } } }, - "disabled_project": { "empty_state": { "inbox": { @@ -2273,7 +2288,6 @@ } } }, - "stickies": { "title": "Vos notes adhésives", "placeholder": "cliquez pour écrire ici", @@ -2331,7 +2345,6 @@ } } }, - "role_details": { "guest": { "title": "Invité", @@ -2346,7 +2359,6 @@ "description": "Toutes les permissions sont activées dans l'espace de travail." } }, - "user_roles": { "product_or_project_manager": "Chef de produit / Chef de projet", "development_or_engineering": "Développement / Ingénierie", @@ -2359,7 +2371,6 @@ "human_resources": "Ressources Humaines", "other": "Autre" }, - "importer": { "github": { "title": "GitHub", @@ -2370,7 +2381,6 @@ "description": "Importez des éléments de travail et des epics depuis les projets et epics Jira." } }, - "exporter": { "csv": { "title": "CSV", @@ -2399,7 +2409,6 @@ "created": "Créés", "subscribed": "Suivis" }, - "themes": { "theme_options": { "system_preference": { @@ -2445,20 +2454,21 @@ "manual": "Manuel" } }, - "cycle": { "label": "{count, plural, one {Cycle} other {Cycles}}", "no_cycle": "Pas de cycle" }, - "module": { "label": "{count, plural, one {Module} other {Modules}}", "no_module": "Pas de module" }, - "description_versions": { "last_edited_by": "Dernière modification par", "previously_edited_by": "Précédemment modifié par", "edited_by": "Modifié par" + }, + "self_hosted_maintenance_message": { + "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane n'a pas démarré. Cela pourrait être dû au fait qu'un ou plusieurs services Plane ont échoué à démarrer.", + "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Choisissez View Logs depuis setup.sh et les logs Docker pour en être sûr." } -} +} \ No newline at end of file diff --git a/packages/i18n/src/locales/id/accessibility.json b/packages/i18n/src/locales/id/accessibility.json new file mode 100644 index 000000000..732073401 --- /dev/null +++ b/packages/i18n/src/locales/id/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Logo ruang kerja", + "open_workspace_switcher": "Buka penukar ruang kerja", + "open_user_menu": "Buka menu pengguna", + "open_command_palette": "Buka palet perintah", + "open_extended_sidebar": "Buka sidebar diperluas", + "close_extended_sidebar": "Tutup sidebar diperluas", + "create_favorites_folder": "Buat folder favorit", + "open_folder": "Buka folder", + "close_folder": "Tutup folder", + "open_favorites_menu": "Buka menu favorit", + "close_favorites_menu": "Tutup menu favorit", + "enter_folder_name": "Masukkan nama folder", + "create_new_project": "Buat proyek baru", + "open_projects_menu": "Buka menu proyek", + "close_projects_menu": "Tutup menu proyek", + "toggle_quick_actions_menu": "Alihkan menu tindakan cepat", + "open_project_menu": "Buka menu proyek", + "close_project_menu": "Tutup menu proyek", + "collapse_sidebar": "Tutup sidebar", + "expand_sidebar": "Perluas sidebar", + "edition_badge": "Buka modal paket berbayar" + }, + "auth_forms": { + "clear_email": "Hapus email", + "show_password": "Tampilkan kata sandi", + "hide_password": "Sembunyikan kata sandi", + "close_alert": "Tutup peringatan", + "close_popover": "Tutup popover" + } + } +} diff --git a/packages/i18n/src/locales/id/editor.json b/packages/i18n/src/locales/id/editor.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/i18n/src/locales/id/editor.json @@ -0,0 +1 @@ +{} diff --git a/packages/i18n/src/locales/id/translations.json b/packages/i18n/src/locales/id/translations.json index 76da8fa5d..372aefde9 100644 --- a/packages/i18n/src/locales/id/translations.json +++ b/packages/i18n/src/locales/id/translations.json @@ -18,7 +18,6 @@ "pro": "Pro", "upgrade": "Upgrade" }, - "auth": { "common": { "email": { @@ -168,7 +167,6 @@ } } }, - "submit": "Kirim", "cancel": "Batal", "loading": "Memuat", @@ -318,6 +316,8 @@ "failed_to_remove_project_from_favorites": "Tidak dapat menghapus proyek dari favorit. Silakan coba lagi.", "project_created_successfully": "Proyek berhasil dibuat", "project_created_successfully_description": "Proyek berhasil dibuat. Anda sekarang dapat mulai menambahkan item kerja ke dalamnya.", + "project_name_already_taken": "Nama proyek sudah digunakan", + "project_identifier_already_taken": "ID proyek sudah digunakan", "project_cover_image_alt": "Gambar sampul proyek", "name_is_required": "Nama diperlukan", "title_should_be_less_than_255_characters": "Judul harus kurang dari 255 karakter", @@ -367,12 +367,12 @@ "work_management": "Manajemen kerja", "projects_and_issues": "Proyek dan item kerja", "projects_and_issues_description": "Aktifkan atau nonaktifkan ini untuk proyek ini.", - "cycles_description": "Batasi waktu kerja sesuai keinginan Anda per proyek dan ubah frekuensi dari satu periode ke periode berikutnya.", - "modules_description": "Kerja kelompok menjadi pengaturan sub-proyek dengan pemimpin dan penugasnya sendiri.", - "views_description": "Simpan jenis, filter, dan opsi tampilan untuk nanti atau bagikan.", - "pages_description": "Tulis apapun seperti yang Anda tulis.", - "intake_description": "Tetap terhubung dengan item kerja yang Anda ikuti. Aktifkan ini untuk mendapatkan pemberitahuan.", - "time_tracking_description": "Lacak waktu yang dihabiskan untuk item kerja dan proyek.", + "cycles_description": "Tetapkan batas waktu kerja per proyek dan sesuaikan periode waktunya sesuai kebutuhan. Satu siklus bisa 2 minggu, berikutnya 1 minggu.", + "modules_description": "Atur pekerjaan ke dalam sub-proyek dengan pemimpin dan penanggung jawab khusus.", + "views_description": "Simpan pengurutan, filter, dan opsi tampilan khusus atau bagikan dengan tim Anda.", + "pages_description": "Buat dan edit konten bebas bentuk: catatan, dokumen, apa saja.", + "intake_description": "Izinkan non-anggota membagikan bug, masukan, dan saran tanpa mengganggu alur kerja Anda.", + "time_tracking_description": "Catat waktu yang dihabiskan untuk item kerja dan proyek.", "work_management_description": "Kelola pekerjaan dan proyek Anda dengan mudah.", "documentation": "Dokumentasi", "message_support": "Pesan dukungan", @@ -502,7 +502,6 @@ "export": "Ekspor", "member": "{count, plural, one{# anggota} other{# anggota}}", "new_password_must_be_different_from_old_password": "Kata sandi baru harus berbeda dari kata sandi lama", - "project_view": { "sort_by": { "created_at": "Dibuat pada", @@ -510,12 +509,10 @@ "name": "Nama" } }, - "toast": { "success": "Sukses!", "error": "Kesalahan!" }, - "links": { "toasts": { "created": { @@ -544,7 +541,6 @@ } } }, - "home": { "empty": { "quickstart_guide": "Panduan pemula Anda", @@ -612,7 +608,6 @@ "title": "Beranda", "star_us_on_github": "Bintang kami di GitHub" }, - "link": { "modal": { "url": { @@ -626,7 +621,6 @@ } } }, - "common": { "all": "Semua", "states": "Negara-negara", @@ -754,7 +748,8 @@ "message": "Sesuatu telah salah. Silakan coba lagi." }, "required": "Bidang ini diperlukan", - "entity_required": "{entity} diperlukan" + "entity_required": "{entity} diperlukan", + "restricted_entity": "{entity} dibatasi" }, "update_link": "Perbarui tautan", "attach": "Lampirkan", @@ -855,6 +850,7 @@ "live": "Langsung", "change_history": "Riwayat Perubahan", "coming_soon": "Segera hadir", + "member": "Anggota", "members": "Anggota", "you": "Anda", "upgrade_cta": { @@ -870,22 +866,35 @@ "pending": "Tertunda", "invite": "Undang", "view": "Lihat", - "deactivated_user": "Pengguna dinonaktifkan" + "deactivated_user": "Pengguna dinonaktifkan", + "apply": "Terapkan", + "applying": "Terapkan", + "users": "Pengguna", + "admins": "Admin", + "guests": "Tamu", + "on_track": "Sesuai Jalur", + "off_track": "Menyimpang", + "at_risk": "Dalam risiko", + "timeline": "Linimasa", + "completion": "Penyelesaian", + "upcoming": "Mendatang", + "completed": "Selesai", + "in_progress": "Sedang berlangsung", + "planned": "Direncanakan", + "paused": "Dijedaikan", + "no_of": "Jumlah {entity}" }, - "chart": { "x_axis": "Sumbu-X", "y_axis": "Sumbu-Y", "metric": "Metrik" }, - "form": { "title": { "required": "Judul wajib diisi", "max_length": "Judul harus kurang dari {length} karakter" } }, - "entity": { "grouping_title": "Pengelompokan {entity}", "priority": "Prioritas {entity}", @@ -909,7 +918,6 @@ "failed": "Terjadi kesalahan saat menambahkan {entity}" } }, - "epic": { "all": "Semua Epik", "label": "{count, plural, one {Epik} other {Epik}}", @@ -927,7 +935,6 @@ "required": "Judul epik wajib diisi." } }, - "issue": { "label": "{count, plural, one {Item Kerja} other {Item Kerja}}", "all": "Semua Item Kerja", @@ -1090,11 +1097,12 @@ "select": { "error": "Silakan pilih setidaknya satu item kerja", "empty": "Tidak ada item kerja yang dipilih", - "add_selected": "Tambah item kerja yang dipilih" + "add_selected": "Tambah item kerja yang dipilih", + "select_all": "Pilih semua item kerja", + "deselect_all": "Batalkan pilihan semua item kerja" }, "open_in_full_screen": "Buka item kerja dalam layar penuh" }, - "attachment": { "error": "File tidak dapat dilampirkan. Coba unggah lagi.", "only_one_file_allowed": "Hanya satu file yang dapat diunggah pada satu waktu.", @@ -1102,7 +1110,6 @@ "drag_and_drop": "Seret dan jatuhkan di mana saja untuk mengunggah", "delete": "Hapus lampiran" }, - "label": { "select": "Pilih label", "create": { @@ -1112,7 +1119,6 @@ "type": "Ketik untuk menambah label baru" } }, - "sub_work_item": { "update": { "success": "Sub-item kerja berhasil diperbarui", @@ -1121,9 +1127,20 @@ "remove": { "success": "Sub-item kerja berhasil dihapus", "error": "Kesalahan saat menghapus sub-item kerja" + }, + "empty_state": { + "sub_list_filters": { + "title": "Anda tidak memiliki sub-item kerja yang cocok dengan filter yang Anda terapkan.", + "description": "Untuk melihat semua sub-item kerja, hapus semua filter yang diterapkan.", + "action": "Hapus filter" + }, + "list_filters": { + "title": "Anda tidak memiliki item kerja yang cocok dengan filter yang Anda terapkan.", + "description": "Untuk melihat semua item kerja, hapus semua filter yang diterapkan.", + "action": "Hapus filter" + } } }, - "view": { "label": "{count, plural, one {Tampilan} other {Tampilan}}", "create": { @@ -1133,7 +1150,6 @@ "label": "Perbarui Tampilan" } }, - "inbox_issue": { "status": { "pending": { @@ -1219,7 +1235,6 @@ } } }, - "workspace_creation": { "heading": "Buat ruang kerja Anda", "subheading": "Untuk mulai menggunakan Plane, Anda perlu membuat atau bergabung dengan ruang kerja.", @@ -1271,7 +1286,6 @@ } } }, - "workspace_dashboard": { "empty_state": { "general": { @@ -1287,7 +1301,6 @@ } } }, - "workspace_analytics": { "label": "Analitik", "page_label": "{workspace} - Analitik", @@ -1319,20 +1332,38 @@ "custom": "Analitik Kustom" }, "empty_state": { - "general": { - "title": "Lacak kemajuan, beban kerja, dan alokasi. Temukan tren, hilangkan penghalang, dan percepat pekerjaan", - "description": "Lihat lingkup dibandingkan permintaan, perkiraan, dan lingkup cree. Dapatkan kinerja oleh anggota tim dan tim, dan pastikan proyek Anda berjalan tepat waktu.", - "primary_button": { - "text": "Mulai proyek pertama Anda", - "comic": { - "title": "Analitik bekerja terbaik dengan Siklus + Modul", - "description": "Pertama, bagi item kerja Anda ke dalam Siklus dan, jika memungkinkan, kelompokkan item kerja yang menjangkau lebih dari satu siklus ke dalam Modul. Lihat kedua fungsi pada navigasi kiri." - } - } + "customized_insights": { + "description": "Item pekerjaan yang ditugaskan kepada Anda, dipecah berdasarkan status, akan muncul di sini.", + "title": "Belum ada data" + }, + "created_vs_resolved": { + "description": "Item pekerjaan yang dibuat dan diselesaikan dari waktu ke waktu akan muncul di sini.", + "title": "Belum ada data" + }, + "project_insights": { + "title": "Belum ada data", + "description": "Item pekerjaan yang ditugaskan kepada Anda, dipecah berdasarkan status, akan muncul di sini." } - } + }, + "created_vs_resolved": "Dibuat vs Diselesaikan", + "customized_insights": "Wawasan yang Disesuaikan", + "backlog_work_items": "{entity} backlog", + "active_projects": "Proyek Aktif", + "trend_on_charts": "Tren pada grafik", + "all_projects": "Semua Proyek", + "summary_of_projects": "Ringkasan Proyek", + "project_insights": "Wawasan Proyek", + "started_work_items": "{entity} yang telah dimulai", + "total_work_items": "Total {entity}", + "total_projects": "Total Proyek", + "total_admins": "Total Admin", + "total_users": "Total Pengguna", + "total_intake": "Total Pemasukan", + "un_started_work_items": "{entity} yang belum dimulai", + "total_guests": "Total Tamu", + "completed_work_items": "{entity} yang telah selesai", + "total": "Total {entity}" }, - "workspace_projects": { "label": "{count, plural, one {Proyek} other {Proyek}}", "create": { @@ -1407,7 +1438,6 @@ } } }, - "workspace_views": { "add_view": "Tambah tampilan", "empty_state": { @@ -1442,7 +1472,6 @@ } } }, - "workspace_settings": { "label": "Pengaturan ruang kerja", "page_label": "{workspace} - Pengaturan Umum", @@ -1624,7 +1653,6 @@ } } }, - "profile": { "label": "Profil", "page_label": "Pekerjaan Anda", @@ -1687,7 +1715,6 @@ } } }, - "project_settings": { "general": { "enter_project_id": "Masukkan ID proyek", @@ -1833,7 +1860,6 @@ "auto_close_status": "Status penutupan otomatis" } }, - "empty_state": { "labels": { "title": "Belum ada label", @@ -1846,7 +1872,6 @@ } } }, - "project_cycles": { "add_cycle": "Tambah siklus", "more_details": "Detail lebih lanjut", @@ -1966,7 +1991,6 @@ } } }, - "project_issues": { "empty_state": { "no_issues": { @@ -1995,7 +2019,6 @@ } } }, - "project_module": { "add_module": "Tambah Modul", "update_module": "Perbarui Modul", @@ -2049,7 +2072,6 @@ } } }, - "project_views": { "empty_state": { "general": { @@ -2069,7 +2091,6 @@ } } }, - "project_page": { "empty_state": { "general": { @@ -2099,7 +2120,6 @@ } } }, - "command_k": { "empty_state": { "search": { @@ -2107,7 +2127,6 @@ } } }, - "issue_relation": { "empty_state": { "search": { @@ -2118,7 +2137,6 @@ } } }, - "issue_comment": { "empty_state": { "general": { @@ -2127,7 +2145,6 @@ } } }, - "notification": { "label": "Kotak Masuk", "page_label": "{workspace} - Kotak Masuk", @@ -2184,7 +2201,6 @@ "custom": "Kustom" } }, - "active_cycle": { "empty_state": { "progress": { @@ -2204,7 +2220,6 @@ } } }, - "disabled_project": { "empty_state": { "inbox": { @@ -2267,7 +2282,6 @@ } } }, - "stickies": { "title": "Catatan tempel Anda", "placeholder": "klik untuk mengetik di sini", @@ -2325,7 +2339,6 @@ } } }, - "role_details": { "guest": { "title": "Tamu", @@ -2340,7 +2353,6 @@ "description": "Semua izin diatur ke true dalam ruang kerja." } }, - "user_roles": { "product_or_project_manager": "Manajer Produk / Proyek", "development_or_engineering": "Pengembangan / Rekayasa", @@ -2353,7 +2365,6 @@ "human_resources": "Sumber Daya Manusia", "other": "Lainnya" }, - "importer": { "github": { "title": "Github", @@ -2364,7 +2375,6 @@ "description": "Impor item kerja dan epik dari proyek dan epik Jira." } }, - "exporter": { "csv": { "title": "CSV", @@ -2393,7 +2403,6 @@ "created": "Dibuat", "subscribed": "Disubscribe" }, - "themes": { "theme_options": { "system_preference": { @@ -2439,20 +2448,22 @@ "manual": "Manual" } }, - "cycle": { "label": "{count, plural, one {Siklus} other {Siklus}}", "no_cycle": "Tidak ada siklus" }, - "module": { "label": "{count, plural, one {Modul} other {Modul}}", "no_module": "Tidak ada modul" }, - "description_versions": { "last_edited_by": "Terakhir disunting oleh", "previously_edited_by": "Sebelumnya disunting oleh", "edited_by": "Disunting oleh" - } -} + }, + "self_hosted_maintenance_message": { + "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane tidak berhasil dimulai. Ini bisa karena satu atau lebih layanan Plane gagal untuk dimulai.", + "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Pilih View Logs dari setup.sh dan log Docker untuk memastikan." + }, + "no_of": "Jumlah {entity}" +} \ No newline at end of file diff --git a/packages/i18n/src/locales/it/accessibility.json b/packages/i18n/src/locales/it/accessibility.json new file mode 100644 index 000000000..16d22bcbc --- /dev/null +++ b/packages/i18n/src/locales/it/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Logo dell'area di lavoro", + "open_workspace_switcher": "Apri selettore area di lavoro", + "open_user_menu": "Apri menu utente", + "open_command_palette": "Apri tavolozza comandi", + "open_extended_sidebar": "Apri barra laterale estesa", + "close_extended_sidebar": "Chiudi barra laterale estesa", + "create_favorites_folder": "Crea cartella preferiti", + "open_folder": "Apri cartella", + "close_folder": "Chiudi cartella", + "open_favorites_menu": "Apri menu preferiti", + "close_favorites_menu": "Chiudi menu preferiti", + "enter_folder_name": "Inserisci nome cartella", + "create_new_project": "Crea nuovo progetto", + "open_projects_menu": "Apri menu progetti", + "close_projects_menu": "Chiudi menu progetti", + "toggle_quick_actions_menu": "Attiva/disattiva menu azioni rapide", + "open_project_menu": "Apri menu progetto", + "close_project_menu": "Chiudi menu progetto", + "collapse_sidebar": "Comprimi barra laterale", + "expand_sidebar": "Espandi barra laterale", + "edition_badge": "Apri modal piani a pagamento" + }, + "auth_forms": { + "clear_email": "Cancella email", + "show_password": "Mostra password", + "hide_password": "Nascondi password", + "close_alert": "Chiudi avviso", + "close_popover": "Chiudi popover" + } + } +} diff --git a/packages/i18n/src/locales/it/editor.json b/packages/i18n/src/locales/it/editor.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/i18n/src/locales/it/editor.json @@ -0,0 +1 @@ +{} diff --git a/packages/i18n/src/locales/it/translations.json b/packages/i18n/src/locales/it/translations.json index 1da86f360..b859ce217 100644 --- a/packages/i18n/src/locales/it/translations.json +++ b/packages/i18n/src/locales/it/translations.json @@ -18,7 +18,6 @@ "pro": "Pro", "upgrade": "Aggiorna" }, - "auth": { "common": { "email": { @@ -317,6 +316,8 @@ "failed_to_remove_project_from_favorites": "Impossibile rimuovere il progetto dai preferiti. Per favore, riprova.", "project_created_successfully": "Progetto creato con successo", "project_created_successfully_description": "Progetto creato con successo. Ora puoi iniziare ad aggiungere elementi di lavoro.", + "project_name_already_taken": "Il nome del progetto è già stato utilizzato.", + "project_identifier_already_taken": "L'identificatore del progetto è già stato utilizzato.", "project_cover_image_alt": "Immagine di copertina del progetto", "name_is_required": "Il nome è obbligatorio", "title_should_be_less_than_255_characters": "Il titolo deve contenere meno di 255 caratteri", @@ -366,12 +367,12 @@ "work_management": "Gestione del lavoro", "projects_and_issues": "Progetti ed elementi di lavoro", "projects_and_issues_description": "Attiva o disattiva queste opzioni per questo progetto.", - "cycles_description": "Definisci i cicli di lavoro per progetto e modifica la frequenza da un periodo all'altro.", - "modules_description": "Raggruppa il lavoro in configurazioni simili a sotto-progetti con i propri responsabili e assegnatari.", - "views_description": "Salva ordinamenti, filtri e opzioni di visualizzazione per dopo o condividili.", - "pages_description": "Scrivi qualsiasi cosa, come faresti normalmente.", - "intake_description": "Rimani aggiornato sugli elementi di lavoro a cui sei iscritto. Abilita questa opzione per ricevere notifiche.", - "time_tracking_description": "Traccia il tempo speso sugli elementi di lavoro e sui progetti.", + "cycles_description": "Definisci il tempo di lavoro per progetto e adatta il periodo secondo necessità. Un ciclo può durare 2 settimane, il successivo 1 settimana.", + "modules_description": "Organizza il lavoro in sotto-progetti con responsabili e assegnatari dedicati.", + "views_description": "Salva ordinamenti, filtri e opzioni di visualizzazione personalizzati o condividili con il tuo team.", + "pages_description": "Crea e modifica contenuti liberi: appunti, documenti, qualsiasi cosa.", + "intake_description": "Consenti ai non membri di segnalare bug, feedback e suggerimenti senza interrompere il tuo flusso di lavoro.", + "time_tracking_description": "Registra il tempo trascorso su elementi di lavoro e progetti.", "work_management_description": "Gestisci il tuo lavoro e i tuoi progetti con facilità.", "documentation": "Documentazione", "message_support": "Contatta il supporto", @@ -501,10 +502,8 @@ "export": "Esporta", "member": "{count, plural, one {# membro} other {# membri}}", "new_password_must_be_different_from_old_password": "La nuova password deve essere diversa dalla password precedente", - "edited": "Modificato", "bot": "Bot", - "project_view": { "sort_by": { "created_at": "Creato il", @@ -512,12 +511,10 @@ "name": "Nome" } }, - "toast": { "success": "Successo!", "error": "Errore!" }, - "links": { "toasts": { "created": { @@ -546,7 +543,6 @@ } } }, - "home": { "empty": { "quickstart_guide": "La tua guida rapida", @@ -614,7 +610,6 @@ "title": "Home", "star_us_on_github": "Metti una stella su GitHub" }, - "link": { "modal": { "url": { @@ -628,7 +623,6 @@ } } }, - "common": { "all": "Tutti", "states": "Stati", @@ -751,7 +745,8 @@ "message": "Qualcosa è andato storto. Per favore, riprova." }, "required": "Questo campo è obbligatorio", - "entity_required": "{entity} è obbligatorio" + "entity_required": "{entity} è obbligatorio", + "restricted_entity": "{entity} è limitato" }, "update_link": "Aggiorna link", "attach": "Allega", @@ -854,6 +849,7 @@ "live": "Live", "change_history": "Cronologia modifiche", "coming_soon": "Prossimamente", + "member": "Membro", "members": "Membri", "you": "Tu", "upgrade_cta": { @@ -869,22 +865,35 @@ "pending": "In sospeso", "invite": "Invita", "view": "Visualizza", - "deactivated_user": "Utente disattivato" + "deactivated_user": "Utente disattivato", + "apply": "Applica", + "applying": "Applicazione", + "users": "Utenti", + "admins": "Amministratori", + "guests": "Ospiti", + "on_track": "In linea", + "off_track": "Fuori rotta", + "at_risk": "A rischio", + "timeline": "Cronologia", + "completion": "Completamento", + "upcoming": "In arrivo", + "completed": "Completato", + "in_progress": "In corso", + "planned": "Pianificato", + "paused": "In pausa", + "no_of": "N. di {entity}" }, - "chart": { "x_axis": "Asse X", "y_axis": "Asse Y", "metric": "Metrica" }, - "form": { "title": { "required": "Il titolo è obbligatorio", "max_length": "Il titolo deve contenere meno di {length} caratteri" } }, - "entity": { "grouping_title": "Raggruppamento di {entity}", "priority": "Priorità di {entity}", @@ -908,7 +917,6 @@ "failed": "Errore nell'aggiunta di {entity}" } }, - "epic": { "all": "Tutti gli Epic", "label": "{count, plural, one {Epic} other {Epic}}", @@ -926,7 +934,6 @@ "required": "Il titolo dell'Epic è obbligatorio." } }, - "issue": { "label": "{count, plural, one {Elemento di lavoro} other {Elementi di lavoro}}", "all": "Tutti gli elementi di lavoro", @@ -1089,11 +1096,12 @@ "select": { "error": "Seleziona almeno un elemento di lavoro", "empty": "Nessun elemento di lavoro selezionato", - "add_selected": "Aggiungi gli elementi di lavoro selezionati" + "add_selected": "Aggiungi gli elementi di lavoro selezionati", + "select_all": "Seleziona tutto", + "deselect_all": "Deseleziona tutto" }, "open_in_full_screen": "Apri l'elemento di lavoro a schermo intero" }, - "attachment": { "error": "Impossibile allegare il file. Riprova a caricarlo.", "only_one_file_allowed": "È possibile caricare un solo file alla volta.", @@ -1101,7 +1109,6 @@ "drag_and_drop": "Trascina e rilascia ovunque per caricare", "delete": "Elimina allegato" }, - "label": { "select": "Seleziona etichetta", "create": { @@ -1111,7 +1118,6 @@ "type": "Digita per aggiungere una nuova etichetta" } }, - "sub_work_item": { "update": { "success": "Sotto-elemento di lavoro aggiornato con successo", @@ -1120,9 +1126,20 @@ "remove": { "success": "Sotto-elemento di lavoro rimosso con successo", "error": "Errore nella rimozione del sotto-elemento di lavoro" + }, + "empty_state": { + "sub_list_filters": { + "title": "Non hai sotto-elementi di lavoro che corrispondono ai filtri che hai applicato.", + "description": "Per vedere tutti i sotto-elementi di lavoro, cancella tutti i filtri applicati.", + "action": "Cancella filtri" + }, + "list_filters": { + "title": "Non hai elementi di lavoro che corrispondono ai filtri che hai applicato.", + "description": "Per vedere tutti gli elementi di lavoro, cancella tutti i filtri applicati.", + "action": "Cancella filtri" + } } }, - "view": { "label": "{count, plural, one {Visualizzazione} other {Visualizzazioni}}", "create": { @@ -1132,7 +1149,6 @@ "label": "Aggiorna visualizzazione" } }, - "inbox_issue": { "status": { "pending": { @@ -1218,7 +1234,6 @@ } } }, - "workspace_creation": { "heading": "Crea il tuo spazio di lavoro", "subheading": "Per iniziare a usare Plane, devi creare o unirti a uno spazio di lavoro.", @@ -1270,7 +1285,6 @@ } } }, - "workspace_dashboard": { "empty_state": { "general": { @@ -1286,7 +1300,6 @@ } } }, - "workspace_analytics": { "label": "Analisi", "page_label": "{workspace} - Analisi", @@ -1318,20 +1331,38 @@ "custom": "Analisi personalizzata" }, "empty_state": { - "general": { - "title": "Traccia il progresso, i carichi di lavoro e le assegnazioni. Individua tendenze, rimuovi gli ostacoli e accelera il lavoro", - "description": "Visualizza l'ambito rispetto alla domanda, le stime e il fenomeno del scope creep. Ottieni le prestazioni dei membri del team e dei team, e assicurati che il tuo progetto rispetti le scadenze.", - "primary_button": { - "text": "Inizia il tuo primo progetto", - "comic": { - "title": "Le analisi funzionano meglio con Cicli + Moduli", - "description": "Prima, definisci i tuoi elementi di lavoro in cicli e, se puoi, raggruppa quelli che si estendono per più di un ciclo in moduli. Dai un'occhiata ad entrambi nel menu di sinistra." - } - } + "customized_insights": { + "description": "Gli elementi di lavoro assegnati a te, suddivisi per stato, verranno visualizzati qui.", + "title": "Nessun dato disponibile" + }, + "created_vs_resolved": { + "description": "Gli elementi di lavoro creati e risolti nel tempo verranno visualizzati qui.", + "title": "Nessun dato disponibile" + }, + "project_insights": { + "title": "Nessun dato disponibile", + "description": "Gli elementi di lavoro assegnati a te, suddivisi per stato, verranno visualizzati qui." } - } + }, + "created_vs_resolved": "Creato vs Risolto", + "customized_insights": "Approfondimenti personalizzati", + "backlog_work_items": "{entity} nel backlog", + "active_projects": "Progetti attivi", + "trend_on_charts": "Tendenza nei grafici", + "all_projects": "Tutti i progetti", + "summary_of_projects": "Riepilogo dei progetti", + "project_insights": "Approfondimenti sul progetto", + "started_work_items": "{entity} iniziati", + "total_work_items": "Totale {entity}", + "total_projects": "Progetti totali", + "total_admins": "Totale amministratori", + "total_users": "Totale utenti", + "total_intake": "Entrate totali", + "un_started_work_items": "{entity} non avviati", + "total_guests": "Totale ospiti", + "completed_work_items": "{entity} completati", + "total": "Totale {entity}" }, - "workspace_projects": { "label": "{count, plural, one {Progetto} other {Progetti}}", "create": { @@ -1406,7 +1437,6 @@ } } }, - "workspace_views": { "add_view": "Aggiungi visualizzazione", "empty_state": { @@ -1441,7 +1471,6 @@ } } }, - "workspace_settings": { "label": "Impostazioni dello spazio di lavoro", "page_label": "{workspace} - Impostazioni generali", @@ -1623,7 +1652,6 @@ } } }, - "profile": { "label": "Profilo", "page_label": "Il tuo lavoro", @@ -1686,7 +1714,6 @@ } } }, - "project_settings": { "general": { "enter_project_id": "Inserisci l'ID del progetto", @@ -1832,7 +1859,6 @@ "auto_close_status": "Stato di chiusura automatica" } }, - "empty_state": { "labels": { "title": "Nessuna etichetta ancora", @@ -1845,7 +1871,6 @@ } } }, - "project_cycles": { "add_cycle": "Aggiungi ciclo", "more_details": "Altri dettagli", @@ -1971,7 +1996,6 @@ } } }, - "project_issues": { "empty_state": { "no_issues": { @@ -2000,7 +2024,6 @@ } } }, - "project_module": { "add_module": "Aggiungi Modulo", "update_module": "Aggiorna Modulo", @@ -2054,7 +2077,6 @@ } } }, - "project_views": { "empty_state": { "general": { @@ -2074,7 +2096,6 @@ } } }, - "project_page": { "empty_state": { "general": { @@ -2104,7 +2125,6 @@ } } }, - "command_k": { "empty_state": { "search": { @@ -2112,7 +2132,6 @@ } } }, - "issue_relation": { "empty_state": { "search": { @@ -2123,7 +2142,6 @@ } } }, - "issue_comment": { "empty_state": { "general": { @@ -2132,7 +2150,6 @@ } } }, - "notification": { "label": "Notifiche", "page_label": "{workspace} - Notifiche", @@ -2189,7 +2206,6 @@ "custom": "Personalizzato" } }, - "active_cycle": { "empty_state": { "progress": { @@ -2209,7 +2225,6 @@ } } }, - "disabled_project": { "empty_state": { "inbox": { @@ -2272,7 +2287,6 @@ } } }, - "stickies": { "title": "I tuoi stickies", "placeholder": "clicca per scrivere qui", @@ -2330,7 +2344,6 @@ } } }, - "role_details": { "guest": { "title": "Ospite", @@ -2345,7 +2358,6 @@ "description": "Tutti i permessi impostati su true all'interno dello spazio di lavoro." } }, - "user_roles": { "product_or_project_manager": "Product / Project Manager", "development_or_engineering": "Sviluppo / Ingegneria", @@ -2358,7 +2370,6 @@ "human_resources": "Risorse umane", "other": "Altro" }, - "importer": { "github": { "title": "Github", @@ -2369,7 +2380,6 @@ "description": "Importa elementi di lavoro ed epic dai progetti e dagli epic di Jira." } }, - "exporter": { "csv": { "title": "CSV", @@ -2398,7 +2408,6 @@ "created": "Creati", "subscribed": "Iscritti" }, - "themes": { "theme_options": { "system_preference": { @@ -2444,20 +2453,21 @@ "manual": "Manuale" } }, - "cycle": { "label": "{count, plural, one {Ciclo} other {Cicli}}", "no_cycle": "Nessun ciclo" }, - "module": { "label": "{count, plural, one {Modulo} other {Moduli}}", "no_module": "Nessun modulo" }, - "description_versions": { "last_edited_by": "Ultima modifica di", "previously_edited_by": "Precedentemente modificato da", "edited_by": "Modificato da" + }, + "self_hosted_maintenance_message": { + "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane non si è avviato. Questo potrebbe essere dovuto al fatto che uno o più servizi Plane non sono riusciti ad avviarsi.", + "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Scegli View Logs da setup.sh e dai log Docker per essere sicuro." } -} +} \ No newline at end of file diff --git a/packages/i18n/src/locales/ja/accessibility.json b/packages/i18n/src/locales/ja/accessibility.json new file mode 100644 index 000000000..b983500ff --- /dev/null +++ b/packages/i18n/src/locales/ja/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "ワークスペースロゴ", + "open_workspace_switcher": "ワークスペーススイッチャーを開く", + "open_user_menu": "ユーザーメニューを開く", + "open_command_palette": "コマンドパレットを開く", + "open_extended_sidebar": "拡張サイドバーを開く", + "close_extended_sidebar": "拡張サイドバーを閉じる", + "create_favorites_folder": "お気に入りフォルダを作成", + "open_folder": "フォルダを開く", + "close_folder": "フォルダを閉じる", + "open_favorites_menu": "お気に入りメニューを開く", + "close_favorites_menu": "お気に入りメニューを閉じる", + "enter_folder_name": "フォルダ名を入力", + "create_new_project": "新しいプロジェクトを作成", + "open_projects_menu": "プロジェクトメニューを開く", + "close_projects_menu": "プロジェクトメニューを閉じる", + "toggle_quick_actions_menu": "クイックアクションメニューの切り替え", + "open_project_menu": "プロジェクトメニューを開く", + "close_project_menu": "プロジェクトメニューを閉じる", + "collapse_sidebar": "サイドバーを折りたたむ", + "expand_sidebar": "サイドバーを展開", + "edition_badge": "有料プランのモーダルを開く" + }, + "auth_forms": { + "clear_email": "メールをクリア", + "show_password": "パスワードを表示", + "hide_password": "パスワードを非表示", + "close_alert": "アラートを閉じる", + "close_popover": "ポップオーバーを閉じる" + } + } +} diff --git a/packages/i18n/src/locales/ja/editor.json b/packages/i18n/src/locales/ja/editor.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/i18n/src/locales/ja/editor.json @@ -0,0 +1 @@ +{} diff --git a/packages/i18n/src/locales/ja/translations.json b/packages/i18n/src/locales/ja/translations.json index f9765692f..4c6f27a6e 100644 --- a/packages/i18n/src/locales/ja/translations.json +++ b/packages/i18n/src/locales/ja/translations.json @@ -18,7 +18,6 @@ "pro": "プロ", "upgrade": "アップグレード" }, - "auth": { "common": { "email": { @@ -168,7 +167,6 @@ } } }, - "submit": "送信", "cancel": "キャンセル", "loading": "読み込み中", @@ -318,6 +316,8 @@ "failed_to_remove_project_from_favorites": "プロジェクトをお気に入りから削除できませんでした。もう一度お試しください。", "project_created_successfully": "プロジェクトが正常に作成されました", "project_created_successfully_description": "プロジェクトが正常に作成されました。作業項目を追加できるようになりました。", + "project_name_already_taken": "プロジェクト名は既に使用されています。", + "project_identifier_already_taken": "プロジェクト識別子は既に使用されています。", "project_cover_image_alt": "プロジェクトのカバー画像", "name_is_required": "名前は必須です", "title_should_be_less_than_255_characters": "タイトルは255文字未満である必要があります", @@ -367,12 +367,12 @@ "work_management": "作業管理", "projects_and_issues": "プロジェクトと作業項目", "projects_and_issues_description": "このプロジェクトでオン/オフを切り替えます。", - "cycles_description": "プロジェクトごとに作業を時間枠で区切り、期間を次の期間に変更します。", - "modules_description": "サブプロジェクトのような設定で、独自のリーダーと担当者を持つグループ作業を行います。", - "views_description": "並び替え、フィルター、表示オプションを後で使用するために保存するか、共有します。", - "pages_description": "何でも書けるように何でも書きます。", - "intake_description": "購読している作業項目の最新情報を受け取ります。通知を受け取るには有効にしてください。", - "time_tracking_description": "作業項目とプロジェクトの作業時間を追跡します。", + "cycles_description": "プロジェクトごとに作業の時間枠を設定し、必要に応じて期間を調整します。1サイクルは2週間、次は1週間でもかまいません。", + "modules_description": "専任のリーダーと担当者を持つサブプロジェクトに作業を整理します。", + "views_description": "カスタムの並び替え、フィルター、表示オプションを保存するか、チームと共有します。", + "pages_description": "自由形式のコンテンツを作成・編集できます。メモ、ドキュメント、何でもOKです。", + "intake_description": "非メンバーがバグ、フィードバック、提案を共有できるようにし、ワークフローを妨げないようにします。", + "time_tracking_description": "作業項目やプロジェクトに費やした時間を記録します。", "work_management_description": "作業とプロジェクトを簡単に管理します。", "documentation": "ドキュメント", "message_support": "サポートにメッセージ", @@ -504,7 +504,6 @@ "new_password_must_be_different_from_old_password": "新しいパスワードは古いパスワードと異なる必要があります", "edited": "編集済み", "bot": "ボット", - "project_view": { "sort_by": { "created_at": "作成日時", @@ -512,12 +511,10 @@ "name": "名前" } }, - "toast": { "success": "成功!", "error": "エラー!" }, - "links": { "toasts": { "created": { @@ -546,7 +543,6 @@ } } }, - "home": { "empty": { "quickstart_guide": "クイックスタートガイド", @@ -614,7 +610,6 @@ "title": "ホーム", "star_us_on_github": "GitHubでスターをつける" }, - "link": { "modal": { "url": { @@ -628,7 +623,6 @@ } } }, - "common": { "all": "すべて", "states": "ステータス", @@ -756,7 +750,8 @@ "message": "問題が発生しました。もう一度お試しください。" }, "required": "この項目は必須です", - "entity_required": "{entity}は必須です" + "entity_required": "{entity}は必須です", + "restricted_entity": "{entity} は制限されています" }, "update_link": "リンクを更新", "attach": "添付", @@ -856,6 +851,7 @@ "live": "ライブ", "change_history": "変更履歴", "coming_soon": "近日公開", + "member": "メンバー", "members": "メンバー", "you": "あなた", "upgrade_cta": { @@ -871,22 +867,35 @@ "pending": "保留中", "invite": "招待", "view": "ビュー", - "deactivated_user": "無効化されたユーザー" + "deactivated_user": "無効化されたユーザー", + "apply": "適用", + "applying": "適用中", + "users": "ユーザー", + "admins": "管理者", + "guests": "ゲスト", + "on_track": "順調", + "off_track": "遅れ", + "at_risk": "リスクあり", + "timeline": "タイムライン", + "completion": "完了", + "upcoming": "今後の予定", + "completed": "完了", + "in_progress": "進行中", + "planned": "計画済み", + "paused": "一時停止", + "no_of": "{entity} の数" }, - "chart": { "x_axis": "エックス アクシス", "y_axis": "ワイ アクシス", "metric": "メトリック" }, - "form": { "title": { "required": "タイトルは必須です", "max_length": "タイトルは{length}文字未満である必要があります" } }, - "entity": { "grouping_title": "{entity}のグループ化", "priority": "{entity}の優先度", @@ -910,7 +919,6 @@ "failed": "{entity}の追加中にエラーが発生しました" } }, - "epic": { "all": "すべてのエピック", "label": "{count, plural, one {エピック} other {エピック}}", @@ -928,7 +936,6 @@ "required": "エピックのタイトルは必須です。" } }, - "issue": { "label": "{count, plural, one {作業項目} other {作業項目}}", "all": "すべての作業項目", @@ -1091,11 +1098,12 @@ "select": { "error": "少なくとも1つの作業項目を選択してください", "empty": "作業項目が選択されていません", - "add_selected": "選択した作業項目を追加" + "add_selected": "選択した作業項目を追加", + "select_all": "すべて選択", + "deselect_all": "すべての選択を解除" }, "open_in_full_screen": "作業項目をフルスクリーンで開く" }, - "attachment": { "error": "ファイルを添付できませんでした。もう一度アップロードしてください。", "only_one_file_allowed": "一度にアップロードできるファイルは1つだけです。", @@ -1103,7 +1111,6 @@ "drag_and_drop": "どこにでもドラッグ&ドロップでアップロード", "delete": "添付ファイルを削除" }, - "label": { "select": "ラベルを選択", "create": { @@ -1113,7 +1120,6 @@ "type": "新しいラベルを追加するには入力してください" } }, - "sub_work_item": { "update": { "success": "サブ作業項目を更新しました", @@ -1122,9 +1128,20 @@ "remove": { "success": "サブ作業項目を削除しました", "error": "サブ作業項目の削除中にエラーが発生しました" + }, + "empty_state": { + "sub_list_filters": { + "title": "適用されたフィルターに一致するサブ作業項目がありません。", + "description": "すべてのサブ作業項目を表示するには、すべての適用されたフィルターをクリアしてください。", + "action": "フィルターをクリア" + }, + "list_filters": { + "title": "適用されたフィルターに一致する作業項目がありません。", + "description": "すべての作業項目を表示するには、すべての適用されたフィルターをクリアしてください。", + "action": "フィルターをクリア" + } } }, - "view": { "label": "{count, plural, one {ビュー} other {ビュー}}", "create": { @@ -1134,7 +1151,6 @@ "label": "ビューを更新" } }, - "inbox_issue": { "status": { "pending": { @@ -1220,7 +1236,6 @@ } } }, - "workspace_creation": { "heading": "ワークスペースを作成", "subheading": "Planeを使用するには、ワークスペースを作成するか参加する必要があります。", @@ -1272,7 +1287,6 @@ } } }, - "workspace_dashboard": { "empty_state": { "general": { @@ -1288,7 +1302,6 @@ } } }, - "workspace_analytics": { "label": "アナリティクス", "page_label": "{workspace} - アナリティクス", @@ -1320,20 +1333,38 @@ "custom": "カスタムアナリティクス" }, "empty_state": { - "general": { - "title": "進捗、ワークロード、割り当てを追跡。傾向を把握し、ブロッカーを解消して、作業をより速く進めましょう", - "description": "スコープと需要、見積もり、スコープクリープを確認できます。チームメンバーとチームのパフォーマンスを把握し、プロジェクトが予定通りに進むようにします。", - "primary_button": { - "text": "最初のプロジェクトを開始", - "comic": { - "title": "アナリティクスはサイクル + モジュールで最も効果を発揮", - "description": "まず、作業項目をサイクルでタイムボックス化し、可能であれば、複数のサイクルにまたがる作業項目をモジュールにグループ化します。左のナビゲーションで両方を確認してください。" - } - } + "customized_insights": { + "description": "あなたに割り当てられた作業項目は、ステータスごとに分類されてここに表示されます。", + "title": "まだデータがありません" + }, + "created_vs_resolved": { + "description": "時間の経過とともに作成および解決された作業項目がここに表示されます。", + "title": "まだデータがありません" + }, + "project_insights": { + "title": "まだデータがありません", + "description": "あなたに割り当てられた作業項目は、ステータスごとに分類されてここに表示されます。" } - } + }, + "created_vs_resolved": "作成 vs 解決", + "customized_insights": "カスタマイズされたインサイト", + "backlog_work_items": "バックログの{entity}", + "active_projects": "アクティブなプロジェクト", + "trend_on_charts": "グラフの傾向", + "all_projects": "すべてのプロジェクト", + "summary_of_projects": "プロジェクトの概要", + "project_insights": "プロジェクトのインサイト", + "started_work_items": "開始された{entity}", + "total_work_items": "{entity}の合計", + "total_projects": "プロジェクト合計", + "total_admins": "管理者の合計", + "total_users": "ユーザー総数", + "total_intake": "総収入", + "un_started_work_items": "未開始の{entity}", + "total_guests": "ゲストの合計", + "completed_work_items": "完了した{entity}", + "total": "{entity}の合計" }, - "workspace_projects": { "label": "{count, plural, one {プロジェクト} other {プロジェクト}}", "create": { @@ -1407,7 +1438,6 @@ } } }, - "workspace_views": { "add_view": "ビューを追加", "empty_state": { @@ -1442,7 +1472,6 @@ } } }, - "workspace_settings": { "label": "ワークスペース設定", "page_label": "{workspace} - 一般設定", @@ -1624,7 +1653,6 @@ } } }, - "profile": { "label": "プロフィール", "page_label": "あなたの作業", @@ -1687,7 +1715,6 @@ } } }, - "project_settings": { "general": { "enter_project_id": "プロジェクトIDを入力", @@ -1833,7 +1860,6 @@ "auto_close_status": "自動クローズステータス" } }, - "empty_state": { "labels": { "title": "ラベルがまだありません", @@ -1846,7 +1872,6 @@ } } }, - "project_cycles": { "add_cycle": "サイクルを追加", "more_details": "詳細情報", @@ -1972,7 +1997,6 @@ } } }, - "project_issues": { "empty_state": { "no_issues": { @@ -2001,7 +2025,6 @@ } } }, - "project_module": { "add_module": "モジュールを追加", "update_module": "モジュールを更新", @@ -2055,7 +2078,6 @@ } } }, - "project_views": { "empty_state": { "general": { @@ -2075,7 +2097,6 @@ } } }, - "project_page": { "empty_state": { "general": { @@ -2105,7 +2126,6 @@ } } }, - "command_k": { "empty_state": { "search": { @@ -2113,7 +2133,6 @@ } } }, - "issue_relation": { "empty_state": { "search": { @@ -2124,7 +2143,6 @@ } } }, - "issue_comment": { "empty_state": { "general": { @@ -2133,7 +2151,6 @@ } } }, - "notification": { "label": "受信トレイ", "page_label": "{workspace} - 受信トレイ", @@ -2190,7 +2207,6 @@ "custom": "カスタム" } }, - "active_cycle": { "empty_state": { "progress": { @@ -2210,7 +2226,6 @@ } } }, - "disabled_project": { "empty_state": { "inbox": { @@ -2273,7 +2288,6 @@ } } }, - "stickies": { "title": "あなたの付箋", "placeholder": "ここをクリックして入力", @@ -2331,7 +2345,6 @@ } } }, - "role_details": { "guest": { "title": "ゲスト", @@ -2346,7 +2359,6 @@ "description": "ワークスペース内のすべての権限が有効。" } }, - "user_roles": { "product_or_project_manager": "プロダクト/プロジェクトマネージャー", "development_or_engineering": "開発/エンジニアリング", @@ -2359,7 +2371,6 @@ "human_resources": "人事", "other": "その他" }, - "importer": { "github": { "title": "GitHub", @@ -2370,7 +2381,6 @@ "description": "Jiraプロジェクトとエピックから作業項目とエピックをインポートします。" } }, - "exporter": { "csv": { "title": "CSV", @@ -2399,7 +2409,6 @@ "created": "作成済み", "subscribed": "購読中" }, - "themes": { "theme_options": { "system_preference": { @@ -2445,20 +2454,21 @@ "manual": "手動" } }, - "cycle": { "label": "{count, plural, one {サイクル} other {サイクル}}", "no_cycle": "サイクルなし" }, - "module": { "label": "{count, plural, one {モジュール} other {モジュール}}", "no_module": "モジュールなし" }, - "description_versions": { "last_edited_by": "最終編集者", "previously_edited_by": "以前の編集者", "edited_by": "編集者" + }, + "self_hosted_maintenance_message": { + "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Planeが起動しませんでした。これは1つまたは複数のPlaneサービスの起動に失敗したことが原因である可能性があります。", + "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "setup.shとDockerログからView Logsを選択して確認してください。" } -} +} \ No newline at end of file diff --git a/packages/i18n/src/locales/ko/accessibility.json b/packages/i18n/src/locales/ko/accessibility.json new file mode 100644 index 000000000..298a7e122 --- /dev/null +++ b/packages/i18n/src/locales/ko/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "워크스페이스 로고", + "open_workspace_switcher": "워크스페이스 전환기 열기", + "open_user_menu": "사용자 메뉴 열기", + "open_command_palette": "명령 팔레트 열기", + "open_extended_sidebar": "확장된 사이드바 열기", + "close_extended_sidebar": "확장된 사이드바 닫기", + "create_favorites_folder": "즐겨찾기 폴더 생성", + "open_folder": "폴더 열기", + "close_folder": "폴더 닫기", + "open_favorites_menu": "즐겨찾기 메뉴 열기", + "close_favorites_menu": "즐겨찾기 메뉴 닫기", + "enter_folder_name": "폴더 이름 입력", + "create_new_project": "새 프로젝트 생성", + "open_projects_menu": "프로젝트 메뉴 열기", + "close_projects_menu": "프로젝트 메뉴 닫기", + "toggle_quick_actions_menu": "빠른 작업 메뉴 토글", + "open_project_menu": "프로젝트 메뉴 열기", + "close_project_menu": "프로젝트 메뉴 닫기", + "collapse_sidebar": "사이드바 축소", + "expand_sidebar": "사이드바 확장", + "edition_badge": "유료 플랜 모달 열기" + }, + "auth_forms": { + "clear_email": "이메일 지우기", + "show_password": "비밀번호 표시", + "hide_password": "비밀번호 숨기기", + "close_alert": "알림 닫기", + "close_popover": "팝오버 닫기" + } + } +} diff --git a/packages/i18n/src/locales/ko/editor.json b/packages/i18n/src/locales/ko/editor.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/i18n/src/locales/ko/editor.json @@ -0,0 +1 @@ +{} diff --git a/packages/i18n/src/locales/ko/translations.json b/packages/i18n/src/locales/ko/translations.json index f1b97d3ae..ee1f61adc 100644 --- a/packages/i18n/src/locales/ko/translations.json +++ b/packages/i18n/src/locales/ko/translations.json @@ -18,7 +18,6 @@ "pro": "프로", "upgrade": "업그레이드" }, - "auth": { "common": { "email": { @@ -168,7 +167,6 @@ } } }, - "submit": "제출", "cancel": "취소", "loading": "로딩 중", @@ -318,6 +316,8 @@ "failed_to_remove_project_from_favorites": "프로젝트를 즐겨찾기에서 제거하지 못했습니다. 다시 시도해주세요.", "project_created_successfully": "프로젝트가 성공적으로 생성되었습니다", "project_created_successfully_description": "프로젝트가 성공적으로 생성되었습니다. 이제 작업 항목을 추가할 수 있습니다.", + "project_name_already_taken": "프로젝트 이름이 이미 사용 중입니다.", + "project_identifier_already_taken": "프로젝트 식별자가 이미 사용 중입니다.", "project_cover_image_alt": "프로젝트 커버 이미지", "name_is_required": "이름이 필요합니다", "title_should_be_less_than_255_characters": "제목은 255자 미만이어야 합니다", @@ -367,12 +367,12 @@ "work_management": "작업 관리", "projects_and_issues": "프로젝트 및 작업 항목", "projects_and_issues_description": "이 프로젝트에서 이들을 켜거나 끕니다.", - "cycles_description": "프로젝트별로 작업을 시간 상자로 나누고, 한 기간에서 다음 기간으로 빈도를 변경합니다.", - "modules_description": "작업을 하위 프로젝트와 같은 설정으로 그룹화하고, 각 설정에 리드와 담당자를 지정합니다.", - "views_description": "정렬, 필터 및 표시 옵션을 나중에 저장하거나 공유합니다.", - "pages_description": "무엇이든 작성하세요.", - "intake_description": "구독한 작업 항목에 대한 최신 정보를 유지하세요. 알림을 받으려면 이 기능을 활성화하세요.", - "time_tracking_description": "작업 항목 및 프로젝트에 소요된 시간을 추적합니다.", + "cycles_description": "프로젝트별로 작업 시간을 설정하고 필요에 따라 기간을 조정하세요. 한 주기는 2주일일 수 있고, 다음은 1주일일 수 있습니다.", + "modules_description": "작업을 전담 리더와 담당자가 있는 하위 프로젝트로 구성하세요.", + "views_description": "사용자 정의 정렬, 필터 및 표시 옵션을 저장하거나 팀과 공유하세요.", + "pages_description": "자유 형식의 콘텐츠를 작성하고 편집하세요. 메모, 문서, 무엇이든 가능합니다.", + "intake_description": "비회원이 버그, 피드백, 제안을 공유할 수 있도록 하되, 워크플로우를 방해하지 않도록 합니다.", + "time_tracking_description": "작업 항목 및 프로젝트에 소요된 시간을 기록하세요.", "work_management_description": "작업 및 프로젝트를 쉽게 관리합니다.", "documentation": "문서", "message_support": "지원 메시지", @@ -504,7 +504,6 @@ "new_password_must_be_different_from_old_password": "새 비밀번호는 이전 비밀번호와 다르게 설정해야 합니다", "edited": "수정됨", "bot": "봇", - "project_view": { "sort_by": { "created_at": "생성일", @@ -512,12 +511,10 @@ "name": "이름" } }, - "toast": { "success": "성공!", "error": "오류!" }, - "links": { "toasts": { "created": { @@ -546,7 +543,6 @@ } } }, - "home": { "empty": { "quickstart_guide": "빠른 시작 가이드", @@ -614,7 +610,6 @@ "title": "홈", "star_us_on_github": "GitHub에서 별표" }, - "link": { "modal": { "url": { @@ -628,7 +623,6 @@ } } }, - "common": { "all": "모두", "states": "상태", @@ -756,7 +750,8 @@ "message": "문제가 발생했습니다. 다시 시도해주세요." }, "required": "이 필드는 필수입니다", - "entity_required": "{entity}가 필요합니다" + "entity_required": "{entity}가 필요합니다", + "restricted_entity": "{entity}은(는) 제한되어 있습니다" }, "update_link": "링크 업데이트", "attach": "첨부", @@ -857,6 +852,7 @@ "live": "라이브", "change_history": "변경 기록", "coming_soon": "곧 출시", + "member": "멤버", "members": "멤버", "you": "나", "upgrade_cta": { @@ -872,22 +868,35 @@ "pending": "보류 중", "invite": "초대", "view": "보기", - "deactivated_user": "비활성화된 사용자" + "deactivated_user": "비활성화된 사용자", + "apply": "적용", + "applying": "적용 중", + "users": "사용자", + "admins": "관리자", + "guests": "게스트", + "on_track": "계획대로 진행 중", + "off_track": "계획 이탈", + "at_risk": "위험", + "timeline": "타임라인", + "completion": "완료", + "upcoming": "예정된", + "completed": "완료됨", + "in_progress": "진행 중", + "planned": "계획된", + "paused": "일시 중지됨", + "no_of": "{entity} 수" }, - "chart": { "x_axis": "X축", "y_axis": "Y축", "metric": "메트릭" }, - "form": { "title": { "required": "제목이 필요합니다", "max_length": "제목은 {length}자 미만이어야 합니다" } }, - "entity": { "grouping_title": "{entity} 그룹화", "priority": "{entity} 우선순위", @@ -911,7 +920,6 @@ "failed": "{entity} 추가 중 오류 발생" } }, - "epic": { "all": "모든 에픽", "label": "{count, plural, one {에픽} other {에픽}}", @@ -929,7 +937,6 @@ "required": "에픽 제목이 필요합니다." } }, - "issue": { "label": "{count, plural, one {작업 항목} other {작업 항목}}", "all": "모든 작업 항목", @@ -1092,11 +1099,12 @@ "select": { "error": "최소 하나의 작업 항목을 선택하세요", "empty": "선택된 작업 항목 없음", - "add_selected": "선택된 작업 항목 추가" + "add_selected": "선택된 작업 항목 추가", + "select_all": "모두 선택", + "deselect_all": "모두 선택 해제" }, "open_in_full_screen": "작업 항목을 전체 화면으로 열기" }, - "attachment": { "error": "파일을 첨부할 수 없습니다. 다시 업로드하세요.", "only_one_file_allowed": "한 번에 하나의 파일만 업로드할 수 있습니다.", @@ -1104,7 +1112,6 @@ "drag_and_drop": "업로드하려면 아무 곳에나 드래그 앤 드롭하세요", "delete": "첨부 파일 삭제" }, - "label": { "select": "레이블 선택", "create": { @@ -1114,7 +1121,6 @@ "type": "새 레이블을 추가하려면 입력하세요" } }, - "sub_work_item": { "update": { "success": "하위 작업 항목이 성공적으로 업데이트되었습니다", @@ -1123,9 +1129,20 @@ "remove": { "success": "하위 작업 항목이 성공적으로 제거되었습니다", "error": "하위 작업 항목 제거 중 오류 발생" + }, + "empty_state": { + "sub_list_filters": { + "title": "적용된 필터에 일치하는 하위 작업 항목이 없습니다.", + "description": "모든 하위 작업 항목을 보려면 모든 적용된 필터를 지우세요.", + "action": "필터 지우기" + }, + "list_filters": { + "title": "적용된 필터에 일치하는 작업 항목이 없습니다.", + "description": "모든 작업 항목을 보려면 모든 적용된 필터를 지우세요.", + "action": "필터 지우기" + } } }, - "view": { "label": "{count, plural, one {뷰} other {뷰}}", "create": { @@ -1135,7 +1152,6 @@ "label": "뷰 업데이트" } }, - "inbox_issue": { "status": { "pending": { @@ -1221,7 +1237,6 @@ } } }, - "workspace_creation": { "heading": "작업 공간 생성", "subheading": "Plane을 사용하려면 작업 공간을 생성하거나 참여해야 합니다.", @@ -1273,7 +1288,6 @@ } } }, - "workspace_dashboard": { "empty_state": { "general": { @@ -1289,7 +1303,6 @@ } } }, - "workspace_analytics": { "label": "분석", "page_label": "{workspace} - 분석", @@ -1321,20 +1334,38 @@ "custom": "맞춤형 분석" }, "empty_state": { - "general": { - "title": "진행 상황, 작업량 및 할당을 추적하세요. 트렌드를 파악하고, 차단 요소를 제거하며, 작업을 더 빠르게 진행하세요", - "description": "범위 대 수요, 추정치 및 범위 크리프를 확인하세요. 팀원과 팀의 성과를 확인하고 프로젝트가 제시간에 진행되도록 하세요.", - "primary_button": { - "text": "첫 번째 프로젝트 시작", - "comic": { - "title": "분석은 주기 + 모듈과 함께 작동합니다", - "description": "먼저 작업 항목을 주기로 시간 상자화하고, 주기를 초과하는 작업 항목을 모듈로 그룹화하세요. 왼쪽 탐색에서 둘 다 확인하세요." - } - } + "customized_insights": { + "description": "귀하에게 할당된 작업 항목이 상태별로 나누어 여기에 표시됩니다.", + "title": "아직 데이터가 없습니다" + }, + "created_vs_resolved": { + "description": "시간이 지나면서 생성되고 해결된 작업 항목이 여기에 표시됩니다.", + "title": "아직 데이터가 없습니다" + }, + "project_insights": { + "title": "아직 데이터가 없습니다", + "description": "귀하에게 할당된 작업 항목이 상태별로 나누어 여기에 표시됩니다." } - } + }, + "created_vs_resolved": "생성됨 vs 해결됨", + "customized_insights": "맞춤형 인사이트", + "backlog_work_items": "백로그 {entity}", + "active_projects": "활성 프로젝트", + "trend_on_charts": "차트의 추세", + "all_projects": "모든 프로젝트", + "summary_of_projects": "프로젝트 요약", + "project_insights": "프로젝트 인사이트", + "started_work_items": "시작된 {entity}", + "total_work_items": "총 {entity}", + "total_projects": "총 프로젝트 수", + "total_admins": "총 관리자 수", + "total_users": "총 사용자 수", + "total_intake": "총 수입", + "un_started_work_items": "시작되지 않은 {entity}", + "total_guests": "총 게스트 수", + "completed_work_items": "완료된 {entity}", + "total": "총 {entity}" }, - "workspace_projects": { "label": "{count, plural, one {프로젝트} other {프로젝트}}", "create": { @@ -1409,7 +1440,6 @@ } } }, - "workspace_views": { "add_view": "뷰 추가", "empty_state": { @@ -1444,7 +1474,6 @@ } } }, - "workspace_settings": { "label": "작업 공간 설정", "page_label": "{workspace} - 일반 설정", @@ -1626,7 +1655,6 @@ } } }, - "profile": { "label": "프로필", "page_label": "나의 작업", @@ -1689,7 +1717,6 @@ } } }, - "project_settings": { "general": { "enter_project_id": "프로젝트 ID 입력", @@ -1835,7 +1862,6 @@ "auto_close_status": "자동 닫기 상태" } }, - "empty_state": { "labels": { "title": "레이블 없음", @@ -1848,7 +1874,6 @@ } } }, - "project_cycles": { "add_cycle": "주기 추가", "more_details": "자세히 보기", @@ -1974,7 +1999,6 @@ } } }, - "project_issues": { "empty_state": { "no_issues": { @@ -2003,7 +2027,6 @@ } } }, - "project_module": { "add_module": "모듈 추가", "update_module": "모듈 업데이트", @@ -2057,7 +2080,6 @@ } } }, - "project_views": { "empty_state": { "general": { @@ -2077,7 +2099,6 @@ } } }, - "project_page": { "empty_state": { "general": { @@ -2107,7 +2128,6 @@ } } }, - "command_k": { "empty_state": { "search": { @@ -2115,7 +2135,6 @@ } } }, - "issue_relation": { "empty_state": { "search": { @@ -2126,7 +2145,6 @@ } } }, - "issue_comment": { "empty_state": { "general": { @@ -2135,7 +2153,6 @@ } } }, - "notification": { "label": "받은 편지함", "page_label": "{workspace} - 받은 편지함", @@ -2192,7 +2209,6 @@ "custom": "사용자 정의" } }, - "active_cycle": { "empty_state": { "progress": { @@ -2212,7 +2228,6 @@ } } }, - "disabled_project": { "empty_state": { "inbox": { @@ -2275,7 +2290,6 @@ } } }, - "stickies": { "title": "나의 스티키", "placeholder": "여기에 입력하려면 클릭하세요", @@ -2333,7 +2347,6 @@ } } }, - "role_details": { "guest": { "title": "게스트", @@ -2348,7 +2361,6 @@ "description": "작업 공간 내에서 모든 권한이 true로 설정됨." } }, - "user_roles": { "product_or_project_manager": "제품 / 프로젝트 관리자", "development_or_engineering": "개발 / 엔지니어링", @@ -2361,7 +2373,6 @@ "human_resources": "인사 / 자원", "other": "기타" }, - "importer": { "github": { "title": "Github", @@ -2372,7 +2383,6 @@ "description": "Jira 프로젝트 및 에픽에서 작업 항목과 에픽을 가져옵니다." } }, - "exporter": { "csv": { "title": "CSV", @@ -2401,7 +2411,6 @@ "created": "생성됨", "subscribed": "구독됨" }, - "themes": { "theme_options": { "system_preference": { @@ -2447,20 +2456,21 @@ "manual": "수동" } }, - "cycle": { "label": "{count, plural, one {주기} other {주기}}", "no_cycle": "주기 없음" }, - "module": { "label": "{count, plural, one {모듈} other {모듈}}", "no_module": "모듈 없음" }, - "description_versions": { "last_edited_by": "마지막 편집자", "previously_edited_by": "이전 편집자", "edited_by": "편집자" + }, + "self_hosted_maintenance_message": { + "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane이 시작되지 않았습니다. 이는 하나 이상의 Plane 서비스가 시작에 실패했기 때문일 수 있습니다.", + "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "확실히 하려면 setup.sh와 Docker 로그에서 View Logs를 선택하세요." } -} +} \ No newline at end of file diff --git a/packages/i18n/src/locales/pl/accessibility.json b/packages/i18n/src/locales/pl/accessibility.json new file mode 100644 index 000000000..c1407911a --- /dev/null +++ b/packages/i18n/src/locales/pl/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Logo obszaru roboczego", + "open_workspace_switcher": "Otwórz przełącznik obszaru roboczego", + "open_user_menu": "Otwórz menu użytkownika", + "open_command_palette": "Otwórz paletę poleceń", + "open_extended_sidebar": "Otwórz rozszerzoną pasek boczny", + "close_extended_sidebar": "Zamknij rozszerzoną pasek boczny", + "create_favorites_folder": "Utwórz folder ulubionych", + "open_folder": "Otwórz folder", + "close_folder": "Zamknij folder", + "open_favorites_menu": "Otwórz menu ulubionych", + "close_favorites_menu": "Zamknij menu ulubionych", + "enter_folder_name": "Wprowadź nazwę folderu", + "create_new_project": "Utwórz nowy projekt", + "open_projects_menu": "Otwórz menu projektów", + "close_projects_menu": "Zamknij menu projektów", + "toggle_quick_actions_menu": "Przełącz menu szybkich akcji", + "open_project_menu": "Otwórz menu projektu", + "close_project_menu": "Zamknij menu projektu", + "collapse_sidebar": "Zwiń pasek boczny", + "expand_sidebar": "Rozwiń pasek boczny", + "edition_badge": "Otwórz modal płatnych planów" + }, + "auth_forms": { + "clear_email": "Wyczyść e-mail", + "show_password": "Pokaż hasło", + "hide_password": "Ukryj hasło", + "close_alert": "Zamknij alert", + "close_popover": "Zamknij popover" + } + } +} diff --git a/packages/i18n/src/locales/pl/editor.json b/packages/i18n/src/locales/pl/editor.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/i18n/src/locales/pl/editor.json @@ -0,0 +1 @@ +{} diff --git a/packages/i18n/src/locales/pl/translations.json b/packages/i18n/src/locales/pl/translations.json index 5e004ee63..b26e6e2f4 100644 --- a/packages/i18n/src/locales/pl/translations.json +++ b/packages/i18n/src/locales/pl/translations.json @@ -316,6 +316,8 @@ "failed_to_remove_project_from_favorites": "Nie udało się usunąć projektu z ulubionych. Spróbuj ponownie.", "project_created_successfully": "Projekt utworzono pomyślnie", "project_created_successfully_description": "Projekt został pomyślnie utworzony. Teraz możesz dodawać elementy pracy.", + "project_name_already_taken": "Nazwa projektu jest już zajęta.", + "project_identifier_already_taken": "Identyfikator projektu jest już zajęty.", "project_cover_image_alt": "Obraz w tle projektu", "name_is_required": "Nazwa jest wymagana", "title_should_be_less_than_255_characters": "Nazwa musi mieć mniej niż 255 znaków", @@ -365,12 +367,12 @@ "work_management": "Zarządzanie pracą", "projects_and_issues": "Projekty i elementy pracy", "projects_and_issues_description": "Włączaj lub wyłączaj te funkcje w projekcie.", - "cycles_description": "Ograniczaj pracę w czasie, zmieniaj częstotliwość pomiędzy okresami.", - "modules_description": "Grupuj pracę w podobne podprojekty z własnymi leaderami i przypisanymi osobami.", - "views_description": "Zapisuj sortowanie, filtry i opcje wyświetlania do późniejszego użytku lub udostępniania.", - "pages_description": "Pisz dowolne treści w standardowej formie.", - "intake_description": "Śledź zgłoszenia, aby mieć wgląd w nowe zadania i prośby.", - "time_tracking_description": "Rejestruj czas spędzony na poszczególnych elementach pracy i projektach.", + "cycles_description": "Określ ramy czasowe pracy dla każdego projektu i dostosuj okres w razie potrzeby. Jeden cykl może trwać 2 tygodnie, a następny 1 tydzień.", + "modules_description": "Organizuj pracę w podprojekty z dedykowanymi liderami i przypisanymi osobami.", + "views_description": "Zapisz niestandardowe sortowania, filtry i opcje wyświetlania lub udostępnij je zespołowi.", + "pages_description": "Twórz i edytuj treści o swobodnej formie – notatki, dokumenty, cokolwiek.", + "intake_description": "Pozwól osobom spoza zespołu zgłaszać błędy, opinie i sugestie bez zakłócania przepływu pracy.", + "time_tracking_description": "Rejestruj czas spędzony na elementach pracy i projektach.", "work_management_description": "Łatwo zarządzaj swoją pracą i projektami.", "documentation": "Dokumentacja", "message_support": "Skontaktuj się z pomocą", @@ -500,10 +502,8 @@ "export": "Eksportuj", "member": "{count, plural, one{# członek} few{# członkowie} other{# członków}}", "new_password_must_be_different_from_old_password": "Nowe hasło musi być innym niż stare hasło", - "edited": "Edytowano", "bot": "Bot", - "project_view": { "sort_by": { "created_at": "Utworzono dnia", @@ -750,7 +750,8 @@ "message": "Coś poszło nie tak. Spróbuj ponownie." }, "required": "To pole jest wymagane", - "entity_required": "{entity} jest wymagane" + "entity_required": "{entity} jest wymagane", + "restricted_entity": "{entity} jest ograniczony" }, "update_link": "Zaktualizuj link", "attach": "Dołącz", @@ -851,6 +852,7 @@ "live": "Na żywo", "change_history": "Historia zmian", "coming_soon": "Wkrótce", + "member": "Członek", "members": "Członkowie", "you": "Ty", "upgrade_cta": { @@ -866,7 +868,23 @@ "pending": "Oczekujące", "invite": "Zaproś", "view": "Widok", - "deactivated_user": "Dezaktywowany użytkownik" + "deactivated_user": "Dezaktywowany użytkownik", + "apply": "Zastosuj", + "applying": "Zastosowanie", + "users": "Użytkownicy", + "admins": "Administratorzy", + "guests": "Goście", + "on_track": "Na dobrej drodze", + "off_track": "Poza planem", + "at_risk": "W zagrożeniu", + "timeline": "Oś czasu", + "completion": "Zakończenie", + "upcoming": "Nadchodzące", + "completed": "Zakończone", + "in_progress": "W trakcie", + "planned": "Zaplanowane", + "paused": "Wstrzymane", + "no_of": "Liczba {entity}" }, "chart": { "x_axis": "Oś X", @@ -1081,7 +1099,9 @@ "select": { "error": "Wybierz co najmniej jeden element pracy", "empty": "Nie wybrano żadnych elementów pracy", - "add_selected": "Dodaj wybrane elementy pracy" + "add_selected": "Dodaj wybrane elementy pracy", + "select_all": "Wybierz wszystko", + "deselect_all": "Odznacz wszystko" }, "open_in_full_screen": "Otwórz element pracy na pełnym ekranie" }, @@ -1109,6 +1129,18 @@ "remove": { "success": "Podrzędny element pracy usunięto pomyślnie", "error": "Błąd podczas usuwania elementu podrzędnego" + }, + "empty_state": { + "sub_list_filters": { + "title": "Nie masz elementów podrzędnych, które pasują do filtrów, które zastosowałeś.", + "description": "Aby zobaczyć wszystkie elementy podrzędne, wyczyść wszystkie zastosowane filtry.", + "action": "Wyczyść filtry" + }, + "list_filters": { + "title": "Nie masz elementów pracy, które pasują do filtrów, które zastosowałeś.", + "description": "Aby zobaczyć wszystkie elementy pracy, wyczyść wszystkie zastosowane filtry.", + "action": "Wyczyść filtry" + } } }, "view": { @@ -1302,18 +1334,37 @@ "custom": "Analizy niestandardowe" }, "empty_state": { - "general": { - "title": "Śledź postępy, obciążenie i alokacje. Identyfikuj trendy, usuwaj przeszkody i przyspieszaj pracę", - "description": "Obserwuj zakres vs. zapotrzebowanie, szacunki i zakres. Sprawdzaj wydajność członków i zespołów, upewnij się, że projekty kończą się na czas.", - "primary_button": { - "text": "Zacznij pierwszy projekt", - "comic": { - "title": "Analizy najlepiej działają z Cyklem + Modułami", - "description": "Najpierw ogranicz pracę w cyklach i grupuj zadania w modułach obejmujących wiele cykli. Znajdziesz je w menu po lewej." - } - } + "customized_insights": { + "description": "Przypisane do Ciebie elementy pracy, podzielone według stanu, pojawią się tutaj.", + "title": "Brak danych" + }, + "created_vs_resolved": { + "description": "Elementy pracy utworzone i rozwiązane w czasie pojawią się tutaj.", + "title": "Brak danych" + }, + "project_insights": { + "title": "Brak danych", + "description": "Przypisane do Ciebie elementy pracy, podzielone według stanu, pojawią się tutaj." } - } + }, + "created_vs_resolved": "Utworzone vs Rozwiązane", + "customized_insights": "Dostosowane informacje", + "backlog_work_items": "{entity} w backlogu", + "active_projects": "Aktywne projekty", + "trend_on_charts": "Trend na wykresach", + "all_projects": "Wszystkie projekty", + "summary_of_projects": "Podsumowanie projektów", + "project_insights": "Wgląd w projekt", + "started_work_items": "Rozpoczęte {entity}", + "total_work_items": "Łączna liczba {entity}", + "total_projects": "Łączna liczba projektów", + "total_admins": "Łączna liczba administratorów", + "total_users": "Łączna liczba użytkowników", + "total_intake": "Całkowity dochód", + "un_started_work_items": "Nierozpoczęte {entity}", + "total_guests": "Łączna liczba gości", + "completed_work_items": "Ukończone {entity}", + "total": "Łączna liczba {entity}" }, "workspace_projects": { "label": "{count, plural, one {Projekt} few {Projekty} other {Projektów}}", @@ -2404,20 +2455,21 @@ "manual": "Ręcznie" } }, - "cycle": { "label": "{count, plural, one {Cykl} few {Cykle} other {Cyklów}}", "no_cycle": "Brak cyklu" }, - "module": { "label": "{count, plural, one {Moduł} few {Moduły} other {Modułów}}", "no_module": "Brak modułu" }, - "description_versions": { "last_edited_by": "Ostatnio edytowane przez", "previously_edited_by": "Wcześniej edytowane przez", "edited_by": "Edytowane przez" + }, + "self_hosted_maintenance_message": { + "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane nie uruchomił się. Może to być spowodowane tym, że jedna lub więcej usług Plane nie mogła się uruchomić.", + "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Wybierz View Logs z setup.sh i logów Docker, aby mieć pewność." } -} +} \ No newline at end of file diff --git a/packages/i18n/src/locales/pt-BR/accessibility.json b/packages/i18n/src/locales/pt-BR/accessibility.json new file mode 100644 index 000000000..de90eeb36 --- /dev/null +++ b/packages/i18n/src/locales/pt-BR/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Logo do espaço de trabalho", + "open_workspace_switcher": "Abrir seletor de espaço de trabalho", + "open_user_menu": "Abrir menu do usuário", + "open_command_palette": "Abrir paleta de comandos", + "open_extended_sidebar": "Abrir barra lateral estendida", + "close_extended_sidebar": "Fechar barra lateral estendida", + "create_favorites_folder": "Criar pasta de favoritos", + "open_folder": "Abrir pasta", + "close_folder": "Fechar pasta", + "open_favorites_menu": "Abrir menu de favoritos", + "close_favorites_menu": "Fechar menu de favoritos", + "enter_folder_name": "Digite o nome da pasta", + "create_new_project": "Criar novo projeto", + "open_projects_menu": "Abrir menu de projetos", + "close_projects_menu": "Fechar menu de projetos", + "toggle_quick_actions_menu": "Alternar menu de ações rápidas", + "open_project_menu": "Abrir menu do projeto", + "close_project_menu": "Fechar menu do projeto", + "collapse_sidebar": "Recolher barra lateral", + "expand_sidebar": "Expandir barra lateral", + "edition_badge": "Abrir modal de planos pagos" + }, + "auth_forms": { + "clear_email": "Limpar e-mail", + "show_password": "Mostrar senha", + "hide_password": "Ocultar senha", + "close_alert": "Fechar alerta", + "close_popover": "Fechar popover" + } + } +} diff --git a/packages/i18n/src/locales/pt-BR/editor.json b/packages/i18n/src/locales/pt-BR/editor.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/i18n/src/locales/pt-BR/editor.json @@ -0,0 +1 @@ +{} diff --git a/packages/i18n/src/locales/pt-BR/translations.json b/packages/i18n/src/locales/pt-BR/translations.json index 3f9c53980..6e7f216ab 100644 --- a/packages/i18n/src/locales/pt-BR/translations.json +++ b/packages/i18n/src/locales/pt-BR/translations.json @@ -18,7 +18,6 @@ "pro": "Pro", "upgrade": "Upgrade" }, - "auth": { "common": { "email": { @@ -168,7 +167,6 @@ } } }, - "submit": "Enviar", "cancel": "Cancelar", "loading": "Carregando", @@ -318,6 +316,8 @@ "failed_to_remove_project_from_favorites": "Não foi possível remover o projeto dos favoritos. Por favor, tente novamente.", "project_created_successfully": "Projeto criado com sucesso", "project_created_successfully_description": "Projeto criado com sucesso. Agora você pode começar a adicionar itens de trabalho a ele.", + "project_name_already_taken": "O nome do projeto já está em uso.", + "project_identifier_already_taken": "O identificador do projeto já está em uso.", "project_cover_image_alt": "Imagem de capa do projeto", "name_is_required": "Nome é obrigatório", "title_should_be_less_than_255_characters": "O título deve ter menos de 255 caracteres", @@ -367,12 +367,12 @@ "work_management": "Gerenciamento de trabalho", "projects_and_issues": "Projetos e itens de trabalho", "projects_and_issues_description": "Ative ou desative estes neste projeto.", - "cycles_description": "Defina o tempo de trabalho como achar melhor por projeto e altere a frequência de um período para o próximo.", - "modules_description": "Agrupe o trabalho em configurações semelhantes a subprojetos com seus próprios líderes e responsáveis.", - "views_description": "Salve classificações, filtros e opções de exibição para mais tarde ou compartilhe-os.", - "pages_description": "Escreva qualquer coisa como você escreveria normalmente.", - "intake_description": "Mantenha-se informado sobre os itens de trabalho aos quais você está inscrito. Ative isso para ser notificado.", - "time_tracking_description": "Rastreie o tempo gasto em itens de trabalho e projetos.", + "cycles_description": "Defina o tempo de trabalho por projeto e ajuste o período conforme necessário. Um ciclo pode durar 2 semanas, o próximo 1 semana.", + "modules_description": "Organize o trabalho em subprojetos com líderes e responsáveis dedicados.", + "views_description": "Salve classificações, filtros e opções de exibição personalizadas ou compartilhe com sua equipe.", + "pages_description": "Crie e edite conteúdo livre – anotações, documentos, qualquer coisa.", + "intake_description": "Permita que não membros compartilhem bugs, feedbacks e sugestões sem interromper seu fluxo de trabalho.", + "time_tracking_description": "Registre o tempo gasto em itens de trabalho e projetos.", "work_management_description": "Gerencie seu trabalho e projetos com facilidade.", "documentation": "Documentação", "message_support": "Suporte por mensagem", @@ -504,7 +504,6 @@ "new_password_must_be_different_from_old_password": "Nova senha deve ser diferente da senha antiga", "edited": "editado", "bot": "robô", - "project_view": { "sort_by": { "created_at": "Criado em", @@ -512,12 +511,10 @@ "name": "Nome" } }, - "toast": { "success": "Sucesso!", "error": "Erro!" }, - "links": { "toasts": { "created": { @@ -546,7 +543,6 @@ } } }, - "home": { "empty": { "quickstart_guide": "Seu guia de início rápido", @@ -614,7 +610,6 @@ "title": "Página inicial", "star_us_on_github": "Nos dê uma estrela no GitHub" }, - "link": { "modal": { "url": { @@ -628,7 +623,6 @@ } } }, - "common": { "all": "Todos", "states": "Estados", @@ -756,7 +750,8 @@ "message": "Algo deu errado. Por favor, tente novamente." }, "required": "Este campo é obrigatório", - "entity_required": "{entity} é obrigatório" + "entity_required": "{entity} é obrigatório", + "restricted_entity": "{entity} está restrito" }, "update_link": "Atualizar link", "attach": "Anexar", @@ -857,6 +852,7 @@ "live": "Ao vivo", "change_history": "Histórico de alterações", "coming_soon": "Em breve", + "member": "Membro", "members": "Membros", "you": "Você", "upgrade_cta": { @@ -872,22 +868,35 @@ "pending": "Pendente", "invite": "Convidar", "view": "Visualizar", - "deactivated_user": "Usuário desativado" + "deactivated_user": "Usuário desativado", + "apply": "Aplicar", + "applying": "Aplicando", + "users": "Usuários", + "admins": "Administradores", + "guests": "Convidados", + "on_track": "No caminho certo", + "off_track": "Fora do caminho", + "at_risk": "Em risco", + "timeline": "Linha do tempo", + "completion": "Conclusão", + "upcoming": "Próximo", + "completed": "Concluído", + "in_progress": "Em andamento", + "planned": "Planejado", + "paused": "Pausado", + "no_of": "Nº de {entity}" }, - "chart": { "x_axis": "Eixo X", "y_axis": "Eixo Y", "metric": "Métrica" }, - "form": { "title": { "required": "Título é obrigatório", "max_length": "O título deve ter menos de {length} caracteres" } }, - "entity": { "grouping_title": "Agrupamento de {entity}", "priority": "Prioridade de {entity}", @@ -911,7 +920,6 @@ "failed": "Erro ao adicionar {entity}" } }, - "epic": { "all": "Todos os Épicos", "label": "{count, plural, one {Épico} other {Épicos}}", @@ -929,7 +937,6 @@ "required": "O título do épico é obrigatório." } }, - "issue": { "label": "{count, plural, one {Item de trabalho} other {Itens de trabalho}}", "all": "Todos os Itens de trabalho", @@ -1092,11 +1099,12 @@ "select": { "error": "Selecione pelo menos um item de trabalho", "empty": "Nenhum item de trabalho selecionado", - "add_selected": "Adicionar itens de trabalho selecionados" + "add_selected": "Adicionar itens de trabalho selecionados", + "select_all": "Selecionar tudo", + "deselect_all": "Desmarcar tudo" }, "open_in_full_screen": "Abrir item de trabalho em tela cheia" }, - "attachment": { "error": "Não foi possível anexar o arquivo. Tente enviar novamente.", "only_one_file_allowed": "Apenas um arquivo pode ser enviado por vez.", @@ -1104,7 +1112,6 @@ "drag_and_drop": "Arraste e solte em qualquer lugar para enviar", "delete": "Excluir anexo" }, - "label": { "select": "Selecionar etiqueta", "create": { @@ -1114,7 +1121,6 @@ "type": "Digite para adicionar uma nova etiqueta" } }, - "sub_work_item": { "update": { "success": "Sub-item de trabalho atualizado com sucesso", @@ -1123,9 +1129,20 @@ "remove": { "success": "Sub-item de trabalho removido com sucesso", "error": "Erro ao remover sub-item de trabalho" + }, + "empty_state": { + "sub_list_filters": { + "title": "Você não tem sub-itens de trabalho que correspondem aos filtros que você aplicou.", + "description": "Para ver todos os sub-itens de trabalho, limpe todos os filtros aplicados.", + "action": "Limpar filtros" + }, + "list_filters": { + "title": "Você não tem itens de trabalho que correspondem aos filtros que você aplicou.", + "description": "Para ver todos os itens de trabalho, limpe todos os filtros aplicados.", + "action": "Limpar filtros" + } } }, - "view": { "label": "{count, plural, one {Visualização} other {Visualizações}}", "create": { @@ -1135,7 +1152,6 @@ "label": "Atualizar Visualização" } }, - "inbox_issue": { "status": { "pending": { @@ -1221,7 +1237,6 @@ } } }, - "workspace_creation": { "heading": "Crie seu espaço de trabalho", "subheading": "Para começar a usar o Plane, você precisa criar ou entrar em um espaço de trabalho.", @@ -1273,7 +1288,6 @@ } } }, - "workspace_dashboard": { "empty_state": { "general": { @@ -1289,7 +1303,6 @@ } } }, - "workspace_analytics": { "label": "Análises", "page_label": "{workspace} - Análises", @@ -1321,20 +1334,38 @@ "custom": "Análises Personalizadas" }, "empty_state": { - "general": { - "title": "Acompanhe o progresso, as cargas de trabalho e as alocações. Identifique tendências, remova bloqueadores e mova o trabalho mais rapidamente", - "description": "Veja o escopo versus a demanda, as estimativas e o aumento do escopo. Obtenha o desempenho por membros da equipe e equipes, e certifique-se de que seu projeto seja executado no prazo.", - "primary_button": { - "text": "Comece seu primeiro projeto", - "comic": { - "title": "A análise funciona melhor com Ciclos + Módulos", - "description": "Primeiro, coloque seus itens de trabalho em Ciclos e, se puder, agrupe os itens de trabalho que abrangem mais de um ciclo em Módulos. Confira ambos na navegação à esquerda." - } - } + "customized_insights": { + "description": "Os itens de trabalho atribuídos a você, divididos por estado, aparecerão aqui.", + "title": "Ainda não há dados" + }, + "created_vs_resolved": { + "description": "Os itens de trabalho criados e resolvidos ao longo do tempo aparecerão aqui.", + "title": "Ainda não há dados" + }, + "project_insights": { + "title": "Ainda não há dados", + "description": "Os itens de trabalho atribuídos a você, divididos por estado, aparecerão aqui." } - } + }, + "created_vs_resolved": "Criado vs Resolvido", + "customized_insights": "Insights personalizados", + "backlog_work_items": "{entity} no backlog", + "active_projects": "Projetos ativos", + "trend_on_charts": "Tendência nos gráficos", + "all_projects": "Todos os projetos", + "summary_of_projects": "Resumo dos projetos", + "project_insights": "Insights do projeto", + "started_work_items": "{entity} iniciados", + "total_work_items": "Total de {entity}", + "total_projects": "Total de projetos", + "total_admins": "Total de administradores", + "total_users": "Total de usuários", + "total_intake": "Receita total", + "un_started_work_items": "{entity} não iniciados", + "total_guests": "Total de convidados", + "completed_work_items": "{entity} concluídos", + "total": "Total de {entity}" }, - "workspace_projects": { "label": "{count, plural, one {Projeto} other {Projetos}}", "create": { @@ -1409,7 +1440,6 @@ } } }, - "workspace_views": { "add_view": "Adicionar visualização", "empty_state": { @@ -1444,7 +1474,6 @@ } } }, - "workspace_settings": { "label": "Configurações do espaço de trabalho", "page_label": "{workspace} - Configurações gerais", @@ -1626,7 +1655,6 @@ } } }, - "profile": { "label": "Perfil", "page_label": "Seu trabalho", @@ -1689,7 +1717,6 @@ } } }, - "project_settings": { "general": { "enter_project_id": "Inserir ID do projeto", @@ -1835,7 +1862,6 @@ "auto_close_status": "Status de fechamento automático" } }, - "empty_state": { "labels": { "title": "Nenhuma etiqueta ainda", @@ -1848,7 +1874,6 @@ } } }, - "project_cycles": { "add_cycle": "Adicionar ciclo", "more_details": "Mais detalhes", @@ -1968,7 +1993,6 @@ } } }, - "project_issues": { "empty_state": { "no_issues": { @@ -1997,7 +2021,6 @@ } } }, - "project_module": { "add_module": "Adicionar Módulo", "update_module": "Atualizar Módulo", @@ -2051,7 +2074,6 @@ } } }, - "project_views": { "empty_state": { "general": { @@ -2071,7 +2093,6 @@ } } }, - "project_page": { "empty_state": { "general": { @@ -2101,7 +2122,6 @@ } } }, - "command_k": { "empty_state": { "search": { @@ -2109,7 +2129,6 @@ } } }, - "issue_relation": { "empty_state": { "search": { @@ -2120,7 +2139,6 @@ } } }, - "issue_comment": { "empty_state": { "general": { @@ -2129,7 +2147,6 @@ } } }, - "notification": { "label": "Caixa de entrada", "page_label": "{workspace} - Caixa de entrada", @@ -2186,7 +2203,6 @@ "custom": "Personalizado" } }, - "active_cycle": { "empty_state": { "progress": { @@ -2268,7 +2284,6 @@ } } }, - "stickies": { "title": "Suas anotações", "placeholder": "clique para digitar aqui", @@ -2326,7 +2341,6 @@ } } }, - "role_details": { "guest": { "title": "Convidado", @@ -2341,7 +2355,6 @@ "description": "Todas as permissões definidas como verdadeiras dentro do espaço de trabalho." } }, - "user_roles": { "product_or_project_manager": "Gerente de Produto / Projeto", "development_or_engineering": "Desenvolvimento / Engenharia", @@ -2354,7 +2367,6 @@ "human_resources": "Recursos Humanos", "other": "Outro" }, - "importer": { "github": { "title": "Github", @@ -2365,7 +2377,6 @@ "description": "Importe itens de trabalho e épicos de projetos e épicos do Jira." } }, - "exporter": { "csv": { "title": "CSV", @@ -2394,7 +2405,6 @@ "created": "Criado", "subscribed": "Inscrito" }, - "themes": { "theme_options": { "system_preference": { @@ -2440,20 +2450,21 @@ "manual": "Manual" } }, - "cycle": { "label": "{count, plural, one {Ciclo} other {Ciclos}}", "no_cycle": "Nenhum ciclo" }, - "module": { "label": "{count, plural, one {Módulo} other {Módulos}}", "no_module": "Nenhum módulo" }, - "description_versions": { "last_edited_by": "Última edição por", "previously_edited_by": "Anteriormente editado por", "edited_by": "Editado por" + }, + "self_hosted_maintenance_message": { + "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "O Plane não inicializou. Isso pode ser porque um ou mais serviços do Plane falharam ao iniciar.", + "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Escolha View Logs do setup.sh e logs do Docker para ter certeza." } -} +} \ No newline at end of file diff --git a/packages/i18n/src/locales/ro/accessibility.json b/packages/i18n/src/locales/ro/accessibility.json new file mode 100644 index 000000000..52f555481 --- /dev/null +++ b/packages/i18n/src/locales/ro/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Logo spațiu de lucru", + "open_workspace_switcher": "Deschide comutator spațiu de lucru", + "open_user_menu": "Deschide meniul utilizatorului", + "open_command_palette": "Deschide paleta de comenzi", + "open_extended_sidebar": "Deschide bara laterală extinsă", + "close_extended_sidebar": "Închide bara laterală extinsă", + "create_favorites_folder": "Creează folder de favorite", + "open_folder": "Deschide folderul", + "close_folder": "Închide folderul", + "open_favorites_menu": "Deschide meniul de favorite", + "close_favorites_menu": "Închide meniul de favorite", + "enter_folder_name": "Introduceți numele folderului", + "create_new_project": "Creează proiect nou", + "open_projects_menu": "Deschide meniul de proiecte", + "close_projects_menu": "Închide meniul de proiecte", + "toggle_quick_actions_menu": "Comută meniul de acțiuni rapide", + "open_project_menu": "Deschide meniul proiectului", + "close_project_menu": "Închide meniul proiectului", + "collapse_sidebar": "Restrânge bara laterală", + "expand_sidebar": "Extinde bara laterală", + "edition_badge": "Deschide modalul planurilor plătite" + }, + "auth_forms": { + "clear_email": "Șterge e-mailul", + "show_password": "Afișează parola", + "hide_password": "Ascunde parola", + "close_alert": "Închide alerta", + "close_popover": "Închide popover-ul" + } + } +} diff --git a/packages/i18n/src/locales/ro/editor.json b/packages/i18n/src/locales/ro/editor.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/i18n/src/locales/ro/editor.json @@ -0,0 +1 @@ +{} diff --git a/packages/i18n/src/locales/ro/translations.json b/packages/i18n/src/locales/ro/translations.json index 05a775c8e..8f40c0a22 100644 --- a/packages/i18n/src/locales/ro/translations.json +++ b/packages/i18n/src/locales/ro/translations.json @@ -18,7 +18,6 @@ "pro": "Pro", "upgrade": "Treci la versiunea superioară" }, - "auth": { "common": { "email": { @@ -168,7 +167,6 @@ } } }, - "submit": "Trimite", "cancel": "Anulează", "loading": "Se încarcă", @@ -318,6 +316,8 @@ "failed_to_remove_project_from_favorites": "Nu s-a putut elimina proiectul din favorite. Încearcă din nou.", "project_created_successfully": "Proiect creat cu succes", "project_created_successfully_description": "Proiect creat cu succes. Poți începe să adaugi activități în el.", + "project_name_already_taken": "Numele proiectului este deja folosit.", + "project_identifier_already_taken": "Identificatorul proiectului este deja folosit.", "project_cover_image_alt": "Coperta proiectului", "name_is_required": "Numele este obligatoriu", "title_should_be_less_than_255_characters": "Titlul trebuie să conțină mai puțin de 255 de caractere", @@ -367,12 +367,12 @@ "work_management": "Gestionare muncă", "projects_and_issues": "Proiecte și activități", "projects_and_issues_description": "Activează sau dezactivează aceste opțiuni pentru proiect.", - "cycles_description": "Împarte munca în unități de timp pentru fiecare proiect și modifică frecvența după cum consideri.", - "modules_description": "Grupează munca în sub-proiecte cu lideri și responsabili proprii.", - "views_description": "Salvează sortările, filtrele și opțiunile de afișare pentru mai târziu sau pentru a le distribui.", - "pages_description": "Scrie orice, cum știi tu să scrii.", - "intake_description": "Rămâi la curent cu activitățile la care ești abonat. Activează pentru notificări.", - "time_tracking_description": "Urmărește timpul petrecut pe activități și proiecte.", + "cycles_description": "Stabilește perioade de timp pentru fiecare proiect și ajustează-le după cum este necesar. Un ciclu poate dura 2 săptămâni, următorul 1 săptămână.", + "modules_description": "Organizează munca în sub-proiecte cu lideri și responsabili dedicați.", + "views_description": "Salvează sortările, filtrele și opțiunile de afișare personalizate sau distribuie-le echipei tale.", + "pages_description": "Creează și editează conținut liber: note, documente, orice.", + "intake_description": "Permite utilizatorilor care nu sunt membri să trimită erori, feedback și sugestii fără a perturba fluxul de lucru.", + "time_tracking_description": "Înregistrează timpul petrecut pe activități și proiecte.", "work_management_description": "Gestionează-ți munca și proiectele cu ușurință.", "documentation": "Documentație", "message_support": "Trimite mesaj la suport", @@ -502,7 +502,6 @@ "export": "Exportă", "member": "{count, plural, one{# membru} other{# membri}}", "new_password_must_be_different_from_old_password": "Parola nouă trebuie să fie diferită de parola veche", - "project_view": { "sort_by": { "created_at": "Creat la", @@ -510,12 +509,10 @@ "name": "Nume" } }, - "toast": { "success": "Succes!", "error": "Eroare!" }, - "links": { "toasts": { "created": { @@ -544,7 +541,6 @@ } } }, - "home": { "empty": { "quickstart_guide": "Ghid de pornire rapidă", @@ -612,7 +608,6 @@ "title": "Acasă", "star_us_on_github": "Dă-ne o stea pe GitHub" }, - "link": { "modal": { "url": { @@ -626,7 +621,6 @@ } } }, - "common": { "all": "Toate", "states": "Stări", @@ -754,7 +748,8 @@ "message": "Ceva a funcționat greșit. Te rugăm să încerci din nou." }, "required": "Acest câmp este obligatoriu", - "entity_required": "{entity} este obligatoriu" + "entity_required": "{entity} este obligatoriu", + "restricted_entity": "{entity} este restricționat" }, "update_link": "Actualizează link-ul", "attach": "Atașează", @@ -855,6 +850,7 @@ "live": "În direct", "change_history": "Istoric modificări", "coming_soon": "În curând", + "member": "Membru", "members": "Membri", "you": "Tu", "upgrade_cta": { @@ -870,22 +866,35 @@ "pending": "În așteptare", "invite": "Invită", "view": "Vizualizează", - "deactivated_user": "Utilizator dezactivat" + "deactivated_user": "Utilizator dezactivat", + "apply": "Aplică", + "applying": "Aplicând", + "users": "Utilizatori", + "admins": "Administratori", + "guests": "Invitați", + "on_track": "Pe drumul cel bun", + "off_track": "În afara traiectoriei", + "at_risk": "În pericol", + "timeline": "Cronologie", + "completion": "Finalizare", + "upcoming": "Viitor", + "completed": "Finalizat", + "in_progress": "În desfășurare", + "planned": "Planificat", + "paused": "Pauzat", + "no_of": "Nr. de {entity}" }, - "chart": { "x_axis": "axa-X", "y_axis": "axa-Y", "metric": "Indicator" }, - "form": { "title": { "required": "Titlul este obligatoriu", "max_length": "Titlul trebuie să conțină mai puțin de {length} caractere" } }, - "entity": { "grouping_title": "Grupare {entity}", "priority": "Prioritate {entity}", @@ -909,7 +918,6 @@ "failed": "Eroare la adăugarea {entity}" } }, - "epic": { "all": "Toate Sarcinile majore", "label": "{count, plural, one {Sarcină majoră} other {Sarcini majore}}", @@ -927,7 +935,6 @@ "required": "Titlul sarcinii majore este obligatoriu." } }, - "issue": { "label": "{count, plural, one {Activitate} other {Activități}}", "all": "Toate activitățile", @@ -1090,11 +1097,12 @@ "select": { "error": "Selectează cel puțin o activitate", "empty": "Nicio activitate selectată", - "add_selected": "Adaugă activitățile selectate" + "add_selected": "Adaugă activitățile selectate", + "select_all": "Selectează tot", + "deselect_all": "Deselează tot" }, "open_in_full_screen": "Deschide activitatea pe tot ecranul" }, - "attachment": { "error": "Fișierul nu a putut fi atașat. Încearcă să încarci din nou.", "only_one_file_allowed": "Se poate încărca doar un fișier o dată.", @@ -1102,7 +1110,6 @@ "drag_and_drop": "Trage și plasează oriunde pentru a încărca", "delete": "Șterge atașamentul" }, - "label": { "select": "Selectează eticheta", "create": { @@ -1112,7 +1119,6 @@ "type": "Tastează pentru a adăuga o etichetă nouă" } }, - "sub_work_item": { "update": { "success": "Sub-activitatea a fost actualizată cu succes", @@ -1121,9 +1127,20 @@ "remove": { "success": "Sub-activitatea a fost eliminată cu succes", "error": "Eroare la eliminarea sub-activității" + }, + "empty_state": { + "sub_list_filters": { + "title": "Nu ai sub-elemente de lucru care corespund filtrelor pe care le-ai aplicat.", + "description": "Pentru a vedea toate sub-elementele de lucru, șterge toate filtrele aplicate.", + "action": "Șterge filtrele" + }, + "list_filters": { + "title": "Nu ai elemente de lucru care corespund filtrelor pe care le-ai aplicat.", + "description": "Pentru a vedea toate elementele de lucru, șterge toate filtrele aplicate.", + "action": "Șterge filtrele" + } } }, - "view": { "label": "{count, plural, one {Perspectivă} other {Perspective}}", "create": { @@ -1133,7 +1150,6 @@ "label": "Actualizează perspectiva" } }, - "inbox_issue": { "status": { "pending": { @@ -1219,7 +1235,6 @@ } } }, - "workspace_creation": { "heading": "Creează spațiul tău de lucru", "subheading": "Pentru a începe să folosești Plane, trebuie să creezi sau să te alături unui spațiu de lucru.", @@ -1271,7 +1286,6 @@ } } }, - "workspace_dashboard": { "empty_state": { "general": { @@ -1287,7 +1301,6 @@ } } }, - "workspace_analytics": { "label": "Statistici", "page_label": "{workspace} - Statistici", @@ -1319,20 +1332,38 @@ "custom": "Analitice personalizate" }, "empty_state": { - "general": { - "title": "Urmărește progresul, activitățile și alocările. Observă tendințele, elimină blocajele și accelerează munca", - "description": "Vezi raportul dintre activitățile asumate și cerere, estimările și eventualele extinderi neplanificate ale activităților asumate. Obține performanța pe membri și echipe și asigură-te că proiectul tău se încadrează în timp.", - "primary_button": { - "text": "Începe primul tău proiect", - "comic": { - "title": "Statisticile funcționează cel mai bine cu Cicluri + Module", - "description": "Mai întâi, încadrează-ți activitățile în Cicluri și, dacă poți, grupează-le pe cele care se întind pe mai multe cicluri în Module. Le găsești în meniul din stânga." - } - } + "customized_insights": { + "description": "Elementele de lucru atribuite ție, împărțite pe stări, vor apărea aici.", + "title": "Nu există date încă" + }, + "created_vs_resolved": { + "description": "Elementele de lucru create și rezolvate în timp vor apărea aici.", + "title": "Nu există date încă" + }, + "project_insights": { + "title": "Nu există date încă", + "description": "Elementele de lucru atribuite ție, împărțite pe stări, vor apărea aici." } - } + }, + "created_vs_resolved": "Creat vs Rezolvat", + "customized_insights": "Perspective personalizate", + "backlog_work_items": "{entity} din backlog", + "active_projects": "Proiecte active", + "trend_on_charts": "Tendință în grafice", + "all_projects": "Toate proiectele", + "summary_of_projects": "Sumarul proiectelor", + "project_insights": "Informații despre proiect", + "started_work_items": "{entity} începute", + "total_work_items": "Totalul {entity}", + "total_projects": "Total proiecte", + "total_admins": "Total administratori", + "total_users": "Total utilizatori", + "total_intake": "Venit total", + "un_started_work_items": "{entity} neîncepute", + "total_guests": "Total invitați", + "completed_work_items": "{entity} finalizate", + "total": "Totalul {entity}" }, - "workspace_projects": { "label": "{count, plural, one {Proiect} other {Proiecte}}", "create": { @@ -1407,7 +1438,6 @@ } } }, - "workspace_views": { "add_view": "Adaugă perspectivă", "empty_state": { @@ -1442,7 +1472,6 @@ } } }, - "workspace_settings": { "label": "Setări spațiu de lucru", "page_label": "{workspace} - Setări generale", @@ -1624,7 +1653,6 @@ } } }, - "profile": { "label": "Profil", "page_label": "Munca ta", @@ -1687,7 +1715,6 @@ } } }, - "project_settings": { "general": { "enter_project_id": "Introdu ID-ul proiectului", @@ -1833,7 +1860,6 @@ "auto_close_status": "Stare închidere automată" } }, - "empty_state": { "labels": { "title": "Nicio etichetă încă", @@ -1846,7 +1872,6 @@ } } }, - "project_cycles": { "add_cycle": "Adaugă ciclu", "more_details": "Mai multe detalii", @@ -1966,7 +1991,6 @@ } } }, - "project_issues": { "empty_state": { "no_issues": { @@ -1995,7 +2019,6 @@ } } }, - "project_module": { "add_module": "Adaugă Modul", "update_module": "Actualizează Modul", @@ -2049,7 +2072,6 @@ } } }, - "project_views": { "empty_state": { "general": { @@ -2069,7 +2091,6 @@ } } }, - "project_page": { "empty_state": { "general": { @@ -2099,7 +2120,6 @@ } } }, - "command_k": { "empty_state": { "search": { @@ -2107,7 +2127,6 @@ } } }, - "issue_relation": { "empty_state": { "search": { @@ -2118,7 +2137,6 @@ } } }, - "issue_comment": { "empty_state": { "general": { @@ -2127,7 +2145,6 @@ } } }, - "notification": { "label": "Căsuță de mesaje", "page_label": "{workspace} - Căsuță de mesaje", @@ -2184,7 +2201,6 @@ "custom": "Personalizat" } }, - "active_cycle": { "empty_state": { "progress": { @@ -2204,7 +2220,6 @@ } } }, - "disabled_project": { "empty_state": { "inbox": { @@ -2267,7 +2282,6 @@ } } }, - "stickies": { "title": "Notițele tale", "placeholder": "click pentru a scrie aici", @@ -2325,7 +2339,6 @@ } } }, - "role_details": { "guest": { "title": "Invitat", @@ -2340,7 +2353,6 @@ "description": "Toate permisiunile setate pe adevărat în cadrul workspace-ului." } }, - "user_roles": { "product_or_project_manager": "Manager de produs / proiect", "development_or_engineering": "Dezvoltare / Inginerie", @@ -2353,7 +2365,6 @@ "human_resources": "Resurse umane", "other": "Altceva" }, - "importer": { "github": { "title": "Github", @@ -2364,7 +2375,6 @@ "description": "Importă activități și episoade din proiectele și episoadele Jira." } }, - "exporter": { "csv": { "title": "CSV", @@ -2393,7 +2403,6 @@ "created": "Create", "subscribed": "Urmărite" }, - "themes": { "theme_options": { "system_preference": { @@ -2439,20 +2448,21 @@ "manual": "Manual" } }, - "cycle": { "label": "{count, plural, one {Ciclu} other {Cicluri}}", "no_cycle": "Niciun ciclu" }, - "module": { "label": "{count, plural, one {Modul} other {Module}}", "no_module": "Niciun modul" }, - "description_versions": { "last_edited_by": "Ultima editare de către", "previously_edited_by": "Editat anterior de către", "edited_by": "Editat de" + }, + "self_hosted_maintenance_message": { + "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane nu a pornit. Aceasta ar putea fi din cauza că unul sau mai multe servicii Plane au eșuat să pornească.", + "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Alegeți View Logs din setup.sh și logurile Docker pentru a fi siguri." } -} +} \ No newline at end of file diff --git a/packages/i18n/src/locales/ru/accessibility.json b/packages/i18n/src/locales/ru/accessibility.json new file mode 100644 index 000000000..dd4dde76b --- /dev/null +++ b/packages/i18n/src/locales/ru/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Логотип рабочей области", + "open_workspace_switcher": "Открыть переключатель рабочей области", + "open_user_menu": "Открыть пользовательское меню", + "open_command_palette": "Открыть палитру команд", + "open_extended_sidebar": "Открыть расширенную боковую панель", + "close_extended_sidebar": "Закрыть расширенную боковую панель", + "create_favorites_folder": "Создать папку избранного", + "open_folder": "Открыть папку", + "close_folder": "Закрыть папку", + "open_favorites_menu": "Открыть меню избранного", + "close_favorites_menu": "Закрыть меню избранного", + "enter_folder_name": "Введите имя папки", + "create_new_project": "Создать новый проект", + "open_projects_menu": "Открыть меню проектов", + "close_projects_menu": "Закрыть меню проектов", + "toggle_quick_actions_menu": "Переключить меню быстрых действий", + "open_project_menu": "Открыть меню проекта", + "close_project_menu": "Закрыть меню проекта", + "collapse_sidebar": "Свернуть боковую панель", + "expand_sidebar": "Развернуть боковую панель", + "edition_badge": "Открыть модал платных планов" + }, + "auth_forms": { + "clear_email": "Очистить email", + "show_password": "Показать пароль", + "hide_password": "Скрыть пароль", + "close_alert": "Закрыть уведомление", + "close_popover": "Закрыть всплывающее окно" + } + } +} diff --git a/packages/i18n/src/locales/ru/editor.json b/packages/i18n/src/locales/ru/editor.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/i18n/src/locales/ru/editor.json @@ -0,0 +1 @@ +{} diff --git a/packages/i18n/src/locales/ru/translations.json b/packages/i18n/src/locales/ru/translations.json index a02057af9..1981999b7 100644 --- a/packages/i18n/src/locales/ru/translations.json +++ b/packages/i18n/src/locales/ru/translations.json @@ -316,6 +316,8 @@ "failed_to_remove_project_from_favorites": "Не удалось удалить проект из избранного. Попробуйте снова.", "project_created_successfully": "Проект успешно создан", "project_created_successfully_description": "Проект успешно создан. Теперь вы можете добавлять рабочие элементы.", + "project_name_already_taken": "Имя проекта уже используется.", + "project_identifier_already_taken": "Идентификатор проекта уже используется.", "project_cover_image_alt": "Обложка проекта", "name_is_required": "Требуется имя", "title_should_be_less_than_255_characters": "Заголовок должен быть короче 255 символов", @@ -365,12 +367,12 @@ "work_management": "Управление рабочими элементами", "projects_and_issues": "Проекты и рабочие элементы", "projects_and_issues_description": "Включить/отключить для этого проекта", - "cycles_description": "Группировка рабочих элементов по временным интервалам с возможностью изменения периодичности.", - "modules_description": "Группировка рабочих элементов в подпроекты с собственными руководителями и назначенными.", - "views_description": "Сохранение сортировок, фильтров и отображений для повторного использования или совместного доступа.", - "pages_description": "Создание любых текстовых материалов", - "intake_description": "Отслеживание рабочих элементов, на которые вы подписаны. Включите для получения уведомлений.", - "time_tracking_description": "Учет времени, затраченного на рабочие элементы и проекты.", + "cycles_description": "Ограничьте работу по времени для каждого проекта и при необходимости изменяйте период. Один цикл может длиться 2 недели, следующий — 1 неделю.", + "modules_description": "Организуйте работу в подпроекты с назначенными руководителями и исполнителями.", + "views_description": "Сохраните пользовательские сортировки, фильтры и параметры отображения или поделитесь ими с командой.", + "pages_description": "Создавайте и редактируйте свободный контент: заметки, документы, что угодно.", + "intake_description": "Позвольте участникам вне команды сообщать об ошибках, оставлять отзывы и предложения, не нарушая ваш рабочий процесс.", + "time_tracking_description": "Записывайте время, потраченное на рабочие элементы и проекты.", "work_management_description": "Управление рабочими элементами и проектами", "documentation": "Документация", "message_support": "Написать в поддержку", @@ -502,7 +504,6 @@ "new_password_must_be_different_from_old_password": "Новое пароль должен отличаться от старого пароля", "edited": "Редактировано", "bot": "Бот", - "project_view": { "sort_by": { "created_at": "Дата создания", @@ -510,12 +511,10 @@ "name": "Имя" } }, - "toast": { "success": "Успех!", "error": "Ошибка!" }, - "links": { "toasts": { "created": { @@ -544,7 +543,6 @@ } } }, - "home": { "empty": { "quickstart_guide": "Руководство по началу работы", @@ -612,7 +610,6 @@ "title": "Главная", "star_us_on_github": "Оцените нас на GitHub" }, - "link": { "modal": { "url": { @@ -626,7 +623,6 @@ } } }, - "common": { "all": "Все", "states": "Статусы", @@ -754,7 +750,8 @@ "message": "Что-то пошло не так. Попробуйте позже." }, "required": "Это поле обязательно", - "entity_required": "{entity} обязательно" + "entity_required": "{entity} обязательно", + "restricted_entity": "{entity} ограничен" }, "update_link": "обновить ссылку", "attach": "Прикрепить", @@ -855,6 +852,7 @@ "live": "В прямом эфире", "change_history": "История изменений", "coming_soon": "Скоро", + "member": "Участник", "members": "Участники", "you": "Вы", "upgrade_cta": { @@ -870,22 +868,35 @@ "pending": "Ожидание", "invite": "Пригласить", "view": "Просмотр", - "deactivated_user": "Деактивированный пользователь" + "deactivated_user": "Деактивированный пользователь", + "apply": "Применить", + "applying": "Применение", + "users": "Пользователи", + "admins": "Администраторы", + "guests": "Гости", + "on_track": "По плану", + "off_track": "Отклонение от плана", + "at_risk": "Под угрозой", + "timeline": "Хронология", + "completion": "Завершение", + "upcoming": "Предстоящие", + "completed": "Завершено", + "in_progress": "В процессе", + "planned": "Запланировано", + "paused": "На паузе", + "no_of": "Количество {entity}" }, - "chart": { "x_axis": "Ось X", "y_axis": "Ось Y", "metric": "Метрика" }, - "form": { "title": { "required": "Название обязательно", "max_length": "Название должно быть короче {length} символов" } }, - "entity": { "grouping_title": "Группировка {entity}", "priority": "Приоритет {entity}", @@ -909,7 +920,6 @@ "failed": "Ошибка добавления {entity}" } }, - "epic": { "all": "Все эпики", "label": "{count, plural, one {Эпик} other {Эпики}}", @@ -927,7 +937,6 @@ "required": "Название эпика обязательно" } }, - "issue": { "label": "{count, plural, one {Рабочий элемент} other {Рабочие элементы}}", "all": "Все рабочие элементы", @@ -1090,11 +1099,12 @@ "select": { "error": "Выберите хотя бы один рабочий элемент", "empty": "Рабочие элементы не выбраны", - "add_selected": "Добавить выбранные рабочие элементы" + "add_selected": "Добавить выбранные рабочие элементы", + "select_all": "Выбрать все", + "deselect_all": "Снять выделение со всех" }, "open_in_full_screen": "Открыть рабочий элемент в полном экране" }, - "attachment": { "error": "Ошибка прикрепления файла", "only_one_file_allowed": "Можно загрузить только один файл", @@ -1102,7 +1112,6 @@ "drag_and_drop": "Перетащите файл для загрузки", "delete": "Удалить вложение" }, - "label": { "select": "Выбрать метку", "create": { @@ -1112,7 +1121,6 @@ "type": "Введите новую метку" } }, - "sub_work_item": { "update": { "success": "Подэлемент успешно обновлен", @@ -1121,9 +1129,20 @@ "remove": { "success": "Подэлемент успешно удален", "error": "Ошибка удаления подэлемента" + }, + "empty_state": { + "sub_list_filters": { + "title": "У вас нет подэлементов, которые соответствуют примененным фильтрам.", + "description": "Чтобы увидеть все подэлементы, очистите все примененные фильтры.", + "action": "Очистить фильтры" + }, + "list_filters": { + "title": "У вас нет рабочих элементов, которые соответствуют примененным фильтрам.", + "description": "Чтобы увидеть все рабочие элементы, очистите все примененные фильтры.", + "action": "Очистить фильтры" + } } }, - "view": { "label": "{count, plural, one {Представление} other {Представления}}", "create": { @@ -1133,7 +1152,6 @@ "label": "Обновить представление" } }, - "inbox_issue": { "status": { "pending": { @@ -1219,7 +1237,6 @@ } } }, - "workspace_creation": { "heading": "Создайте рабочее пространство", "subheading": "Чтобы начать использовать Plane, создайте или присоединитесь к рабочему пространству.", @@ -1271,7 +1288,6 @@ } } }, - "workspace_dashboard": { "empty_state": { "general": { @@ -1287,7 +1303,6 @@ } } }, - "workspace_analytics": { "label": "Аналитика", "page_label": "{workspace} - Аналитика", @@ -1319,20 +1334,38 @@ "custom": "Пользовательская аналитика" }, "empty_state": { - "general": { - "title": "Отслеживайте прогресс, загрузку и распределение ресурсов", - "description": "Анализируйте объёмы работ, оценивайте сроки и контролируйте выполнение проектов. Отслеживайте производительность команды и соблюдайте сроки.", - "primary_button": { - "text": "Начать первый проект", - "comic": { - "title": "Аналитика лучше всего работает с Циклами + Модулями", - "description": "Сначала группируйте рабочие элементы в Циклы, а при возможности - объединяйте рабочие элементы в Модули. Найдите оба раздела в левом меню." - } - } + "customized_insights": { + "description": "Назначенные вам рабочие элементы, разбитые по статусам, появятся здесь.", + "title": "Данных пока нет" + }, + "created_vs_resolved": { + "description": "Созданные и решённые со временем рабочие элементы появятся здесь.", + "title": "Данных пока нет" + }, + "project_insights": { + "title": "Данных пока нет", + "description": "Назначенные вам рабочие элементы, разбитые по статусам, появятся здесь." } - } + }, + "created_vs_resolved": "Создано vs Решено", + "customized_insights": "Индивидуальные аналитические данные", + "backlog_work_items": "{entity} в бэклоге", + "active_projects": "Активные проекты", + "trend_on_charts": "Тренд на графиках", + "all_projects": "Все проекты", + "summary_of_projects": "Сводка по проектам", + "project_insights": "Аналитика проекта", + "started_work_items": "Начатые {entity}", + "total_work_items": "Общее количество {entity}", + "total_projects": "Всего проектов", + "total_admins": "Всего администраторов", + "total_users": "Всего пользователей", + "total_intake": "Общий доход", + "un_started_work_items": "Не начатые {entity}", + "total_guests": "Всего гостей", + "completed_work_items": "Завершённые {entity}", + "total": "Общее количество {entity}" }, - "workspace_projects": { "label": "{count, plural, one {Проект} other {Проекты}}", "create": { @@ -1407,7 +1440,6 @@ } } }, - "workspace_views": { "add_view": "Добавить представление", "empty_state": { @@ -1442,7 +1474,6 @@ } } }, - "workspace_settings": { "label": "Настройки пространства", "page_label": "{workspace} - Основные настройки", @@ -1624,7 +1655,6 @@ } } }, - "profile": { "label": "Профиль", "page_label": "Ваша работа", @@ -1687,7 +1717,6 @@ } } }, - "project_settings": { "general": { "enter_project_id": "Введите ID проекта", @@ -1833,7 +1862,6 @@ "auto_close_status": "Статус автоматического закрытия" } }, - "empty_state": { "labels": { "title": "Нет меток", @@ -1846,7 +1874,6 @@ } } }, - "project_cycles": { "add_cycle": "Добавить цикл", "more_details": "Подробнее", @@ -1972,7 +1999,6 @@ } } }, - "project_issues": { "empty_state": { "no_issues": { @@ -2001,7 +2027,6 @@ } } }, - "project_module": { "add_module": "Добавить модуль", "update_module": "Обновить модуль", @@ -2055,7 +2080,6 @@ } } }, - "project_views": { "empty_state": { "general": { @@ -2075,7 +2099,6 @@ } } }, - "project_page": { "empty_state": { "general": { @@ -2105,7 +2128,6 @@ } } }, - "command_k": { "empty_state": { "search": { @@ -2113,7 +2135,6 @@ } } }, - "issue_relation": { "empty_state": { "search": { @@ -2124,7 +2145,6 @@ } } }, - "issue_comment": { "empty_state": { "general": { @@ -2133,7 +2153,6 @@ } } }, - "notification": { "label": "Входящие", "page_label": "{workspace} - Входящие", @@ -2190,7 +2209,6 @@ "custom": "Другое" } }, - "active_cycle": { "empty_state": { "progress": { @@ -2210,7 +2228,6 @@ } } }, - "disabled_project": { "empty_state": { "inbox": { @@ -2273,7 +2290,6 @@ } } }, - "stickies": { "title": "Ваши стикеры", "placeholder": "нажмите, чтобы написать", @@ -2331,7 +2347,6 @@ } } }, - "role_details": { "guest": { "title": "Гость", @@ -2346,7 +2361,6 @@ "description": "Полные права доступа в рамках рабочего пространства." } }, - "user_roles": { "product_or_project_manager": "Продукт / Проект менеджер", "development_or_engineering": "Разработка / Инжиниринг", @@ -2359,7 +2373,6 @@ "human_resources": "HR / Кадры", "other": "Другое" }, - "importer": { "github": { "title": "GitHub", @@ -2370,7 +2383,6 @@ "description": "Импорт рабочих элементов и эпиков из проектов Jira." } }, - "exporter": { "csv": { "title": "CSV", @@ -2399,7 +2411,6 @@ "created": "Созданные", "subscribed": "Подписанные" }, - "themes": { "theme_options": { "system_preference": { @@ -2445,20 +2456,22 @@ "manual": "Вручную" } }, - "cycle": { "label": "{count, plural, one {Цикл} other {Циклы}}", "no_cycle": "Нет цикла" }, - "module": { "label": "{count, plural, one {Модуль} other {Модули}}", "no_module": "Нет модуля" }, - "description_versions": { "last_edited_by": "Последнее редактирование", "previously_edited_by": "Ранее отредактировано", "edited_by": "Отредактировано" - } -} + }, + "self_hosted_maintenance_message": { + "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane не запустился. Это может быть из-за того, что один или несколько сервисов Plane не смогли запуститься.", + "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Выберите View Logs из setup.sh и логов Docker, чтобы убедиться." + }, + "no_of": "Количество {entity}" +} \ No newline at end of file diff --git a/packages/i18n/src/locales/sk/accessibility.json b/packages/i18n/src/locales/sk/accessibility.json new file mode 100644 index 000000000..26c5c8be6 --- /dev/null +++ b/packages/i18n/src/locales/sk/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Logo pracovného priestoru", + "open_workspace_switcher": "Otvoriť prepínač pracovného priestoru", + "open_user_menu": "Otvoriť používateľské menu", + "open_command_palette": "Otvoriť paletu príkazov", + "open_extended_sidebar": "Otvoriť rozšírený bočný panel", + "close_extended_sidebar": "Zavrieť rozšírený bočný panel", + "create_favorites_folder": "Vytvoriť priečinok obľúbených", + "open_folder": "Otvoriť priečinok", + "close_folder": "Zavrieť priečinok", + "open_favorites_menu": "Otvoriť menu obľúbených", + "close_favorites_menu": "Zavrieť menu obľúbených", + "enter_folder_name": "Zadajte názov priečinka", + "create_new_project": "Vytvoriť nový projekt", + "open_projects_menu": "Otvoriť menu projektov", + "close_projects_menu": "Zavrieť menu projektov", + "toggle_quick_actions_menu": "Prepnúť menu rýchlych akcií", + "open_project_menu": "Otvoriť menu projektu", + "close_project_menu": "Zavrieť menu projektu", + "collapse_sidebar": "Zbaliť bočný panel", + "expand_sidebar": "Rozbaliť bočný panel", + "edition_badge": "Otvoriť modal platených plánov" + }, + "auth_forms": { + "clear_email": "Vymazať e-mail", + "show_password": "Zobraziť heslo", + "hide_password": "Skryť heslo", + "close_alert": "Zavrieť upozornenie", + "close_popover": "Zavrieť vyskakovacie okno" + } + } +} diff --git a/packages/i18n/src/locales/sk/editor.json b/packages/i18n/src/locales/sk/editor.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/i18n/src/locales/sk/editor.json @@ -0,0 +1 @@ +{} diff --git a/packages/i18n/src/locales/sk/translations.json b/packages/i18n/src/locales/sk/translations.json index 0af27ab37..af6971aae 100644 --- a/packages/i18n/src/locales/sk/translations.json +++ b/packages/i18n/src/locales/sk/translations.json @@ -316,6 +316,8 @@ "failed_to_remove_project_from_favorites": "Nepodarilo sa odstrániť projekt z obľúbených. Skúste to prosím znova.", "project_created_successfully": "Projekt bol úspešne vytvorený", "project_created_successfully_description": "Projekt bol úspešne vytvorený. Teraz môžete začať pridávať pracovné položky.", + "project_name_already_taken": "Názov projektu je už použitý.", + "project_identifier_already_taken": "Identifikátor projektu je už použitý.", "project_cover_image_alt": "Úvodný obrázok projektu", "name_is_required": "Názov je povinný", "title_should_be_less_than_255_characters": "Názov by mal byť kratší ako 255 znakov", @@ -365,12 +367,12 @@ "work_management": "Správa práce", "projects_and_issues": "Projekty a pracovné položky", "projects_and_issues_description": "Aktivujte alebo deaktivujte tieto funkcie v projekte.", - "cycles_description": "Časovo ohraničte prácu podľa potreby a meňte frekvenciu medzi obdobiami.", - "modules_description": "Zoskupujte prácu do podobných podprojektov s vlastnými vedúcimi a priradenými.", - "views_description": "Uložte triedenie, filtre a zobrazenie na neskôr alebo na zdieľanie.", - "pages_description": "Píšte čokoľvek, ako obvykle.", - "intake_description": "Majte prehľad o pracovných položkách, ktoré odoberáte. Aktivujte toto pre zasielanie oznámení.", - "time_tracking_description": "Sledujte čas strávený na pracovných položkách a projektoch.", + "cycles_description": "Časovo ohraničte prácu podľa projektu a upravte obdobie podľa potreby. Jeden cyklus môže mať 2 týždne, ďalší 1 týždeň.", + "modules_description": "Organizujte prácu do podprojektov s určenými vedúcimi a priradenými osobami.", + "views_description": "Uložte vlastné triedenia, filtre a možnosti zobrazenia alebo ich zdieľajte so svojím tímom.", + "pages_description": "Vytvárajte a upravujte voľne štruktúrovaný obsah – poznámky, dokumenty, čokoľvek.", + "intake_description": "Umožnite nečlenom zdieľať chyby, spätnú väzbu a návrhy bez narušenia vášho pracovného postupu.", + "time_tracking_description": "Zaznamenajte čas strávený na pracovných položkách a projektoch.", "work_management_description": "Spravujte svoju prácu a projekty jednoducho.", "documentation": "Dokumentácia", "message_support": "Kontaktovať podporu", @@ -502,7 +504,6 @@ "new_password_must_be_different_from_old_password": "Nové heslo musí byť odlišné od starého hesla", "edited": "Upravené", "bot": "Bot", - "project_view": { "sort_by": { "created_at": "Vytvorené dňa", @@ -510,12 +511,10 @@ "name": "Názov" } }, - "toast": { "success": "Úspech!", "error": "Chyba!" }, - "links": { "toasts": { "created": { @@ -544,7 +543,6 @@ } } }, - "home": { "empty": { "quickstart_guide": "Váš sprievodca rýchlym štartom", @@ -612,7 +610,6 @@ "title": "Domov", "star_us_on_github": "Ohodnoťte nás na GitHube" }, - "link": { "modal": { "url": { @@ -626,7 +623,6 @@ } } }, - "common": { "all": "Všetko", "states": "Stavy", @@ -754,7 +750,8 @@ "message": "Niečo sa pokazilo. Skúste to prosím znova." }, "required": "Toto pole je povinné", - "entity_required": "{entity} je povinná" + "entity_required": "{entity} je povinná", + "restricted_entity": "{entity} je obmedzený" }, "update_link": "Aktualizovať odkaz", "attach": "Pripojiť", @@ -855,6 +852,7 @@ "live": "Živé", "change_history": "História zmien", "coming_soon": "Už čoskoro", + "member": "Člen", "members": "Členovia", "you": "Vy", "upgrade_cta": { @@ -870,22 +868,35 @@ "pending": "Čakajúce", "invite": "Pozvať", "view": "Zobraziť", - "deactivated_user": "Deaktivovaný používateľ" + "deactivated_user": "Deaktivovaný používateľ", + "apply": "Použiť", + "applying": "Používanie", + "users": "Používatelia", + "admins": "Administrátori", + "guests": "Hostia", + "on_track": "Na správnej ceste", + "off_track": "Mimo plán", + "at_risk": "V ohrození", + "timeline": "Časová os", + "completion": "Dokončenie", + "upcoming": "Nadchádzajúce", + "completed": "Dokončené", + "in_progress": "Prebieha", + "planned": "Plánované", + "paused": "Pozastavené", + "no_of": "Počet {entity}" }, - "chart": { "x_axis": "Os X", "y_axis": "Os Y", "metric": "Metrika" }, - "form": { "title": { "required": "Názov je povinný", "max_length": "Názov by mal byť kratší ako {length} znakov" } }, - "entity": { "grouping_title": "Zoskupenie {entity}", "priority": "Priorita {entity}", @@ -909,7 +920,6 @@ "failed": "Chyba pri pridávaní {entity}" } }, - "epic": { "all": "Všetky epiky", "label": "{count, plural, one {Epika} few {Epiky} other {Epík}}", @@ -927,7 +937,6 @@ "required": "Názov epiky je povinný." } }, - "issue": { "label": "{count, plural, one {Pracovná položka} few {Pracovné položky} other {Pracovných položiek}}", "all": "Všetky pracovné položky", @@ -1090,11 +1099,12 @@ "select": { "error": "Vyberte aspoň jednu pracovnú položku", "empty": "Nie sú vybrané žiadne pracovné položky", - "add_selected": "Pridať vybrané pracovné položky" + "add_selected": "Pridať vybrané pracovné položky", + "select_all": "Vybrať všetko", + "deselect_all": "Zrušiť výber všetkého" }, "open_in_full_screen": "Otvoriť pracovnú položku na celú obrazovku" }, - "attachment": { "error": "Súbor sa nedá pripojiť. Skúste to prosím znova.", "only_one_file_allowed": "Je možné nahrať iba jeden súbor naraz.", @@ -1102,7 +1112,6 @@ "drag_and_drop": "Pretiahnite súbor kamkoľvek pre nahratie", "delete": "Zmazať prílohu" }, - "label": { "select": "Vybrať štítok", "create": { @@ -1112,7 +1121,6 @@ "type": "Zadajte pre vytvorenie nového štítka" } }, - "sub_work_item": { "update": { "success": "Podriadená pracovná položka bola úspešne aktualizovaná", @@ -1121,9 +1129,20 @@ "remove": { "success": "Podriadená pracovná položka bola úspešne odstránená", "error": "Chyba pri odstraňovaní podriadenej položky" + }, + "empty_state": { + "sub_list_filters": { + "title": "Nemáte podriadené pracovné položky, ktoré zodpovedajú použitým filtrom.", + "description": "Pre zobrazenie všetkých podriadených pracovných položiek vymažte všetky použité filtre.", + "action": "Vymazať filtre" + }, + "list_filters": { + "title": "Nemáte pracovné položky, ktoré zodpovedajú použitým filtrom.", + "description": "Pre zobrazenie všetkých pracovných položiek vymažte všetky použité filtre.", + "action": "Vymazať filtre" + } } }, - "view": { "label": "{count, plural, one {Pohľad} few {Pohľady} other {Pohľadov}}", "create": { @@ -1133,7 +1152,6 @@ "label": "Aktualizovať pohľad" } }, - "inbox_issue": { "status": { "pending": { @@ -1219,7 +1237,6 @@ } } }, - "workspace_creation": { "heading": "Vytvorte si pracovný priestor", "subheading": "Na používanie Plane musíte vytvoriť alebo sa pripojiť k pracovnému priestoru.", @@ -1271,7 +1288,6 @@ } } }, - "workspace_dashboard": { "empty_state": { "general": { @@ -1287,7 +1303,6 @@ } } }, - "workspace_analytics": { "label": "Analytika", "page_label": "{workspace} - Analytika", @@ -1319,20 +1334,38 @@ "custom": "Vlastná analytika" }, "empty_state": { - "general": { - "title": "Sledujte pokrok, vyťaženie a alokácie. Identifikujte trendy, odstráňte prekážky a zrýchlite prácu", - "description": "Sledujte rozsah vs. dopyt, odhady a rozsah. Zistite výkonnosť členov a tímov, zabezpečte včasné dokončenie projektov.", - "primary_button": { - "text": "Začnite prvý projekt", - "comic": { - "title": "Analytika funguje najlepšie s Cykly + Moduly", - "description": "Najprv časovo ohraničte prácu do cyklov a zoskupte položky presahujúce cyklus do modulov. Nájdete ich v ľavom menu." - } - } + "customized_insights": { + "description": "Pracovné položky priradené vám, rozdelené podľa stavu, sa zobrazia tu.", + "title": "Zatiaľ žiadne údaje" + }, + "created_vs_resolved": { + "description": "Pracovné položky vytvorené a vyriešené v priebehu času sa zobrazia tu.", + "title": "Zatiaľ žiadne údaje" + }, + "project_insights": { + "title": "Zatiaľ žiadne údaje", + "description": "Pracovné položky priradené vám, rozdelené podľa stavu, sa zobrazia tu." } - } + }, + "created_vs_resolved": "Vytvorené vs Vyriešené", + "customized_insights": "Prispôsobené prehľady", + "backlog_work_items": "{entity} v backlogu", + "active_projects": "Aktívne projekty", + "trend_on_charts": "Trend na grafoch", + "all_projects": "Všetky projekty", + "summary_of_projects": "Súhrn projektov", + "project_insights": "Prehľad projektu", + "started_work_items": "Spustené {entity}", + "total_work_items": "Celkový počet {entity}", + "total_projects": "Celkový počet projektov", + "total_admins": "Celkový počet administrátorov", + "total_users": "Celkový počet používateľov", + "total_intake": "Celkový príjem", + "un_started_work_items": "Nespustené {entity}", + "total_guests": "Celkový počet hostí", + "completed_work_items": "Dokončené {entity}", + "total": "Celkový počet {entity}" }, - "workspace_projects": { "label": "{count, plural, one {Projekt} few {Projekty} other {Projektov}}", "create": { @@ -1407,7 +1440,6 @@ } } }, - "workspace_views": { "add_view": "Pridať pohľad", "empty_state": { @@ -1442,7 +1474,6 @@ } } }, - "workspace_settings": { "label": "Nastavenia pracovného priestoru", "page_label": "{workspace} - Všeobecné nastavenia", @@ -1623,7 +1654,6 @@ } } }, - "profile": { "label": "Profil", "page_label": "Vaša práca", @@ -1686,7 +1716,6 @@ } } }, - "project_settings": { "general": { "enter_project_id": "Zadajte ID projektu", @@ -1832,7 +1861,6 @@ "auto_close_status": "Stav pre automatické uzatvorenie" } }, - "empty_state": { "labels": { "title": "Žiadne štítky", @@ -1845,7 +1873,6 @@ } } }, - "project_cycles": { "add_cycle": "Pridať cyklus", "more_details": "Viac detailov", @@ -1971,7 +1998,6 @@ } } }, - "project_issues": { "empty_state": { "no_issues": { @@ -2000,7 +2026,6 @@ } } }, - "project_module": { "add_module": "Pridať modul", "update_module": "Aktualizovať modul", @@ -2054,7 +2079,6 @@ } } }, - "project_views": { "empty_state": { "general": { @@ -2074,7 +2098,6 @@ } } }, - "project_page": { "empty_state": { "general": { @@ -2104,7 +2127,6 @@ } } }, - "command_k": { "empty_state": { "search": { @@ -2112,7 +2134,6 @@ } } }, - "issue_relation": { "empty_state": { "search": { @@ -2123,7 +2144,6 @@ } } }, - "issue_comment": { "empty_state": { "general": { @@ -2132,7 +2152,6 @@ } } }, - "notification": { "label": "Schránka", "page_label": "{workspace} - Schránka", @@ -2189,7 +2208,6 @@ "custom": "Vlastné" } }, - "active_cycle": { "empty_state": { "progress": { @@ -2209,7 +2227,6 @@ } } }, - "disabled_project": { "empty_state": { "inbox": { @@ -2272,7 +2289,6 @@ } } }, - "stickies": { "title": "Vaše poznámky", "placeholder": "kliknutím začnite písať", @@ -2330,7 +2346,6 @@ } } }, - "role_details": { "guest": { "title": "Hosť", @@ -2345,7 +2360,6 @@ "description": "Má všetky oprávnenia v priestore." } }, - "user_roles": { "product_or_project_manager": "Produktový/Projektový manažér", "development_or_engineering": "Vývoj/Inžinierstvo", @@ -2358,7 +2372,6 @@ "human_resources": "Ľudské zdroje", "other": "Iné" }, - "importer": { "github": { "title": "GitHub", @@ -2369,7 +2382,6 @@ "description": "Importujte položky a epiky z Jira." } }, - "exporter": { "csv": { "title": "CSV", @@ -2398,7 +2410,6 @@ "created": "Vytvorené", "subscribed": "Odobierané" }, - "themes": { "theme_options": { "system_preference": { @@ -2444,20 +2455,21 @@ "manual": "Manuálne" } }, - "cycle": { "label": "{count, plural, one {Cyklus} few {Cykly} other {Cyklov}}", "no_cycle": "Žiadny cyklus" }, - "module": { "label": "{count, plural, one {Modul} few {Moduly} other {Modulov}}", "no_module": "Žiadny modul" }, - "description_versions": { "last_edited_by": "Naposledy upravené používateľom", "previously_edited_by": "Predtým upravené používateľom", "edited_by": "Upravené používateľom" + }, + "self_hosted_maintenance_message": { + "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane sa nespustil. Toto môže byť spôsobené tým, že sa jedna alebo viac služieb Plane nepodarilo spustiť.", + "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Vyberte View Logs z setup.sh a Docker logov, aby ste si boli istí." } -} +} \ No newline at end of file diff --git a/packages/i18n/src/locales/tr-TR/accessibility.json b/packages/i18n/src/locales/tr-TR/accessibility.json new file mode 100644 index 000000000..80a35611c --- /dev/null +++ b/packages/i18n/src/locales/tr-TR/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Çalışma alanı logosu", + "open_workspace_switcher": "Çalışma alanı değiştiricisini aç", + "open_user_menu": "Kullanıcı menüsünü aç", + "open_command_palette": "Komut paletini aç", + "open_extended_sidebar": "Genişletilmiş kenar çubuğunu aç", + "close_extended_sidebar": "Genişletilmiş kenar çubuğunu kapat", + "create_favorites_folder": "Favoriler klasörü oluştur", + "open_folder": "Klasörü aç", + "close_folder": "Klasörü kapat", + "open_favorites_menu": "Favoriler menüsünü aç", + "close_favorites_menu": "Favoriler menüsünü kapat", + "enter_folder_name": "Klasör adını girin", + "create_new_project": "Yeni proje oluştur", + "open_projects_menu": "Projeler menüsünü aç", + "close_projects_menu": "Projeler menüsünü kapat", + "toggle_quick_actions_menu": "Hızlı eylemler menüsünü aç/kapat", + "open_project_menu": "Proje menüsünü aç", + "close_project_menu": "Proje menüsünü kapat", + "collapse_sidebar": "Kenar çubuğunu daralt", + "expand_sidebar": "Kenar çubuğunu genişlet", + "edition_badge": "Ücretli planlar modalını aç" + }, + "auth_forms": { + "clear_email": "E-postayı temizle", + "show_password": "Şifreyi göster", + "hide_password": "Şifreyi gizle", + "close_alert": "Uyarıyı kapat", + "close_popover": "Açılır pencereyi kapat" + } + } +} diff --git a/packages/i18n/src/locales/tr-TR/editor.json b/packages/i18n/src/locales/tr-TR/editor.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/i18n/src/locales/tr-TR/editor.json @@ -0,0 +1 @@ +{} diff --git a/packages/i18n/src/locales/tr-TR/translations.json b/packages/i18n/src/locales/tr-TR/translations.json index 011741760..a4ae00670 100644 --- a/packages/i18n/src/locales/tr-TR/translations.json +++ b/packages/i18n/src/locales/tr-TR/translations.json @@ -18,7 +18,6 @@ "pro": "Pro", "upgrade": "Apgreyd" }, - "auth": { "common": { "email": { @@ -168,7 +167,6 @@ } } }, - "submit": "Gönder", "cancel": "İptal", "loading": "Yükleniyor", @@ -318,6 +316,8 @@ "failed_to_remove_project_from_favorites": "Proje favorilerden kaldırılamadı. Lütfen tekrar deneyin.", "project_created_successfully": "Proje başarıyla oluşturuldu", "project_created_successfully_description": "Proje başarıyla oluşturuldu. Artık iş öğeleri eklemeye başlayabilirsiniz.", + "project_name_already_taken": "Proje ismi zaten kullanılıyor.", + "project_identifier_already_taken": "Proje kimliği zaten kullanılıyor.", "project_cover_image_alt": "Proje kapak resmi", "name_is_required": "Ad gereklidir", "title_should_be_less_than_255_characters": "Başlık 255 karakterden az olmalı", @@ -367,12 +367,12 @@ "work_management": "İş Yönetimi", "projects_and_issues": "Projeler ve İş Öğeleri", "projects_and_issues_description": "Bu projede bu özellikleri açıp kapatın.", - "cycles_description": "İşleri proje başına uygun şekilde zaman dilimlerine ayırın ve sıklığı değiştirin.", - "modules_description": "Kendi liderleri ve atananları olan alt proje benzeri gruplar oluşturun.", - "views_description": "Sıralama, filtre ve görüntüleme seçeneklerini kaydedin veya paylaşın.", - "pages_description": "Herhangi bir şey yazabilirsiniz.", - "intake_description": "Abone olduğunuz iş öğelerinden haberdar olun. Bildirim almak için etkinleştirin.", - "time_tracking_description": "İş öğeleri ve projelerde harcanan zamanı takip edin.", + "cycles_description": "Projeye göre işi zamanla sınırlandırın ve gerektiğinde zaman dilimini ayarlayın. Bir döngü 2 hafta, bir sonraki 1 hafta olabilir.", + "modules_description": "İşi, özel liderler ve atanmış kişilerle alt projelere ayırın.", + "views_description": "Özel sıralamaları, filtreleri ve görüntüleme seçeneklerini kaydedin veya ekibinizle paylaşın.", + "pages_description": "Serbest biçimli içerikler oluşturun ve düzenleyin; notlar, belgeler, her şey.", + "intake_description": "Üye olmayanların hata, geri bildirim ve öneri paylaşmasına izin verin; iş akışınızı bozmadan.", + "time_tracking_description": "İş öğeleri ve projelerde harcanan zamanı kaydedin.", "work_management_description": "İşlerinizi ve projelerinizi kolayca yönetin.", "documentation": "Dokümantasyon", "message_support": "Destekle iletişim", @@ -504,7 +504,6 @@ "new_password_must_be_different_from_old_password": "Yeni şifre eski şifreden farklı olmalı", "edited": "düzenlendi", "bot": "Bot", - "project_view": { "sort_by": { "created_at": "Oluşturulma tarihi", @@ -512,12 +511,10 @@ "name": "Ad" } }, - "toast": { "success": "Başarılı!", "error": "Hata!" }, - "links": { "toasts": { "created": { @@ -546,7 +543,6 @@ } } }, - "home": { "empty": { "quickstart_guide": "Hızlı başlangıç rehberiniz", @@ -614,7 +610,6 @@ "title": "Ana Sayfa", "star_us_on_github": "Bizi GitHub'da yıldızlayın" }, - "link": { "modal": { "url": { @@ -628,7 +623,6 @@ } } }, - "common": { "all": "Tümü", "states": "Durumlar", @@ -756,7 +750,8 @@ "message": "Bir hata oluştu. Lütfen tekrar deneyin." }, "required": "Bu alan gereklidir", - "entity_required": "{entity} gereklidir" + "entity_required": "{entity} gereklidir", + "restricted_entity": "{entity} kısıtlanmıştır" }, "update_link": "Bağlantıyı güncelle", "attach": "Ekle", @@ -858,6 +853,7 @@ "live": "Canlı", "change_history": "Değişiklik Geçmişi", "coming_soon": "Çok Yakında", + "member": "Üye", "members": "Üyeler", "you": "Siz", "upgrade_cta": { @@ -872,22 +868,36 @@ "deleting": "Siliniyor", "pending": "Beklemede", "invite": "Davet Et", - "view": "Görünüm" + "view": "Görünüm", + "deactivated_user": "Devre dışı bırakılmış kullanıcı", + "apply": "Uygula", + "applying": "Uygulanıyor", + "users": "Kullanıcılar", + "admins": "Yöneticiler", + "guests": "Misafirler", + "on_track": "Yolunda", + "off_track": "Yolunda değil", + "at_risk": "Risk altında", + "timeline": "Zaman çizelgesi", + "completion": "Tamamlama", + "upcoming": "Yaklaşan", + "completed": "Tamamlandı", + "in_progress": "Devam ediyor", + "planned": "Planlandı", + "paused": "Durduruldu", + "no_of": "{entity} sayısı" }, - "chart": { "x_axis": "X ekseni", "y_axis": "Y ekseni", "metric": "Metrik" }, - "form": { "title": { "required": "Başlık gereklidir", "max_length": "Başlık {length} karakterden az olmalı" } }, - "entity": { "grouping_title": "{entity} Gruplandırma", "priority": "{entity} Önceliği", @@ -911,7 +921,6 @@ "failed": "{entity} eklenirken hata oluştu" } }, - "epic": { "all": "Tüm Epikler", "label": "{count, plural, one {Epik} other {Epikler}}", @@ -929,7 +938,6 @@ "required": "Epik başlığı gereklidir." } }, - "issue": { "label": "{count, plural, one {İş öğesi} other {İş öğeleri}}", "all": "Tüm İş Öğeleri", @@ -1092,11 +1100,12 @@ "select": { "error": "Lütfen en az bir iş öğesi seçin", "empty": "Hiç iş öğesi seçilmedi", - "add_selected": "Seçilen iş öğelerini ekle" + "add_selected": "Seçilen iş öğelerini ekle", + "select_all": "Tümünü seç", + "deselect_all": "Tümünü seçme" }, "open_in_full_screen": "İş öğesini tam ekranda aç" }, - "attachment": { "error": "Dosya eklenemedi. Tekrar yüklemeyi deneyin.", "only_one_file_allowed": "Aynı anda yalnızca bir dosya yüklenebilir.", @@ -1104,7 +1113,6 @@ "drag_and_drop": "Yüklemek için herhangi bir yere sürükleyip bırakın", "delete": "Eki sil" }, - "label": { "select": "Etiket seç", "create": { @@ -1114,7 +1122,6 @@ "type": "Yeni etiket eklemek için yazın" } }, - "sub_work_item": { "update": { "success": "Alt iş öğesi başarıyla güncellendi", @@ -1123,9 +1130,20 @@ "remove": { "success": "Alt iş öğesi başarıyla kaldırıldı", "error": "Alt iş öğesi kaldırılırken hata oluştu" + }, + "empty_state": { + "sub_list_filters": { + "title": "Alt iş öğelerinizin filtreleriyle eşleşmiyor.", + "description": "Tüm alt iş öğelerini görmek için tüm uygulanan filtreleri temizleyin.", + "action": "Filtreleri temizle" + }, + "list_filters": { + "title": "İş öğelerinizin filtreleriyle eşleşmiyor.", + "description": "Tüm iş öğelerini görmek için tüm uygulanan filtreleri temizleyin.", + "action": "Filtreleri temizle" + } } }, - "view": { "label": "{count, plural, one {Görünüm} other {Görünümler}}", "create": { @@ -1135,7 +1153,6 @@ "label": "Görünümü Güncelle" } }, - "inbox_issue": { "status": { "pending": { @@ -1221,7 +1238,6 @@ } } }, - "workspace_creation": { "heading": "Çalışma Alanınızı Oluşturun", "subheading": "Plane'i kullanmaya başlamak için bir çalışma alanı oluşturmalı veya katılmalısınız.", @@ -1273,7 +1289,6 @@ } } }, - "workspace_dashboard": { "empty_state": { "general": { @@ -1289,7 +1304,6 @@ } } }, - "workspace_analytics": { "label": "Analitik", "page_label": "{workspace} - Analitik", @@ -1321,20 +1335,38 @@ "custom": "Özel Analitik" }, "empty_state": { - "general": { - "title": "İlerlemeyi, iş yükünü ve tahsisatları izleyin. Eğilimleri tespit edin, engelleri kaldırın ve işleri hızlandırın", - "description": "Kapsam ve talep, tahminler ve kapsam genişlemesini görün. Takım üyeleri ve ekiplerin performansını izleyin ve projenizin zamanında ilerlemesini sağlayın.", - "primary_button": { - "text": "İlk projenizi başlatın", - "comic": { - "title": "Analitik Döngüler + Modüllerle en iyi şekilde çalışır", - "description": "Öncelikle, iş öğelerinizi Döngülere zamanlayın ve mümkünse, bir döngüden uzun süren iş öğelerini Modüllerde gruplayın. Her ikisini de sol gezintide bulabilirsiniz." - } - } + "customized_insights": { + "description": "Size atanan iş öğeleri, duruma göre ayrılarak burada gösterilecektir.", + "title": "Henüz veri yok" + }, + "created_vs_resolved": { + "description": "Zaman içinde oluşturulan ve çözümlenen iş öğeleri burada gösterilecektir.", + "title": "Henüz veri yok" + }, + "project_insights": { + "title": "Henüz veri yok", + "description": "Size atanan iş öğeleri, duruma göre ayrılarak burada gösterilecektir." } - } + }, + "created_vs_resolved": "Oluşturulan vs Çözülen", + "customized_insights": "Özelleştirilmiş İçgörüler", + "backlog_work_items": "Backlog {entity}", + "active_projects": "Aktif Projeler", + "trend_on_charts": "Grafiklerdeki eğilim", + "all_projects": "Tüm Projeler", + "summary_of_projects": "Projelerin Özeti", + "project_insights": "Proje İçgörüleri", + "started_work_items": "Başlatılan {entity}", + "total_work_items": "Toplam {entity}", + "total_projects": "Toplam Proje", + "total_admins": "Toplam Yönetici", + "total_users": "Toplam Kullanıcı", + "total_intake": "Toplam Gelir", + "un_started_work_items": "Başlanmamış {entity}", + "total_guests": "Toplam Misafir", + "completed_work_items": "Tamamlanmış {entity}", + "total": "Toplam {entity}" }, - "workspace_projects": { "label": "{count, plural, one {Proje} other {Projeler}}", "create": { @@ -1409,7 +1441,6 @@ } } }, - "workspace_views": { "add_view": "Görünüm ekle", "empty_state": { @@ -1444,7 +1475,6 @@ } } }, - "workspace_settings": { "label": "Çalışma Alanı Ayarları", "page_label": "{workspace} - Genel ayarlar", @@ -1626,7 +1656,6 @@ } } }, - "profile": { "label": "Profil", "page_label": "Sizin İşleriniz", @@ -1689,7 +1718,6 @@ } } }, - "project_settings": { "general": { "enter_project_id": "Proje ID girin", @@ -1812,7 +1840,6 @@ "auto_close_status": "Otomatik kapatma durumu" } }, - "empty_state": { "labels": { "title": "Henüz etiket yok", @@ -1825,7 +1852,6 @@ } } }, - "project_cycles": { "add_cycle": "Döngü ekle", "more_details": "Daha fazla detay", @@ -1951,7 +1977,6 @@ } } }, - "project_issues": { "empty_state": { "no_issues": { @@ -1980,7 +2005,6 @@ } } }, - "project_module": { "add_module": "Modül Ekle", "update_module": "Modülü Güncelle", @@ -2034,7 +2058,6 @@ } } }, - "project_views": { "empty_state": { "general": { @@ -2054,7 +2077,6 @@ } } }, - "project_page": { "empty_state": { "general": { @@ -2084,7 +2106,6 @@ } } }, - "command_k": { "empty_state": { "search": { @@ -2092,7 +2113,6 @@ } } }, - "issue_relation": { "empty_state": { "search": { @@ -2103,7 +2123,6 @@ } } }, - "issue_comment": { "empty_state": { "general": { @@ -2112,7 +2131,6 @@ } } }, - "notification": { "label": "Bildirimler", "page_label": "{workspace} - Bildirimler", @@ -2169,7 +2187,6 @@ "custom": "Özel" } }, - "active_cycle": { "empty_state": { "progress": { @@ -2189,7 +2206,6 @@ } } }, - "disabled_project": { "empty_state": { "inbox": { @@ -2229,7 +2245,6 @@ } } }, - "workspace_draft_issues": { "draft_an_issue": "Taslak iş öğesi oluştur", "empty_state": { @@ -2253,7 +2268,6 @@ } } }, - "stickies": { "title": "Yapışkan Notlarınız", "placeholder": "buraya yazmak için tıkla", @@ -2311,7 +2325,6 @@ } } }, - "role_details": { "guest": { "title": "Misafir", @@ -2326,7 +2339,6 @@ "description": "Çalışma alanı içinde tüm izinler aktif." } }, - "user_roles": { "product_or_project_manager": "Ürün / Proje Yöneticisi", "development_or_engineering": "Geliştirme / Mühendislik", @@ -2339,7 +2351,6 @@ "human_resources": "İnsan Kaynakları", "other": "Diğer" }, - "importer": { "github": { "title": "Github", @@ -2350,7 +2361,6 @@ "description": "Jira projelerinden ve epiklerinden iş öğelerini içe aktarın." } }, - "exporter": { "csv": { "title": "CSV", @@ -2373,14 +2383,12 @@ "short_description": "JSON olarak aktar" } }, - "default_global_view": { "all_issues": "Tüm iş öğeleri", "assigned": "Atanan", "created": "Oluşturulan", "subscribed": "Abone olunan" }, - "themes": { "theme_options": { "system_preference": { @@ -2403,7 +2411,6 @@ } } }, - "project_modules": { "status": { "backlog": "Bekleme Listesi", @@ -2427,20 +2434,21 @@ "manual": "Manuel" } }, - "cycle": { "label": "{count, plural, one {Döngü} other {Döngüler}}", "no_cycle": "Döngü yok" }, - "module": { "label": "{count, plural, one {Modül} other {Modüller}}", "no_module": "Modül yok" }, - "description_versions": { "last_edited_by": "Son düzenleyen", "previously_edited_by": "Önceki düzenleyen", "edited_by": "Tarafından düzenlendi" + }, + "self_hosted_maintenance_message": { + "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane başlatılamadı. Bu, bir veya daha fazla Plane servisinin başlatılamaması nedeniyle olabilir.", + "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Emin olmak için setup.sh ve Docker loglarından View Logs'u seçin." } -} +} \ No newline at end of file diff --git a/packages/i18n/src/locales/ua/accessibility.json b/packages/i18n/src/locales/ua/accessibility.json new file mode 100644 index 000000000..427667312 --- /dev/null +++ b/packages/i18n/src/locales/ua/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Логотип робочого простору", + "open_workspace_switcher": "Відкрити перемикач робочого простору", + "open_user_menu": "Відкрити меню користувача", + "open_command_palette": "Відкрити палітру команд", + "open_extended_sidebar": "Відкрити розширену бічну панель", + "close_extended_sidebar": "Закрити розширену бічну панель", + "create_favorites_folder": "Створити папку улюблених", + "open_folder": "Відкрити папку", + "close_folder": "Закрити папку", + "open_favorites_menu": "Відкрити меню улюблених", + "close_favorites_menu": "Закрити меню улюблених", + "enter_folder_name": "Введіть назву папки", + "create_new_project": "Створити новий проект", + "open_projects_menu": "Відкрити меню проектів", + "close_projects_menu": "Закрити меню проектів", + "toggle_quick_actions_menu": "Перемкнути меню швидких дій", + "open_project_menu": "Відкрити меню проекту", + "close_project_menu": "Закрити меню проекту", + "collapse_sidebar": "Згорнути бічну панель", + "expand_sidebar": "Розгорнути бічну панель", + "edition_badge": "Відкрити модал платних планів" + }, + "auth_forms": { + "clear_email": "Очистити email", + "show_password": "Показати пароль", + "hide_password": "Приховати пароль", + "close_alert": "Закрити сповіщення", + "close_popover": "Закрити спливаюче вікно" + } + } +} diff --git a/packages/i18n/src/locales/ua/editor.json b/packages/i18n/src/locales/ua/editor.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/i18n/src/locales/ua/editor.json @@ -0,0 +1 @@ +{} diff --git a/packages/i18n/src/locales/ua/translations.json b/packages/i18n/src/locales/ua/translations.json index b939f991e..bfa6c3281 100644 --- a/packages/i18n/src/locales/ua/translations.json +++ b/packages/i18n/src/locales/ua/translations.json @@ -316,6 +316,8 @@ "failed_to_remove_project_from_favorites": "Не вдалося видалити проєкт із вибраного. Спробуйте ще раз.", "project_created_successfully": "Проєкт успішно створено", "project_created_successfully_description": "Проєкт успішно створений. Тепер ви можете почати додавати робочі одиниці.", + "project_name_already_taken": "Назва проекту вже використовується.", + "project_identifier_already_taken": "Ідентифікатор проекту вже використовується.", "project_cover_image_alt": "Обкладинка проєкту", "name_is_required": "Назва є обов’язковою", "title_should_be_less_than_255_characters": "Назва має бути коротшою за 255 символів", @@ -365,12 +367,12 @@ "work_management": "Управління роботою", "projects_and_issues": "Проєкти та робочі одиниці", "projects_and_issues_description": "Увімкніть або вимкніть ці функції в проєкті.", - "cycles_description": "Обмежуйте роботу в часі за потреби й регулюйте періоди.", - "modules_description": "Групуйте роботу в тематичні підпроєкти з окремими керівниками та виконавцями.", - "views_description": "Зберігайте сортування, фільтри та варіанти відображення для подальшого використання чи спільного доступу.", - "pages_description": "Пишіть що завгодно у вигляді звичайних сторінок.", - "intake_description": "Будьте в курсі робочих одиниць, на які ви підписані, отримуйте повідомлення.", - "time_tracking_description": "Відстежуйте час, витрачений на робочі одиниці та проєкти.", + "cycles_description": "Обмежуйте роботу в часі для кожного проєкту та за потреби коригуйте період. Один цикл може тривати 2 тижні, наступний — 1 тиждень.", + "modules_description": "Організуйте роботу в підпроєкти з окремими керівниками та виконавцями.", + "views_description": "Зберігайте власні сортування, фільтри та варіанти відображення або діліться ними з командою.", + "pages_description": "Створюйте та редагуйте довільний вміст: нотатки, документи, що завгодно.", + "intake_description": "Дозвольте неучасникам ділитися помилками, відгуками й пропозиціями без порушення робочого процесу.", + "time_tracking_description": "Фіксуйте час, витрачений на робочі одиниці та проєкти.", "work_management_description": "Зручно керуйте своєю роботою та проєктами.", "documentation": "Документація", "message_support": "Звернутися в підтримку", @@ -502,7 +504,6 @@ "new_password_must_be_different_from_old_password": "Новий пароль повинен бути відмінним від старого пароля", "edited": "Редагувано", "bot": "Бот", - "project_view": { "sort_by": { "created_at": "Створено", @@ -748,8 +749,9 @@ "title": "Помилка!", "message": "Щось пішло не так. Будь ласка, спробуйте ще раз." }, - "required": "Це поле є обов’язковим", - "entity_required": "{entity} є обов’язковим" + "required": "Це поле є обов'язковим", + "entity_required": "{entity} є обов'язковим", + "restricted_entity": "{entity} обмежено" }, "update_link": "Оновити посилання", "attach": "Прикріпити", @@ -850,6 +852,7 @@ "live": "Наживо", "change_history": "Історія змін", "coming_soon": "Незабаром", + "member": "Учасник", "members": "Учасники", "you": "Ви", "upgrade_cta": { @@ -865,7 +868,23 @@ "pending": "Очікує", "invite": "Запросити", "view": "Подання", - "deactivated_user": "Деактивований користувач" + "deactivated_user": "Деактивований користувач", + "apply": "Застосувати", + "applying": "Застосовується", + "users": "Користувачі", + "admins": "Адміністратори", + "guests": "Гості", + "on_track": "У межах графіку", + "off_track": "Поза графіком", + "at_risk": "Під загрозою", + "timeline": "Хронологія", + "completion": "Завершення", + "upcoming": "Майбутнє", + "completed": "Завершено", + "in_progress": "В процесі", + "planned": "Заплановано", + "paused": "Призупинено", + "no_of": "Кількість {entity}" }, "chart": { "x_axis": "Вісь X", @@ -1080,7 +1099,9 @@ "select": { "error": "Виберіть принаймні одну робочу одиницю", "empty": "Не вибрано жодної робочої одиниці", - "add_selected": "Додати вибрані робочі одиниці" + "add_selected": "Додати вибрані робочі одиниці", + "select_all": "Вибрати всі", + "deselect_all": "Скасувати вибір усіх" }, "open_in_full_screen": "Відкрити робочу одиницю на повний екран" }, @@ -1108,6 +1129,18 @@ "remove": { "success": "Похідну робочу одиницю успішно вилучено", "error": "Помилка під час вилучення похідної одиниці" + }, + "empty_state": { + "sub_list_filters": { + "title": "Ви не маєте похідних робочих одиниць, які відповідають застосованим фільтрам.", + "description": "Щоб побачити всі похідні робочі одиниці, очистіть всі застосовані фільтри.", + "action": "Очистити фільтри" + }, + "list_filters": { + "title": "Ви не маєте робочих одиниць, які відповідають застосованим фільтрам.", + "description": "Щоб побачити всі робочі одиниці, очистіть всі застосовані фільтри.", + "action": "Очистити фільтри" + } } }, "view": { @@ -1301,18 +1334,37 @@ "custom": "Користувацька аналітика" }, "empty_state": { - "general": { - "title": "Відстежуйте прогрес, навантаження й розподіл. Виявляйте тенденції, усувайте перешкоди й прискорюйте роботу", - "description": "Стежте за обсягом проти попиту, оцінками та обсягом. Визначайте ефективність учасників і команд, аби вчасно виконувати проєкти.", - "primary_button": { - "text": "Розпочніть перший проєкт", - "comic": { - "title": "Аналітика найкраще працює з Циклами + Модулями", - "description": "Спочатку обмежте роботу в часі через Цикли та згрупуйте робочі одиниці, які тривають довше, у Модулі. Все це в лівому меню." - } - } + "customized_insights": { + "description": "Призначені вам робочі елементи, розбиті за станом, з’являться тут.", + "title": "Ще немає даних" + }, + "created_vs_resolved": { + "description": "Створені та вирішені з часом робочі елементи з’являться тут.", + "title": "Ще немає даних" + }, + "project_insights": { + "title": "Ще немає даних", + "description": "Призначені вам робочі елементи, розбиті за станом, з’являться тут." } - } + }, + "created_vs_resolved": "Створено vs Вирішено", + "customized_insights": "Персоналізовані аналітичні дані", + "backlog_work_items": "{entity} у беклозі", + "active_projects": "Активні проєкти", + "trend_on_charts": "Тенденція на графіках", + "all_projects": "Усі проєкти", + "summary_of_projects": "Зведення проєктів", + "project_insights": "Аналітика проєкту", + "started_work_items": "Розпочаті {entity}", + "total_work_items": "Усього {entity}", + "total_projects": "Усього проєктів", + "total_admins": "Усього адміністраторів", + "total_users": "Усього користувачів", + "total_intake": "Загальний дохід", + "un_started_work_items": "Нерозпочаті {entity}", + "total_guests": "Усього гостей", + "completed_work_items": "Завершені {entity}", + "total": "Усього {entity}" }, "workspace_projects": { "label": "{count, plural, one {Проєкт} few {Проєкти} other {Проєктів}}", @@ -2403,20 +2455,21 @@ "manual": "Вручну" } }, - "cycle": { "label": "{count, plural, one {Цикл} few {Цикли} other {Циклів}}", "no_cycle": "Немає циклу" }, - "module": { "label": "{count, plural, one {Модуль} few {Модулі} other {Модулів}}", "no_module": "Немає модуля" }, - "description_versions": { "last_edited_by": "Останнє редагування", "previously_edited_by": "Раніше відредаговано", "edited_by": "Відредаговано" + }, + "self_hosted_maintenance_message": { + "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane не запустився. Це може бути через те, що один або декілька сервісів Plane не змогли запуститися.", + "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Виберіть View Logs з setup.sh та логів Docker, щоб переконатися." } -} +} \ No newline at end of file diff --git a/packages/i18n/src/locales/vi-VN/accessibility.json b/packages/i18n/src/locales/vi-VN/accessibility.json new file mode 100644 index 000000000..b3ab93530 --- /dev/null +++ b/packages/i18n/src/locales/vi-VN/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "Logo không gian làm việc", + "open_workspace_switcher": "Mở trình chuyển đổi không gian làm việc", + "open_user_menu": "Mở menu người dùng", + "open_command_palette": "Mở bảng lệnh", + "open_extended_sidebar": "Mở thanh bên mở rộng", + "close_extended_sidebar": "Đóng thanh bên mở rộng", + "create_favorites_folder": "Tạo thư mục yêu thích", + "open_folder": "Mở thư mục", + "close_folder": "Đóng thư mục", + "open_favorites_menu": "Mở menu yêu thích", + "close_favorites_menu": "Đóng menu yêu thích", + "enter_folder_name": "Nhập tên thư mục", + "create_new_project": "Tạo dự án mới", + "open_projects_menu": "Mở menu dự án", + "close_projects_menu": "Đóng menu dự án", + "toggle_quick_actions_menu": "Bật/tắt menu hành động nhanh", + "open_project_menu": "Mở menu dự án", + "close_project_menu": "Đóng menu dự án", + "collapse_sidebar": "Thu gọn thanh bên", + "expand_sidebar": "Mở rộng thanh bên", + "edition_badge": "Mở modal gói trả phí" + }, + "auth_forms": { + "clear_email": "Xóa email", + "show_password": "Hiển thị mật khẩu", + "hide_password": "Ẩn mật khẩu", + "close_alert": "Đóng cảnh báo", + "close_popover": "Đóng popover" + } + } +} diff --git a/packages/i18n/src/locales/vi-VN/editor.json b/packages/i18n/src/locales/vi-VN/editor.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/i18n/src/locales/vi-VN/editor.json @@ -0,0 +1 @@ +{} diff --git a/packages/i18n/src/locales/vi-VN/translations.json b/packages/i18n/src/locales/vi-VN/translations.json index 225fffeb0..3b31f81fe 100644 --- a/packages/i18n/src/locales/vi-VN/translations.json +++ b/packages/i18n/src/locales/vi-VN/translations.json @@ -316,6 +316,8 @@ "failed_to_remove_project_from_favorites": "Không thể xóa dự án khỏi mục yêu thích. Vui lòng thử lại.", "project_created_successfully": "Dự án đã được tạo thành công", "project_created_successfully_description": "Dự án đã được tạo thành công. Bây giờ bạn có thể bắt đầu thêm mục công việc.", + "project_name_already_taken": "Tên dự án đã được sử dụng.", + "project_identifier_already_taken": "ID dự án đã được sử dụng.", "project_cover_image_alt": "Ảnh bìa dự án", "name_is_required": "Tên là bắt buộc", "title_should_be_less_than_255_characters": "Tiêu đề phải ít hơn 255 ký tự", @@ -365,12 +367,12 @@ "work_management": "Quản lý công việc", "projects_and_issues": "Dự án và mục công việc", "projects_and_issues_description": "Bật hoặc tắt các tính năng này trong dự án này. Có thể thay đổi theo thời gian phù hợp với nhu cầu.", - "cycles_description": "Thiết lập khung thời gian cho dự án theo nhu cầu, có thể thay đổi tần suất theo các khoảng thời gian khác nhau.", - "modules_description": "Nhóm công việc thành cấu trúc giống như các dự án con, với người phụ trách và người được phân công riêng.", - "views_description": "Lưu các tùy chọn sắp xếp, lọc và hiển thị để sử dụng hoặc chia sẻ sau này.", - "pages_description": "Viết bất cứ thứ gì giống như viết bất cứ thứ gì.", - "intake_description": "Cập nhật những mục công việc bạn đã đăng ký. Bật tính năng này để nhận thông báo.", - "time_tracking_description": "Theo dõi thời gian dành cho mục công việc và dự án.", + "cycles_description": "Thiết lập thời gian làm việc theo dự án và điều chỉnh thời gian nếu cần. Một chu kỳ có thể là 2 tuần, chu kỳ tiếp theo là 1 tuần.", + "modules_description": "Tổ chức công việc thành các dự án con với người lãnh đạo và người được phân công riêng.", + "views_description": "Lưu các tùy chọn sắp xếp, lọc và hiển thị tùy chỉnh hoặc chia sẻ chúng với nhóm của bạn.", + "pages_description": "Tạo và chỉnh sửa nội dung tự do: ghi chú, tài liệu, bất cứ thứ gì.", + "intake_description": "Cho phép người không phải thành viên chia sẻ lỗi, phản hồi và đề xuất mà không làm gián đoạn quy trình làm việc của bạn.", + "time_tracking_description": "Ghi lại thời gian dành cho các mục công việc và dự án.", "work_management_description": "Quản lý công việc và dự án của bạn một cách dễ dàng.", "documentation": "Tài liệu", "message_support": "Liên hệ hỗ trợ", @@ -502,7 +504,6 @@ "new_password_must_be_different_from_old_password": "Mật khẩu mới phải khác mật khẩu cũ", "edited": "đã chỉnh sửa", "bot": "bot", - "project_view": { "sort_by": { "created_at": "Thời gian tạo", @@ -749,7 +750,8 @@ "message": "Đã xảy ra lỗi. Vui lòng thử lại." }, "required": "Trường này là bắt buộc", - "entity_required": "{entity} là bắt buộc" + "entity_required": "{entity} là bắt buộc", + "restricted_entity": "{entity} bị hạn chế" }, "update_link": "Cập nhật liên kết", "attach": "Đính kèm", @@ -849,6 +851,7 @@ "live": "Trực tiếp", "change_history": "Lịch sử thay đổi", "coming_soon": "Sắp ra mắt", + "member": "Thành viên", "members": "Thành viên", "you": "Bạn", "upgrade_cta": { @@ -864,7 +867,23 @@ "pending": "Đang chờ xử lý", "invite": "Mời", "view": "Xem", - "deactivated_user": "Người dùng bị vô hiệu hóa" + "deactivated_user": "Người dùng bị vô hiệu hóa", + "apply": "Áp dụng", + "applying": "Đang áp dụng", + "users": "Người dùng", + "admins": "Quản trị viên", + "guests": "Khách", + "on_track": "Đúng tiến độ", + "off_track": "Chệch hướng", + "at_risk": "Có nguy cơ", + "timeline": "Dòng thời gian", + "completion": "Hoàn thành", + "upcoming": "Sắp tới", + "completed": "Đã hoàn thành", + "in_progress": "Đang tiến hành", + "planned": "Đã lên kế hoạch", + "paused": "Tạm dừng", + "no_of": "Số lượng {entity}" }, "chart": { "x_axis": "Trục X", @@ -1079,7 +1098,9 @@ "select": { "error": "Vui lòng chọn ít nhất một mục công việc", "empty": "Chưa chọn mục công việc", - "add_selected": "Thêm mục công việc đã chọn" + "add_selected": "Thêm mục công việc đã chọn", + "select_all": "Chọn tất cả", + "deselect_all": "Bỏ chọn tất cả" }, "open_in_full_screen": "Mở mục công việc trong chế độ toàn màn hình" }, @@ -1107,6 +1128,18 @@ "remove": { "success": "Đã xóa mục công việc con thành công", "error": "Đã xảy ra lỗi khi xóa mục công việc con" + }, + "empty_state": { + "sub_list_filters": { + "title": "Bạn không có mục công việc con nào phù hợp với các bộ lọc mà bạn đã áp dụng.", + "description": "Để xem tất cả các mục công việc con, hãy xóa tất cả các bộ lọc đã áp dụng.", + "action": "Xóa bộ lọc" + }, + "list_filters": { + "title": "Bạn không có mục công việc nào phù hợp với các bộ lọc mà bạn đã áp dụng.", + "description": "Để xem tất cả các mục công việc, hãy xóa tất cả các bộ lọc đã áp dụng.", + "action": "Xóa bộ lọc" + } } }, "view": { @@ -1300,18 +1333,37 @@ "custom": "Phân tích tùy chỉnh" }, "empty_state": { - "general": { - "title": "Theo dõi tiến độ, khối lượng công việc và phân công. Khám phá xu hướng, loại bỏ rào cản và đẩy nhanh công việc", - "description": "Xem phạm vi so với nhu cầu, ước tính và mở rộng phạm vi. Nhận hiệu suất của thành viên nhóm và nhóm, đảm bảo dự án của bạn đúng tiến độ.", - "primary_button": { - "text": "Bắt đầu dự án đầu tiên của bạn", - "comic": { - "title": "Phân tích hoạt động tốt nhất trong chu kỳ + mô-đun", - "description": "Đầu tiên, giới hạn mục công việc của bạn trong chu kỳ và nếu có thể, nhóm mục công việc kéo dài nhiều chu kỳ thành mô-đun. Xem cả hai trong thanh điều hướng bên trái." - } - } + "customized_insights": { + "description": "Các hạng mục công việc được giao cho bạn, phân loại theo trạng thái, sẽ hiển thị tại đây.", + "title": "Chưa có dữ liệu" + }, + "created_vs_resolved": { + "description": "Các hạng mục công việc được tạo và giải quyết theo thời gian sẽ hiển thị tại đây.", + "title": "Chưa có dữ liệu" + }, + "project_insights": { + "title": "Chưa có dữ liệu", + "description": "Các hạng mục công việc được giao cho bạn, phân loại theo trạng thái, sẽ hiển thị tại đây." } - } + }, + "created_vs_resolved": "Đã tạo vs Đã giải quyết", + "customized_insights": "Thông tin chi tiết tùy chỉnh", + "backlog_work_items": "{entity} tồn đọng", + "active_projects": "Dự án đang hoạt động", + "trend_on_charts": "Xu hướng trên biểu đồ", + "all_projects": "Tất cả dự án", + "summary_of_projects": "Tóm tắt dự án", + "project_insights": "Thông tin chi tiết dự án", + "started_work_items": "{entity} đã bắt đầu", + "total_work_items": "Tổng số {entity}", + "total_projects": "Tổng số dự án", + "total_admins": "Tổng số quản trị viên", + "total_users": "Tổng số người dùng", + "total_intake": "Tổng thu", + "un_started_work_items": "{entity} chưa bắt đầu", + "total_guests": "Tổng số khách", + "completed_work_items": "{entity} đã hoàn thành", + "total": "Tổng số {entity}" }, "workspace_projects": { "label": "{count, plural, one {dự án} other {dự án}}", @@ -2401,20 +2453,21 @@ "manual": "Thủ công" } }, - "cycle": { "label": "{count, plural, one {chu kỳ} other {chu kỳ}}", "no_cycle": "Không có chu kỳ" }, - "module": { "label": "{count, plural, one {mô-đun} other {mô-đun}}", "no_module": "Không có mô-đun" }, - "description_versions": { "last_edited_by": "Chỉnh sửa lần cuối bởi", "previously_edited_by": "Trước đây được chỉnh sửa bởi", "edited_by": "Được chỉnh sửa bởi" + }, + "self_hosted_maintenance_message": { + "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane không khởi động được. Điều này có thể do một hoặc nhiều dịch vụ Plane không khởi động được.", + "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Chọn View Logs từ setup.sh và log Docker để chắc chắn." } -} +} \ No newline at end of file diff --git a/packages/i18n/src/locales/zh-CN/accessibility.json b/packages/i18n/src/locales/zh-CN/accessibility.json new file mode 100644 index 000000000..fea84d063 --- /dev/null +++ b/packages/i18n/src/locales/zh-CN/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "工作空间徽标", + "open_workspace_switcher": "打开工作空间切换器", + "open_user_menu": "打开用户菜单", + "open_command_palette": "打开命令面板", + "open_extended_sidebar": "打开扩展侧边栏", + "close_extended_sidebar": "关闭扩展侧边栏", + "create_favorites_folder": "创建收藏夹文件夹", + "open_folder": "打开文件夹", + "close_folder": "关闭文件夹", + "open_favorites_menu": "打开收藏夹菜单", + "close_favorites_menu": "关闭收藏夹菜单", + "enter_folder_name": "输入文件夹名称", + "create_new_project": "创建新项目", + "open_projects_menu": "打开项目菜单", + "close_projects_menu": "关闭项目菜单", + "toggle_quick_actions_menu": "切换快速操作菜单", + "open_project_menu": "打开项目菜单", + "close_project_menu": "关闭项目菜单", + "collapse_sidebar": "折叠侧边栏", + "expand_sidebar": "展开侧边栏", + "edition_badge": "打开付费计划模态框" + }, + "auth_forms": { + "clear_email": "清除邮箱", + "show_password": "显示密码", + "hide_password": "隐藏密码", + "close_alert": "关闭警告", + "close_popover": "关闭弹出框" + } + } +} diff --git a/packages/i18n/src/locales/zh-CN/editor.json b/packages/i18n/src/locales/zh-CN/editor.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/i18n/src/locales/zh-CN/editor.json @@ -0,0 +1 @@ +{} diff --git a/packages/i18n/src/locales/zh-CN/translations.json b/packages/i18n/src/locales/zh-CN/translations.json index 633a18c94..304b435a8 100644 --- a/packages/i18n/src/locales/zh-CN/translations.json +++ b/packages/i18n/src/locales/zh-CN/translations.json @@ -18,7 +18,6 @@ "pro": "专业版", "upgrade": "升级" }, - "auth": { "common": { "email": { @@ -168,7 +167,6 @@ } } }, - "submit": "提交", "cancel": "取消", "loading": "加载中", @@ -318,6 +316,8 @@ "failed_to_remove_project_from_favorites": "无法从收藏中移除项目。请重试。", "project_created_successfully": "项目创建成功", "project_created_successfully_description": "项目创建成功。您现在可以开始添加工作项了。", + "project_name_already_taken": "项目名称已被使用。", + "project_identifier_already_taken": "项目标识符已被使用。", "project_cover_image_alt": "项目封面图片", "name_is_required": "名称为必填项", "title_should_be_less_than_255_characters": "标题应少于255个字符", @@ -367,12 +367,12 @@ "work_management": "工作管理", "projects_and_issues": "项目和工作项", "projects_and_issues_description": "在此项目中开启或关闭这些功能。", - "cycles_description": "根据项目需要设置时间框,可以根据不同时期更改频率。", - "modules_description": "将工作分组为类似子项目的设置,具有各自的负责人和分配者。", - "views_description": "保存排序、筛选和显示选项以供后续使用或分享。", - "pages_description": "像写任何东西一样写任何东西。", - "intake_description": "及时了解您订阅的工作项。启用此功能以获取通知。", - "time_tracking_description": "跟踪工作项和项目的时间消耗。", + "cycles_description": "为每个项目设置时间框,并根据需要调整周期。一个周期可以是两周,下一个周期是一周。", + "modules_description": "将工作组织为子项目,并指定专门的负责人和受理人。", + "views_description": "保存自定义排序、筛选和显示选项,或与团队共享。", + "pages_description": "创建和编辑自由格式的内容:笔记、文档,任何内容。", + "intake_description": "允许非成员提交 Bug、反馈和建议,且不会干扰您的工作流程。", + "time_tracking_description": "记录在工作项和项目上花费的时间。", "work_management_description": "轻松管理您的工作和项目。", "documentation": "文档", "message_support": "联系支持", @@ -504,7 +504,6 @@ "new_password_must_be_different_from_old_password": "新密码必须不同于旧密码", "edited": "已编辑", "bot": "机器人", - "project_view": { "sort_by": { "created_at": "创建时间", @@ -512,12 +511,10 @@ "name": "名称" } }, - "toast": { "success": "成功!", "error": "错误!" }, - "links": { "toasts": { "created": { @@ -546,7 +543,6 @@ } } }, - "home": { "empty": { "quickstart_guide": "快速入门指南", @@ -614,7 +610,6 @@ "title": "首页", "star_us_on_github": "在GitHub上为我们加星" }, - "link": { "modal": { "url": { @@ -628,7 +623,6 @@ } } }, - "common": { "all": "全部", "states": "状态", @@ -756,7 +750,8 @@ "message": "发生错误。请重试。" }, "required": "此字段为必填项", - "entity_required": "{entity}为必填项" + "entity_required": "{entity}为必填项", + "restricted_entity": "{entity}已被限制" }, "update_link": "更新链接", "attach": "附加", @@ -856,6 +851,7 @@ "live": "实时", "change_history": "变更历史", "coming_soon": "即将推出", + "member": "成员", "members": "成员", "you": "你", "upgrade_cta": { @@ -871,22 +867,35 @@ "pending": "待处理", "invite": "邀请", "view": "查看", - "deactivated_user": "已停用用户" + "deactivated_user": "已停用用户", + "apply": "应用", + "applying": "应用中", + "users": "用户", + "admins": "管理员", + "guests": "访客", + "on_track": "进展顺利", + "off_track": "偏离轨道", + "at_risk": "有风险", + "timeline": "时间轴", + "completion": "完成", + "upcoming": "即将发生", + "completed": "已完成", + "in_progress": "进行中", + "planned": "已计划", + "paused": "暂停", + "no_of": "{entity} 的数量" }, - "chart": { "x_axis": "X轴", "y_axis": "Y轴", "metric": "指标" }, - "form": { "title": { "required": "标题为必填项", "max_length": "标题应少于 {length} 个字符" } }, - "entity": { "grouping_title": "{entity}分组", "priority": "{entity}优先级", @@ -910,7 +919,6 @@ "failed": "添加{entity}时出错" } }, - "epic": { "all": "所有史诗", "label": "{count, plural, one {史诗} other {史诗}}", @@ -928,7 +936,6 @@ "required": "史诗标题为必填项" } }, - "issue": { "label": "{count, plural, one {工作项} other {工作项}}", "all": "所有工作项", @@ -1091,11 +1098,12 @@ "select": { "error": "请至少选择一个工作项", "empty": "未选择工作项", - "add_selected": "添加所选工作项" + "add_selected": "添加所选工作项", + "select_all": "全选", + "deselect_all": "取消全选" }, "open_in_full_screen": "在全屏中打开工作项" }, - "attachment": { "error": "无法附加文件。请重新上传。", "only_one_file_allowed": "一次只能上传一个文件。", @@ -1103,7 +1111,6 @@ "drag_and_drop": "拖放到任意位置以上传", "delete": "删除附件" }, - "label": { "select": "选择标签", "create": { @@ -1113,7 +1120,6 @@ "type": "输入以添加新标签" } }, - "sub_work_item": { "update": { "success": "子工作项更新成功", @@ -1122,9 +1128,20 @@ "remove": { "success": "子工作项移除成功", "error": "移除子工作项时出错" + }, + "empty_state": { + "sub_list_filters": { + "title": "您没有符合您应用的过滤器的子工作项。", + "description": "要查看所有子工作项,请清除所有应用的过滤器。", + "action": "清除过滤器" + }, + "list_filters": { + "title": "您没有符合您应用的过滤器的工作项。", + "description": "要查看所有工作项,请清除所有应用的过滤器。", + "action": "清除过滤器" + } } }, - "view": { "label": "{count, plural, one {视图} other {视图}}", "create": { @@ -1134,7 +1151,6 @@ "label": "更新视图" } }, - "inbox_issue": { "status": { "pending": { @@ -1220,7 +1236,6 @@ } } }, - "workspace_creation": { "heading": "创建您的工作区", "subheading": "要开始使用 Plane,您需要创建或加入一个工作区。", @@ -1272,7 +1287,6 @@ } } }, - "workspace_dashboard": { "empty_state": { "general": { @@ -1288,7 +1302,6 @@ } } }, - "workspace_analytics": { "label": "分析", "page_label": "{workspace} - 分析", @@ -1320,20 +1333,38 @@ "custom": "自定义分析" }, "empty_state": { - "general": { - "title": "跟踪进度、工作量和分配。发现趋势、消除障碍并加快工作进度", - "description": "查看范围与需求、估算和范围蔓延。获取团队成员和团队的表现,确保您的项目按时运行。", - "primary_button": { - "text": "开始您的第一个项目", - "comic": { - "title": "分析在周期 + 模块中效果最佳", - "description": "首先,将您的工作项限定在周期中,如果可能的话,将跨越多个周期的工作项分组到模块中。在左侧导航栏中查看这两项。" - } - } + "customized_insights": { + "description": "分配给您的工作项将按状态分类显示在此处。", + "title": "暂无数据" + }, + "created_vs_resolved": { + "description": "随着时间推移创建和解决的工作项将显示在此处。", + "title": "暂无数据" + }, + "project_insights": { + "title": "暂无数据", + "description": "分配给您的工作项将按状态分类显示在此处。" } - } + }, + "created_vs_resolved": "已创建 vs 已解决", + "customized_insights": "自定义洞察", + "backlog_work_items": "待办的{entity}", + "active_projects": "活跃项目", + "trend_on_charts": "图表趋势", + "all_projects": "所有项目", + "summary_of_projects": "项目概览", + "project_insights": "项目洞察", + "started_work_items": "已开始的{entity}", + "total_work_items": "{entity}总数", + "total_projects": "项目总数", + "total_admins": "管理员总数", + "total_users": "用户总数", + "total_intake": "总收入", + "un_started_work_items": "未开始的{entity}", + "total_guests": "访客总数", + "completed_work_items": "已完成的{entity}", + "total": "{entity}总数" }, - "workspace_projects": { "label": "{count, plural, one {项目} other {项目}}", "create": { @@ -1407,7 +1438,6 @@ } } }, - "workspace_views": { "add_view": "添加视图", "empty_state": { @@ -1442,7 +1472,6 @@ } } }, - "workspace_settings": { "label": "工作区设置", "page_label": "{workspace} - 常规设置", @@ -1624,7 +1653,6 @@ } } }, - "profile": { "label": "个人资料", "page_label": "您的工作", @@ -1687,7 +1715,6 @@ } } }, - "project_settings": { "general": { "enter_project_id": "输入项目 ID", @@ -1814,7 +1841,6 @@ "auto_close_status": "自动关闭状态" } }, - "empty_state": { "labels": { "title": "尚无标签", @@ -1827,7 +1853,6 @@ } } }, - "project_cycles": { "add_cycle": "添加周期", "more_details": "更多详情", @@ -1953,7 +1978,6 @@ } } }, - "project_issues": { "empty_state": { "no_issues": { @@ -1982,7 +2006,6 @@ } } }, - "project_module": { "add_module": "添加模块", "update_module": "更新模块", @@ -2036,7 +2059,6 @@ } } }, - "project_views": { "empty_state": { "general": { @@ -2056,7 +2078,6 @@ } } }, - "project_page": { "empty_state": { "general": { @@ -2086,7 +2107,6 @@ } } }, - "command_k": { "empty_state": { "search": { @@ -2094,7 +2114,6 @@ } } }, - "issue_relation": { "empty_state": { "search": { @@ -2105,7 +2124,6 @@ } } }, - "issue_comment": { "empty_state": { "general": { @@ -2114,7 +2132,6 @@ } } }, - "notification": { "label": "收件箱", "page_label": "{workspace} - 收件箱", @@ -2171,7 +2188,6 @@ "custom": "自定义" } }, - "active_cycle": { "empty_state": { "progress": { @@ -2191,7 +2207,6 @@ } } }, - "disabled_project": { "empty_state": { "inbox": { @@ -2254,7 +2269,6 @@ } } }, - "stickies": { "title": "您的便签", "placeholder": "点击此处输入", @@ -2312,7 +2326,6 @@ } } }, - "role_details": { "guest": { "title": "访客", @@ -2327,7 +2340,6 @@ "description": "在工作区内所有权限均设置为允许。" } }, - "user_roles": { "product_or_project_manager": "产品/项目经理", "development_or_engineering": "开发/工程", @@ -2340,7 +2352,6 @@ "human_resources": "人力资源", "other": "其他" }, - "importer": { "github": { "title": "GitHub", @@ -2351,7 +2362,6 @@ "description": "从 Jira 项目和史诗导入工作项和史诗。" } }, - "exporter": { "csv": { "title": "CSV", @@ -2380,7 +2390,6 @@ "created": "已创建", "subscribed": "已订阅" }, - "themes": { "theme_options": { "system_preference": { @@ -2426,20 +2435,21 @@ "manual": "手动" } }, - "cycle": { "label": "{count, plural, one {周期} other {周期}}", "no_cycle": "无周期" }, - "module": { "label": "{count, plural, one {模块} other {模块}}", "no_module": "无模块" }, - "description_versions": { "last_edited_by": "最后编辑者", "previously_edited_by": "之前编辑者", "edited_by": "编辑者" + }, + "self_hosted_maintenance_message": { + "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane 未能启动。这可能是因为一个或多个 Plane 服务启动失败。", + "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "请选择“查看日志”来查看 setup.sh 和 Docker 日志,以确认问题。" } -} +} \ No newline at end of file diff --git a/packages/i18n/src/locales/zh-TW/accessibility.json b/packages/i18n/src/locales/zh-TW/accessibility.json new file mode 100644 index 000000000..75747f861 --- /dev/null +++ b/packages/i18n/src/locales/zh-TW/accessibility.json @@ -0,0 +1,34 @@ +{ + "aria_labels": { + "projects_sidebar": { + "workspace_logo": "工作空間標誌", + "open_workspace_switcher": "打開工作空間切換器", + "open_user_menu": "打開用戶選單", + "open_command_palette": "打開命令面板", + "open_extended_sidebar": "打開擴展側邊欄", + "close_extended_sidebar": "關閉擴展側邊欄", + "create_favorites_folder": "創建收藏夾文件夾", + "open_folder": "打開文件夾", + "close_folder": "關閉文件夾", + "open_favorites_menu": "打開收藏夾選單", + "close_favorites_menu": "關閉收藏夾選單", + "enter_folder_name": "輸入文件夾名稱", + "create_new_project": "創建新項目", + "open_projects_menu": "打開項目選單", + "close_projects_menu": "關閉項目選單", + "toggle_quick_actions_menu": "切換快速操作選單", + "open_project_menu": "打開項目選單", + "close_project_menu": "關閉項目選單", + "collapse_sidebar": "摺疊側邊欄", + "expand_sidebar": "展開側邊欄", + "edition_badge": "打開付費計劃模態框" + }, + "auth_forms": { + "clear_email": "清除電子郵件", + "show_password": "顯示密碼", + "hide_password": "隱藏密碼", + "close_alert": "關閉警告", + "close_popover": "關閉彈出框" + } + } +} diff --git a/packages/i18n/src/locales/zh-TW/editor.json b/packages/i18n/src/locales/zh-TW/editor.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/i18n/src/locales/zh-TW/editor.json @@ -0,0 +1 @@ +{} diff --git a/packages/i18n/src/locales/zh-TW/translations.json b/packages/i18n/src/locales/zh-TW/translations.json index f5de553bd..5f3165ecb 100644 --- a/packages/i18n/src/locales/zh-TW/translations.json +++ b/packages/i18n/src/locales/zh-TW/translations.json @@ -18,7 +18,6 @@ "pro": "專業版", "upgrade": "升級" }, - "auth": { "common": { "email": { @@ -168,7 +167,6 @@ } } }, - "submit": "送出", "cancel": "取消", "loading": "載入中", @@ -318,6 +316,8 @@ "failed_to_remove_project_from_favorites": "無法從我的最愛移除專案。請再試一次。", "project_created_successfully": "專案建立成功", "project_created_successfully_description": "專案建立成功。您現在可以開始新增工作事項。", + "project_name_already_taken": "專案名稱已被使用。", + "project_identifier_already_taken": "專案識別碼已被使用。", "project_cover_image_alt": "專案封面圖片", "name_is_required": "名稱為必填", "title_should_be_less_than_255_characters": "標題不應超過 255 個字元", @@ -367,12 +367,12 @@ "work_management": "工作管理", "projects_and_issues": "專案與工作事項", "projects_and_issues_description": "為此專案開啟或關閉這些功能。", - "cycles_description": "依需要為每個專案設定時間區段,依週期變更頻率。", - "modules_description": "將工作分組成類似子專案的設置,並擁有自己的負責人和指派對象。", - "views_description": "儲存排序、篩選和顯示選項以供稍後使用或分享。", - "pages_description": "撰寫任何內容,就像寫任何東西一樣。", - "intake_description": "持續追蹤您訂閱的工作事項。啟用此功能以接收通知。", - "time_tracking_description": "追蹤工作事項和專案的花費時間。", + "cycles_description": "為每個專案設定工作時間區段,並依需求調整週期。一個週期可以是兩週,下一個是一週。", + "modules_description": "將工作組織成子專案,並指派專屬的負責人與任務對象。", + "views_description": "儲存自訂排序、篩選和顯示選項,或與團隊分享。", + "pages_description": "建立與編輯自由格式內容:筆記、文件,任何內容皆可。", + "intake_description": "允許非成員分享錯誤、回饋和建議,而不會中斷您的工作流程。", + "time_tracking_description": "記錄在工作事項和專案上花費的時間。", "work_management_description": "輕鬆管理您的工作和專案。", "documentation": "文件", "message_support": "聯絡支援", @@ -504,7 +504,6 @@ "new_password_must_be_different_from_old_password": "新密碼必須與舊密碼不同", "edited": "已編輯", "bot": "機器人", - "project_view": { "sort_by": { "created_at": "建立時間", @@ -512,12 +511,10 @@ "name": "名稱" } }, - "toast": { "success": "成功!", "error": "錯誤!" }, - "links": { "toasts": { "created": { @@ -546,7 +543,6 @@ } } }, - "home": { "empty": { "quickstart_guide": "您的快速入門指南", @@ -614,7 +610,6 @@ "title": "首頁", "star_us_on_github": "在 GitHub 上給我們星星" }, - "link": { "modal": { "url": { @@ -628,7 +623,6 @@ } } }, - "common": { "all": "全部", "states": "狀態", @@ -756,7 +750,8 @@ "message": "發生錯誤。請再試一次。" }, "required": "此欄位為必填", - "entity_required": "{entity} 為必填" + "entity_required": "{entity} 為必填", + "restricted_entity": "{entity}已被限制" }, "update_link": "更新連結", "attach": "附加", @@ -857,6 +852,7 @@ "live": "即時", "change_history": "變更歷史記錄", "coming_soon": "即將推出", + "member": "成員", "members": "成員", "you": "您", "upgrade_cta": { @@ -872,22 +868,35 @@ "pending": "待處理", "invite": "邀請", "view": "檢視", - "deactivated_user": "已停用用戶" + "deactivated_user": "已停用用戶", + "apply": "應用", + "applying": "應用中", + "users": "使用者", + "admins": "管理員", + "guests": "訪客", + "on_track": "進展順利", + "off_track": "偏離軌道", + "timeline": "時間軸", + "completion": "完成", + "upcoming": "即將發生", + "completed": "已完成", + "in_progress": "進行中", + "planned": "已計劃", + "paused": "暫停", + "at_risk": "有風險", + "no_of": "{entity} 的數量" }, - "chart": { "x_axis": "X 軸", "y_axis": "Y 軸", "metric": "指標" }, - "form": { "title": { "required": "標題為必填", "max_length": "標題不應超過 {length} 個字元" } }, - "entity": { "grouping_title": "{entity} 分組", "priority": "{entity} 優先順序", @@ -911,7 +920,6 @@ "failed": "新增 {entity} 時發生錯誤" } }, - "epic": { "all": "所有 Epic", "label": "{count, plural, one {Epic} other {Epic}}", @@ -929,7 +937,6 @@ "required": "Epic 標題為必填。" } }, - "issue": { "label": "{count, plural, one {工作事項} other {工作事項}}", "all": "所有工作事項", @@ -1092,11 +1099,12 @@ "select": { "error": "請至少選擇一個工作事項", "empty": "未選擇工作事項", - "add_selected": "新增已選取的工作事項" + "add_selected": "新增已選取的工作事項", + "select_all": "全選", + "deselect_all": "取消全選" }, "open_in_full_screen": "以全螢幕開啟工作事項" }, - "attachment": { "error": "無法附加檔案。請重新上傳。", "only_one_file_allowed": "一次只能上傳一個檔案。", @@ -1104,7 +1112,6 @@ "drag_and_drop": "拖曳到任何位置以上傳", "delete": "刪除附件" }, - "label": { "select": "選擇標籤", "create": { @@ -1114,7 +1121,6 @@ "type": "輸入以新增標籤" } }, - "sub_work_item": { "update": { "success": "子工作事項更新成功", @@ -1123,9 +1129,20 @@ "remove": { "success": "子工作事項移除成功", "error": "移除子工作事項時發生錯誤" + }, + "empty_state": { + "sub_list_filters": { + "title": "您沒有符合您應用過的過濾器的子工作事項。", + "description": "要查看所有子工作事項,請清除所有應用過的過濾器。", + "action": "清除過濾器" + }, + "list_filters": { + "title": "您沒有符合您應用過的過濾器的工作事項。", + "description": "要查看所有工作事項,請清除所有應用過的過濾器。", + "action": "清除過濾器" + } } }, - "view": { "label": "{count, plural, one {檢視} other {檢視}}", "create": { @@ -1135,7 +1152,6 @@ "label": "更新檢視" } }, - "inbox_issue": { "status": { "pending": { @@ -1221,7 +1237,6 @@ } } }, - "workspace_creation": { "heading": "建立您的工作區", "subheading": "若要開始使用 Plane,您需要建立或加入工作區。", @@ -1273,7 +1288,6 @@ } } }, - "workspace_dashboard": { "empty_state": { "general": { @@ -1289,7 +1303,6 @@ } } }, - "workspace_analytics": { "label": "分析", "page_label": "{workspace} - 分析", @@ -1321,20 +1334,38 @@ "custom": "自訂分析" }, "empty_state": { - "general": { - "title": "追蹤進度、工作量和分配。發現趨勢、移除阻礙,加快工作進展", - "description": "檢視範圍與需求、評估和範圍擴展。取得團隊成員和團隊的績效,確保您的專案按時進行。", - "primary_button": { - "text": "開始您的第一個專案", - "comic": { - "title": "分析最適合搭配週期 + 模組使用", - "description": "首先,將您的工作事項時間區段到週期中,如果可以的話,將跨週期的工作事項分組到模組中。請檢視左側導覽列中的兩個功能。" - } - } + "customized_insights": { + "description": "指派給您的工作項目將依狀態分類顯示在此處。", + "title": "尚無資料" + }, + "created_vs_resolved": { + "description": "隨著時間推移所建立與解決的工作項目將顯示在此處。", + "title": "尚無資料" + }, + "project_insights": { + "title": "尚無資料", + "description": "指派給您的工作項目將依狀態分類顯示在此處。" } - } + }, + "created_vs_resolved": "已建立 vs 已解決", + "customized_insights": "自訂化洞察", + "backlog_work_items": "待辦的{entity}", + "active_projects": "啟用中的專案", + "trend_on_charts": "圖表趨勢", + "all_projects": "所有專案", + "summary_of_projects": "專案摘要", + "project_insights": "專案洞察", + "started_work_items": "已開始的{entity}", + "total_work_items": "{entity}總數", + "total_projects": "專案總數", + "total_admins": "管理員總數", + "total_users": "使用者總數", + "total_intake": "總收入", + "un_started_work_items": "未開始的{entity}", + "total_guests": "訪客總數", + "completed_work_items": "已完成的{entity}", + "total": "{entity}總數" }, - "workspace_projects": { "label": "{count, plural, one {專案} other {專案}}", "create": { @@ -1409,7 +1440,6 @@ } } }, - "workspace_views": { "add_view": "新增檢視", "empty_state": { @@ -1444,7 +1474,6 @@ } } }, - "workspace_settings": { "label": "工作區設定", "page_label": "{workspace} - 一般設定", @@ -1626,7 +1655,6 @@ } } }, - "profile": { "label": "個人資料", "page_label": "您的工作", @@ -1689,7 +1717,6 @@ } } }, - "project_settings": { "general": { "enter_project_id": "輸入專案 ID", @@ -1835,7 +1862,6 @@ "auto_close_status": "自動關閉狀態" } }, - "empty_state": { "labels": { "title": "尚無標籤", @@ -1848,7 +1874,6 @@ } } }, - "project_cycles": { "add_cycle": "新增週期", "more_details": "更多詳細資訊", @@ -1974,7 +1999,6 @@ } } }, - "project_issues": { "empty_state": { "no_issues": { @@ -2003,7 +2027,6 @@ } } }, - "project_module": { "add_module": "新增模組", "update_module": "更新模組", @@ -2057,7 +2080,6 @@ } } }, - "project_views": { "empty_state": { "general": { @@ -2077,7 +2099,6 @@ } } }, - "project_page": { "empty_state": { "general": { @@ -2107,7 +2128,6 @@ } } }, - "command_k": { "empty_state": { "search": { @@ -2115,7 +2135,6 @@ } } }, - "issue_relation": { "empty_state": { "search": { @@ -2126,7 +2145,6 @@ } } }, - "issue_comment": { "empty_state": { "general": { @@ -2135,7 +2153,6 @@ } } }, - "notification": { "label": "收件匣", "page_label": "{workspace} - 收件匣", @@ -2192,7 +2209,6 @@ "custom": "自訂" } }, - "active_cycle": { "empty_state": { "progress": { @@ -2212,7 +2228,6 @@ } } }, - "disabled_project": { "empty_state": { "inbox": { @@ -2275,7 +2290,6 @@ } } }, - "stickies": { "title": "您的便利貼", "placeholder": "點選此處輸入", @@ -2333,7 +2347,6 @@ } } }, - "role_details": { "guest": { "title": "訪客", @@ -2348,7 +2361,6 @@ "description": "工作區內的所有權限都設為允許。" } }, - "user_roles": { "product_or_project_manager": "產品/專案經理", "development_or_engineering": "開發/工程", @@ -2361,7 +2373,6 @@ "human_resources": "人力資源", "other": "其他" }, - "importer": { "github": { "title": "GitHub", @@ -2372,7 +2383,6 @@ "description": "從 Jira 專案和 Epic 匯入工作事項和 Epic。" } }, - "exporter": { "csv": { "title": "CSV", @@ -2401,7 +2411,6 @@ "created": "已建立", "subscribed": "已訂閱" }, - "themes": { "theme_options": { "system_preference": { @@ -2447,20 +2456,21 @@ "manual": "手動" } }, - "cycle": { "label": "{count, plural, one {週期} other {週期}}", "no_cycle": "無週期" }, - "module": { "label": "{count, plural, one {模組} other {模組}}", "no_module": "無模組" }, - "description_versions": { "last_edited_by": "最後編輯者", "previously_edited_by": "先前編輯者", "edited_by": "編輯者" + }, + "self_hosted_maintenance_message": { + "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane 未能啟動。這可能是因為一個或多個 Plane 服務啟動失敗。", + "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "從 setup.sh 和 Docker 日誌中選擇 View Logs 來確認。" } -} +} \ No newline at end of file diff --git a/packages/i18n/src/store/index.ts b/packages/i18n/src/store/index.ts index ff4cee107..c75d7b8a3 100644 --- a/packages/i18n/src/store/index.ts +++ b/packages/i18n/src/store/index.ts @@ -3,7 +3,7 @@ import get from "lodash/get"; import merge from "lodash/merge"; import { makeAutoObservable, runInAction } from "mobx"; // constants -import { FALLBACK_LANGUAGE, SUPPORTED_LANGUAGES, LANGUAGE_STORAGE_KEY } from "../constants"; +import { FALLBACK_LANGUAGE, SUPPORTED_LANGUAGES, LANGUAGE_STORAGE_KEY, ETranslationFiles } from "../constants"; // core translations imports import coreEn from "../locales/en/core.json"; // types @@ -130,54 +130,32 @@ export class TranslationStore { } } + /** + * Helper function to import and merge multiple translation files for a language + * @param language - The language code + * @param files - Array of file names to import (without .json extension) + * @returns Promise that resolves to merged translations + */ + private async importAndMergeFiles(language: TLanguage, files: string[]): Promise { + try { + const importPromises = files.map((file) => import(`../locales/${language}/${file}.json`)); + + const modules = await Promise.all(importPromises); + const merged = modules.reduce((acc, module) => merge(acc, module.default), {}); + return { default: merged }; + } catch (error) { + throw new Error(`Failed to import and merge files for ${language}: ${error}`); + } + } + /** * Imports the translations for the given language * @param language - The language to import the translations for * @returns {Promise} */ - private importLanguageFile(language: TLanguage): Promise { - switch (language) { - case "en": - return import("../locales/en/translations.json"); - case "fr": - return import("../locales/fr/translations.json"); - case "es": - return import("../locales/es/translations.json"); - case "ja": - return import("../locales/ja/translations.json"); - case "zh-CN": - return import("../locales/zh-CN/translations.json"); - case "zh-TW": - return import("../locales/zh-TW/translations.json"); - case "ru": - return import("../locales/ru/translations.json"); - case "it": - return import("../locales/it/translations.json"); - case "cs": - return import("../locales/cs/translations.json"); - case "sk": - return import("../locales/sk/translations.json"); - case "de": - return import("../locales/de/translations.json"); - case "ua": - return import("../locales/ua/translations.json"); - case "pl": - return import("../locales/pl/translations.json"); - case "ko": - return import("../locales/ko/translations.json"); - case "pt-BR": - return import("../locales/pt-BR/translations.json"); - case "id": - return import("../locales/id/translations.json"); - case "ro": - return import("../locales/ro/translations.json"); - case "vi-VN": - return import("../locales/vi-VN/translations.json"); - case "tr-TR": - return import("../locales/tr-TR/translations.json"); - default: - throw new Error(`Unsupported language: ${language}`); - } + private async importLanguageFile(language: TLanguage): Promise { + const files = Object.values(ETranslationFiles); + return this.importAndMergeFiles(language, files); } /** Checks if the language is valid based on the supported languages */ diff --git a/packages/logger/.eslintrc.js b/packages/logger/.eslintrc.js index 558b8f76e..b11b7bb6d 100644 --- a/packages/logger/.eslintrc.js +++ b/packages/logger/.eslintrc.js @@ -3,7 +3,4 @@ module.exports = { root: true, extends: ["@plane/eslint-config/library.js"], parser: "@typescript-eslint/parser", - parserOptions: { - project: true, - }, }; diff --git a/packages/logger/package.json b/packages/logger/package.json index bc4a91c77..c81c467c2 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -1,14 +1,21 @@ { "name": "@plane/logger", - "version": "0.26.1", + "version": "0.27.0", "license": "AGPL-3.0", "description": "Logger shared across multiple apps internally", "private": true, - "main": "./src/index.ts", - "types": "./src/index.ts", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist/**" + ], "scripts": { + "build": "tsup", + "dev": "tsup --watch", "lint": "eslint src --ext .ts,.tsx", - "lint:errors": "eslint src --ext .ts,.tsx --quiet" + "lint:errors": "eslint src --ext .ts,.tsx --quiet", + "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" }, "dependencies": { "winston": "^3.17.0", @@ -17,6 +24,7 @@ "devDependencies": { "@plane/eslint-config": "*", "@types/node": "^22.5.4", - "typescript": "^5.3.3" + "tsup": "8.4.0", + "typescript": "5.8.3" } } diff --git a/packages/logger/tsup.config.ts b/packages/logger/tsup.config.ts new file mode 100644 index 000000000..85bf72fce --- /dev/null +++ b/packages/logger/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + splitting: false, + sourcemap: true, + clean: true, + external: ["winston", "winston-daily-rotate-file"], + treeshake: true, +}); diff --git a/packages/propel/package.json b/packages/propel/package.json index cd55e9349..e6922c718 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -1,6 +1,6 @@ { "name": "@plane/propel", - "version": "0.26.1", + "version": "0.27.0", "private": true, "license": "AGPL-3.0", "scripts": { @@ -9,10 +9,13 @@ }, "exports": { "./ui/*": "./src/ui/*.tsx", - "./charts/*": "./src/charts/*/index.ts" + "./charts/*": "./src/charts/*/index.ts", + "./table": "./src/table/index.ts", + "./styles/fonts": "./src/styles/fonts/index.css" }, "dependencies": { "@radix-ui/react-slot": "^1.1.1", + "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "lucide-react": "^0.469.0", "react": "^18.3.1", @@ -26,6 +29,6 @@ "@plane/typescript-config": "*", "@types/react": "18.3.1", "@types/react-dom": "18.3.0", - "typescript": "^5.3.3" + "typescript": "5.8.3" } } diff --git a/packages/propel/postcss.config.js b/packages/propel/postcss.config.js index 12a703d90..9b1e55fc4 100644 --- a/packages/propel/postcss.config.js +++ b/packages/propel/postcss.config.js @@ -1,6 +1,2 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = require("@plane/tailwind-config/postcss.config.js"); diff --git a/packages/propel/src/charts/area-chart/root.tsx b/packages/propel/src/charts/area-chart/root.tsx index 7d4e9e6ba..b90de27cf 100644 --- a/packages/propel/src/charts/area-chart/root.tsx +++ b/packages/propel/src/charts/area-chart/root.tsx @@ -29,13 +29,21 @@ export const AreaChart = React.memo((props: // states const [activeArea, setActiveArea] = useState(null); const [activeLegend, setActiveLegend] = useState(null); + // derived values - const itemKeys = useMemo(() => areas.map((area) => area.key), [areas]); - const itemLabels: Record = useMemo( - () => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.label }), {}), - [areas] - ); - const itemDotColors = useMemo(() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.fill }), {}), [areas]); + const { itemKeys, itemLabels, itemDotColors } = useMemo(() => { + const keys: string[] = []; + const labels: Record = {}; + const colors: Record = {}; + + for (const area of areas) { + keys.push(area.key); + labels[area.key] = area.label; + colors[area.key] = area.fill; + } + + return { itemKeys: keys, itemLabels: labels, itemDotColors: colors }; + }, [areas]); const renderAreas = useMemo( () => @@ -77,7 +85,7 @@ export const AreaChart = React.memo((props: // get the last data point const lastPoint = data[data.length - 1]; // for the y-value in the last point, use its yAxis key value - const lastYValue = lastPoint[yAxis.key] || 0; + const lastYValue = lastPoint[yAxis.key] ?? 0; // create data for a straight line that has points at each x-axis position return data.map((item, index) => { // calculate the y value for this point on the straight line @@ -91,7 +99,6 @@ export const AreaChart = React.memo((props: }; }); }, [data, xAxis.key]); - return (
@@ -128,8 +135,8 @@ export const AreaChart = React.memo((props: value: yAxis.label, angle: -90, position: "bottom", - offset: -24, - dx: -16, + offset: yAxis.offset ?? -24, + dx: yAxis.dx ?? -16, className: AXIS_LABEL_CLASSNAME, } } diff --git a/packages/propel/src/charts/bar-chart/bar.tsx b/packages/propel/src/charts/bar-chart/bar.tsx index 5cc9dac2f..a13e154b2 100644 --- a/packages/propel/src/charts/bar-chart/bar.tsx +++ b/packages/propel/src/charts/bar-chart/bar.tsx @@ -1,10 +1,38 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import React from "react"; // plane imports -import { TChartData } from "@plane/types"; +import { TBarChartShapeVariant, TBarItem, TChartData } from "@plane/types"; import { cn } from "@plane/utils"; -// Helper to calculate percentage +// Constants +const MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT = 14; // Minimum height required to show text inside bar +const BAR_TOP_BORDER_RADIUS = 4; // Border radius for the top of bars +const BAR_BOTTOM_BORDER_RADIUS = 4; // Border radius for the bottom of bars +const DEFAULT_LOLLIPOP_LINE_WIDTH = 2; // Width of lollipop stick +const DEFAULT_LOLLIPOP_CIRCLE_RADIUS = 8; // Radius of lollipop circle + +// Types +interface TShapeProps { + x: number; + y: number; + width: number; + height: number; + dataKey: string; + payload: any; + opacity?: number; +} + +interface TBarProps extends TShapeProps { + fill: string | ((payload: any) => string); + stackKeys: string[]; + textClassName?: string; + showPercentage?: boolean; + showTopBorderRadius?: boolean; + showBottomBorderRadius?: boolean; + dotted?: boolean; +} + +// Helper Functions const calculatePercentage = ( data: TChartData, stackKeys: T[], @@ -14,11 +42,36 @@ const calculatePercentage = ( return total === 0 ? 0 : Math.round((data[currentKey] / total) * 100); }; -const MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT = 14; // Minimum height needed to show text inside -const BAR_TOP_BORDER_RADIUS = 4; // Border radius for each bar -const BAR_BOTTOM_BORDER_RADIUS = 4; // Border radius for each bar +const getBarPath = (x: number, y: number, width: number, height: number, topRadius: number, bottomRadius: number) => ` + M${x},${y + topRadius} + Q${x},${y} ${x + topRadius},${y} + L${x + width - topRadius},${y} + Q${x + width},${y} ${x + width},${y + topRadius} + L${x + width},${y + height - bottomRadius} + Q${x + width},${y + height} ${x + width - bottomRadius},${y + height} + L${x + bottomRadius},${y + height} + Q${x},${y + height} ${x},${y + height - bottomRadius} + Z +`; -export const CustomBar = React.memo((props: any) => { +const PercentageText = ({ + x, + y, + percentage, + className, +}: { + x: number; + y: number; + percentage: number; + className?: string; +}) => ( + + {percentage}% + +); + +// Base Components +const CustomBar = React.memo((props: TBarProps) => { const { opacity, fill, @@ -34,56 +87,104 @@ export const CustomBar = React.memo((props: any) => { showTopBorderRadius, showBottomBorderRadius, } = props; - // Calculate text position - const TEXT_PADDING_Y = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT - height / 2)); - const textY = y + height - TEXT_PADDING_Y; // Position inside bar if tall enough - // derived values + + if (!height) return null; + const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey); + const TEXT_PADDING_Y = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT - height / 2)); + const textY = y + height - TEXT_PADDING_Y; + const showText = - // from props showPercentage && - // height of the bar is greater than or equal to the minimum height required to show the text height >= MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT && - // bar percentage text has some value currentBarPercentage !== undefined && - // bar percentage is a number !Number.isNaN(currentBarPercentage); const topBorderRadius = showTopBorderRadius ? BAR_TOP_BORDER_RADIUS : 0; const bottomBorderRadius = showBottomBorderRadius ? BAR_BOTTOM_BORDER_RADIUS : 0; - if (!height) return null; - return ( {showText && ( - - {currentBarPercentage}% - + )} ); }); + +const CustomBarLollipop = React.memo((props: TBarProps) => { + const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage, dotted } = props; + + const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey); + + return ( + + + + {showPercentage && ( + + )} + + ); +}); + +// Shape Variants +/** + * Factory function to create shape variants with consistent props + * @param Component - The base component to render + * @param factoryProps - Additional props to pass to the component + * @returns A function that creates the shape with proper props + */ +const createShapeVariant = + (Component: React.ComponentType, factoryProps?: Partial) => + (shapeProps: TShapeProps, bar: TBarItem, stackKeys: string[]): JSX.Element => { + const showTopBorderRadius = bar.showTopBorderRadius?.(shapeProps.dataKey, shapeProps.payload); + const showBottomBorderRadius = bar.showBottomBorderRadius?.(shapeProps.dataKey, shapeProps.payload); + + return ( + + ); + }; + +export const barShapeVariants: Record< + TBarChartShapeVariant, + (props: TShapeProps, bar: TBarItem, stackKeys: string[]) => JSX.Element +> = { + bar: createShapeVariant(CustomBar), // Standard bar with rounded corners + lollipop: createShapeVariant(CustomBarLollipop), // Line with circle at top + "lollipop-dotted": createShapeVariant(CustomBarLollipop, { dotted: true }), // Dotted line lollipop variant +}; + +// Display names CustomBar.displayName = "CustomBar"; +CustomBarLollipop.displayName = "CustomBarLollipop"; diff --git a/packages/propel/src/charts/bar-chart/root.tsx b/packages/propel/src/charts/bar-chart/root.tsx index abe936d5c..e66242524 100644 --- a/packages/propel/src/charts/bar-chart/root.tsx +++ b/packages/propel/src/charts/bar-chart/root.tsx @@ -19,7 +19,7 @@ import { TBarChartProps } from "@plane/types"; import { getLegendProps } from "../components/legend"; import { CustomXAxisTick, CustomYAxisTick } from "../components/tick"; import { CustomTooltip } from "../components/tooltip"; -import { CustomBar } from "./bar"; +import { barShapeVariants } from "./bar"; export const BarChart = React.memo((props: TBarChartProps) => { const { @@ -36,17 +36,27 @@ export const BarChart = React.memo((props: T y: 10, }, showTooltip = true, + customTooltipContent, } = props; // states const [activeBar, setActiveBar] = useState(null); const [activeLegend, setActiveLegend] = useState(null); + // derived values - const stackKeys = useMemo(() => bars.map((bar) => bar.key), [bars]); - const stackLabels: Record = useMemo( - () => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.label }), {}), - [bars] - ); - const stackDotColors = useMemo(() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.fill }), {}), [bars]); + const { stackKeys, stackLabels, stackDotColors } = useMemo(() => { + const keys: string[] = []; + const labels: Record = {}; + const colors: Record = {}; + + for (const bar of bars) { + keys.push(bar.key); + labels[bar.key] = bar.label; + // For tooltip, we need a string color. If fill is a function, use a default color + colors[bar.key] = typeof bar.fill === "function" ? "#000000" : bar.fill; + } + + return { stackKeys: keys, stackLabels: labels, stackDotColors: colors }; + }, [bars]); const renderBars = useMemo( () => @@ -57,20 +67,8 @@ export const BarChart = React.memo((props: T stackId={bar.stackId} opacity={!!activeLegend && activeLegend !== bar.key ? 0.1 : 1} shape={(shapeProps: any) => { - const showTopBorderRadius = bar.showTopBorderRadius?.(shapeProps.dataKey, shapeProps.payload); - const showBottomBorderRadius = bar.showBottomBorderRadius?.(shapeProps.dataKey, shapeProps.payload); - - return ( - - ); + const shapeVariant = barShapeVariants[bar.shapeVariant ?? "bar"]; + return shapeVariant(shapeProps, bar, stackKeys); }} className="[&_path]:transition-opacity [&_path]:duration-200" onMouseEnter={() => setActiveBar(bar.key)} @@ -102,7 +100,7 @@ export const BarChart = React.memo((props: T axisLine={false} label={{ value: xAxis.label, - dy: 28, + dy: xAxis.dy ?? 28, className: AXIS_LABEL_CLASSNAME, }} tickCount={tickCount.x} @@ -115,8 +113,8 @@ export const BarChart = React.memo((props: T value: yAxis.label, angle: -90, position: "bottom", - offset: -24, - dx: -16, + offset: yAxis.offset ?? -24, + dx: yAxis.dx ?? -16, className: AXIS_LABEL_CLASSNAME, }} tick={(props) => } @@ -141,17 +139,20 @@ export const BarChart = React.memo((props: T wrapperStyle={{ pointerEvents: "auto", }} - content={({ active, label, payload }) => ( - - )} + content={({ active, label, payload }) => { + if (customTooltipContent) return customTooltipContent({ active, label, payload }); + return ( + + ); + }} /> )} {renderBars} diff --git a/packages/propel/src/charts/components/legend.tsx b/packages/propel/src/charts/components/legend.tsx index 2be69c5cb..3c4558120 100644 --- a/packages/propel/src/charts/components/legend.tsx +++ b/packages/propel/src/charts/components/legend.tsx @@ -15,16 +15,17 @@ export const getLegendProps = (args: TChartLegend): LegendProps => { overflow: "hidden", ...(layout === "vertical" ? { - top: 0, - alignItems: "center", - height: "100%", - } + top: 0, + alignItems: "center", + height: "100%", + } : { - left: 0, - bottom: 0, - width: "100%", - justifyContent: "center", - }), + left: 0, + bottom: 0, + width: "100%", + justifyContent: "center", + }), + ...args.wrapperStyles, }, content: , }; @@ -33,8 +34,8 @@ export const getLegendProps = (args: TChartLegend): LegendProps => { const CustomLegend = React.forwardRef< HTMLDivElement, React.ComponentProps<"div"> & - Pick & - TChartLegend + Pick & + TChartLegend >((props, ref) => { const { formatter, layout, onClick, onMouseEnter, onMouseLeave, payload } = props; diff --git a/packages/propel/src/charts/components/tick.tsx b/packages/propel/src/charts/components/tick.tsx index e26e25ef3..4b64e8373 100644 --- a/packages/propel/src/charts/components/tick.tsx +++ b/packages/propel/src/charts/components/tick.tsx @@ -4,10 +4,10 @@ import React from "react"; // Common classnames const AXIS_TICK_CLASSNAME = "fill-custom-text-300 text-sm"; -export const CustomXAxisTick = React.memo(({ x, y, payload }: any) => ( +export const CustomXAxisTick = React.memo(({ x, y, payload, getLabel }: any) => ( - {payload.value} + {getLabel ? getLabel(payload.value) : payload.value} )); @@ -20,4 +20,28 @@ export const CustomYAxisTick = React.memo(({ x, y, payload }: any) => ( )); + CustomYAxisTick.displayName = "CustomYAxisTick"; + +export const CustomRadarAxisTick = React.memo( + ({ x, y, payload, getLabel, cx, cy, offset = 16 }: any) => { + // Calculate direction vector from center to tick + const dx = x - cx; + const dy = y - cy; + // Normalize and apply offset + const length = Math.sqrt(dx * dx + dy * dy); + const normX = dx / length; + const normY = dy / length; + const labelX = x + normX * offset; + const labelY = y + normY * offset; + + return ( + + + {getLabel ? getLabel(payload.value) : payload.value} + + + ); + } +); +CustomRadarAxisTick.displayName = "CustomRadarAxisTick"; diff --git a/packages/propel/src/charts/line-chart/root.tsx b/packages/propel/src/charts/line-chart/root.tsx index 6812797b7..28a02fc30 100644 --- a/packages/propel/src/charts/line-chart/root.tsx +++ b/packages/propel/src/charts/line-chart/root.tsx @@ -38,13 +38,21 @@ export const LineChart = React.memo((props: // states const [activeLine, setActiveLine] = useState(null); const [activeLegend, setActiveLegend] = useState(null); + // derived values - const itemKeys = useMemo(() => lines.map((line) => line.key), [lines]); - const itemLabels: Record = useMemo( - () => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.label }), {}), - [lines] - ); - const itemDotColors = useMemo(() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.stroke }), {}), [lines]); + const { itemKeys, itemLabels, itemDotColors } = useMemo(() => { + const keys: string[] = []; + const labels: Record = {}; + const colors: Record = {}; + + for (const line of lines) { + keys.push(line.key); + labels[line.key] = line.label; + colors[line.key] = line.stroke; + } + + return { itemKeys: keys, itemLabels: labels, itemDotColors: colors }; + }, [lines]); const renderLines = useMemo( () => @@ -114,7 +122,7 @@ export const LineChart = React.memo((props: angle: -90, position: "bottom", offset: -24, - dx: -16, + dx: yAxis.dx ?? -16, className: AXIS_LABEL_CLASSNAME, } } diff --git a/packages/propel/src/charts/pie-chart/root.tsx b/packages/propel/src/charts/pie-chart/root.tsx index 1110260b9..f49acfd7e 100644 --- a/packages/propel/src/charts/pie-chart/root.tsx +++ b/packages/propel/src/charts/pie-chart/root.tsx @@ -128,7 +128,7 @@ export const PieChart = React.memo((props: T className: "text-custom-background-90/80 cursor-pointer", }} wrapperStyle={{ - pointerEvents: "auto", + pointerEvents: "none", }} content={({ active, payload }) => { if (!active || !payload || !payload.length) return null; diff --git a/packages/propel/src/charts/radar-chart/index.ts b/packages/propel/src/charts/radar-chart/index.ts new file mode 100644 index 000000000..50a9c47c0 --- /dev/null +++ b/packages/propel/src/charts/radar-chart/index.ts @@ -0,0 +1 @@ +export * from "./root"; \ No newline at end of file diff --git a/packages/propel/src/charts/radar-chart/root.tsx b/packages/propel/src/charts/radar-chart/root.tsx new file mode 100644 index 000000000..b8a1b95d7 --- /dev/null +++ b/packages/propel/src/charts/radar-chart/root.tsx @@ -0,0 +1,95 @@ +import { useMemo, useState } from "react"; +import { + PolarGrid, + Radar, + RadarChart as CoreRadarChart, + ResponsiveContainer, + PolarAngleAxis, + Tooltip, + Legend, +} from "recharts"; +import { TRadarChartProps } from "@plane/types"; +import { getLegendProps } from "../components/legend"; +import { CustomRadarAxisTick } from "../components/tick"; +import { CustomTooltip } from "../components/tooltip"; + +const RadarChart = (props: TRadarChartProps) => { + const { data, radars, margin, showTooltip, legend, className, angleAxis } = props; + + // states + const [, setActiveIndex] = useState(null); + const [activeLegend, setActiveLegend] = useState(null); + + const { itemKeys, itemLabels, itemDotColors } = useMemo(() => { + const keys: string[] = []; + const labels: Record = {}; + const colors: Record = {}; + + for (const radar of radars) { + keys.push(radar.key); + labels[radar.key] = radar.name; + colors[radar.key] = radar.stroke ?? radar.fill ?? "#000000"; + } + return { itemKeys: keys, itemLabels: labels, itemDotColors: colors }; + }, [radars]); + + return ( +
+ + + + } /> + {showTooltip && ( + ( + + )} + /> + )} + {legend && ( + // @ts-expect-error recharts types are not up to date + { + // @ts-expect-error recharts types are not up to date + const key: string | undefined = payload.payload?.key; + if (!key) return; + setActiveLegend(key); + setActiveIndex(null); + }} + onMouseLeave={() => setActiveLegend(null)} + {...getLegendProps(legend)} + /> + )} + {radars.map((radar) => ( + + ))} + + +
+ ); +}; + +export { RadarChart }; diff --git a/packages/propel/src/charts/scatter-chart/index.ts b/packages/propel/src/charts/scatter-chart/index.ts new file mode 100644 index 000000000..50a9c47c0 --- /dev/null +++ b/packages/propel/src/charts/scatter-chart/index.ts @@ -0,0 +1 @@ +export * from "./root"; \ No newline at end of file diff --git a/packages/propel/src/charts/scatter-chart/root.tsx b/packages/propel/src/charts/scatter-chart/root.tsx new file mode 100644 index 000000000..5187d131b --- /dev/null +++ b/packages/propel/src/charts/scatter-chart/root.tsx @@ -0,0 +1,159 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +"use client"; + +import React, { useMemo, useState } from "react"; +import { + CartesianGrid, + ScatterChart as CoreScatterChart, + Legend, + Scatter, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +// plane imports +import { AXIS_LABEL_CLASSNAME } from "@plane/constants"; +import { TScatterChartProps } from "@plane/types"; +// local components +import { getLegendProps } from "../components/legend"; +import { CustomXAxisTick, CustomYAxisTick } from "../components/tick"; +import { CustomTooltip } from "../components/tooltip"; + +export const ScatterChart = React.memo((props: TScatterChartProps) => { + const { + data, + scatterPoints, + margin, + xAxis, + yAxis, + className, + tickCount = { + x: undefined, + y: 10, + }, + legend, + showTooltip = true, + customTooltipContent, + } = props; + // states + const [activePoint, setActivePoint] = useState(null); + const [activeLegend, setActiveLegend] = useState(null); + + //derived values + const { itemKeys, itemLabels, itemDotColors } = useMemo(() => { + const keys: string[] = []; + const labels: Record = {}; + const colors: Record = {}; + + for (const point of scatterPoints) { + keys.push(point.key); + labels[point.key] = point.label; + colors[point.key] = point.fill; + } + return { itemKeys: keys, itemLabels: labels, itemDotColors: colors }; + }, [scatterPoints]); + + const renderPoints = useMemo( + () => + scatterPoints.map((point) => ( + setActivePoint(point.key)} + onMouseLeave={() => setActivePoint(null)} + /> + )), + [activeLegend, scatterPoints] + ); + + return ( +
+ + + + } + tickLine={false} + axisLine={false} + label={ + xAxis.label && { + value: xAxis.label, + dy: 28, + className: AXIS_LABEL_CLASSNAME, + } + } + tickCount={tickCount.x} + /> + } + tickCount={tickCount.y} + allowDecimals={!!yAxis.allowDecimals} + /> + {legend && ( + // @ts-expect-error recharts types are not up to date + setActiveLegend(payload.value)} + onMouseLeave={() => setActiveLegend(null)} + formatter={(value) => itemLabels[value]} + {...getLegendProps(legend)} + /> + )} + {showTooltip && ( + + customTooltipContent ? ( + customTooltipContent({ active, label, payload }) + ) : ( + + ) + } + /> + )} + {renderPoints} + + +
+ ); +}); +ScatterChart.displayName = "ScatterChart"; \ No newline at end of file diff --git a/packages/propel/src/globals.css b/packages/propel/src/globals.css deleted file mode 100644 index ee2896808..000000000 --- a/packages/propel/src/globals.css +++ /dev/null @@ -1,12 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer base { - * { - @apply border-border; - } - body { - @apply font-sans antialiased bg-background text-foreground; - } -} diff --git a/web/styles/fonts/Inter/LICENSE b/packages/propel/src/styles/fonts/Inter/LICENSE similarity index 100% rename from web/styles/fonts/Inter/LICENSE rename to packages/propel/src/styles/fonts/Inter/LICENSE diff --git a/web/styles/fonts/Inter/inter-v13-latin-200.woff2 b/packages/propel/src/styles/fonts/Inter/inter-v13-latin-200.woff2 similarity index 100% rename from web/styles/fonts/Inter/inter-v13-latin-200.woff2 rename to packages/propel/src/styles/fonts/Inter/inter-v13-latin-200.woff2 diff --git a/web/styles/fonts/Inter/inter-v13-latin-300.woff2 b/packages/propel/src/styles/fonts/Inter/inter-v13-latin-300.woff2 similarity index 100% rename from web/styles/fonts/Inter/inter-v13-latin-300.woff2 rename to packages/propel/src/styles/fonts/Inter/inter-v13-latin-300.woff2 diff --git a/web/styles/fonts/Inter/inter-v13-latin-500.woff2 b/packages/propel/src/styles/fonts/Inter/inter-v13-latin-500.woff2 similarity index 100% rename from web/styles/fonts/Inter/inter-v13-latin-500.woff2 rename to packages/propel/src/styles/fonts/Inter/inter-v13-latin-500.woff2 diff --git a/web/styles/fonts/Inter/inter-v13-latin-600.woff2 b/packages/propel/src/styles/fonts/Inter/inter-v13-latin-600.woff2 similarity index 100% rename from web/styles/fonts/Inter/inter-v13-latin-600.woff2 rename to packages/propel/src/styles/fonts/Inter/inter-v13-latin-600.woff2 diff --git a/web/styles/fonts/Inter/inter-v13-latin-700.woff2 b/packages/propel/src/styles/fonts/Inter/inter-v13-latin-700.woff2 similarity index 100% rename from web/styles/fonts/Inter/inter-v13-latin-700.woff2 rename to packages/propel/src/styles/fonts/Inter/inter-v13-latin-700.woff2 diff --git a/web/styles/fonts/Inter/inter-v13-latin-800.woff2 b/packages/propel/src/styles/fonts/Inter/inter-v13-latin-800.woff2 similarity index 100% rename from web/styles/fonts/Inter/inter-v13-latin-800.woff2 rename to packages/propel/src/styles/fonts/Inter/inter-v13-latin-800.woff2 diff --git a/web/styles/fonts/Inter/inter-v13-latin-regular.woff2 b/packages/propel/src/styles/fonts/Inter/inter-v13-latin-regular.woff2 similarity index 100% rename from web/styles/fonts/Inter/inter-v13-latin-regular.woff2 rename to packages/propel/src/styles/fonts/Inter/inter-v13-latin-regular.woff2 diff --git a/web/styles/fonts/Material-Symbols-Rounded/LICENSE b/packages/propel/src/styles/fonts/Material-Symbols-Rounded/LICENSE similarity index 100% rename from web/styles/fonts/Material-Symbols-Rounded/LICENSE rename to packages/propel/src/styles/fonts/Material-Symbols-Rounded/LICENSE diff --git a/web/styles/fonts/Material-Symbols-Rounded/material-symbols-rounded-v168-latin-regular.woff2 b/packages/propel/src/styles/fonts/Material-Symbols-Rounded/material-symbols-rounded-v168-latin-regular.woff2 similarity index 100% rename from web/styles/fonts/Material-Symbols-Rounded/material-symbols-rounded-v168-latin-regular.woff2 rename to packages/propel/src/styles/fonts/Material-Symbols-Rounded/material-symbols-rounded-v168-latin-regular.woff2 diff --git a/web/styles/fonts/main.css b/packages/propel/src/styles/fonts/index.css similarity index 63% rename from web/styles/fonts/main.css rename to packages/propel/src/styles/fonts/index.css index 7263a01a9..7d6779d22 100644 --- a/web/styles/fonts/main.css +++ b/packages/propel/src/styles/fonts/index.css @@ -4,7 +4,7 @@ font-family: Inter; font-style: normal; font-weight: 200; - src: url("fonts/Inter/inter-v13-latin-200.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + src: url("./Inter/inter-v13-latin-200.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ } /* inter-300 - latin */ @@ -13,7 +13,7 @@ font-family: Inter; font-style: normal; font-weight: 300; - src: url("fonts/Inter/inter-v13-latin-300.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + src: url("./Inter/inter-v13-latin-300.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ } /* inter-regular - latin */ @@ -22,7 +22,7 @@ font-family: Inter; font-style: normal; font-weight: 405; - src: url("fonts/Inter/inter-v13-latin-regular.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + src: url("./Inter/inter-v13-latin-regular.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ } /* inter-500 - latin */ @@ -31,7 +31,16 @@ font-family: Inter; font-style: normal; font-weight: 500; - src: url("fonts/Inter/inter-v13-latin-500.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + src: url("./Inter/inter-v13-latin-500.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} + +/* inter-600 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: Inter; + font-style: normal; + font-weight: 600; + src: url("./Inter/inter-v13-latin-600.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ } /* inter-700 - latin */ @@ -40,7 +49,7 @@ font-family: Inter; font-style: normal; font-weight: 700; - src: url("fonts/Inter/inter-v13-latin-700.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + src: url("./Inter/inter-v13-latin-700.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ } /* inter-800 - latin */ @@ -49,7 +58,7 @@ font-family: Inter; font-style: normal; font-weight: 800; - src: url("fonts/Inter/inter-v13-latin-800.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + src: url("./Inter/inter-v13-latin-800.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ } /* material-symbols-rounded-regular - latin */ @@ -58,7 +67,7 @@ font-family: "Material Symbols Rounded"; font-style: normal; font-weight: 400; - src: url("fonts/Material-Symbols-Rounded/material-symbols-rounded-v168-latin-regular.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + src: url("./Material-Symbols-Rounded/material-symbols-rounded-v168-latin-regular.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ } .material-symbols-rounded { diff --git a/packages/propel/src/table/core.tsx b/packages/propel/src/table/core.tsx new file mode 100644 index 000000000..577b79b2e --- /dev/null +++ b/packages/propel/src/table/core.tsx @@ -0,0 +1,76 @@ +import * as React from "react"; + +import { cn } from "@plane/utils"; + +const Table = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ + + ) +); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +); +TableHeader.displayName = "TableHeader"; + +const TableBody = React.forwardRef>( + ({ className, ...props }, ref) => +); +TableBody.displayName = "TableBody"; + +const TableFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +); +TableFooter.displayName = "TableFooter"; + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +); +TableRow.displayName = "TableRow"; + +const TableHead = React.forwardRef>(({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)); +TableHead.displayName = "TableHead"; + +const TableCell = React.forwardRef>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]", className)} + {...props} + /> +)); +TableCell.displayName = "TableCell"; + +const TableCaption = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +TableCaption.displayName = "TableCaption"; + +export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }; diff --git a/packages/propel/src/table/index.ts b/packages/propel/src/table/index.ts new file mode 100644 index 000000000..8b83d73fe --- /dev/null +++ b/packages/propel/src/table/index.ts @@ -0,0 +1 @@ +export * from "./core"; \ No newline at end of file diff --git a/packages/propel/tsconfig.json b/packages/propel/tsconfig.json index 1f695a242..f811f5e05 100644 --- a/packages/propel/tsconfig.json +++ b/packages/propel/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "@plane/typescript-config/react-library.json", "compilerOptions": { - "outDir": "dist" + "jsx": "react", + "lib": ["esnext", "dom"] }, "include": ["src"], "exclude": ["node_modules", "dist"] diff --git a/packages/services/package.json b/packages/services/package.json index 0ef4ed415..449e9efed 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -1,6 +1,6 @@ { "name": "@plane/services", - "version": "0.26.1", + "version": "0.27.0", "license": "AGPL-3.0", "private": true, "main": "./src/index.ts", diff --git a/packages/services/src/analytics/analytics.service.ts b/packages/services/src/analytics/analytics.service.ts deleted file mode 100644 index c012fd26f..000000000 --- a/packages/services/src/analytics/analytics.service.ts +++ /dev/null @@ -1,93 +0,0 @@ -// constants -import { API_BASE_URL } from "@plane/constants"; -// types -import { - IAnalyticsParams, - IAnalyticsResponse, - IDefaultAnalyticsResponse, - IExportAnalyticsFormData, - ISaveAnalyticsFormData, -} from "@plane/types"; -// services -import { APIService } from "../api.service"; - -export class AnalyticsService extends APIService { - constructor(BASE_URL?: string) { - super(BASE_URL || API_BASE_URL); - } - - /** - * Retrieves analytics data for a specific workspace - * @param {string} workspaceSlug - The unique identifier for the workspace - * @param {IAnalyticsParams} params - Parameters for filtering analytics data - * @param {string|number} [params.project] - Optional project identifier that will be converted to string - * @returns {Promise} The analytics data for the workspace - * @throws {Error} Throws response data if the request fails - */ - async getAnalytics(workspaceSlug: string, params: IAnalyticsParams): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/analytics/`, { - params: { - ...params, - project: params?.project ? params.project.toString() : null, - }, - }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - /** - * Retrieves default analytics data for a workspace - * @param {string} workspaceSlug - The unique identifier for the workspace - * @param {Partial} [params] - Optional parameters for filtering default analytics - * @param {string|number} [params.project] - Optional project identifier that will be converted to string - * @returns {Promise} The default analytics data - * @throws {Error} Throws response data if the request fails - */ - async getDefaultAnalytics( - workspaceSlug: string, - params?: Partial - ): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/default-analytics/`, { - params: { - ...params, - project: params?.project ? params.project.toString() : null, - }, - }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - /** - * Saves analytics view configuration for a workspace - * @param {string} workspaceSlug - The unique identifier for the workspace - * @param {ISaveAnalyticsFormData} data - The analytics configuration data to save - * @returns {Promise} The response from saving the analytics view - * @throws {Error} Throws response data if the request fails - */ - async save(workspaceSlug: string, data: ISaveAnalyticsFormData): Promise { - return this.post(`/api/workspaces/${workspaceSlug}/analytic-view/`, data) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - /** - * Exports analytics data for a workspace - * @param {string} workspaceSlug - The unique identifier for the workspace - * @param {IExportAnalyticsFormData} data - Configuration for the analytics export - * @returns {Promise} The exported analytics data - * @throws {Error} Throws response data if the request fails - */ - async export(workspaceSlug: string, data: IExportAnalyticsFormData): Promise { - return this.post(`/api/workspaces/${workspaceSlug}/export-analytics/`, data) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } -} diff --git a/packages/services/src/analytics/index.ts b/packages/services/src/analytics/index.ts deleted file mode 100644 index 7655bd442..000000000 --- a/packages/services/src/analytics/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./analytics.service"; diff --git a/packages/services/src/developer/api-token.service.ts b/packages/services/src/developer/api-token.service.ts index 74dc9135d..703ec9d32 100644 --- a/packages/services/src/developer/api-token.service.ts +++ b/packages/services/src/developer/api-token.service.ts @@ -9,12 +9,11 @@ export class APITokenService extends APIService { /** * Retrieves all API tokens for a specific workspace - * @param {string} workspaceSlug - The unique identifier for the workspace * @returns {Promise} Array of API tokens associated with the workspace * @throws {Error} Throws response data if the request fails */ - async list(workspaceSlug: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/api-tokens/`) + async list(): Promise { + return this.get(`/api/users/api-tokens/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; @@ -23,13 +22,12 @@ export class APITokenService extends APIService { /** * Retrieves a specific API token by its ID - * @param {string} workspaceSlug - The unique identifier for the workspace * @param {string} tokenId - The unique identifier of the API token * @returns {Promise} The requested API token's details * @throws {Error} Throws response data if the request fails */ - async retrieve(workspaceSlug: string, tokenId: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/api-tokens/${tokenId}`) + async retrieve(tokenId: string): Promise { + return this.get(`/api/users/api-tokens/${tokenId}`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; @@ -38,13 +36,12 @@ export class APITokenService extends APIService { /** * Creates a new API token for a workspace - * @param {string} workspaceSlug - The unique identifier for the workspace * @param {Partial} data - The data for creating the new API token * @returns {Promise} The newly created API token * @throws {Error} Throws response data if the request fails */ - async create(workspaceSlug: string, data: Partial): Promise { - return this.post(`/api/workspaces/${workspaceSlug}/api-tokens/`, data) + async create(data: Partial): Promise { + return this.post(`/api/users/api-tokens/`, data) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; @@ -53,13 +50,12 @@ export class APITokenService extends APIService { /** * Deletes a specific API token from the workspace - * @param {string} workspaceSlug - The unique identifier for the workspace * @param {string} tokenId - The unique identifier of the API token to delete * @returns {Promise} The deleted API token's details * @throws {Error} Throws response data if the request fails */ - async destroy(workspaceSlug: string, tokenId: string): Promise { - return this.delete(`/api/workspaces/${workspaceSlug}/api-tokens/${tokenId}`) + async destroy(tokenId: string): Promise { + return this.delete(`/api/users/api-tokens/${tokenId}`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; diff --git a/packages/services/src/file/file.service.ts b/packages/services/src/file/file.service.ts index 59c054faf..32edd4eb4 100644 --- a/packages/services/src/file/file.service.ts +++ b/packages/services/src/file/file.service.ts @@ -1,6 +1,7 @@ // plane imports import { API_BASE_URL } from "@plane/constants"; // api service +import { TDuplicateAssetData, TDuplicateAssetResponse } from "@plane/types"; import { APIService } from "../api.service"; // helpers import { getAssetIdFromUrl } from "./helper"; @@ -64,4 +65,19 @@ export class FileService extends APIService { throw error?.response?.data; }); } + + /** + * Duplicates assets + * @param {string} workspaceSlug - The workspace slug + * @param {TDuplicateAssetData} data - The data for the duplicate assets + * @returns {Promise} Promise resolving to a record of asset IDs + * @throws {Error} If the request fails + */ + async duplicateAssets(workspaceSlug: string, data: TDuplicateAssetData): Promise { + return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/duplicate-assets/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } diff --git a/packages/services/src/index.ts b/packages/services/src/index.ts index 3c4908462..b1f966840 100644 --- a/packages/services/src/index.ts +++ b/packages/services/src/index.ts @@ -1,5 +1,4 @@ export * from "./ai"; -export * from "./analytics"; export * from "./developer"; export * from "./auth"; export * from "./cycle"; diff --git a/packages/shared-state/package.json b/packages/shared-state/package.json index eaf231f38..167c1794a 100644 --- a/packages/shared-state/package.json +++ b/packages/shared-state/package.json @@ -1,6 +1,6 @@ { "name": "@plane/shared-state", - "version": "0.26.1", + "version": "0.27.0", "license": "AGPL-3.0", "description": "Shared state shared across multiple apps internally", "private": true, @@ -16,6 +16,6 @@ "devDependencies": { "@plane/eslint-config": "*", "@types/node": "^22.5.4", - "typescript": "^5.3.3" + "typescript": "5.8.3" } } diff --git a/packages/tailwind-config/package.json b/packages/tailwind-config/package.json index 88e35df4a..8e6f51822 100644 --- a/packages/tailwind-config/package.json +++ b/packages/tailwind-config/package.json @@ -1,11 +1,12 @@ { "name": "@plane/tailwind-config", - "version": "0.26.1", + "version": "0.27.0", "license": "AGPL-3.0", "description": "common tailwind configuration across monorepo", "main": "tailwind.config.js", "private": true, "devDependencies": { + "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/typography": "^0.5.9", "autoprefixer": "^10.4.14", "postcss": "^8.4.38", diff --git a/packages/tailwind-config/tailwind.config.js b/packages/tailwind-config/tailwind.config.js index 700831d12..168c54e62 100644 --- a/packages/tailwind-config/tailwind.config.js +++ b/packages/tailwind-config/tailwind.config.js @@ -461,14 +461,15 @@ module.exports = { "onboarding-gradient-200": "var( --gradient-onboarding-200)", "onboarding-gradient-300": "var( --gradient-onboarding-300)", }, - }, - fontFamily: { - custom: ["Inter", "sans-serif"], + fontFamily: { + custom: ["Inter", "sans-serif"], + }, }, }, plugins: [ require("tailwindcss-animate"), require("@tailwindcss/typography"), + require("@tailwindcss/container-queries"), function ({ addUtilities }) { const newUtilities = { // Mobile screens diff --git a/packages/types/package.json b/packages/types/package.json index 609e3537a..2249a9cec 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@plane/types", - "version": "0.26.1", + "version": "0.27.0", "license": "AGPL-3.0", "private": true, "types": "./src/index.d.ts", diff --git a/packages/types/src/analytics.d.ts b/packages/types/src/analytics.d.ts index ec417e73f..80c773fa2 100644 --- a/packages/types/src/analytics.d.ts +++ b/packages/types/src/analytics.d.ts @@ -1,116 +1,67 @@ +import { ChartXAxisProperty, ChartYAxisMetric } from "@plane/constants"; +import { TChartData } from "./charts"; +import { Row } from "@tanstack/react-table"; + +export type TAnalyticsTabsBase = "overview" | "work-items"; +export type TAnalyticsGraphsBase = "projects" | "work-items" | "custom-work-items"; +export interface AnalyticsTab { + key: TAnalyticsTabsBase; + label: string; + content: React.FC; + isDisabled: boolean; +} +export type TAnalyticsFilterParams = { + project_ids?: string; + cycle_id?: string; + module_id?: string; +}; + +// service types + export interface IAnalyticsResponse { - total: number; - distribution: IAnalyticsData; - extras: { - assignee_details: IAnalyticsAssigneeDetails[]; - cycle_details: IAnalyticsCycleDetails[]; - label_details: IAnalyticsLabelDetails[]; - module_details: IAnalyticsModuleDetails[]; - state_details: IAnalyticsStateDetails[]; - }; + [key: string]: any; } -export interface IAnalyticsData { - [key: string]: { - dimension: string | null; - segment?: string; - count?: number; - estimate?: number | null; - }[]; +export interface IAnalyticsResponseFields { + count: number; + filter_count: number; } -export interface IAnalyticsAssigneeDetails { - assignees__avatar_url: string | null; - assignees__display_name: string | null; - assignees__first_name: string; - assignees__id: string | null; - assignees__last_name: string; +// chart types + +export interface IChartResponse { + schema: Record; + data: TChartData[]; } -export interface IAnalyticsCycleDetails { - issue_cycle__cycle__name: string | null; - issue_cycle__cycle_id: string | null; +// table types + +export interface WorkItemInsightColumns { + project_id?: string; + project__name?: string; + cancelled_work_items: number; + completed_work_items: number; + backlog_work_items: number; + un_started_work_items: number; + started_work_items: number; + // incase of peek view, we will display the display_name instead of project__name + display_name?: string; + avatar_url?: string; + assignee_id?: string; } -export interface IAnalyticsLabelDetails { - labels__color: string | null; - labels__id: string | null; - labels__name: string | null; -} - -export interface IAnalyticsModuleDetails { - issue_module__module__name: string | null; - issue_module__module_id: string | null; -} - -export interface IAnalyticsStateDetails { - state__color: string; - state__name: string; - state_id: string; -} - -export type TXAxisValues = - | "state_id" - | "state__group" - | "labels__id" - | "assignees__id" - | "estimate_point__value" - | "issue_cycle__cycle_id" - | "issue_module__module_id" - | "priority" - | "start_date" - | "target_date" - | "created_at" - | "completed_at"; - -export type TYAxisValues = "issue_count" | "estimate"; +export type AnalyticsTableDataMap = { + "work-items": WorkItemInsightColumns; +}; export interface IAnalyticsParams { - x_axis: TXAxisValues; - y_axis: TYAxisValues; - segment?: TXAxisValues | null; - project?: string[] | null; - cycle?: string | null; - module?: string | null; + x_axis: ChartXAxisProperty; + y_axis: ChartYAxisMetric; + group_by?: ChartXAxisProperty; } -export interface ISaveAnalyticsFormData { - name: string; - description: string; - query_dict: IExportAnalyticsFormData; -} -export interface IExportAnalyticsFormData { - x_axis: TXAxisValues; - y_axis: TYAxisValues; - segment?: TXAxisValues | null; - project?: string[]; -} - -export interface IDefaultAnalyticsUser { - assignees__avatar_url: string | null; - assignees__first_name: string; - assignees__last_name: string; - assignees__display_name: string; - assignees__id: string; - count: number; -} - -export interface IDefaultAnalyticsResponse { - issue_completed_month_wise: { month: number; count: number }[]; - most_issue_closed_user: IDefaultAnalyticsUser[]; - most_issue_created_user: { - created_by__avatar_url: string | null; - created_by__first_name: string; - created_by__last_name: string; - created_by__display_name: string; - created_by__id: string; - count: number; - }[]; - open_estimate_sum: number; - open_issues: number; - open_issues_classified: { state_group: string; state_count: number }[]; - pending_issue_user: IDefaultAnalyticsUser[]; - total_estimate_sum: number; - total_issues: number; - total_issues_classified: { state_group: string; state_count: number }[]; -} +export type ExportConfig = { + key: string; + value: (row: Row) => string | number; + label?: string; +}; diff --git a/packages/types/src/calendar.d.ts b/packages/types/src/calendar.d.ts index cb27e2d10..348d93b1f 100644 --- a/packages/types/src/calendar.d.ts +++ b/packages/types/src/calendar.d.ts @@ -2,3 +2,28 @@ export interface ICalendarRange { startDate: Date; endDate: Date; } + +export interface ICalendarDate { + date: Date; + year: number; + month: number; + day: number; + week: number; // week number wrt year, eg- 51, 52 + is_current_month: boolean; + is_current_week: boolean; + is_today: boolean; +} + +export interface ICalendarWeek { + [date: string]: ICalendarDate; +} + +export interface ICalendarMonth { + [monthIndex: string]: { + [weekNumber: string]: ICalendarWeek; + }; +} + +export interface ICalendarPayload { + [year: string]: ICalendarMonth; +} diff --git a/packages/types/src/charts/common.d.ts b/packages/types/src/charts/common.d.ts new file mode 100644 index 000000000..85034c2fe --- /dev/null +++ b/packages/types/src/charts/common.d.ts @@ -0,0 +1,16 @@ + + +export type TChartColorScheme = "modern" | "horizon" | "earthen"; + +export type TChartDatum = { + key: string; + name: string; + count: number; +} & Record; + +export type TChart = { + data: TChartDatum[]; + schema: Record; +}; + + diff --git a/packages/types/src/charts.d.ts b/packages/types/src/charts/index.d.ts similarity index 61% rename from packages/types/src/charts.d.ts rename to packages/types/src/charts/index.d.ts index b1fc2997d..685aed214 100644 --- a/packages/types/src/charts.d.ts +++ b/packages/types/src/charts/index.d.ts @@ -1,7 +1,12 @@ +// ============================================================ +// Chart Base +// ============================================================ +export * from "./common"; export type TChartLegend = { align: "left" | "center" | "right"; verticalAlign: "top" | "middle" | "bottom"; layout: "horizontal" | "vertical"; + wrapperStyles?: React.CSSProperties; }; export type TChartMargin = { @@ -22,6 +27,7 @@ type TChartProps = { key: keyof TChartData; label?: string; strokeColor?: string; + dy?: number; }; yAxis: { allowDecimals?: boolean; @@ -29,6 +35,8 @@ type TChartProps = { key: keyof TChartData; label?: string; strokeColor?: string; + offset?: number; + dx?: number; }; className?: string; legend?: TChartLegend; @@ -38,8 +46,15 @@ type TChartProps = { y?: number; }; showTooltip?: boolean; + customTooltipContent?: (props: { active?: boolean; label: string; payload: any }) => React.ReactNode; }; +// ============================================================ +// Bar Chart +// ============================================================ + +export type TBarChartShapeVariant = "bar" | "lollipop" | "lollipop-dotted"; + export type TBarItem = { key: T; label: string; @@ -49,6 +64,7 @@ export type TBarItem = { stackId: string; showTopBorderRadius?: (barKey: string, payload: any) => boolean; showBottomBorderRadius?: (barKey: string, payload: any) => boolean; + shapeVariant?: TBarChartShapeVariant; }; export type TBarChartProps = TChartProps & { @@ -56,6 +72,10 @@ export type TBarChartProps = TChartProps = { key: T; label: string; @@ -71,6 +91,25 @@ export type TLineChartProps = TChartProps[]; }; +// ============================================================ +// Scatter Chart +// ============================================================ + +export type TScatterPointItem = { + key: T; + label: string; + fill: string; + stroke: string; +}; + +export type TScatterChartProps = TChartProps & { + scatterPoints: TScatterPointItem[]; +}; + +// ============================================================ +// Area Chart +// ============================================================ + export type TAreaItem = { key: T; label: string; @@ -92,6 +131,10 @@ export type TAreaChartProps = TChartProps = { key: T; fill: string; @@ -119,6 +162,10 @@ export type TPieChartProps = Pick< customLegend?: (props: any) => React.ReactNode; }; +// ============================================================ +// Tree Map +// ============================================================ + export type TreeMapItem = { name: string; value: number; @@ -158,3 +205,32 @@ export type TContentVisibility = { top: TTopSectionConfig; bottom: TBottomSectionConfig; }; + +// ============================================================ +// Radar Chart +// ============================================================ + +export type TRadarItem = { + key: T; + name: string; + fill?: string; + stroke?: string; + fillOpacity?: number; + dot?: { + r: number; + fillOpacity: number; + }; +}; + +export type TRadarChartProps = Pick< + TChartProps, + "className" | "showTooltip" | "margin" | "data" | "legend" +> & { + dataKey: T; + radars: TRadarItem[]; + angleAxis: { + key: keyof TChartData; + label?: string; + strokeColor?: string; + }; +}; diff --git a/packages/types/src/cycle/cycle.d.ts b/packages/types/src/cycle/cycle.d.ts index 638d974e6..218219914 100644 --- a/packages/types/src/cycle/cycle.d.ts +++ b/packages/types/src/cycle/cycle.d.ts @@ -136,3 +136,16 @@ export type TPublicCycle = { name: string; status: string; }; + +export type TProgressChartData = { + date: string; + scope: number; + completed: number; + backlog: number; + started: number; + unstarted: number; + cancelled: number; + pending: number; + ideal: number; + actual: number; +}[]; diff --git a/packages/types/src/enums.ts b/packages/types/src/enums.ts index 53138a1d7..7776e9f24 100644 --- a/packages/types/src/enums.ts +++ b/packages/types/src/enums.ts @@ -67,3 +67,19 @@ export enum EFileAssetType { PROJECT_DESCRIPTION = "PROJECT_DESCRIPTION", TEAM_SPACE_COMMENT_DESCRIPTION = "TEAM_SPACE_COMMENT_DESCRIPTION", } + +export type TEditorAssetType = + | EFileAssetType.COMMENT_DESCRIPTION + | EFileAssetType.ISSUE_DESCRIPTION + | EFileAssetType.DRAFT_ISSUE_DESCRIPTION + | EFileAssetType.PAGE_DESCRIPTION + | EFileAssetType.TEAM_SPACE_DESCRIPTION + | EFileAssetType.INITIATIVE_DESCRIPTION + | EFileAssetType.PROJECT_DESCRIPTION + | EFileAssetType.TEAM_SPACE_COMMENT_DESCRIPTION; + +export enum EUpdateStatus { + OFF_TRACK = "OFF-TRACK", + ON_TRACK = "ON-TRACK", + AT_RISK = "AT-RISK", +} diff --git a/packages/types/src/file.d.ts b/packages/types/src/file.d.ts index 8bcaade6c..d26533221 100644 --- a/packages/types/src/file.d.ts +++ b/packages/types/src/file.d.ts @@ -1,16 +1,16 @@ -import { EFileAssetType } from "./enums" +import { EFileAssetType } from "./enums"; export type TFileMetaDataLite = { name: string; // file size in bytes size: number; type: string; -} +}; export type TFileEntityInfo = { entity_identifier: string; entity_type: EFileAssetType; -} +}; export type TFileMetaData = TFileMetaDataLite & TFileEntityInfo; @@ -29,4 +29,13 @@ export type TFileSignedURLResponse = { "x-amz-signature": string; }; }; -}; \ No newline at end of file +}; + +export type TDuplicateAssetData = { + entity_id: string; + entity_type: EFileAssetType; + project_id?: string; + asset_ids: string[]; +}; + +export type TDuplicateAssetResponse = Record; // asset_id -> new_asset_id diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index b6af3b562..ba70ec5c7 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -10,7 +10,7 @@ export * from "./issues"; export * from "./module"; export * from "./views"; export * from "./integration"; -export * from "./pages"; +export * from "./page"; export * from "./ai"; export * from "./estimate"; export * from "./importer"; @@ -43,3 +43,5 @@ export * from "./home"; export * from "./stickies"; export * from "./utils"; export * from "./payment"; +export * from "./layout"; +export * from "./analytics"; diff --git a/packages/types/src/instance/ai.d.ts b/packages/types/src/instance/ai.d.ts index 0ac34557a..5bfd1a6ba 100644 --- a/packages/types/src/instance/ai.d.ts +++ b/packages/types/src/instance/ai.d.ts @@ -1 +1 @@ -export type TInstanceAIConfigurationKeys = "OPENAI_API_KEY" | "GPT_ENGINE"; +export type TInstanceAIConfigurationKeys = "LLM_API_KEY" | "LLM_MODEL"; diff --git a/packages/types/src/instance/base.d.ts b/packages/types/src/instance/base.d.ts index dc5ee5fc7..79b1e642f 100644 --- a/packages/types/src/instance/base.d.ts +++ b/packages/types/src/instance/base.d.ts @@ -49,7 +49,7 @@ export interface IInstanceConfig { posthog_api_key: string | undefined; posthog_host: string | undefined; has_unsplash_configured: boolean; - has_openai_configured: boolean; + has_llm_configured: boolean; file_size_limit: number | undefined; is_smtp_configured: boolean; app_base_url: string | undefined; diff --git a/packages/types/src/issues.d.ts b/packages/types/src/issues.d.ts index a630d0ba2..fc7c14ed8 100644 --- a/packages/types/src/issues.d.ts +++ b/packages/types/src/issues.d.ts @@ -220,6 +220,11 @@ export type GroupByColumnTypes = | "created_by" | "team_project"; +type TGetColumns = { + isWorkspaceLevel?: boolean; + projectId?: string; +}; + export interface IGroupByColumn { id: string; name: string; diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index 18a150c49..01c0b2f3a 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -1,4 +1,4 @@ -import { EIssueServiceType } from "@plane/constants"; +import { EIssueServiceType, EIssuesStoreType } from "@plane/constants"; import { TIssuePriorities } from "../issues"; import { TIssueAttachment } from "./issue_attachment"; import { TIssueLink } from "./issue_link"; @@ -64,6 +64,7 @@ export type TIssue = TBaseIssue & { tempId?: string; // sourceIssueId is used to store the original issue id when creating a copy of an issue. Used in cloning property values. It is not a part of the API response. sourceIssueId?: string; + state__group?: string | null; }; export type TIssueMap = { @@ -118,7 +119,7 @@ export type TBulkOperationsPayload = { properties: Partial; }; -export type TIssueDetailWidget = "sub-issues" | "relations" | "links" | "attachments"; +export type TWorkItemWidgets = "sub-work-items" | "relations" | "links" | "attachments"; export type TIssueServiceType = EIssueServiceType.ISSUES | EIssueServiceType.EPICS | EIssueServiceType.WORK_ITEMS; @@ -180,3 +181,10 @@ export type TPublicIssuesResponse = { extra_stats: null; results: TPublicIssueResponseResults; }; + +export interface IWorkItemPeekOverview { + embedIssue?: boolean; + embedRemoveCurrentNotification?: () => void; + is_draft?: boolean; + storeType?: EIssuesStoreType; +} \ No newline at end of file diff --git a/packages/types/src/issues/issue_sub_issues.d.ts b/packages/types/src/issues/issue_sub_issues.d.ts index e604761ed..d78d69503 100644 --- a/packages/types/src/issues/issue_sub_issues.d.ts +++ b/packages/types/src/issues/issue_sub_issues.d.ts @@ -10,9 +10,11 @@ export type TSubIssuesStateDistribution = { export type TIssueSubIssues = { state_distribution: TSubIssuesStateDistribution; - sub_issues: TIssue[]; + sub_issues: TSubIssueResponse; }; +export type TSubIssueResponse = TIssue[] | { [key: string]: TIssue[] }; + export type TIssueSubIssuesStateDistributionMap = { [issue_id: string]: TSubIssuesStateDistribution; }; @@ -20,3 +22,20 @@ export type TIssueSubIssuesStateDistributionMap = { export type TIssueSubIssuesIdMap = { [issue_id: string]: string[]; }; + +export type TSubIssueOperations = { + copyLink: (path: string) => void; + fetchSubIssues: (workspaceSlug: string, projectId: string, parentIssueId: string) => Promise; + addSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => Promise; + updateSubIssue: ( + workspaceSlug: string, + projectId: string, + parentIssueId: string, + issueId: string, + issueData: Partial, + oldIssue?: Partial, + fromModal?: boolean + ) => Promise; + removeSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => Promise; + deleteSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => Promise; +}; diff --git a/web/core/components/gantt-chart/types/index.ts b/packages/types/src/layout/gantt.d.ts similarity index 94% rename from web/core/components/gantt-chart/types/index.ts rename to packages/types/src/layout/gantt.d.ts index ad5b2afde..990ae3fc3 100644 --- a/web/core/components/gantt-chart/types/index.ts +++ b/packages/types/src/layout/gantt.d.ts @@ -9,6 +9,7 @@ export interface IGanttBlock { sort_order: number | undefined; start_date: string | undefined; target_date: string | undefined; + project_id: string | undefined; } export interface IBlockUpdateData { @@ -25,6 +26,7 @@ export interface IBlockUpdateDependencyData { id: string; start_date?: string; target_date?: string; + project_id?: string; } export type TGanttViews = "week" | "month" | "quarter"; diff --git a/packages/types/src/layout/index.ts b/packages/types/src/layout/index.ts new file mode 100644 index 000000000..88de77a54 --- /dev/null +++ b/packages/types/src/layout/index.ts @@ -0,0 +1 @@ +export * from "./gantt"; diff --git a/packages/types/src/pages.d.ts b/packages/types/src/page/core.d.ts similarity index 91% rename from packages/types/src/pages.d.ts rename to packages/types/src/page/core.d.ts index 183d015bf..5dcc44149 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/page/core.d.ts @@ -1,9 +1,9 @@ -import { TLogoProps } from "./common"; -import { EPageAccess } from "./enums"; +import { TLogoProps } from "../common"; +import { EPageAccess } from "../enums"; +import { TPageExtended } from "./extended"; -export type TPage = { +export type TPage = TPageExtended & { access: EPageAccess | undefined; - anchor?: string | null | undefined; archived_at: string | null | undefined; color: string | undefined; created_at: Date | undefined; @@ -16,7 +16,6 @@ export type TPage = { name: string | undefined; owned_by: string | undefined; project_ids?: string[] | undefined; - team: string | null | undefined; updated_at: Date | undefined; updated_by: string | undefined; workspace: string | undefined; diff --git a/packages/types/src/page/extended.d.ts b/packages/types/src/page/extended.d.ts new file mode 100644 index 000000000..7edfae828 --- /dev/null +++ b/packages/types/src/page/extended.d.ts @@ -0,0 +1 @@ +export type TPageExtended = {}; diff --git a/packages/types/src/page/index.d.ts b/packages/types/src/page/index.d.ts new file mode 100644 index 000000000..c6c1c2a06 --- /dev/null +++ b/packages/types/src/page/index.d.ts @@ -0,0 +1,2 @@ +export * from "./core"; +export * from "./extended"; diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts index e1d9117a1..9d6b03ab1 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -1,14 +1,5 @@ import { EUserProjectRoles } from "@plane/constants"; -import type { - IProjectViewProps, - IUser, - IUserLite, - IUserMemberLite, - IWorkspace, - IWorkspaceLite, - TLogoProps, - TStateGroups, -} from ".."; +import type { IUser, IUserLite, IWorkspace, TLogoProps, TStateGroups } from ".."; import { TUserPermissions } from "../enums"; export interface IPartialProject { @@ -91,30 +82,21 @@ export interface IProjectMemberLite { member_id: string; } -export interface IProjectMember { - id: string; - member: IUserMemberLite; - project: IProjectLite; - workspace: IWorkspaceLite; - comment: string; - role: TUserPermissions; - - preferences: ProjectPreferences; - - view_props: IProjectViewProps; - default_props: IProjectViewProps; - - created_at: Date; - updated_at: Date; - created_by: string; - updated_by: string; -} - -export interface IProjectMembership { - id: string; +export type TProjectMembership = { member: string; - role: TUserPermissions; -} + role: TUserPermissions | EUserProjectRoles; +} & ( + | { + id: string; + original_role: EUserProjectRoles; + created_at: string; + } + | { + id: null; + original_role: null; + created_at: null; + } +); export interface IProjectBulkAddFormData { members: { role: TUserPermissions | EUserProjectRoles; member_id: string }[]; @@ -159,3 +141,7 @@ export interface ISearchIssueResponse { workspace__slug: string; type_id: string; } + +export type TPartialProject = IPartialProject; + +export type TProject = TPartialProject & IProject; diff --git a/packages/types/src/search.d.ts b/packages/types/src/search.d.ts index 41138a46e..413ca6bc0 100644 --- a/packages/types/src/search.d.ts +++ b/packages/types/src/search.d.ts @@ -1,7 +1,7 @@ import { ICycle } from "./cycle"; import { TIssue } from "./issues/issue"; import { IModule } from "./module"; -import { TPage } from "./pages"; +import { TPage } from "./page"; import { IProject } from "./project"; import { IUser } from "./users"; import { IWorkspace } from "./workspace"; diff --git a/packages/types/src/state.d.ts b/packages/types/src/state.d.ts index d28194dc9..38d0abe12 100644 --- a/packages/types/src/state.d.ts +++ b/packages/types/src/state.d.ts @@ -12,6 +12,7 @@ export interface IState { project_id: string; sequence: number; workspace_id: string; + order: number; } export interface IStateLite { diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index e5140fdef..7694c2406 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -1,3 +1,4 @@ +import { EStartOfTheWeek } from "@plane/constants"; import { IIssueActivity, TIssuePriorities, TStateGroups } from "."; import { TUserPermissions } from "./enums"; @@ -11,6 +12,7 @@ export interface IUserLite { id: string; is_bot: boolean; last_name: string; + joining_date?: string; } export interface IUser extends IUserLite { // only for uploading the cover image @@ -64,6 +66,7 @@ export type TUserProfile = { language: string; created_at: Date | string; updated_at: Date | string; + start_of_the_week: EStartOfTheWeek; }; export interface IInstanceAdminStatus { @@ -76,6 +79,8 @@ export interface IUserSettings { workspace: { last_workspace_id: string | undefined; last_workspace_slug: string | undefined; + last_workspace_name: string | undefined; + last_workspace_logo: string | undefined; fallback_workspace_id: string | undefined; fallback_workspace_slug: string | undefined; invites: number | undefined; @@ -155,14 +160,7 @@ export interface IUserProfileProjectSegregation { id: string; pending_issues: number; }[]; - user_data: Pick< - IUser, - | "avatar_url" - | "cover_image_url" - | "display_name" - | "first_name" - | "last_name" - > & { + user_data: Pick & { date_joined: Date; user_timezone: string; }; diff --git a/packages/types/src/workspace.d.ts b/packages/types/src/workspace.d.ts index 4393a911f..0b81e3b91 100644 --- a/packages/types/src/workspace.d.ts +++ b/packages/types/src/workspace.d.ts @@ -1,4 +1,4 @@ -import type { ICycle, IProjectMember, IUser, IUserLite, IWorkspaceViewProps, TPaginationInfo } from "@plane/types"; +import type { ICycle, TProjectMembership, IUser, IUserLite, IWorkspaceViewProps, TPaginationInfo } from "@plane/types"; import { EUserWorkspaceRoles } from "@plane/constants"; // TODO: check if importing this over here causes circular dependency import { TUserPermissions } from "./enums"; @@ -93,7 +93,7 @@ export interface IWorkspaceMemberMe { export interface ILastActiveWorkspaceDetails { workspace_details: IWorkspace; - project_details?: IProjectMember[]; + project_details?: TProjectMembership[]; } export interface IWorkspaceDefaultSearchResult { diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index e190ff3c8..f5b3d1a85 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@plane/typescript-config", - "version": "0.26.1", + "version": "0.27.0", "license": "AGPL-3.0", "private": true, "files": [ diff --git a/packages/ui/package.json b/packages/ui/package.json index d09dd64d9..5b3cab5cd 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@plane/ui", "description": "UI components shared across multiple apps internally", "private": true, - "version": "0.26.1", + "version": "0.27.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", @@ -71,7 +71,7 @@ "postcss-cli": "^11.0.0", "postcss-nested": "^6.0.1", "storybook": "^8.1.1", - "tsup": "^8.4.0", - "typescript": "5.3.3" + "tsup": "8.4.0", + "typescript": "5.8.3" } } diff --git a/packages/ui/src/avatar/avatar.tsx b/packages/ui/src/avatar/avatar.tsx index 0c57cceba..84a8ab895 100644 --- a/packages/ui/src/avatar/avatar.tsx +++ b/packages/ui/src/avatar/avatar.tsx @@ -160,7 +160,7 @@ export const Avatar: React.FC = (props) => { color: fallbackTextColor ?? "#ffffff", }} > - {name ? name[0].toUpperCase() : fallbackText ?? "?"} + {name?.[0]?.toUpperCase() ?? fallbackText ?? "?"} )} diff --git a/packages/ui/src/breadcrumbs/breadcrumbs.stories.tsx b/packages/ui/src/breadcrumbs/breadcrumbs.stories.tsx new file mode 100644 index 000000000..9b8cb8140 --- /dev/null +++ b/packages/ui/src/breadcrumbs/breadcrumbs.stories.tsx @@ -0,0 +1,233 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Home, Settings, Briefcase, GridIcon, Layers2, FileIcon } from "lucide-react"; +import * as React from "react"; +import { ContrastIcon, EpicIcon, LayersIcon } from "../icons"; +import { Breadcrumbs } from "./breadcrumbs"; +import { BreadcrumbNavigationDropdown } from "./navigation-dropdown"; + +const meta: Meta = { + title: "UI/Breadcrumbs", + component: Breadcrumbs, + tags: ["autodocs"], + argTypes: { + isLoading: { + control: "boolean", + description: "Shows loading state of breadcrumbs", + }, + onBack: { + action: "onBack", + description: "Callback function when back button is clicked", + }, + }, +}; + +type TBreadcrumbBlockProps = { + href?: string; + label?: string; + icon?: React.ReactNode; + disableTooltip?: boolean; +}; + +// TODO: remove this component and use web Link component +const BreadcrumbBlock: React.FC = (props) => { + const { label, icon, disableTooltip = false } = props; + + return ( + <> + + {icon &&
{icon}
} + {label &&
{label}
} +
+ + ); +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: [ + } />, + } />, + } + />, + ], + }, +}; + +export const WithLoading: Story = { + args: { + isLoading: true, + children: [ + } />, + } />, + ], + }, +}; + +export const WithCustomComponent: Story = { + args: { + children: [ + } />, + + + Custom Component + + } + />, + ], + }, +}; + +export const SingleItem: Story = { + args: { + children: [} />], + }, +}; + +export const WithNavigationDropdown: Story = { + args: { + children: [ + } />, + console.log("Project Alpha selected"), + }, + { + key: "project-2", + title: "Project Beta", + + action: () => console.log("Project Beta selected"), + }, + { + key: "project-3", + title: "Project Gamma", + + action: () => console.log("Project Gamma selected"), + }, + ]} + /> + } + showSeparator={false} + />, + } />, + ], + }, +}; + +export const WithNavigationDropdownAndIcons: Story = { + args: { + children: [ + } />} + />, + console.log("Project Alpha selected"), + }, + { + key: "project-2", + title: "Project Beta", + icon: Briefcase, + + // disabled: true, + action: () => console.log("Project Beta selected"), + }, + { + key: "project-3", + title: "Project Gamma", + icon: Briefcase, + + action: () => console.log("Project Gamma selected"), + }, + ]} + /> + } + showSeparator={false} + />, + console.log("Feature Alpha selected"), + }, + { + key: "feature-2", + title: "Work items", + icon: LayersIcon, + + // disabled: true, + action: () => console.log("Feature Beta selected"), + }, + { + key: "feature-3", + title: "Cycles", + icon: ContrastIcon, + + action: () => console.log("Feature Gamma selected"), + }, + { + key: "feature-3", + title: "Modules", + icon: GridIcon, + + action: () => console.log("Feature Gamma selected"), + }, + { + key: "feature-3", + title: "Views", + icon: Layers2, + + action: () => console.log("Feature Gamma selected"), + }, + { + key: "feature-3", + title: "Pages", + icon: FileIcon, + + action: () => console.log("Feature Gamma selected"), + }, + ]} + /> + } + showSeparator={false} + />, + } />} + isLast + />, + ], + }, +}; diff --git a/packages/ui/src/breadcrumbs/breadcrumbs.tsx b/packages/ui/src/breadcrumbs/breadcrumbs.tsx index 031825691..af0ba9b4f 100644 --- a/packages/ui/src/breadcrumbs/breadcrumbs.tsx +++ b/packages/ui/src/breadcrumbs/breadcrumbs.tsx @@ -1,13 +1,25 @@ -import * as React from "react"; import { ChevronRight } from "lucide-react"; +import * as React from "react"; +import { cn } from "../../helpers"; +import { Tooltip } from "../tooltip"; type BreadcrumbsProps = { + className?: string; children: React.ReactNode; onBack?: () => void; isLoading?: boolean; }; -const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps) => { +export const BreadcrumbItemLoader = () => ( +
+
+ + +
+
+); + +const Breadcrumbs = ({ className, children, onBack, isLoading = false }: BreadcrumbsProps) => { const [isSmallScreen, setIsSmallScreen] = React.useState(false); React.useEffect(() => { @@ -22,35 +34,31 @@ const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps) const childrenArray = React.Children.toArray(children); - const BreadcrumbItemLoader = ( -
- - -
- ); - return ( -
+
{!isSmallScreen && ( <> - {childrenArray.map((child, index) => ( - - {index > 0 && !isSmallScreen && ( -
-
- )} -
0 ? "hidden sm:flex" : "flex"}`}> - {isLoading ? BreadcrumbItemLoader : child} -
-
- ))} + {childrenArray.map((child, index) => { + if (isLoading) { + return ( + <> + + + ); + } + if (React.isValidElement(child)) { + return React.cloneElement(child, { + isLast: index === childrenArray.length - 1, + }); + } + return child; + })} )} {isSmallScreen && childrenArray.length > 1 && ( <> -
+
{onBack && ( ... @@ -58,8 +66,16 @@ const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps) )}
-
- {isLoading ? BreadcrumbItemLoader : childrenArray[childrenArray.length - 1]} +
+ {isLoading ? ( + + ) : React.isValidElement(childrenArray[childrenArray.length - 1]) ? ( + React.cloneElement(childrenArray[childrenArray.length - 1] as React.ReactElement, { + isLast: true, + }) + ) : ( + childrenArray[childrenArray.length - 1] + )}
)} @@ -68,17 +84,107 @@ const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps) ); }; -type Props = { - type?: "text" | "component"; +// breadcrumb item +type BreadcrumbItemProps = { component?: React.ReactNode; - link?: JSX.Element; + showSeparator?: boolean; + isLast?: boolean; }; -const BreadcrumbItem: React.FC = (props) => { - const { type = "text", component, link } = props; - return <>{type !== "text" ?
{component}
: link}; +const BreadcrumbItem: React.FC = (props) => { + const { component, showSeparator = true, isLast = false } = props; + return ( +
+ {component} + {showSeparator && !isLast && } +
+ ); }; -Breadcrumbs.BreadcrumbItem = BreadcrumbItem; +// breadcrumb icon +type BreadcrumbIconProps = { + children: React.ReactNode; + className?: string; +}; -export { Breadcrumbs, BreadcrumbItem }; +const BreadcrumbIcon: React.FC = (props) => { + const { children, className } = props; + return
{children}
; +}; + +// breadcrumb label +type BreadcrumbLabelProps = { + children: React.ReactNode; + className?: string; +}; + +const BreadcrumbLabel: React.FC = (props) => { + const { children, className } = props; + return ( +
+ {children} +
+ ); +}; + +// breadcrumb separator +type BreadcrumbSeparatorProps = { + className?: string; + containerClassName?: string; + iconClassName?: string; + showDivider?: boolean; +}; + +const BreadcrumbSeparator: React.FC = (props) => { + const { className, containerClassName, iconClassName, showDivider = false } = props; + return ( +
+ {showDivider && } +
+ +
+
+ ); +}; + +// breadcrumb wrapper +type BreadcrumbItemWrapperProps = { + label?: string; + disableTooltip?: boolean; + children: React.ReactNode; + className?: string; + type?: "link" | "text"; + isLast?: boolean; +}; + +const BreadcrumbItemWrapper: React.FC = (props) => { + const { label, disableTooltip = false, children, className, type = "link", isLast = false } = props; + return ( + +
+ {children} +
+
+ ); +}; + +Breadcrumbs.Item = BreadcrumbItem; +Breadcrumbs.Icon = BreadcrumbIcon; +Breadcrumbs.Label = BreadcrumbLabel; +Breadcrumbs.Separator = BreadcrumbSeparator; +Breadcrumbs.ItemWrapper = BreadcrumbItemWrapper; + +export { Breadcrumbs, BreadcrumbItem, BreadcrumbIcon, BreadcrumbLabel, BreadcrumbSeparator, BreadcrumbItemWrapper }; diff --git a/packages/ui/src/breadcrumbs/index.ts b/packages/ui/src/breadcrumbs/index.ts index 05a8bdbf1..192bd5751 100644 --- a/packages/ui/src/breadcrumbs/index.ts +++ b/packages/ui/src/breadcrumbs/index.ts @@ -1,2 +1,3 @@ export * from "./breadcrumbs"; export * from "./navigation-dropdown"; +export * from "./navigation-search-dropdown"; diff --git a/packages/ui/src/breadcrumbs/navigation-dropdown.tsx b/packages/ui/src/breadcrumbs/navigation-dropdown.tsx index a716ca65e..503e13eb2 100644 --- a/packages/ui/src/breadcrumbs/navigation-dropdown.tsx +++ b/packages/ui/src/breadcrumbs/navigation-dropdown.tsx @@ -1,42 +1,54 @@ "use client"; +import { CheckIcon } from "lucide-react"; import * as React from "react"; -import { CheckIcon, ChevronDownIcon } from "lucide-react"; +import { cn } from "../../helpers"; // ui import { CustomMenu, TContextMenuItem } from "../dropdowns"; -// helpers -import { cn } from "../../helpers"; +import { Tooltip } from "../tooltip"; +import { Breadcrumbs } from "./breadcrumbs"; type TBreadcrumbNavigationDropdownProps = { selectedItemKey: string; navigationItems: TContextMenuItem[]; navigationDisabled?: boolean; + handleOnClick?: () => void; + isLast?: boolean; }; export const BreadcrumbNavigationDropdown = (props: TBreadcrumbNavigationDropdownProps) => { - const { selectedItemKey, navigationItems, navigationDisabled = false } = props; + const { selectedItemKey, navigationItems, navigationDisabled = false, handleOnClick, isLast = false } = props; + const [isOpen, setIsOpen] = React.useState(false); // derived values const selectedItem = navigationItems.find((item) => item.key === selectedItemKey); const selectedItemIcon = selectedItem?.icon ? ( - + ) : undefined; // if no selected item, return null if (!selectedItem) return null; - const NavigationButton = ({ className }: { className?: string }) => ( -
  • - {selectedItemIcon && ( -
    {selectedItemIcon}
    - )} -
    {selectedItem.title}
    -
  • + const NavigationButton = () => ( + + + ); if (navigationDisabled) { @@ -46,13 +58,37 @@ export const BreadcrumbNavigationDropdown = (props: TBreadcrumbNavigationDropdow return ( - - -
    + <> + + + } placement="bottom-start" + className="h-full rounded" + customButtonClassName={cn( + "group flex items-center gap-0.5 rounded hover:bg-custom-background-90 outline-none cursor-pointer h-full rounded", + { + "bg-custom-background-90": isOpen, + } + )} closeOnSelect + menuButtonOnClick={() => { + setIsOpen(!isOpen); + }} + onMenuClose={() => { + setIsOpen(false); + }} > {navigationItems.map((item) => { if (item.shouldRender === false) return null; @@ -74,7 +110,7 @@ export const BreadcrumbNavigationDropdown = (props: TBreadcrumbNavigationDropdow )} disabled={item.disabled} > - {item.icon && } + {item.icon && }
    {item.title}
    {item.description && ( diff --git a/packages/ui/src/breadcrumbs/navigation-search-dropdown.tsx b/packages/ui/src/breadcrumbs/navigation-search-dropdown.tsx new file mode 100644 index 000000000..0439d1d33 --- /dev/null +++ b/packages/ui/src/breadcrumbs/navigation-search-dropdown.tsx @@ -0,0 +1,96 @@ +import * as React from "react"; +import { useState } from "react"; +import { ICustomSearchSelectOption } from "@plane/types"; +import { cn } from "../../helpers"; +import { CustomSearchSelect } from "../dropdowns"; +import { Tooltip } from "../tooltip"; +import { Breadcrumbs } from "./breadcrumbs"; + +type TBreadcrumbNavigationSearchDropdownProps = { + icon?: React.JSX.Element; + title?: string; + selectedItem: string; + navigationItems: ICustomSearchSelectOption[]; + onChange?: (value: string) => void; + navigationDisabled?: boolean; + isLast?: boolean; + handleOnClick?: () => void; + disableRootHover?: boolean; +}; + +export const BreadcrumbNavigationSearchDropdown: React.FC = (props) => { + const { + icon, + title, + selectedItem, + navigationItems, + onChange, + navigationDisabled = false, + isLast = false, + handleOnClick, + } = props; + // state + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + return ( + { + setIsDropdownOpen(true); + }} + onClose={() => { + setIsDropdownOpen(false); + }} + options={navigationItems} + value={selectedItem} + onChange={(value: string) => { + if (value !== selectedItem) { + onChange?.(value); + } + }} + customButton={ + <> + + + + + + } + disabled={navigationDisabled} + className="h-full rounded" + customButtonClassName={cn( + "group flex items-center gap-0.5 rounded hover:bg-custom-background-90 outline-none cursor-pointer h-full rounded", + { + "bg-custom-background-90": isDropdownOpen, + } + )} + /> + ); +}; diff --git a/packages/ui/src/calendar.tsx b/packages/ui/src/calendar.tsx index 9fdbab176..80b160cc1 100644 --- a/packages/ui/src/calendar.tsx +++ b/packages/ui/src/calendar.tsx @@ -17,6 +17,7 @@ export const Calendar = ({ className, classNames, showOutsideDays = true, ...pro ; }; export const CollapsibleButton: FC = (props) => { @@ -21,6 +22,7 @@ export const CollapsibleButton: FC = (props) => { actionItemElement, className = "", titleClassName = "", + ChevronIcon = DropdownIcon, } = props; return (
    = (props) => {
    {!hideChevron && ( - void; onClose?: () => void; - containerClassName?: (isOpen: boolean) => string; + containerClassName?: string | ((isOpen: boolean) => string); tabIndex?: number; placement?: Placement; disabled?: boolean; diff --git a/packages/ui/src/dropdown/multi-select.tsx b/packages/ui/src/dropdown/multi-select.tsx index 25f22c6be..400e2c728 100644 --- a/packages/ui/src/dropdown/multi-select.tsx +++ b/packages/ui/src/dropdown/multi-select.tsx @@ -1,19 +1,14 @@ -import React, { FC, useMemo, useRef, useState } from "react"; -import sortBy from "lodash/sortBy"; -// headless ui import { Combobox } from "@headlessui/react"; -// popper-js +import sortBy from "lodash/sortBy"; +import React, { FC, useMemo, useRef, useState } from "react"; import { usePopper } from "react-popper"; -// plane helpers +// plane imports import { useOutsideClickDetector } from "@plane/hooks"; -// components +// local imports +import { cn } from "../../helpers"; +import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed"; import { DropdownButton } from "./common"; import { DropdownOptions } from "./common/options"; -// hooks -import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed"; -// helper -import { cn } from "../../helpers"; -// types import { IMultiSelectDropdown } from "./dropdown"; export const MultiSelectDropdown: FC = (props) => { @@ -118,7 +113,10 @@ export const MultiSelectDropdown: FC = (props) => { ref={dropdownRef} value={value} onChange={onChange} - className={cn("h-full", containerClassName)} + className={cn( + "h-full", + typeof containerClassName === "function" ? containerClassName(isOpen) : containerClassName + )} tabIndex={tabIndex} multiple onKeyDown={handleKeyDown} diff --git a/packages/ui/src/dropdown/single-select.tsx b/packages/ui/src/dropdown/single-select.tsx index bcdff40c1..9614feb51 100644 --- a/packages/ui/src/dropdown/single-select.tsx +++ b/packages/ui/src/dropdown/single-select.tsx @@ -1,19 +1,14 @@ -import React, { FC, useMemo, useRef, useState } from "react"; -import sortBy from "lodash/sortBy"; -// headless ui import { Combobox } from "@headlessui/react"; -// popper-js +import sortBy from "lodash/sortBy"; +import React, { FC, useMemo, useRef, useState } from "react"; import { usePopper } from "react-popper"; -// plane helpers +// plane imports import { useOutsideClickDetector } from "@plane/hooks"; -// components +// local imports +import { cn } from "../../helpers"; +import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed"; import { DropdownButton } from "./common"; import { DropdownOptions } from "./common/options"; -// hooks -import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed"; -// helper -import { cn } from "../../helpers"; -// types import { ISingleSelectDropdown } from "./dropdown"; export const Dropdown: FC = (props) => { @@ -118,7 +113,10 @@ export const Dropdown: FC = (props) => { ref={dropdownRef} value={value} onChange={onChange} - className={cn("h-full", containerClassName)} + className={cn( + "h-full", + typeof containerClassName === "function" ? containerClassName(isOpen) : containerClassName + )} tabIndex={tabIndex} onKeyDown={handleKeyDown} disabled={disabled} diff --git a/packages/ui/src/dropdowns/context-menu/item.tsx b/packages/ui/src/dropdowns/context-menu/item.tsx index 831243920..8e2050d9d 100644 --- a/packages/ui/src/dropdowns/context-menu/item.tsx +++ b/packages/ui/src/dropdowns/context-menu/item.tsx @@ -1,8 +1,10 @@ -import React from "react"; +import { ChevronRight } from "lucide-react"; +import React, { useState, useRef, useContext } from "react"; +import { usePopper } from "react-popper"; // helpers import { cn } from "../../../helpers"; // types -import { TContextMenuItem } from "./root"; +import { TContextMenuItem, ContextMenuContext, Portal } from "./root"; type ContextMenuItemProps = { handleActiveItem: () => void; @@ -14,45 +16,230 @@ type ContextMenuItemProps = { export const ContextMenuItem: React.FC = (props) => { const { handleActiveItem, handleClose, isActive, item } = props; + // Nested menu state + const [isNestedOpen, setIsNestedOpen] = useState(false); + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + const [activeNestedIndex, setActiveNestedIndex] = useState(0); + const nestedMenuRef = useRef(null); + + const contextMenuContext = useContext(ContextMenuContext); + const hasNestedItems = item.nestedMenuItems && item.nestedMenuItems.length > 0; + const renderedNestedItems = item.nestedMenuItems?.filter((nestedItem) => nestedItem.shouldRender !== false) || []; + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "right-start", + strategy: "fixed", + modifiers: [ + { + name: "offset", + options: { + offset: [0, 4], + }, + }, + { + name: "flip", + options: { + fallbackPlacements: ["left-start", "right-end", "left-end", "top-start", "bottom-start"], + }, + }, + { + name: "preventOverflow", + options: { + padding: 8, + }, + }, + ], + }); + + const closeNestedMenu = React.useCallback(() => { + setIsNestedOpen(false); + setActiveNestedIndex(0); + }, []); + + // Register this nested menu with the main context + React.useEffect(() => { + if (contextMenuContext && hasNestedItems) { + return contextMenuContext.registerSubmenu(closeNestedMenu); + } + }, [contextMenuContext, hasNestedItems, closeNestedMenu]); + + const handleItemClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (hasNestedItems) { + // Toggle nested menu + if (!isNestedOpen && contextMenuContext) { + contextMenuContext.closeAllSubmenus(); + } + setIsNestedOpen(!isNestedOpen); + } else { + // Execute action for regular items + item.action(); + if (item.closeOnClick !== false) handleClose(); + } + }; + + const handleMouseEnter = () => { + handleActiveItem(); + + if (hasNestedItems) { + // Close other submenus and open this one + if (contextMenuContext) { + contextMenuContext.closeAllSubmenus(); + } + setIsNestedOpen(true); + } + }; + + const handleNestedItemClick = (nestedItem: TContextMenuItem, e?: React.MouseEvent) => { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + + nestedItem.action(); + if (nestedItem.closeOnClick !== false) { + handleClose(); // Close the entire context menu + } + }; + + // Handle keyboard navigation for nested items + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isNestedOpen || !hasNestedItems) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveNestedIndex((prev) => (prev + 1) % renderedNestedItems.length); + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveNestedIndex((prev) => (prev - 1 + renderedNestedItems.length) % renderedNestedItems.length); + } + if (e.key === "Enter") { + e.preventDefault(); + const nestedItem = renderedNestedItems[activeNestedIndex]; + if (!nestedItem.disabled) { + handleNestedItemClick(nestedItem); + } + } + if (e.key === "ArrowLeft") { + e.preventDefault(); + closeNestedMenu(); + } + }; + + if (isNestedOpen && nestedMenuRef.current) { + const menuElement = nestedMenuRef.current; + menuElement.addEventListener("keydown", handleKeyDown); + // Ensure the menu can receive keyboard events + menuElement.setAttribute("tabindex", "-1"); + menuElement.focus(); + return () => { + menuElement.removeEventListener("keydown", handleKeyDown); + }; + } + }, [isNestedOpen, activeNestedIndex, renderedNestedItems, hasNestedItems, closeNestedMenu]); + if (item.shouldRender === false) return null; return ( - + + {/* Nested Menu */} + {hasNestedItems && isNestedOpen && ( + +
    +
    + {renderedNestedItems.map((nestedItem, index) => ( + + ))} +
    - +
    )} - + ); }; diff --git a/packages/ui/src/dropdowns/context-menu/root.tsx b/packages/ui/src/dropdowns/context-menu/root.tsx index 61554d7bd..480607dba 100644 --- a/packages/ui/src/dropdowns/context-menu/root.tsx +++ b/packages/ui/src/dropdowns/context-menu/root.tsx @@ -21,15 +21,46 @@ export type TContextMenuItem = { disabled?: boolean; className?: string; iconClassName?: string; + nestedMenuItems?: TContextMenuItem[]; }; +// Portal component for nested menus +interface PortalProps { + children: React.ReactNode; + container?: Element | null; +} + +export const Portal: React.FC = ({ children, container }) => { + const [mounted, setMounted] = React.useState(false); + + React.useEffect(() => { + setMounted(true); + return () => setMounted(false); + }, []); + + if (!mounted) { + return null; + } + + const targetContainer = container || document.body; + return ReactDOM.createPortal(children, targetContainer); +}; + +// Context for managing nested menus +export const ContextMenuContext = React.createContext<{ + closeAllSubmenus: () => void; + registerSubmenu: (closeSubmenu: () => void) => () => void; + portalContainer?: Element | null; +} | null>(null); + type ContextMenuProps = { parentRef: React.RefObject; items: TContextMenuItem[]; + portalContainer?: Element | null; }; const ContextMenuWithoutPortal: React.FC = (props) => { - const { parentRef, items } = props; + const { parentRef, items, portalContainer } = props; // states const [isOpen, setIsOpen] = useState(false); const [position, setPosition] = useState({ @@ -39,11 +70,24 @@ const ContextMenuWithoutPortal: React.FC = (props) => { const [activeItemIndex, setActiveItemIndex] = useState(0); // refs const contextMenuRef = useRef(null); + const submenuClosersRef = useRef void>>(new Set()); // derived values const renderedItems = items.filter((item) => item.shouldRender !== false); const { isMobile } = usePlatformOS(); + const closeAllSubmenus = React.useCallback(() => { + submenuClosersRef.current.forEach((closeSubmenu) => closeSubmenu()); + }, []); + + const registerSubmenu = React.useCallback((closeSubmenu: () => void) => { + submenuClosersRef.current.add(closeSubmenu); + return () => { + submenuClosersRef.current.delete(closeSubmenu); + }; + }, []); + const handleClose = () => { + closeAllSubmenus(); setIsOpen(false); setActiveItemIndex(0); }; @@ -121,13 +165,42 @@ const ContextMenuWithoutPortal: React.FC = (props) => { }; }, [activeItemIndex, isOpen, renderedItems, setIsOpen]); - // close on clicking outside - useOutsideClickDetector(contextMenuRef, handleClose); + // Custom handler for nested menu portal clicks + React.useEffect(() => { + const handleDocumentClick = (event: MouseEvent) => { + const target = event.target as HTMLElement; + + // Check if the click is on a nested menu element + const isNestedMenuClick = target.closest('[data-context-submenu="true"]'); + const isMainMenuClick = contextMenuRef.current?.contains(target); + + // Also check if the target itself has the data attribute + const isNestedMenuElement = target.hasAttribute("data-context-submenu"); + + // If it's a nested menu click, main menu click, or nested menu element, don't close + if (isNestedMenuClick || isMainMenuClick || isNestedMenuElement) { + return; + } + + // If menu is open and it's an outside click, close it + if (isOpen) { + handleClose(); + } + }; + + if (isOpen) { + // Use capture phase to ensure we handle the event before other handlers + document.addEventListener("mousedown", handleDocumentClick, true); + return () => { + document.removeEventListener("mousedown", handleDocumentClick, true); + }; + } + }, [isOpen, handleClose]); return (
    = (props) => { top: position.y, left: position.x, }} + data-context-menu="true" > - {renderedItems.map((item, index) => ( - setActiveItemIndex(index)} - handleClose={handleClose} - isActive={index === activeItemIndex} - item={item} - /> - ))} + + {renderedItems.map((item, index) => ( + setActiveItemIndex(index)} + handleClose={handleClose} + isActive={index === activeItemIndex} + item={item} + /> + ))} +
    ); diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 24c8a106a..d043ced70 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -1,5 +1,5 @@ import { Menu } from "@headlessui/react"; -import { ChevronDown, MoreHorizontal } from "lucide-react"; +import { ChevronDown, ChevronRight, MoreHorizontal } from "lucide-react"; import * as React from "react"; import ReactDOM from "react-dom"; import { usePopper } from "react-popper"; @@ -10,10 +10,50 @@ import { cn } from "../../helpers"; // hooks import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down"; // types -import { ICustomMenuDropdownProps, ICustomMenuItemProps } from "./helper"; +import { + ICustomMenuDropdownProps, + ICustomMenuItemProps, + ICustomSubMenuProps, + ICustomSubMenuTriggerProps, + ICustomSubMenuContentProps, +} from "./helper"; + +interface PortalProps { + children: React.ReactNode; + container?: Element | null; + asChild?: boolean; +} + +const Portal: React.FC = ({ children, container, asChild = false }) => { + const [mounted, setMounted] = React.useState(false); + + React.useEffect(() => { + setMounted(true); + return () => setMounted(false); + }, []); + + if (!mounted) { + return null; + } + + const targetContainer = container || document.body; + + if (asChild) { + return ReactDOM.createPortal(children, targetContainer); + } + + return ReactDOM.createPortal(
    {children}
    , targetContainer); +}; + +// Context for main menu to communicate with submenus +const MenuContext = React.createContext<{ + closeAllSubmenus: () => void; + registerSubmenu: (closeSubmenu: () => void) => () => void; +} | null>(null); const CustomMenu = (props: ICustomMenuDropdownProps) => { const { + ariaLabel, buttonClassName = "", customButtonClassName = "", customButtonTabIndex = 0, @@ -44,19 +84,35 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { const [isOpen, setIsOpen] = React.useState(false); // refs const dropdownRef = React.useRef(null); + const submenuClosersRef = React.useRef void>>(new Set()); const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: placement ?? "auto", }); + const closeAllSubmenus = React.useCallback(() => { + submenuClosersRef.current.forEach((closeSubmenu) => closeSubmenu()); + }, []); + + const registerSubmenu = React.useCallback((closeSubmenu: () => void) => { + submenuClosersRef.current.add(closeSubmenu); + return () => { + submenuClosersRef.current.delete(closeSubmenu); + }; + }, []); + const openDropdown = () => { setIsOpen(true); if (referenceElement) referenceElement.focus(); }; - const closeDropdown = () => { - if (isOpen) onMenuClose?.(); + + const closeDropdown = React.useCallback(() => { + if (isOpen) { + closeAllSubmenus(); + onMenuClose?.(); + } setIsOpen(false); - }; + }, [isOpen, closeAllSubmenus, onMenuClose]); const selectActiveItem = () => { const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector( @@ -74,7 +130,11 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { const handleMenuButtonClick = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); - isOpen ? closeDropdown() : openDropdown(); + if (isOpen) { + closeDropdown(); + } else { + openDropdown(); + } if (menuButtonOnClick) menuButtonOnClick(); }; @@ -85,13 +145,43 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { const handleMouseLeave = () => { if (openOnHover && isOpen) { setTimeout(() => { - closeDropdown(); - }, 500); + // Only close if menu is still open + if (isOpen) { + closeDropdown(); + } + }, 150); // Small delay to allow moving to submenu } }; useOutsideClickDetector(dropdownRef, closeDropdown, useCaptureForOutsideClick); + // Custom handler for submenu portal clicks + React.useEffect(() => { + const handleDocumentClick = (event: MouseEvent) => { + const target = event.target as HTMLElement; + const isSubmenuClick = target.closest('[data-prevent-outside-click="true"]'); + const isMainMenuClick = dropdownRef.current?.contains(target); + + // If it's a submenu click or main menu click, don't close + if (isSubmenuClick || isMainMenuClick) { + return; + } + + // If menu is open and it's an outside click, close it + if (isOpen) { + closeDropdown(); + } + }; + + if (isOpen) { + document.addEventListener("mousedown", handleDocumentClick, useCaptureForOutsideClick); + + return () => { + document.removeEventListener("mousedown", handleDocumentClick, useCaptureForOutsideClick); + }; + } + }, [isOpen, closeDropdown, useCaptureForOutsideClick]); + let menuItems = ( { style={styles.popper} {...attributes.popper} > - {children} + {children}
    ); @@ -135,6 +225,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { onClick={handleOnClick} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} + data-main-menu="true" > {({ open }) => ( <> @@ -147,6 +238,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { className={customButtonClassName} tabIndex={customButtonTabIndex} disabled={disabled} + aria-label={ariaLabel} > {customButton} @@ -164,6 +256,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80" } ${buttonClassName}`} tabIndex={customButtonTabIndex} + aria-label={ariaLabel} > @@ -183,6 +276,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { onClick={handleMenuButtonClick} tabIndex={customButtonTabIndex} disabled={disabled} + aria-label={ariaLabel} > {label} {!noChevron && } @@ -198,8 +292,161 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { ); }; +// SubMenu context for closing submenu from nested items +const SubMenuContext = React.createContext<{ closeSubmenu: () => void } | null>(null); + +// Hook to use submenu context +const useSubMenu = () => React.useContext(SubMenuContext); + +// SubMenu implementation +const SubMenu: React.FC = (props) => { + const { + children, + trigger, + disabled = false, + className = "", + contentClassName = "", + placement = "right-start", + } = props; + + const [isOpen, setIsOpen] = React.useState(false); + const [referenceElement, setReferenceElement] = React.useState(null); + const [popperElement, setPopperElement] = React.useState(null); + const submenuRef = React.useRef(null); + + const menuContext = React.useContext(MenuContext); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement, + strategy: "fixed", // Use fixed positioning to escape overflow constraints + modifiers: [ + { + name: "offset", + options: { + offset: [0, 4], + }, + }, + { + name: "flip", + options: { + fallbackPlacements: ["left-start", "right-end", "left-end", "top-start", "bottom-start"], + }, + }, + { + name: "preventOverflow", + options: { + padding: 8, + }, + }, + ], + }); + + const closeSubmenu = React.useCallback(() => { + setIsOpen(false); + }, []); + + // Register this submenu with the main menu context + React.useEffect(() => { + if (menuContext) { + return menuContext.registerSubmenu(closeSubmenu); + } + }, [menuContext, closeSubmenu]); + + const toggleSubmenu = () => { + if (!disabled) { + // Close other submenus when opening this one + if (!isOpen && menuContext) { + menuContext.closeAllSubmenus(); + } + setIsOpen(!isOpen); + } + }; + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + toggleSubmenu(); + }; + + // Close submenu when clicking on other menu items + React.useEffect(() => { + const handleMenuItemClick = (e: Event) => { + const target = e.target as HTMLElement; + // Check if the click is on a menu item that's not part of this submenu + if (target.closest('[role="menuitem"]') && !submenuRef.current?.contains(target)) { + closeSubmenu(); + } + }; + + document.addEventListener("click", handleMenuItemClick); + return () => { + document.removeEventListener("click", handleMenuItemClick); + }; + }, [closeSubmenu]); + + return ( +
    + + + {({ active }) => ( +
    + {trigger} + +
    + )} +
    +
    + + {isOpen && ( + +
    { + // Notify parent menu that we're hovering over submenu + const mainMenuElement = document.querySelector('[data-main-menu="true"]'); + if (mainMenuElement) { + const mouseEnterEvent = new MouseEvent("mouseenter", { bubbles: true }); + mainMenuElement.dispatchEvent(mouseEnterEvent); + } + }} + onMouseLeave={() => { + // Notify parent menu that we're leaving submenu + const mainMenuElement = document.querySelector('[data-main-menu="true"]'); + if (mainMenuElement) { + const mouseLeaveEvent = new MouseEvent("mouseleave", { bubbles: true }); + mainMenuElement.dispatchEvent(mouseLeaveEvent); + } + }} + > + {children} +
    +
    + )} +
    + ); +}; + const MenuItem: React.FC = (props) => { const { children, disabled = false, onClick, className } = props; + const submenuContext = useSubMenu(); return ( @@ -217,6 +464,8 @@ const MenuItem: React.FC = (props) => { onClick={(e) => { close(); onClick?.(e); + // Close submenu if this item is inside a submenu + submenuContext?.closeSubmenu(); }} disabled={disabled} > @@ -227,6 +476,52 @@ const MenuItem: React.FC = (props) => { ); }; +const SubMenuTrigger: React.FC = (props) => { + const { children, disabled = false, className } = props; + + return ( + + {({ active }) => ( +
    + {children} + +
    + )} +
    + ); +}; + +const SubMenuContent: React.FC = (props) => { + const { children, className } = props; + + return ( +
    + {children} +
    + ); +}; + +// Add all components as static properties for external use +CustomMenu.Portal = Portal; CustomMenu.MenuItem = MenuItem; +CustomMenu.SubMenu = SubMenu; +CustomMenu.SubMenuTrigger = SubMenuTrigger; +CustomMenu.SubMenuContent = SubMenuContent; export { CustomMenu }; diff --git a/packages/ui/src/dropdowns/custom-search-select.tsx b/packages/ui/src/dropdowns/custom-search-select.tsx index e592f0dc2..d26163e69 100644 --- a/packages/ui/src/dropdowns/custom-search-select.tsx +++ b/packages/ui/src/dropdowns/custom-search-select.tsx @@ -61,6 +61,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { const openDropdown = () => { setIsOpen(true); if (referenceElement) referenceElement.focus(); + if (onOpen) onOpen(); }; const closeDropdown = () => { @@ -95,11 +96,14 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { )}
    {emailError?.email && !isFocused && ( @@ -92,4 +99,4 @@ export const AuthEmailForm: FC = observer((props) => { ); -}); \ No newline at end of file +}); diff --git a/space/core/components/account/helpers/password-strength-meter.tsx b/space/core/components/account/helpers/password-strength-meter.tsx index 342f77efb..611067355 100644 --- a/space/core/components/account/helpers/password-strength-meter.tsx +++ b/space/core/components/account/helpers/password-strength-meter.tsx @@ -3,7 +3,7 @@ import { FC, useMemo } from "react"; // import { CircleCheck } from "lucide-react"; // helpers -import { cn } from "@/helpers/common.helper"; +import { cn } from "@plane/utils"; import { E_PASSWORD_STRENGTH, // PASSWORD_CRITERIA, diff --git a/space/core/components/common/project-logo.tsx b/space/core/components/common/project-logo.tsx index dfb3a4b80..2dfc04b38 100644 --- a/space/core/components/common/project-logo.tsx +++ b/space/core/components/common/project-logo.tsx @@ -1,7 +1,7 @@ // types import { TLogoProps } from "@plane/types"; // helpers -import { cn } from "@/helpers/common.helper"; +import { cn } from "@plane/utils"; type Props = { className?: string; diff --git a/space/core/components/editor/embeds/mentions/user.tsx b/space/core/components/editor/embeds/mentions/user.tsx index 5a178396b..eb3698a98 100644 --- a/space/core/components/editor/embeds/mentions/user.tsx +++ b/space/core/components/editor/embeds/mentions/user.tsx @@ -1,6 +1,6 @@ import { observer } from "mobx-react"; // helpers -import { cn } from "@/helpers/common.helper"; +import { cn } from "@plane/utils"; // hooks import { useMember, useUser } from "@/hooks/store"; diff --git a/space/core/components/editor/lite-text-editor.tsx b/space/core/components/editor/lite-text-editor.tsx index 6c6a19641..6342d9344 100644 --- a/space/core/components/editor/lite-text-editor.tsx +++ b/space/core/components/editor/lite-text-editor.tsx @@ -1,16 +1,19 @@ import React from "react"; // plane imports -import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TFileHandler } from "@plane/editor"; +import { EditorRefApi, ILiteTextEditorProps, LiteTextEditorWithRef, TFileHandler } from "@plane/editor"; import { MakeOptional } from "@plane/types"; +import { cn } from "@plane/utils"; // components import { EditorMentionsRoot, IssueCommentToolbar } from "@/components/editor"; // helpers -import { cn } from "@/helpers/common.helper"; import { getEditorFileHandlers } from "@/helpers/editor.helper"; import { isCommentEmpty } from "@/helpers/string.helper"; interface LiteTextEditorWrapperProps - extends MakeOptional, "disabledExtensions"> { + extends MakeOptional< + Omit, + "disabledExtensions" | "flaggedExtensions" + > { anchor: string; workspaceId: string; isSubmitting?: boolean; @@ -27,6 +30,7 @@ export const LiteTextEditor = React.forwardRef(ref: React.ForwardedRef): ref is React.MutableRefObject { @@ -41,6 +45,7 @@ export const LiteTextEditor = React.forwardRef, - "disabledExtensions" + Omit, + "disabledExtensions" | "flaggedExtensions" > & { anchor: string; workspaceId: string; }; export const LiteTextReadOnlyEditor = React.forwardRef( - ({ anchor, workspaceId, disabledExtensions, ...props }, ref) => { + ({ anchor, workspaceId, disabledExtensions, flaggedExtensions, ...props }, ref) => { const { getMemberById } = useMember(); return ( , "disabledExtensions"> { + extends MakeOptional< + Omit, + "disabledExtensions" | "flaggedExtensions" + > { anchor: string; uploadFile: TFileHandler["upload"]; workspaceId: string; } export const RichTextEditor = forwardRef((props, ref) => { - const { anchor, containerClassName, uploadFile, workspaceId, disabledExtensions, ...rest } = props; + const { anchor, containerClassName, uploadFile, workspaceId, disabledExtensions, flaggedExtensions, ...rest } = props; const { getMemberById } = useMember(); return ( , - "disabledExtensions" + Omit, + "disabledExtensions" | "flaggedExtensions" > & { anchor: string; workspaceId: string; }; export const RichTextReadOnlyEditor = React.forwardRef( - ({ anchor, workspaceId, disabledExtensions, ...props }, ref) => { + ({ anchor, workspaceId, disabledExtensions, flaggedExtensions, ...props }, ref) => { const { getMemberById } = useMember(); return ( void; diff --git a/space/core/components/issues/filters/applied-filters/state.tsx b/space/core/components/issues/filters/applied-filters/state.tsx index 23bfc87e6..4166dabfb 100644 --- a/space/core/components/issues/filters/applied-filters/state.tsx +++ b/space/core/components/issues/filters/applied-filters/state.tsx @@ -2,7 +2,8 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; -// ui +// plane imports +import { EIconSize } from "@plane/constants"; import { StateGroupIcon } from "@plane/ui"; // hooks import { useStates } from "@/hooks/store"; @@ -26,7 +27,7 @@ export const AppliedStateFilters: React.FC = observer((props) => { return (
    - + {stateDetails.name}
    diff --git a/space/core/components/issues/peek-overview/comment/comment-reactions.tsx b/space/core/components/issues/peek-overview/comment/comment-reactions.tsx index e285e5a8a..6ffb58952 100644 --- a/space/core/components/issues/peek-overview/comment/comment-reactions.tsx +++ b/space/core/components/issues/peek-overview/comment/comment-reactions.tsx @@ -4,10 +4,11 @@ import React from "react"; import { observer } from "mobx-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Tooltip } from "@plane/ui"; +// plane imports +import { cn } from "@plane/utils"; // ui import { ReactionSelector } from "@/components/ui"; // helpers -import { cn } from "@/helpers/common.helper"; import { groupReactions, renderEmoji } from "@/helpers/emoji.helper"; import { queryParamGenerator } from "@/helpers/query-param-generator"; // hooks diff --git a/space/core/components/issues/peek-overview/issue-properties.tsx b/space/core/components/issues/peek-overview/issue-properties.tsx index 596993bc2..f9390a193 100644 --- a/space/core/components/issues/peek-overview/issue-properties.tsx +++ b/space/core/components/issues/peek-overview/issue-properties.tsx @@ -3,14 +3,13 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { CalendarCheck2, Signal } from "lucide-react"; +// plane imports import { useTranslation } from "@plane/i18n"; -// ui import { DoubleCircleIcon, StateGroupIcon, TOAST_TYPE, setToast } from "@plane/ui"; -import { getIssuePriorityFilters } from "@plane/utils"; +import { cn, getIssuePriorityFilters } from "@plane/utils"; // components import { Icon } from "@/components/ui"; // helpers -import { cn } from "@/helpers/common.helper"; import { renderFormattedDate } from "@/helpers/date-time.helper"; import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper"; import { copyTextToClipboard, addSpaceIfCamelCase } from "@/helpers/string.helper"; diff --git a/space/core/components/issues/reactions/issue-vote-reactions.tsx b/space/core/components/issues/reactions/issue-vote-reactions.tsx index 7134c05cf..83f93e0b7 100644 --- a/space/core/components/issues/reactions/issue-vote-reactions.tsx +++ b/space/core/components/issues/reactions/issue-vote-reactions.tsx @@ -3,9 +3,10 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; +// plane imports import { Tooltip } from "@plane/ui"; +import { cn } from "@plane/utils"; // helpers -import { cn } from "@/helpers/common.helper"; import { queryParamGenerator } from "@/helpers/query-param-generator"; // hooks import { useIssueDetails, useUser } from "@/hooks/store"; diff --git a/space/core/lib/toast-provider.tsx b/space/core/lib/toast-provider.tsx index 1083cb6af..20a37c3e9 100644 --- a/space/core/lib/toast-provider.tsx +++ b/space/core/lib/toast-provider.tsx @@ -1,11 +1,10 @@ "use client"; import { ReactNode } from "react"; -import { useTheme } from "next-themes" -// ui +import { useTheme } from "next-themes"; +// plane imports import { Toast } from "@plane/ui"; -// helpers -import { resolveGeneralTheme } from "@/helpers/common.helper"; +import { resolveGeneralTheme } from "@plane/utils"; export const ToastProvider = ({ children }: { children: ReactNode }) => { // themes diff --git a/space/core/store/profile.store.ts b/space/core/store/profile.store.ts index 5523e8dad..c032efba9 100644 --- a/space/core/store/profile.store.ts +++ b/space/core/store/profile.store.ts @@ -1,6 +1,7 @@ import set from "lodash/set"; import { action, makeObservable, observable, runInAction } from "mobx"; // plane imports +import { EStartOfTheWeek } from "@plane/constants"; import { UserService } from "@plane/services"; import { TUserProfile } from "@plane/types"; // store @@ -54,6 +55,7 @@ export class ProfileStore implements IProfileStore { created_at: "", updated_at: "", language: "", + start_of_the_week: EStartOfTheWeek.SUNDAY, }; // services diff --git a/space/helpers/editor.helper.ts b/space/helpers/editor.helper.ts index 5126b99c7..e63ba8834 100644 --- a/space/helpers/editor.helper.ts +++ b/space/helpers/editor.helper.ts @@ -1,9 +1,8 @@ -// plane internal +// plane imports import { MAX_FILE_SIZE } from "@plane/constants"; import { TFileHandler, TReadOnlyFileHandler } from "@plane/editor"; import { SitesFileService } from "@plane/services"; -// helpers -import { getFileURL } from "@/helpers/file.helper"; +import { getFileURL } from "@plane/utils"; // services const sitesFileService = new SitesFileService(); @@ -29,6 +28,7 @@ export const getReadOnlyEditorFileHandlers = (args: Pick true, getAssetSrc: async (path) => { if (!path) return ""; if (path?.startsWith("http")) { diff --git a/space/package.json b/space/package.json index a55647e78..2159adf56 100644 --- a/space/package.json +++ b/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "0.26.1", + "version": "0.27.0", "private": true, "license": "AGPL-3.0", "scripts": { @@ -22,9 +22,10 @@ "@plane/constants": "*", "@plane/editor": "*", "@plane/i18n": "*", + "@plane/propel": "*", + "@plane/services": "*", "@plane/types": "*", "@plane/ui": "*", - "@plane/services": "*", "axios": "^1.8.3", "clsx": "^2.0.0", "date-fns": "^4.1.0", @@ -36,7 +37,7 @@ "mobx": "^6.10.0", "mobx-react": "^9.1.1", "mobx-utils": "^6.0.8", - "next": "^14.2.29", + "next": "14.2.30", "next-themes": "^0.2.1", "nprogress": "^0.2.0", "react": "^18.3.1", @@ -62,6 +63,6 @@ "@types/uuid": "^9.0.1", "@types/zxcvbn": "^4.4.4", "@typescript-eslint/eslint-plugin": "^5.48.2", - "typescript": "5.3.3" + "typescript": "5.8.3" } } diff --git a/space/styles/globals.css b/space/styles/globals.css index 8976e83c2..5d27de674 100644 --- a/space/styles/globals.css +++ b/space/styles/globals.css @@ -1,5 +1,4 @@ -@import url("https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700;800&display=swap"); -@import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@48,400,0,0&display=swap"); +@import "@plane/propel/styles/fonts"; @tailwind base; @tailwind components; diff --git a/turbo.json b/turbo.json index 65f289b9c..36e95edbd 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,5 @@ { "$schema": "https://turbo.build/schema.json", - "ui": "tui", "globalEnv": [ "NODE_ENV", "NEXT_PUBLIC_API_BASE_URL", diff --git a/web/.env.example b/web/.env.example index ad5ac4173..15d7a36a9 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,10 +1,12 @@ -NEXT_PUBLIC_API_BASE_URL="" +NEXT_PUBLIC_API_BASE_URL="http://localhost:8000" -NEXT_PUBLIC_ADMIN_BASE_URL="" +NEXT_PUBLIC_WEB_BASE_URL="http://localhost:3000" + +NEXT_PUBLIC_ADMIN_BASE_URL="http://localhost:3001" NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" -NEXT_PUBLIC_SPACE_BASE_URL="" +NEXT_PUBLIC_SPACE_BASE_URL="http://localhost:3002" NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" -NEXT_PUBLIC_LIVE_BASE_URL="" -NEXT_PUBLIC_LIVE_BASE_PATH="/live" \ No newline at end of file +NEXT_PUBLIC_LIVE_BASE_URL="http://localhost:3100" +NEXT_PUBLIC_LIVE_BASE_PATH="/live" diff --git a/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx similarity index 57% rename from web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx index 6416aee12..92ed6b736 100644 --- a/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx @@ -14,18 +14,17 @@ export const WorkspaceActiveCycleHeader = observer(() => {
    - } - /> - } - /> - - - -
    + } + /> + } + /> + + + + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/active-cycles/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/active-cycles/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/active-cycles/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/active-cycles/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/header.tsx new file mode 100644 index 000000000..8872b4cca --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/header.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { observer } from "mobx-react"; +import { BarChart2 } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; + +export const WorkspaceAnalyticsHeader = observer(() => { + const { t } = useTranslation(); + return ( +
    + + + } + /> + } + /> + + +
    + ); +}); diff --git a/web/app/[workspaceSlug]/(projects)/analytics/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx similarity index 70% rename from web/app/[workspaceSlug]/(projects)/analytics/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx index 8dfc8b3b0..3a531a7b2 100644 --- a/web/app/[workspaceSlug]/(projects)/analytics/layout.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx @@ -1,9 +1,9 @@ "use client"; - +// components import { AppHeader, ContentWrapper } from "@/components/core"; import { WorkspaceAnalyticsHeader } from "./header"; -export default function WorkspaceAnalyticsLayout({ children }: { children: React.ReactNode }) { +export default function WorkspaceAnalyticsTabLayout({ children }: { children: React.ReactNode }) { return ( <> } /> diff --git a/web/app/[workspaceSlug]/(projects)/analytics/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx similarity index 52% rename from web/app/[workspaceSlug]/(projects)/analytics/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx index 8875e1465..6100bc8d5 100644 --- a/web/app/[workspaceSlug]/(projects)/analytics/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx @@ -1,38 +1,48 @@ "use client"; -import React, { Fragment } from "react"; +import { useMemo } from "react"; import { observer } from "mobx-react"; -import { useSearchParams } from "next/navigation"; -import { Tab } from "@headlessui/react"; +import { useRouter } from "next/navigation"; // plane package imports -import { ANALYTICS_TABS, EUserPermissionsLevel, EUserPermissions } from "@plane/constants"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Header, EHeaderVariant } from "@plane/ui"; +import { type TabItem, Tabs } from "@plane/ui"; // components -import { CustomAnalytics, ScopeAndDemand } from "@/components/analytics"; +import AnalyticsFilterActions from "@/components/analytics/analytics-filter-actions"; import { PageHead } from "@/components/core"; import { ComicBoxButton, DetailedEmptyState } from "@/components/empty-state"; // hooks import { useCommandPalette, useEventTracker, useProject, useUserPermissions, useWorkspace } from "@/hooks/store"; import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +import { getAnalyticsTabs } from "@/plane-web/components/analytics/tabs"; + +type Props = { + params: { + tabId: string; + workspaceSlug: string; + }; +}; + +const AnalyticsPage = observer((props: Props) => { + // props + const { params } = props; + const { tabId } = params; + + // hooks + const router = useRouter(); -const AnalyticsPage = observer(() => { - const searchParams = useSearchParams(); - const analytics_tab = searchParams.get("analytics_tab"); // plane imports const { t } = useTranslation(); + // store hooks const { toggleCreateProjectModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); const { workspaceProjectIds, loader } = useProject(); const { currentWorkspace } = useWorkspace(); const { allowPermissions } = useUserPermissions(); + // helper hooks const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/onboarding/analytics" }); - // derived values - const pageTitle = currentWorkspace?.name - ? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name }) - : undefined; // permissions const canPerformEmptyStateActions = allowPermissions( @@ -40,44 +50,45 @@ const AnalyticsPage = observer(() => { EUserPermissionsLevel.WORKSPACE ); - // TODO: refactor loader implementation + // derived values + const pageTitle = currentWorkspace?.name + ? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name }) + : undefined; + const ANALYTICS_TABS = useMemo(() => getAnalyticsTabs(t), [t]); + const tabs: TabItem[] = useMemo( + () => + ANALYTICS_TABS.map((tab) => ({ + key: tab.key, + label: tab.label, + content: , + onClick: () => { + router.push(`/${currentWorkspace?.slug}/analytics/${tab.key}`); + }, + disabled: tab.isDisabled, + })), + [ANALYTICS_TABS, router, currentWorkspace?.slug] + ); + const defaultTab = tabId || ANALYTICS_TABS[0].key; + return ( <> {workspaceProjectIds && ( <> {workspaceProjectIds.length > 0 || loader === "init-loader" ? ( -
    - -
    - - {ANALYTICS_TABS.map((tab) => ( - - {({ selected }) => ( - - )} - - ))} - -
    - - - - - - - - -
    +
    + } + />
    ) : ( { + // router + const router = useAppRouter(); + const { workspaceSlug, workItem } = useParams(); + // store hooks + const { getProjectById, loader } = useProject(); + const { + issue: { getIssueById, getIssueIdByIdentifier }, + } = useIssueDetail(); + // derived values + const issueId = getIssueIdByIdentifier(workItem?.toString()); + const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined; + const projectId = issueDetails ? issueDetails?.project_id : undefined; + const projectDetails = projectId ? getProjectById(projectId?.toString()) : undefined; + + if (!workspaceSlug || !projectId || !issueId) return null; + + return ( +
    + + + + + } + /> + + + + {projectId && issueId && ( + + )} + +
    + ); +}); diff --git a/web/app/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx similarity index 92% rename from web/app/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx index e315f3549..735474cd4 100644 --- a/web/app/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx @@ -6,6 +6,7 @@ import { useParams } from "next/navigation"; import { useTheme } from "next-themes"; import useSWR from "swr"; // plane imports +import { EIssueServiceType } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { Loader } from "@plane/ui"; // components @@ -16,6 +17,7 @@ import { IssueDetailRoot } from "@/components/issues"; import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store"; // assets import { useAppRouter } from "@/hooks/use-app-router"; +import { useWorkItemProperties } from "@/plane-web/hooks/use-issue-properties"; import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper"; import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp"; import emptyIssueLight from "@/public/empty-state/search/issues-light.webp"; @@ -53,6 +55,13 @@ const IssueDetailsPage = observer(() => { const issueLoader = !issue || isLoading; const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined; + useWorkItemProperties( + projectId, + workspaceSlug.toString(), + issueId, + issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES + ); + useEffect(() => { const handleToggleIssueDetailSidebar = () => { if (window && window.innerWidth < 768) { @@ -104,6 +113,7 @@ const IssueDetailsPage = observer(() => { workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} issueId={issueId.toString()} + is_archived={!!issue?.archived_at} /> ) diff --git a/web/app/[workspaceSlug]/(projects)/drafts/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx similarity index 96% rename from web/app/[workspaceSlug]/(projects)/drafts/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx index 5e1cc66ef..92cdfb1c1 100644 --- a/web/app/[workspaceSlug]/(projects)/drafts/header.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx @@ -41,9 +41,8 @@ export const WorkspaceDraftHeader = observer(() => {
    - } /> } /> diff --git a/web/app/[workspaceSlug]/(projects)/drafts/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/drafts/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/drafts/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/drafts/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/extended-project-sidebar.tsx b/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx similarity index 98% rename from web/app/[workspaceSlug]/(projects)/extended-project-sidebar.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx index b96f008ab..0cd87200c 100644 --- a/web/app/[workspaceSlug]/(projects)/extended-project-sidebar.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx @@ -8,12 +8,11 @@ import { Plus, Search } from "lucide-react"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui"; -import { cn, copyUrlToClipboard } from "@plane/utils"; +import { cn, copyUrlToClipboard, orderJoinedProjects } from "@plane/utils"; // components import { CreateProjectModal } from "@/components/project"; import { SidebarProjectsListItem } from "@/components/workspace"; // hooks -import { orderJoinedProjects } from "@/helpers/project.helper"; import { useAppTheme, useProject, useUserPermissions } from "@/hooks/store"; import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click"; import { TProject } from "@/plane-web/types"; diff --git a/web/app/[workspaceSlug]/(projects)/extended-sidebar.tsx b/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx similarity index 95% rename from web/app/[workspaceSlug]/(projects)/extended-sidebar.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx index e0003eeba..baa41eb9f 100644 --- a/web/app/[workspaceSlug]/(projects)/extended-sidebar.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx @@ -96,7 +96,7 @@ export const ExtendedAppSidebar = observer(() => { useExtendedSidebarOutsideClickDetector( extendedSidebarRef, - () => toggleExtendedSidebar(false), + () => toggleExtendedSidebar(true), "extended-sidebar-toggle" ); @@ -106,8 +106,8 @@ export const ExtendedAppSidebar = observer(() => { className={cn( "absolute top-0 h-full z-[19] flex flex-col gap-0.5 w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-md pb-6", { - "translate-x-0 opacity-100 pointer-events-auto": extendedSidebarCollapsed, - "-translate-x-full opacity-0 pointer-events-none": !extendedSidebarCollapsed, + "-translate-x-full opacity-0 pointer-events-none": extendedSidebarCollapsed, + "translate-x-0 opacity-100 pointer-events-auto": !extendedSidebarCollapsed, "left-[70px]": sidebarCollapsed, "left-[250px]": !sidebarCollapsed, } diff --git a/web/app/[workspaceSlug]/(projects)/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/header.tsx similarity index 90% rename from web/app/[workspaceSlug]/(projects)/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/header.tsx index 16c106dad..42e3222b3 100644 --- a/web/app/[workspaceSlug]/(projects)/header.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/header.tsx @@ -7,7 +7,7 @@ import { Home } from "lucide-react"; import githubBlackImage from "/public/logos/github-black.png"; import githubWhiteImage from "/public/logos/github-white.png"; // ui -import { GITHUB_REDIRECTED } from "@plane/constants"; +import { GITHUB_REDIRECTED_TRACKER_EVENT } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { Breadcrumbs, Header } from "@plane/ui"; // components @@ -28,9 +28,8 @@ export const WorkspaceDashboardHeader = () => {
    - } /> } /> @@ -40,7 +39,7 @@ export const WorkspaceDashboardHeader = () => { - captureEvent(GITHUB_REDIRECTED, { + captureEvent(GITHUB_REDIRECTED_TRACKER_EVENT, { element: "navbar", }) } diff --git a/web/app/[workspaceSlug]/(projects)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/notifications/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/notifications/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/notifications/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/notifications/layout.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx new file mode 100644 index 000000000..415ea8fbf --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { PageHead } from "@/components/core"; +import { NotificationsRoot } from "@/components/workspace-notifications"; +// hooks +import { useWorkspace } from "@/hooks/store"; + +const WorkspaceDashboardPage = observer(() => { + const { workspaceSlug } = useParams(); + // plane hooks + const { t } = useTranslation(); + // hooks + const { currentWorkspace } = useWorkspace(); + // derived values + const pageTitle = currentWorkspace?.name + ? t("notification.page_label", { workspace: currentWorkspace?.name }) + : undefined; + + return ( + <> + + + + ); +}); + +export default WorkspaceDashboardPage; diff --git a/web/app/[workspaceSlug]/(projects)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx similarity index 95% rename from web/app/[workspaceSlug]/(projects)/profile/[userId]/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx index e97b4751f..ec0c0d86d 100644 --- a/web/app/[workspaceSlug]/(projects)/profile/[userId]/header.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx @@ -10,10 +10,11 @@ import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB, EUserPermissions, EUserPermissi import { useTranslation } from "@plane/i18n"; import { IUserProfileProjectSegregation } from "@plane/types"; import { Breadcrumbs, Header, CustomMenu, UserActivityIcon } from "@plane/ui"; -import { BreadcrumbLink } from "@/components/common"; +import { cn } from "@plane/utils"; // components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; import { ProfileIssuesFilter } from "@/components/profile"; -import { cn } from "@/helpers/common.helper"; +// hooks import { useAppTheme, useUser, useUserPermissions } from "@/hooks/store"; type TUserProfileHeader = { @@ -51,9 +52,8 @@ export const UserProfileHeader: FC = observer((props) => {
    - = observer((props: TProps) => {
    - - + = observer((props: TProps) => { } /> {activeTabBreadcrumbDetail && ( - } diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx similarity index 89% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx index 7b9e84a9e..14f62397d 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx @@ -36,10 +36,9 @@ export const ProjectArchivedIssueDetailsHeader = observer(() => {
    - - + { /> } /> - { /> } /> - { // refs @@ -161,7 +156,8 @@ export const CycleIssuesHeader: React.FC = observer(() => { return ( <> - setAnalyticsModal(false)} cycleDetails={cycleDetails ?? undefined} @@ -170,63 +166,44 @@ export const CycleIssuesHeader: React.FC = observer(() => {
    - - - - - - ... - - - } + - } - /> - } - /> - { router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${value}`); }} - label={ -
    - - {workItemsCount && workItemsCount > 0 ? ( - 1 ? "work items" : "work item" - } in this cycle`} - position="bottom" - > - - {workItemsCount} - - - ) : null} -
    + title={cycleDetails?.name} + icon={ + + + } + isLast /> } + isLast />
    + {workItemsCount && workItemsCount > 0 ? ( + 1 ? "work items" : "work item" + } in this cycle`} + position="bottom" + > + + {workItemsCount} + + + ) : null}
    diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx similarity index 97% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx index 31eb5b249..fc98e941b 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx @@ -4,7 +4,7 @@ import { useCallback, useState } from "react"; import { useParams } from "next/navigation"; // icons import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; -// plane constants +// plane imports import { EIssueLayoutTypes, EIssueFilterType, @@ -12,17 +12,14 @@ import { ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE, } from "@plane/constants"; -// i18n import { useTranslation } from "@plane/i18n"; -// types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; -// ui import { CustomMenu } from "@plane/ui"; +import { isIssueFilterActive } from "@plane/utils"; // components -import { ProjectAnalyticsModal } from "@/components/analytics"; +import { WorkItemsModal } from "@/components/analytics/work-items/modal"; import { DisplayFiltersSelection, FilterSelection, FiltersDropdown, IssueLayoutIcon } from "@/components/issues"; // helpers -import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks import { useIssues, useCycle, useProjectState, useLabel, useMember, useProject } from "@/hooks/store"; @@ -123,7 +120,8 @@ export const CycleIssuesMobileHeader = () => { return ( <> - setAnalyticsModal(false)} cycleDetails={cycleDetails ?? undefined} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx similarity index 74% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx index 2339c2731..41dfde747 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx @@ -2,23 +2,25 @@ import { FC } from "react"; import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; // ui -import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Breadcrumbs, Button, ContrastIcon, Header } from "@plane/ui"; +import { Breadcrumbs, Button, Header } from "@plane/ui"; // components -import { BreadcrumbLink } from "@/components/common"; import { CyclesViewHeader } from "@/components/cycles"; // hooks import { useCommandPalette, useEventTracker, useProject, useUserPermissions } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; // plane web -import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; // constants export const CyclesListHeader: FC = observer(() => { // router const router = useAppRouter(); + const { workspaceSlug } = useParams(); + // store hooks const { toggleCreateCycleModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); @@ -35,15 +37,11 @@ export const CyclesListHeader: FC = observer(() => {
    - - } - /> - } + diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx similarity index 96% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx index 5dfcd25f9..edb4e1bcf 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx @@ -9,12 +9,12 @@ import { useTranslation } from "@plane/i18n"; import { TCycleFilters } from "@plane/types"; // components import { Header, EHeaderVariant } from "@plane/ui"; -import { PageHead } from "@/components/core"; +import { calculateTotalFilters } from "@plane/utils"; +import { PageHead } from "@/components/core/page-title"; import { CyclesView, CycleCreateUpdateModal, CycleAppliedFiltersList } from "@/components/cycles"; import { ComicBoxButton, DetailedEmptyState } from "@/components/empty-state"; import { CycleModuleListLayout } from "@/components/ui"; // helpers -import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks import { useEventTracker, useCycle, useProject, useCycleFilter, useUserPermissions } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; @@ -69,7 +69,7 @@ const ProjectCyclesPage = observer(() => { primaryButton={{ text: t("disabled_project.empty_state.cycle.primary_button.text"), onClick: () => { - router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`); + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); }, disabled: !hasAdminLevelPermission, }} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx similarity index 97% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx index 2200b31f1..3c2796761 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx @@ -12,10 +12,10 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption // ui import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui"; // components +import { isIssueFilterActive } from "@plane/utils"; import { BreadcrumbLink } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; // helpers -import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -93,11 +93,10 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
    - + - } diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx similarity index 93% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx index 5cb8509e0..48ab4d38b 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx @@ -1,15 +1,14 @@ "use client"; import { observer } from "mobx-react"; -// components import { useParams, useSearchParams } from "next/navigation"; -import { EUserPermissionsLevel } from "@plane/constants"; +import { EUserPermissionsLevel, EInboxIssueCurrentTab } from "@plane/constants"; +// components import { EUserProjectRoles } from "@plane/constants/src/user"; import { useTranslation } from "@plane/i18n"; import { PageHead } from "@/components/core"; import { DetailedEmptyState } from "@/components/empty-state"; import { InboxIssueRoot } from "@/components/inbox"; // helpers -import { EInboxIssueCurrentTab } from "@/helpers/inbox.helper"; // hooks import { useProject, useUserPermissions } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; @@ -42,7 +41,7 @@ const ProjectInboxPage = observer(() => { primaryButton={{ text: t("disabled_project.empty_state.inbox.primary_button.text"), onClick: () => { - router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`); + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); }, disabled: !canPerformEmptyStateActions, }} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx similarity index 97% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx index 6b86cd88d..27a78ed3a 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // icons import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; -// plane constants +// plane imports import { EIssueLayoutTypes, EIssueFilterType, @@ -13,14 +13,12 @@ import { ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE, } from "@plane/constants"; -// i18n import { useTranslation } from "@plane/i18n"; -// types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; -// ui import { CustomMenu } from "@plane/ui"; +import { isIssueFilterActive } from "@plane/utils"; // components -import { ProjectAnalyticsModal } from "@/components/analytics"; +import { WorkItemsModal } from "@/components/analytics/work-items/modal"; import { DisplayFiltersSelection, FilterSelection, @@ -28,7 +26,6 @@ import { IssueLayoutIcon, } from "@/components/issues/issue-layouts"; // helpers -import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/hooks/store"; @@ -105,7 +102,7 @@ export const ProjectIssuesMobileHeader = observer(() => { return ( <> - setAnalyticsModal(false)} projectDetails={currentProjectDetails ?? undefined} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx similarity index 98% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx index d35f31465..db3171307 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx @@ -4,12 +4,12 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; // components +import { cn } from "@plane/utils"; import { EmptyState } from "@/components/common"; import { PageHead } from "@/components/core"; import { ModuleLayoutRoot } from "@/components/issues"; import { ModuleAnalyticsSidebar } from "@/components/modules"; // helpers -import { cn } from "@/helpers/common.helper"; // hooks import { useModule, useProject } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx similarity index 75% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx index 6bbbb29a0..4ec808d7a 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx @@ -2,11 +2,10 @@ import { useCallback, useRef, useState } from "react"; import { observer } from "mobx-react"; -import Link from "next/link"; import { useParams } from "next/navigation"; // icons import { PanelRight } from "lucide-react"; -// plane constants +// plane imports import { EIssueLayoutTypes, EIssuesStoreType, @@ -14,24 +13,22 @@ import { ISSUE_DISPLAY_FILTERS_BY_PAGE, EUserPermissions, EUserPermissionsLevel, + EProjectFeatureKey, } from "@plane/constants"; -// types import { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, } from "@plane/types"; -// ui -import { Breadcrumbs, Button, DiceIcon, Tooltip, Header, CustomSearchSelect } from "@plane/ui"; +import { Breadcrumbs, Button, DiceIcon, Header, BreadcrumbNavigationSearchDropdown, Tooltip } from "@plane/ui"; +import { cn, isIssueFilterActive } from "@plane/utils"; // components -import { ProjectAnalyticsModal } from "@/components/analytics"; -import { BreadcrumbLink, SwitcherLabel } from "@/components/common"; +import { WorkItemsModal } from "@/components/analytics/work-items/modal"; +import { SwitcherLabel } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; // helpers import { ModuleQuickActions } from "@/components/modules"; -import { cn } from "@/helpers/common.helper"; -import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks import { useEventTracker, @@ -49,7 +46,7 @@ import { useIssuesActions } from "@/hooks/use-issues-actions"; import useLocalStorage from "@/hooks/use-local-storage"; import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web -import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs"; export const ModuleIssuesHeader: React.FC = observer(() => { // refs @@ -155,71 +152,50 @@ export const ModuleIssuesHeader: React.FC = observer(() => { return ( <> - setAnalyticsModal(false)} moduleDetails={moduleDetails ?? undefined} + projectDetails={currentProjectDetails} />
    - - - - - - - ... - +
    + + + { + router.push(`/${workspaceSlug}/projects/${projectId}/modules/${value}`); + }} + title={moduleDetails?.name} + icon={} + isLast + /> + } + /> + + {workItemsCount && workItemsCount > 0 ? ( + 1 ? "work items" : "work item" + } in this module`} + position="bottom" + > + + {workItemsCount} - } - /> - } - /> - } - /> - - - {workItemsCount && workItemsCount > 0 ? ( - 1 ? "work items" : "work item" - } in this module`} - position="bottom" - > - - {workItemsCount} - - - ) : null} -
    - } - value={moduleId} - onChange={(value: string) => { - router.push(`/${workspaceSlug}/projects/${projectId}/modules/${value}`); - }} - /> - } - /> -
    + + ) : null} +
    diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx similarity index 97% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx index 1f000bae2..71d2b78d4 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // icons import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; -// plane constants +// plane imports import { EIssueLayoutTypes, EIssueFilterType, @@ -13,14 +13,12 @@ import { ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE, } from "@plane/constants"; -// plane i18n import { useTranslation } from "@plane/i18n"; -// types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; -// ui import { CustomMenu } from "@plane/ui"; +import { isIssueFilterActive } from "@plane/utils"; // components -import { ProjectAnalyticsModal } from "@/components/analytics"; +import { WorkItemsModal } from "@/components/analytics/work-items/modal"; import { DisplayFiltersSelection, FilterSelection, @@ -28,7 +26,6 @@ import { IssueLayoutIcon, } from "@/components/issues/issue-layouts"; // helpers -import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks import { useIssues, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store"; @@ -106,10 +103,11 @@ export const ModuleIssuesMobileHeader = observer(() => { return (
    - setAnalyticsModal(false)} moduleDetails={moduleDetails ?? undefined} + projectDetails={currentProjectDetails} />
    { // router const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; // store hooks const { toggleCreateModuleModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); @@ -39,12 +40,11 @@ export const ModulesListHeader: React.FC = observer(() => {
    - - } /> - } +
    diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx similarity index 94% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx index 572cb3862..1a2980760 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx @@ -8,11 +8,11 @@ import { EUserPermissionsLevel, EUserProjectRoles } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { TModuleFilters } from "@plane/types"; // components -import { PageHead } from "@/components/core"; +import { calculateTotalFilters } from "@plane/utils"; +import { PageHead } from "@/components/core/page-title"; import { DetailedEmptyState } from "@/components/empty-state"; import { ModuleAppliedFiltersList, ModulesListView } from "@/components/modules"; // helpers -import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks import { useModuleFilter, useProject, useUserPermissions } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; @@ -61,7 +61,7 @@ const ProjectModulesPage = observer(() => { primaryButton={{ text: t("disabled_project.empty_state.module.primary_button.text"), onClick: () => { - router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`); + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); }, disabled: !canPerformEmptyStateActions, }} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx similarity index 59% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx index d939c6fe5..65e4d9475 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx @@ -2,20 +2,21 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { FileText } from "lucide-react"; +import { EProjectFeatureKey } from "@plane/constants"; // types import { ICustomSearchSelectOption } from "@plane/types"; // ui -import { Breadcrumbs, Header, CustomSearchSelect } from "@plane/ui"; +import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; // components -import { BreadcrumbLink, PageAccessIcon, SwitcherLabel } from "@/components/common"; +import { getPageName } from "@plane/utils"; +import { PageAccessIcon, SwitcherIcon, SwitcherLabel } from "@/components/common"; import { PageHeaderActions } from "@/components/pages/header/actions"; // helpers -import { getPageName } from "@/helpers/page.helper"; // hooks import { useProject } from "@/hooks/store"; // plane web components import { useAppRouter } from "@/hooks/use-app-router"; -import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages"; // plane web hooks import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store"; @@ -31,7 +32,7 @@ export const PageDetailsHeader = observer(() => { const router = useAppRouter(); const { workspaceSlug, pageId, projectId } = useParams(); // store hooks - const { currentProjectDetails, loader } = useProject(); + const { loader } = useProject(); const { getPageById, getCurrentProjectPageIds } = usePageStore(storeType); const page = usePage({ pageId: pageId?.toString() ?? "", @@ -64,45 +65,27 @@ export const PageDetailsHeader = observer(() => {
    - - - - - - - - - } + - } - /> - } - /> - - } + { router.push(`/${workspaceSlug}/projects/${projectId}/pages/${value}`); }} + title={page?.name} + icon={ + + + + } + isLast /> } /> @@ -110,7 +93,7 @@ export const PageDetailsHeader = observer(() => {
    - +
    diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx similarity index 78% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx index ca15df8f5..af1a75f38 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx @@ -3,19 +3,16 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { useParams, useRouter, useSearchParams } from "next/navigation"; -import { FileText } from "lucide-react"; // constants -import { EPageAccess } from "@plane/constants"; +import { EPageAccess, EProjectFeatureKey } from "@plane/constants"; // plane types import { TPage } from "@plane/types"; // plane ui import { Breadcrumbs, Button, Header, setToast, TOAST_TYPE } from "@plane/ui"; -// helpers -import { BreadcrumbLink } from "@/components/common"; // hooks import { useEventTracker, useProject } from "@/hooks/store"; // plane web -import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs"; // plane web hooks import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; @@ -58,15 +55,14 @@ export const PagesListHeader = observer(() => { return (
    -
    - - - } />} - /> - -
    + + +
    {canCurrentUserCreatePage ? ( diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx similarity index 97% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx index 56d89d7f4..270faf985 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx @@ -54,7 +54,7 @@ const ProjectPagesPage = observer(() => { primaryButton={{ text: t("disabled_project.empty_state.page.primary_button.text"), onClick: () => { - router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`); + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); }, disabled: !canPerformEmptyStateActions, }} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx similarity index 89% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx index 3bc057479..8e69ef888 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx @@ -13,6 +13,7 @@ import { EViewAccess, EUserPermissions, EUserPermissionsLevel, + EProjectFeatureKey, } from "@plane/constants"; // types import { @@ -22,14 +23,14 @@ import { IIssueFilterOptions, } from "@plane/types"; // ui -import { Breadcrumbs, Button, Tooltip, Header, CustomSearchSelect } from "@plane/ui"; +import { Breadcrumbs, Button, Tooltip, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; // components -import { BreadcrumbLink, SwitcherLabel } from "@/components/common"; +import { isIssueFilterActive } from "@plane/utils"; +import { SwitcherIcon, SwitcherLabel } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; // constants import { ViewQuickActions } from "@/components/views"; // helpers -import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks import { useCommandPalette, @@ -44,7 +45,7 @@ import { } from "@/hooks/store"; // plane web import { useAppRouter } from "@/hooks/use-app-router"; -import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs"; export const ProjectViewIssuesHeader: React.FC = observer(() => { // refs @@ -164,27 +165,27 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
    - - } - /> - } + - } + { router.push(`/${workspaceSlug}/projects/${projectId}/views/${value}`); }} + title={viewDetails?.name} + icon={ + + + + } + isLast /> } /> diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx similarity index 64% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx index ea9a6ac8a..2d13b0d3e 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx @@ -1,18 +1,19 @@ "use client"; import { observer } from "mobx-react"; -import { Layers } from "lucide-react"; +import { useParams } from "next/navigation"; // ui +import { EProjectFeatureKey } from "@plane/constants"; import { Breadcrumbs, Button, Header } from "@plane/ui"; // components -import { BreadcrumbLink } from "@/components/common"; import { ViewListHeader } from "@/components/views"; // hooks import { useCommandPalette, useProject } from "@/hooks/store"; // plane web -import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs"; export const ProjectViewsHeader = observer(() => { + const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; // store hooks const { toggleCreateViewModal } = useCommandPalette(); const { loader } = useProject(); @@ -22,10 +23,11 @@ export const ProjectViewsHeader = observer(() => {
    - - } />} + diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx similarity index 94% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx index d4a3051ec..d9068966c 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx @@ -8,13 +8,13 @@ import { EUserPermissionsLevel, EUserProjectRoles, EViewAccess } from "@plane/co import { useTranslation } from "@plane/i18n"; import { TViewFilterProps } from "@plane/types"; import { Header, EHeaderVariant } from "@plane/ui"; -import { PageHead } from "@/components/core"; +import { calculateTotalFilters } from "@plane/utils"; +import { PageHead } from "@/components/core/page-title"; import { DetailedEmptyState } from "@/components/empty-state"; import { ProjectViewsList } from "@/components/views"; import { ViewAppliedFiltersList } from "@/components/views/applied-filters"; // constants // helpers -import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks import { useProject, useProjectView, useUserPermissions } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; @@ -68,7 +68,7 @@ const ProjectViewsPage = observer(() => { primaryButton={{ text: t("disabled_project.empty_state.view.primary_button.text"), onClick: () => { - router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`); + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); }, disabled: !canPerformEmptyStateActions, }} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(list)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(list)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(list)/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/sidebar.tsx b/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx similarity index 98% rename from web/app/[workspaceSlug]/(projects)/sidebar.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx index 27a73137e..39d6313ba 100644 --- a/web/app/[workspaceSlug]/(projects)/sidebar.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx @@ -5,11 +5,11 @@ import { observer } from "mobx-react"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useOutsideClickDetector } from "@plane/hooks"; // components +import { cn } from "@plane/utils"; import { SidebarDropdown, SidebarHelpSection, SidebarProjectsList, SidebarQuickActions } from "@/components/workspace"; import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu"; import { SidebarMenuItems } from "@/components/workspace/sidebar/sidebar-menu-items"; // helpers -import { cn } from "@/helpers/common.helper"; // hooks import { useAppTheme, useUserPermissions } from "@/hooks/store"; import { useFavorite } from "@/hooks/store/use-favorite"; diff --git a/web/app/[workspaceSlug]/(projects)/stickies/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx similarity index 94% rename from web/app/[workspaceSlug]/(projects)/stickies/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx index 9e3f1a45d..7f7f9c96f 100644 --- a/web/app/[workspaceSlug]/(projects)/stickies/header.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx @@ -25,9 +25,8 @@ export const WorkspaceStickyHeader = observer(() => {
    - } diff --git a/web/app/[workspaceSlug]/(projects)/stickies/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/stickies/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/stickies/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/stickies/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/stickies/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/stickies/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/stickies/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/stickies/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx similarity index 94% rename from web/app/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx index bf7d78a6d..0d62cd4a2 100644 --- a/web/app/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx @@ -8,7 +8,6 @@ import { DEFAULT_GLOBAL_VIEWS_LIST } from "@plane/constants"; // components import { PageHead } from "@/components/core"; import { AllIssueLayoutRoot, GlobalViewsAppliedFiltersRoot } from "@/components/issues"; -import { GlobalViewsHeader } from "@/components/workspace"; // constants // hooks import { useWorkspace } from "@/hooks/store"; @@ -32,7 +31,6 @@ const GlobalViewIssuesPage = observer(() => {
    - {globalViewId && ( )} diff --git a/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx similarity index 52% rename from web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx index 5a5ee0a6e..399f9aab6 100644 --- a/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx +++ b/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx @@ -1,35 +1,53 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { Layers } from "lucide-react"; // plane constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; +import { + DEFAULT_GLOBAL_VIEWS_LIST, + EIssueFilterType, + EIssuesStoreType, + ISSUE_DISPLAY_FILTERS_BY_PAGE, + EIssueLayoutTypes +} from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; +import { + ICustomSearchSelectOption, + IIssueDisplayFilterOptions, + IIssueDisplayProperties, + IIssueFilterOptions, +} from "@plane/types"; // ui -import { Breadcrumbs, Button, Header } from "@plane/ui"; +import { Breadcrumbs, Button, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; // components -import { BreadcrumbLink } from "@/components/common"; +import { isIssueFilterActive } from "@plane/utils"; +import { BreadcrumbLink, SwitcherLabel } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection } from "@/components/issues"; -import { CreateUpdateWorkspaceViewModal } from "@/components/workspace"; +import { + CreateUpdateWorkspaceViewModal, + WorkspaceViewQuickActions, + DefaultWorkspaceViewQuickActions, +} from "@/components/workspace"; // helpers -import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks import { useLabel, useMember, useIssues, useGlobalView } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { GlobalViewLayoutSelection } from "@/plane-web/components/views/helper"; export const GlobalIssuesHeader = observer(() => { // states const [createViewModal, setCreateViewModal] = useState(false); // router + const router = useAppRouter(); const { workspaceSlug, globalViewId } = useParams(); // store hooks const { issuesFilter: { filters, updateFilters }, } = useIssues(EIssuesStoreType.GLOBAL); - const { getViewDetailsById } = useGlobalView(); + const { getViewDetailsById, currentWorkspaceViews } = useGlobalView(); const { workspaceLabels } = useLabel(); const { workspace: { workspaceMemberIds }, @@ -38,6 +56,7 @@ export const GlobalIssuesHeader = observer(() => { const issueFilters = globalViewId ? filters[globalViewId.toString()] : undefined; + const activeLayout = issueFilters?.displayFilters?.layout; const viewDetails = getViewDetailsById(globalViewId.toString()); const handleFiltersUpdate = useCallback( @@ -95,17 +114,79 @@ export const GlobalIssuesHeader = observer(() => { [workspaceSlug, updateFilters, globalViewId] ); + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlug || !globalViewId) return; + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.DISPLAY_FILTERS, + { layout: layout }, + globalViewId.toString() + ); + }, + [workspaceSlug, updateFilters, globalViewId] + ); + const isLocked = viewDetails?.is_locked; + const isDefaultView = DEFAULT_GLOBAL_VIEWS_LIST.find((view) => view.key === globalViewId); + + const defaultViewDetails = DEFAULT_GLOBAL_VIEWS_LIST.find((view) => view.key === globalViewId); + + const defaultOptions = DEFAULT_GLOBAL_VIEWS_LIST.map((view) => ({ + value: view.key, + query: view.key, + content: , + })); + + const workspaceOptions = (currentWorkspaceViews || []).map((view) => { + const _view = getViewDetailsById(view); + if (!_view) return; + return { + value: _view.id, + query: _view.name, + content: , + }; + }); + + const switcherOptions = [...defaultOptions, ...workspaceOptions].filter( + (option) => option !== undefined + ) as ICustomSearchSelectOption[]; + const currentLayoutFilters = useMemo(() => { + const layout = activeLayout ?? EIssueLayoutTypes.SPREADSHEET; + return ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues[layout]; + }, [activeLayout]); + return ( <> setCreateViewModal(false)} />
    - } />} + } /> + } + /> + { + router.push(`/${workspaceSlug}/workspace-views/${value}`); + }} + title={viewDetails?.name ?? t(defaultViewDetails?.i18n_label ?? "")} + icon={ + + + + } + isLast + /> + } + isLast /> @@ -113,13 +194,18 @@ export const GlobalIssuesHeader = observer(() => { {!isLocked ? ( <> + { { +
    + {viewDetails && } + {isDefaultView && defaultViewDetails && ( + + )} +
    diff --git a/web/app/[workspaceSlug]/(projects)/workspace-views/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/workspace-views/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/workspace-views/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/workspace-views/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx b/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx new file mode 100644 index 000000000..4bd4a5340 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { CommandPalette } from "@/components/command-palette"; +import { ContentWrapper } from "@/components/core"; +import { SettingsHeader } from "@/components/settings"; +import { AuthenticationWrapper } from "@/lib/wrappers"; +import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper"; + +export default function SettingsLayout({ children }: { children: React.ReactNode }) { + return ( + + + +
    + {/* Header */} + + {/* Content */} + +
    {children}
    +
    +
    +
    +
    + ); +} diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/billing/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx similarity index 83% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/billing/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx index 801b04b37..028647669 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/billing/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx @@ -6,6 +6,7 @@ import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; // hooks +import { SettingsContentWrapper } from "@/components/settings"; import { useUserPermissions, useWorkspace } from "@/hooks/store"; // plane web components import { BillingRoot } from "@/plane-web/components/workspace"; @@ -19,14 +20,14 @@ const BillingSettingsPage = observer(() => { const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Billing & Plans` : undefined; if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { - return ; + return ; } return ( - <> + - + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/exports/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx similarity index 71% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/exports/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx index 1b63406de..62aabb10e 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/exports/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx @@ -4,12 +4,14 @@ import { observer } from "mobx-react"; // components import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { cn } from "@plane/utils"; import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import ExportGuide from "@/components/exporter/guide"; // helpers -import { cn } from "@/helpers/common.helper"; // hooks +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import SettingsHeading from "@/components/settings/heading"; import { useUserPermissions, useWorkspace } from "@/hooks/store"; const ExportsPage = observer(() => { @@ -29,23 +31,24 @@ const ExportsPage = observer(() => { // if user is not authorized to view this page if (workspaceUserInfo && !canPerformWorkspaceMemberActions) { - return ; + return ; } return ( - <> +
    -
    -

    {t("workspace_settings.settings.exports.title")}

    -
    +
    - +
    ); }); diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/imports/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx similarity index 61% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/imports/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx index 718742804..10d1a76e6 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/imports/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx @@ -3,40 +3,32 @@ import { observer } from "mobx-react"; // components import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import IntegrationGuide from "@/components/integration/guide"; // hooks +import { SettingsContentWrapper, SettingsHeading } from "@/components/settings"; import { useUserPermissions, useWorkspace } from "@/hooks/store"; const ImportsPage = observer(() => { + // router // store hooks const { currentWorkspace } = useWorkspace(); const { allowPermissions } = useUserPermissions(); - // derived values const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Imports` : undefined; - if (!isAdmin) - return ( - <> - -
    -

    You are not authorized to access this page.

    -
    - - ); + if (!isAdmin) return ; return ( - <> + -
    -
    -

    Imports

    -
    +
    +
    - + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/integrations/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx similarity index 86% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/integrations/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx index ef31bd82f..335631a2e 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/integrations/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx @@ -4,8 +4,10 @@ import { useParams } from "next/navigation"; import useSWR from "swr"; // components import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { SingleIntegrationCard } from "@/components/integration"; +import { SettingsContentWrapper } from "@/components/settings"; import { IntegrationAndImportExportBanner, IntegrationsSettingsLoader } from "@/components/ui"; // constants import { APP_INTEGRATIONS } from "@/constants/fetch-keys"; @@ -26,23 +28,14 @@ const WorkspaceIntegrationsPage = observer(() => { // derived values const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Integrations` : undefined; - - if (!isAdmin) - return ( - <> - -
    -

    You are not authorized to access this page.

    -
    - - ); - const { data: appIntegrations } = useSWR(workspaceSlug && isAdmin ? APP_INTEGRATIONS : null, () => workspaceSlug && isAdmin ? integrationService.getAppIntegrationsList() : null ); + if (!isAdmin) return ; + return ( - <> +
    @@ -56,7 +49,7 @@ const WorkspaceIntegrationsPage = observer(() => { )}
    - + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx new file mode 100644 index 000000000..bc3e69a7a --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { FC, ReactNode } from "react"; +import { observer } from "mobx-react"; +import { usePathname } from "next/navigation"; +// constants +import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_ACCESS } from "@plane/constants"; +// components +import { NotAuthorizedView } from "@/components/auth-screens"; +import { SettingsMobileNav } from "@/components/settings"; +import { getWorkspaceActivePath, pathnameToAccessKey } from "@/components/settings/helper"; +// hooks +import { useUserPermissions } from "@/hooks/store"; +// local components +import { WorkspaceSettingsSidebar } from "./sidebar"; + +export interface IWorkspaceSettingLayout { + children: ReactNode; +} + +const WorkspaceSettingLayout: FC = observer((props) => { + const { children } = props; + // store hooks + const { workspaceUserInfo, getWorkspaceRoleByWorkspaceSlug } = useUserPermissions(); + // next hooks + const pathname = usePathname(); + // derived values + const { workspaceSlug, accessKey } = pathnameToAccessKey(pathname); + const userWorkspaceRole = getWorkspaceRoleByWorkspaceSlug(workspaceSlug.toString()); + + let isAuthorized: boolean | string = false; + if (pathname && workspaceSlug && userWorkspaceRole) { + isAuthorized = WORKSPACE_SETTINGS_ACCESS[accessKey]?.includes(userWorkspaceRole as EUserWorkspaceRoles); + } + + return ( + <> + +
    + {workspaceUserInfo && !isAuthorized ? ( + + ) : ( +
    +
    {}
    +
    {children}
    +
    + )} +
    + + ); +}); + +export default WorkspaceSettingLayout; diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/members/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx similarity index 91% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/members/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx index 8be7a9d22..001c00dd2 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/members/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx @@ -5,19 +5,19 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { Search } from "lucide-react"; // types -import { MEMBER_INVITED, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { IWorkspaceBulkInviteFormData } from "@plane/types"; // ui import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +import { cn, getUserRole } from "@plane/utils"; // components import { NotAuthorizedView } from "@/components/auth-screens"; import { CountChip } from "@/components/common"; import { PageHead } from "@/components/core"; +import { SettingsContentWrapper } from "@/components/settings"; import { WorkspaceMembersList } from "@/components/workspace"; // helpers -import { cn } from "@/helpers/common.helper"; -import { getUserRole } from "@/helpers/user.helper"; // hooks import { useEventTracker, useMember, useUserPermissions, useWorkspace } from "@/hooks/store"; // plane web components @@ -52,7 +52,7 @@ const WorkspaceMembersSettingsPage = observer(() => { return inviteMembersToWorkspace(workspaceSlug.toString(), data) .then(() => { setInviteModal(false); - captureEvent(MEMBER_INVITED, { + captureEvent(MEMBER_TRACKER_EVENTS.invite, { emails: [ ...data.emails.map((email) => ({ email: email.email, @@ -70,7 +70,7 @@ const WorkspaceMembersSettingsPage = observer(() => { }); }) .catch((err) => { - captureEvent(MEMBER_INVITED, { + captureEvent(MEMBER_TRACKER_EVENTS.invite, { emails: [ ...data.emails.map((email) => ({ email: email.email, @@ -95,11 +95,11 @@ const WorkspaceMembersSettingsPage = observer(() => { // if user is not authorized to view this page if (workspaceUserInfo && !canPerformWorkspaceMemberActions) { - return ; + return ; } return ( - <> + { onSubmit={handleWorkspaceInvite} />
    @@ -137,7 +137,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
    - + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/mobile-header-tabs.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/mobile-header-tabs.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/mobile-header-tabs.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/mobile-header-tabs.tsx diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx similarity index 85% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx index 6088cf0a5..736c34810 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx @@ -4,6 +4,7 @@ import { observer } from "mobx-react"; // components import { useTranslation } from "@plane/i18n"; import { PageHead } from "@/components/core"; +import { SettingsContentWrapper } from "@/components/settings"; import { WorkspaceDetails } from "@/components/workspace"; // hooks import { useWorkspace } from "@/hooks/store"; @@ -18,10 +19,10 @@ const WorkspaceSettingsPage = observer(() => { : undefined; return ( - <> + - + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx new file mode 100644 index 000000000..8a97c8b05 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx @@ -0,0 +1,73 @@ +import { useParams, usePathname } from "next/navigation"; +import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react"; +import { + EUserPermissionsLevel, + GROUPED_WORKSPACE_SETTINGS, + WORKSPACE_SETTINGS_CATEGORIES, + EUserWorkspaceRoles, + EUserPermissions, + WORKSPACE_SETTINGS_CATEGORY, +} from "@plane/constants"; +import { SettingsSidebar } from "@/components/settings"; +import { useUserPermissions } from "@/hooks/store/user"; +import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; + +const ICONS = { + general: Building, + members: Users, + export: ArrowUpToLine, + "billing-and-plans": CreditCard, + webhooks: Webhook, +}; + +export const WorkspaceActionIcons = ({ + type, + size, + className, +}: { + type: string; + size?: number; + className?: string; +}) => { + if (type === undefined) return null; + const Icon = ICONS[type as keyof typeof ICONS]; + if (!Icon) return null; + return ; +}; + +type TWorkspaceSettingsSidebarProps = { + isMobile?: boolean; +}; + +export const WorkspaceSettingsSidebar = (props: TWorkspaceSettingsSidebarProps) => { + const { isMobile = false } = props; + // router + const pathname = usePathname(); + const { workspaceSlug } = useParams(); // store hooks + const { allowPermissions } = useUserPermissions(); + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + + return ( + + isAdmin || ![WORKSPACE_SETTINGS_CATEGORY.FEATURES, WORKSPACE_SETTINGS_CATEGORY.DEVELOPER].includes(category) + )} + groupedSettings={GROUPED_WORKSPACE_SETTINGS} + workspaceSlug={workspaceSlug.toString()} + isActive={(data: { href: string }) => + data.href === "/settings" + ? pathname === `/${workspaceSlug}${data.href}/` + : new RegExp(`^/${workspaceSlug}${data.href}/`).test(pathname) + } + shouldRender={(data: { key: string; access?: EUserWorkspaceRoles[] | undefined }) => + data.access + ? shouldRenderSettingLink(workspaceSlug.toString(), data.key) && + allowPermissions(data.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) + : false + } + actionIcons={WorkspaceActionIcons} + /> + ); +}; diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/[webhookId]/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx similarity index 96% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/[webhookId]/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx index 5edc914e9..a775ff3b1 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/[webhookId]/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx @@ -11,6 +11,7 @@ import { TOAST_TYPE, setToast } from "@plane/ui"; // components import { LogoSpinner } from "@/components/common"; import { PageHead } from "@/components/core"; +import { SettingsContentWrapper } from "@/components/settings"; import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "@/components/web-hooks"; // hooks import { useUserPermissions, useWebhook, useWorkspace } from "@/hooks/store"; @@ -87,7 +88,7 @@ const WebhookDetailsPage = observer(() => { ); return ( - <> + setDeleteWebhookModal(false)} />
    @@ -96,7 +97,7 @@ const WebhookDetailsPage = observer(() => {
    {currentWebhook && setDeleteWebhookModal(true)} />}
    - + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx similarity index 72% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx index 2623660da..19f18717e 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx @@ -7,11 +7,11 @@ import useSWR from "swr"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Button } from "@plane/ui"; // components import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { DetailedEmptyState } from "@/components/empty-state"; +import { SettingsContentWrapper, SettingsHeading } from "@/components/settings"; import { WebhookSettingsLoader } from "@/components/ui"; import { WebhooksList, CreateWebhookModal } from "@/components/web-hooks"; // hooks @@ -48,15 +48,15 @@ const WebhooksListPage = observer(() => { }, [showCreateWebhookModal, webhookSecretKey, clearSecretKey]); if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { - return ; + return ; } if (!webhooks) return ; return ( - <> + -
    +
    { setShowCreateWebhookModal(false); }} /> + setShowCreateWebhookModal(true), + }} + /> {Object.keys(webhooks).length > 0 ? (
    -
    -
    {t("workspace_settings.settings.webhooks.title")}
    - -
    ) : (
    -
    -
    {t("workspace_settings.settings.webhooks.title")}
    - -
    setShowCreateWebhookModal(true), + }} />
    )}
    - + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx new file mode 100644 index 000000000..1676ec7e6 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +// ui +import { Button } from "@plane/ui"; +// components +import { PageHead } from "@/components/core"; +import { DetailedEmptyState } from "@/components/empty-state"; +import { ProfileActivityListPage } from "@/components/profile"; +// hooks +import { SettingsHeading } from "@/components/settings"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +const PER_PAGE = 100; + +const ProfileActivityPage = observer(() => { + // states + const [pageCount, setPageCount] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [resultsCount, setResultsCount] = useState(0); + const [isEmpty, setIsEmpty] = useState(false); + // plane hooks + const { t } = useTranslation(); + // derived values + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/profile/activity" }); + + const updateTotalPages = (count: number) => setTotalPages(count); + + const updateResultsCount = (count: number) => setResultsCount(count); + + const updateEmptyState = (isEmpty: boolean) => setIsEmpty(isEmpty); + + const handleLoadMore = () => setPageCount((prev) => prev + 1); + + const activityPages: JSX.Element[] = []; + for (let i = 0; i < pageCount; i++) + activityPages.push( + + ); + + const isLoadMoreVisible = pageCount < totalPages && resultsCount !== 0; + + if (isEmpty) { + return ( +
    + + +
    + ); + } + + return ( + <> + + +
    {activityPages}
    + {isLoadMoreVisible && ( +
    + +
    + )} + + ); +}); + +export default ProfileActivityPage; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx new file mode 100644 index 000000000..9a1883255 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx @@ -0,0 +1,95 @@ +"use client"; + +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// component +import { APITokenService } from "@plane/services"; +import { ApiTokenListItem, CreateApiTokenModal } from "@/components/api-token"; +import { PageHead } from "@/components/core"; +import { DetailedEmptyState } from "@/components/empty-state"; +import { SettingsHeading } from "@/components/settings"; +import { APITokenSettingsLoader } from "@/components/ui"; +import { API_TOKENS_LIST } from "@/constants/fetch-keys"; +// store hooks +import { useWorkspace } from "@/hooks/store"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +// services + +const apiTokenService = new APITokenService(); + +const ApiTokensPage = observer(() => { + // states + const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false); + // router + // plane hooks + const { t } = useTranslation(); + // store hooks + const { currentWorkspace } = useWorkspace(); + // derived values + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/api-tokens" }); + + const { data: tokens } = useSWR(API_TOKENS_LIST, () => apiTokenService.list()); + + const pageTitle = currentWorkspace?.name + ? `${currentWorkspace.name} - ${t("workspace_settings.settings.api_tokens.title")}` + : undefined; + + if (!tokens) { + return ; + } + + return ( +
    + + setIsCreateTokenModalOpen(false)} /> +
    + {tokens.length > 0 ? ( + <> + setIsCreateTokenModalOpen(true), + }} + /> +
    + {tokens.map((token) => ( + + ))} +
    + + ) : ( +
    + setIsCreateTokenModalOpen(true), + }} + /> +
    + setIsCreateTokenModalOpen(true), + }} + /> +
    +
    + )} +
    +
    + ); +}); + +export default ApiTokensPage; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx new file mode 100644 index 000000000..43ff52032 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { ReactNode } from "react"; +// components +import { observer } from "mobx-react"; +import { usePathname } from "next/navigation"; +import { SettingsContentWrapper, SettingsMobileNav } from "@/components/settings"; +import { getProfileActivePath } from "@/components/settings/helper"; +import { ProfileSidebar } from "./sidebar"; + +type Props = { + children: ReactNode; +}; + +const ProfileSettingsLayout = observer((props: Props) => { + const { children } = props; + // router + const pathname = usePathname(); + + return ( + <> + +
    +
    + +
    +
    + {children} +
    +
    + + ); +}); + +export default ProfileSettingsLayout; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx new file mode 100644 index 000000000..cc71877af --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx @@ -0,0 +1,37 @@ +"use client"; + +import useSWR from "swr"; +// components +import { useTranslation } from "@plane/i18n"; +import { PageHead } from "@/components/core"; +import { EmailNotificationForm } from "@/components/profile/notification"; +import { SettingsHeading } from "@/components/settings"; +import { EmailSettingsLoader } from "@/components/ui"; +// services +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() + ); + + if (!data || isLoading) { + return ; + } + + return ( + <> + + + + + + ); +} diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx new file mode 100644 index 000000000..f37178c2a --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +// components +import { LogoSpinner } from "@/components/common"; +import { PageHead } from "@/components/core"; +import { ProfileForm } from "@/components/profile"; +// hooks +import { useUser } from "@/hooks/store"; + +const ProfileSettingsPage = observer(() => { + const { t } = useTranslation(); + // store hooks + const { data: currentUser, userProfile } = useUser(); + + if (!currentUser) + return ( +
    + +
    + ); + + return ( + <> + + + + ); +}); + +export default ProfileSettingsPage; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx new file mode 100644 index 000000000..87479a2c8 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +// components +import { LogoSpinner } from "@/components/common"; +import { PageHead } from "@/components/core"; +import { PreferencesList } from "@/components/preferences/list"; +import { ProfileSettingContentHeader } from "@/components/profile"; +// hooks +import { LanguageTimezone } from "@/components/profile/preferences/language-timezone"; +import { SettingsHeading } from "@/components/settings"; +import { useUserProfile } from "@/hooks/store"; + +const ProfileAppearancePage = observer(() => { + const { t } = useTranslation(); + // hooks + const { data: userProfile } = useUserProfile(); + + return ( + <> + + {userProfile ? ( + <> +
    +
    + + +
    +
    + + +
    +
    + + ) : ( +
    + +
    + )} + + ); +}); + +export default ProfileAppearancePage; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx new file mode 100644 index 000000000..3200959c7 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { Controller, useForm } from "react-hook-form"; +import { Eye, EyeOff } from "lucide-react"; +// plane imports +import { E_PASSWORD_STRENGTH } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; +import { getPasswordStrength } from "@plane/utils"; +// components +import { PasswordStrengthMeter } from "@/components/account"; +import { PageHead } from "@/components/core"; +import { ProfileSettingContentHeader } from "@/components/profile"; +// helpers +import { authErrorHandler } from "@/helpers/authentication.helper"; +// hooks +import { useUser } from "@/hooks/store"; +// services +import { AuthService } from "@/services/auth.service"; + +export interface FormValues { + old_password: string; + new_password: string; + confirm_password: string; +} + +const defaultValues: FormValues = { + old_password: "", + new_password: "", + confirm_password: "", +}; + +const authService = new AuthService(); + +const defaultShowPassword = { + oldPassword: false, + password: false, + confirmPassword: false, +}; + +const SecurityPage = observer(() => { + // store + const { data: currentUser, changePassword } = useUser(); + // states + const [showPassword, setShowPassword] = useState(defaultShowPassword); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); + + // use form + const { + control, + handleSubmit, + watch, + formState: { errors, isSubmitting }, + reset, + } = useForm({ defaultValues }); + // derived values + const oldPassword = watch("old_password"); + const password = watch("new_password"); + const confirmPassword = watch("confirm_password"); + const oldPasswordRequired = !currentUser?.is_password_autoset; + // i18n + const { t } = useTranslation(); + + const isNewPasswordSameAsOldPassword = oldPassword !== "" && password !== "" && password === oldPassword; + + const handleShowPassword = (key: keyof typeof showPassword) => + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + + const handleChangePassword = async (formData: FormValues) => { + const { old_password, new_password } = formData; + try { + const csrfToken = await authService.requestCSRFToken().then((data) => data?.csrf_token); + if (!csrfToken) throw new Error("csrf token not found"); + + await changePassword(csrfToken, { + ...(oldPasswordRequired && { old_password }), + new_password, + }); + + reset(defaultValues); + setShowPassword(defaultShowPassword); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("auth.common.password.toast.change_password.success.title"), + message: t("auth.common.password.toast.change_password.success.message"), + }); + } catch (err: any) { + const errorInfo = authErrorHandler(err.error_code?.toString()); + setToast({ + type: TOAST_TYPE.ERROR, + title: errorInfo?.title ?? t("auth.common.password.toast.error.title"), + message: + typeof errorInfo?.message === "string" ? errorInfo.message : t("auth.common.password.toast.error.message"), + }); + } + }; + + const isButtonDisabled = + getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID || + (oldPasswordRequired && oldPassword.trim() === "") || + password.trim() === "" || + confirmPassword.trim() === "" || + password !== confirmPassword || + password === oldPassword; + + const passwordSupport = password.length > 0 && + getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && ( + + ); + + const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; + + return ( + <> + + +
    +
    + {oldPasswordRequired && ( +
    +

    {t("auth.common.password.current_password.label")}

    +
    + ( + + )} + /> + {showPassword?.oldPassword ? ( + handleShowPassword("oldPassword")} + /> + ) : ( + handleShowPassword("oldPassword")} + /> + )} +
    + {errors.old_password && {errors.old_password.message}} +
    + )} +
    +

    {t("auth.common.password.new_password.label")}

    +
    + ( + setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + /> + )} + /> + {showPassword?.password ? ( + handleShowPassword("password")} + /> + ) : ( + handleShowPassword("password")} + /> + )} +
    + {passwordSupport} + {isNewPasswordSameAsOldPassword && !isPasswordInputFocused && ( + {t("new_password_must_be_different_from_old_password")} + )} +
    +
    +

    {t("auth.common.password.confirm_password.label")}

    +
    + ( + setIsRetryPasswordInputFocused(true)} + onBlur={() => setIsRetryPasswordInputFocused(false)} + /> + )} + /> + {showPassword?.confirmPassword ? ( + handleShowPassword("confirmPassword")} + /> + ) : ( + handleShowPassword("confirmPassword")} + /> + )} +
    + {!!confirmPassword && password !== confirmPassword && renderPasswordMatchError && ( + {t("auth.common.password.errors.match")} + )} +
    +
    + +
    + +
    +
    + + ); +}); + +export default SecurityPage; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx new file mode 100644 index 000000000..7153e11be --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx @@ -0,0 +1,75 @@ +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +import { CircleUser, Activity, Bell, CircleUserRound, KeyRound, Settings2, Blocks, Lock } from "lucide-react"; +// plane imports +import { GROUPED_PROFILE_SETTINGS, PROFILE_SETTINGS_CATEGORIES } from "@plane/constants"; +import { getFileURL } from "@plane/utils"; +// components +import { SettingsSidebar } from "@/components/settings"; +// hooks +import { useUser } from "@/hooks/store/user"; + +const ICONS = { + profile: CircleUser, + security: Lock, + activity: Activity, + preferences: Settings2, + notifications: Bell, + "api-tokens": KeyRound, + connections: Blocks, +}; + +export const ProjectActionIcons = ({ type, size, className }: { type: string; size?: number; className?: string }) => { + if (type === undefined) return null; + const Icon = ICONS[type as keyof typeof ICONS]; + if (!Icon) return null; + return ; +}; + +type TProfileSidebarProps = { + isMobile?: boolean; +}; + +export const ProfileSidebar = observer((props: TProfileSidebarProps) => { + const { isMobile = false } = props; + // router + const pathname = usePathname(); + const { workspaceSlug } = useParams(); + // store hooks + const { data: currentUser } = useUser(); + + return ( + pathname === `/${workspaceSlug}${data.href}/`} + customHeader={ +
    +
    + {!currentUser?.avatar_url || currentUser?.avatar_url === "" ? ( +
    + +
    + ) : ( +
    + {currentUser?.display_name} +
    + )} +
    +
    +
    {currentUser?.display_name}
    +
    {currentUser?.email}
    +
    +
    + } + actionIcons={ProjectActionIcons} + shouldRender + /> + ); +}); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/automations/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx similarity index 79% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/automations/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx index 5fc536d91..c7542b4f0 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/automations/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx @@ -13,6 +13,7 @@ import { NotAuthorizedView } from "@/components/auth-screens"; import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation"; import { PageHead } from "@/components/core"; // hooks +import { SettingsContentWrapper, SettingsHeading } from "@/components/settings"; import { useProject, useUserPermissions } from "@/hooks/store"; const AutomationSettingsPage = observer(() => { @@ -43,20 +44,21 @@ const AutomationSettingsPage = observer(() => { const pageTitle = projectDetails?.name ? `${projectDetails?.name} - Automations` : undefined; if (workspaceUserInfo && !canPerformProjectAdminActions) { - return ; + return ; } return ( - <> + -
    -
    -

    {t("project_settings.automations.label")}

    -
    +
    +
    - + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/estimates/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx similarity index 80% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/estimates/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx index 0a19713e8..db9d17e89 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/estimates/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx @@ -8,6 +8,7 @@ import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { EstimateRoot } from "@/components/estimates"; // hooks +import { SettingsContentWrapper } from "@/components/settings"; import { useProject, useUserPermissions } from "@/hooks/store"; const EstimatesSettingsPage = observer(() => { @@ -23,22 +24,20 @@ const EstimatesSettingsPage = observer(() => { if (!workspaceSlug || !projectId) return <>; if (workspaceUserInfo && !canPerformProjectAdminActions) { - return ; + return ; } return ( - <> + -
    +
    - + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/features/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx similarity index 81% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/features/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx index 23aa8ad45..d84ba10c4 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/features/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx @@ -8,6 +8,7 @@ import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectFeaturesList } from "@/components/project"; // hooks +import { SettingsContentWrapper } from "@/components/settings"; import { useProject, useUserPermissions } from "@/hooks/store"; const FeaturesSettingsPage = observer(() => { @@ -23,20 +24,20 @@ const FeaturesSettingsPage = observer(() => { if (!workspaceSlug || !projectId) return null; if (workspaceUserInfo && !canPerformProjectAdminActions) { - return ; + return ; } return ( - <> + -
    +
    - + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/labels/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx similarity index 87% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/labels/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx index 17a466a80..317e76929 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/labels/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx @@ -10,6 +10,7 @@ import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectSettingsLabelList } from "@/components/labels"; // hooks +import { SettingsContentWrapper } from "@/components/settings"; import { useProject, useUserPermissions } from "@/hooks/store"; const LabelsSettingsPage = observer(() => { @@ -38,19 +39,19 @@ const LabelsSettingsPage = observer(() => { element, }) ); - }, [scrollableContainerRef?.current]); + }, []); if (workspaceUserInfo && !canPerformProjectMemberActions) { - return ; + return ; } return ( - <> + -
    +
    - + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx similarity index 50% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx index 9deaef126..216a74631 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx @@ -1,19 +1,32 @@ "use client"; import { observer } from "mobx-react"; -// components +import { useParams } from "next/navigation"; +// plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// components import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectMemberList, ProjectSettingsMemberDefaults } from "@/components/project"; // hooks +import { SettingsContentWrapper, SettingsHeading } from "@/components/settings"; import { useProject, useUserPermissions } from "@/hooks/store"; +// plane web imports +import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces"; +import { getProjectSettingsPageLabelI18nKey } from "@/plane-web/helpers/project-settings"; const MembersSettingsPage = observer(() => { - // store + // router + const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams(); + // plane hooks + const { t } = useTranslation(); + // store hooks const { currentProjectDetails } = useProject(); const { workspaceUserInfo, allowPermissions } = useUserPermissions(); // derived values + const projectId = routerProjectId?.toString(); + const workspaceSlug = routerWorkspaceSlug?.toString(); const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined; const isProjectMemberOrAdmin = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], @@ -23,17 +36,17 @@ const MembersSettingsPage = observer(() => { const canPerformProjectMemberActions = isProjectMemberOrAdmin || isWorkspaceAdmin; if (workspaceUserInfo && !canPerformProjectMemberActions) { - return ; + return ; } return ( - <> + -
    - - -
    - + + + + +
    ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx similarity index 91% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx index 96ff1bcc3..cf79fa127 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx @@ -16,9 +16,9 @@ import { ProjectDetailsFormLoader, } from "@/components/project"; // hooks +import { SettingsContentWrapper } from "@/components/settings"; import { useProject, useUserPermissions } from "@/hooks/store"; - -const GeneralSettingsPage = observer(() => { +const ProjectSettingsPage = observer(() => { // states const [selectProject, setSelectedProject] = useState(null); const [archiveProject, setArchiveProject] = useState(false); @@ -45,7 +45,7 @@ const GeneralSettingsPage = observer(() => { const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - General Settings` : undefined; return ( - <> + {currentProjectDetails && workspaceSlug && projectId && ( <> @@ -64,7 +64,7 @@ const GeneralSettingsPage = observer(() => { )} -
    +
    {currentProjectDetails && workspaceSlug && projectId && !isLoading ? ( { )}
    - + ); }); -export default GeneralSettingsPage; +export default ProjectSettingsPage; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/states/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx similarity index 68% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/states/page.tsx rename to web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx index 54fca1c08..30f6c3da6 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/states/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx @@ -9,6 +9,7 @@ import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectStateRoot } from "@/components/project-states"; // hook +import { SettingsContentWrapper, SettingsHeading } from "@/components/settings"; import { useProject, useUserPermissions } from "@/hooks/store"; const StatesSettingsPage = observer(() => { @@ -28,19 +29,22 @@ const StatesSettingsPage = observer(() => { ); if (workspaceUserInfo && !canPerformProjectMemberActions) { - return ; + return ; } return ( - <> + -
    -

    {t("common.states")}

    +
    + + {workspaceSlug && projectId && ( + + )}
    - {workspaceSlug && projectId && ( - - )} - + ); }); diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx new file mode 100644 index 000000000..011b240b9 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { ReactNode, useEffect } from "react"; +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +// components +import { SettingsMobileNav } from "@/components/settings"; +import { getProjectActivePath } from "@/components/settings/helper"; +import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar"; +import { useProject } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper"; + +type Props = { + children: ReactNode; +}; + +const ProjectSettingsLayout = observer((props: Props) => { + const { children } = props; + // router + const router = useAppRouter(); + const pathname = usePathname(); + const { workspaceSlug, projectId } = useParams(); + const { joinedProjectIds } = useProject(); + + useEffect(() => { + if (projectId) return; + if (joinedProjectIds.length > 0) { + router.push(`/${workspaceSlug}/settings/projects/${joinedProjectIds[0]}`); + } + }, [joinedProjectIds, router, workspaceSlug, projectId]); + + return ( + <> + + +
    +
    {projectId && }
    +
    {children}
    +
    +
    + + ); +}); + +export default ProjectSettingsLayout; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx new file mode 100644 index 000000000..65ea62701 --- /dev/null +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx @@ -0,0 +1,38 @@ +"use client"; +import Image from "next/image"; +import Link from "next/link"; +import { useTheme } from "next-themes"; +import { Button, getButtonStyling } from "@plane/ui"; +import { cn } from "@plane/utils"; +import { useCommandPalette } from "@/hooks/store"; + +const ProjectSettingsPage = () => { + // store hooks + const { resolvedTheme } = useTheme(); + const { toggleCreateProjectModal } = useCommandPalette(); + // derived values + const resolvedPath = + resolvedTheme === "dark" + ? "/empty-state/project-settings/no-projects-dark.png" + : "/empty-state/project-settings/no-projects-light.png"; + return ( +
    + No projects yet +
    No projects yet
    +
    + Projects act as the foundation for goal-driven work. They let you manage your teams, tasks, and everything you + need to get things done. +
    +
    + + Learn more about projects + + +
    +
    + ); +}; + +export default ProjectSettingsPage; diff --git a/web/app/accounts/forgot-password/layout.tsx b/web/app/(all)/accounts/forgot-password/layout.tsx similarity index 100% rename from web/app/accounts/forgot-password/layout.tsx rename to web/app/(all)/accounts/forgot-password/layout.tsx diff --git a/web/app/accounts/forgot-password/page.tsx b/web/app/(all)/accounts/forgot-password/page.tsx similarity index 95% rename from web/app/accounts/forgot-password/page.tsx rename to web/app/(all)/accounts/forgot-password/page.tsx index 9ee2cc482..a7e3e7ed4 100644 --- a/web/app/accounts/forgot-password/page.tsx +++ b/web/app/(all)/accounts/forgot-password/page.tsx @@ -9,13 +9,12 @@ import { Controller, useForm } from "react-hook-form"; // icons import { CircleCheck } from "lucide-react"; // plane imports -import { FORGOT_PASS_LINK, NAVIGATE_TO_SIGNUP } from "@plane/constants"; +import { AUTH_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { Button, Input, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +import { cn, checkEmailValidity } from "@plane/utils"; // helpers import { EPageTypes } from "@/helpers/authentication.helper"; -import { cn } from "@/helpers/common.helper"; -import { checkEmailValidity } from "@/helpers/string.helper"; // hooks import { useEventTracker, useInstance } from "@/hooks/store"; import useTimer from "@/hooks/use-timer"; @@ -72,7 +71,7 @@ const ForgotPasswordPage = observer(() => { email: formData.email, }) .then(() => { - captureEvent(FORGOT_PASS_LINK, { + captureEvent(AUTH_TRACKER_EVENTS.forgot_password, { state: "SUCCESS", }); setToast({ @@ -83,7 +82,7 @@ const ForgotPasswordPage = observer(() => { setResendCodeTimer(30); }) .catch((err) => { - captureEvent(FORGOT_PASS_LINK, { + captureEvent(AUTH_TRACKER_EVENTS.forgot_password, { state: "FAILED", }); setToast({ @@ -121,7 +120,7 @@ const ForgotPasswordPage = observer(() => { {t("auth.common.new_to_plane")} captureEvent(NAVIGATE_TO_SIGNUP, {})} + onClick={() => captureEvent(AUTH_TRACKER_EVENTS.navigate.sign_up, {})} className="font-semibold text-custom-primary-100 hover:underline" > {t("auth.common.create_account")} diff --git a/web/app/accounts/reset-password/layout.tsx b/web/app/(all)/accounts/reset-password/layout.tsx similarity index 100% rename from web/app/accounts/reset-password/layout.tsx rename to web/app/(all)/accounts/reset-password/layout.tsx diff --git a/web/app/accounts/reset-password/page.tsx b/web/app/(all)/accounts/reset-password/page.tsx similarity index 98% rename from web/app/accounts/reset-password/page.tsx rename to web/app/(all)/accounts/reset-password/page.tsx index e0230f205..388e7a02d 100644 --- a/web/app/accounts/reset-password/page.tsx +++ b/web/app/(all)/accounts/reset-password/page.tsx @@ -9,9 +9,11 @@ import { useSearchParams } from "next/navigation"; import { useTheme } from "next-themes"; import { Eye, EyeOff } from "lucide-react"; // ui +import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { Button, Input } from "@plane/ui"; // components +import { getPasswordStrength } from "@plane/utils"; import { AuthBanner, PasswordStrengthMeter } from "@/components/account"; // helpers import { @@ -21,8 +23,6 @@ import { TAuthErrorInfo, authErrorHandler, } from "@/helpers/authentication.helper"; -import { API_BASE_URL } from "@/helpers/common.helper"; -import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper"; // wrappers import { AuthenticationWrapper } from "@/lib/wrappers"; // services diff --git a/web/app/accounts/set-password/layout.tsx b/web/app/(all)/accounts/set-password/layout.tsx similarity index 100% rename from web/app/accounts/set-password/layout.tsx rename to web/app/(all)/accounts/set-password/layout.tsx diff --git a/web/app/accounts/set-password/page.tsx b/web/app/(all)/accounts/set-password/page.tsx similarity index 98% rename from web/app/accounts/set-password/page.tsx rename to web/app/(all)/accounts/set-password/page.tsx index 5bfa7c08f..872f965dd 100644 --- a/web/app/accounts/set-password/page.tsx +++ b/web/app/(all)/accounts/set-password/page.tsx @@ -9,13 +9,14 @@ import { useSearchParams } from "next/navigation"; import { useTheme } from "next-themes"; import { Eye, EyeOff } from "lucide-react"; // plane imports +import { E_PASSWORD_STRENGTH } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; // components -import { PasswordStrengthMeter } from "@/components/account"; +import { getPasswordStrength } from "@plane/utils"; +import { PasswordStrengthMeter } from "@/components/account/password-strength-meter"; // helpers import { EPageTypes } from "@/helpers/authentication.helper"; -import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper"; // hooks import { useUser } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; diff --git a/web/app/create-workspace/layout.tsx b/web/app/(all)/create-workspace/layout.tsx similarity index 100% rename from web/app/create-workspace/layout.tsx rename to web/app/(all)/create-workspace/layout.tsx diff --git a/web/app/create-workspace/page.tsx b/web/app/(all)/create-workspace/page.tsx similarity index 100% rename from web/app/create-workspace/page.tsx rename to web/app/(all)/create-workspace/page.tsx diff --git a/web/app/installations/[provider]/layout.tsx b/web/app/(all)/installations/[provider]/layout.tsx similarity index 100% rename from web/app/installations/[provider]/layout.tsx rename to web/app/(all)/installations/[provider]/layout.tsx diff --git a/web/app/installations/[provider]/page.tsx b/web/app/(all)/installations/[provider]/page.tsx similarity index 100% rename from web/app/installations/[provider]/page.tsx rename to web/app/(all)/installations/[provider]/page.tsx diff --git a/web/app/invitations/layout.tsx b/web/app/(all)/invitations/layout.tsx similarity index 100% rename from web/app/invitations/layout.tsx rename to web/app/(all)/invitations/layout.tsx diff --git a/web/app/invitations/page.tsx b/web/app/(all)/invitations/page.tsx similarity index 97% rename from web/app/invitations/page.tsx rename to web/app/(all)/invitations/page.tsx index df6befa68..5e5f1958f 100644 --- a/web/app/invitations/page.tsx +++ b/web/app/(all)/invitations/page.tsx @@ -9,19 +9,18 @@ import { useTheme } from "next-themes"; import useSWR, { mutate } from "swr"; import { CheckCircle2 } from "lucide-react"; // plane imports -import { ROLE, MEMBER_ACCEPTED, EUserPermissions } from "@plane/constants"; +import { ROLE, EUserPermissions, MEMBER_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // types import type { IWorkspaceMemberInvitation } from "@plane/types"; // ui import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +import { truncateText, getUserRole } from "@plane/utils"; // components import { EmptyState } from "@/components/common"; import { WorkspaceLogo } from "@/components/workspace/logo"; import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys"; // helpers -import { truncateText } from "@/helpers/string.helper"; -import { getUserRole } from "@/helpers/user.helper"; // hooks import { useEventTracker, useUser, useUserProfile, useWorkspace } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; @@ -87,7 +86,7 @@ const UserInvitationsPage = observer(() => { const invitation = invitations?.find((i) => i.id === firstInviteId); const redirectWorkspace = invitations?.find((i) => i.id === firstInviteId)?.workspace; joinWorkspaceMetricGroup(redirectWorkspace?.id); - captureEvent(MEMBER_ACCEPTED, { + captureEvent(MEMBER_TRACKER_EVENTS.accept, { member_id: invitation?.id, // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain role: getUserRole((invitation?.role as unknown as EUserPermissions)!), @@ -113,7 +112,7 @@ const UserInvitationsPage = observer(() => { }); }) .catch(() => { - captureEvent(MEMBER_ACCEPTED, { + captureEvent(MEMBER_TRACKER_EVENTS.accept, { project_id: undefined, accepted_from: "App", state: "FAILED", diff --git a/web/app/(all)/layout.preload.tsx b/web/app/(all)/layout.preload.tsx new file mode 100644 index 000000000..18ca3b4b3 --- /dev/null +++ b/web/app/(all)/layout.preload.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect } from "react"; +import ReactDOM from "react-dom"; + +// https://nextjs.org/docs/app/api-reference/functions/generate-metadata#link-relpreload +export const usePreloadResources = () => { + useEffect(() => { + const preloadItem = (url: string) => { + ReactDOM.preload(url, { as: "fetch", crossOrigin: "use-credentials" }); + }; + + const urls = [ + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/instances/`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/profile/`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/settings/`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/workspaces/?v=${Date.now()}`, + ]; + + urls.forEach(url => preloadItem(url)); + }, []); +}; + +export const PreloadResources = () => { + usePreloadResources(); + return null; +}; diff --git a/web/app/(all)/layout.tsx b/web/app/(all)/layout.tsx new file mode 100644 index 000000000..32589c4bf --- /dev/null +++ b/web/app/(all)/layout.tsx @@ -0,0 +1,31 @@ +import { Metadata, Viewport } from "next"; + +import { PreloadResources } from "./layout.preload"; + +// styles +import "@/styles/command-pallette.css"; +import "@/styles/emoji.css"; +import "@/styles/react-day-picker.css"; + +export const metadata: Metadata = { + robots: { + index: false, + follow: false, + }, +}; + +export const viewport: Viewport = { + minimumScale: 1, + initialScale: 1, + width: "device-width", + viewportFit: "cover", +}; + +export default function AppLayout({ children }: { children: React.ReactNode }) { + return ( + <> + + {children} + + ); +} diff --git a/web/app/onboarding/layout.tsx b/web/app/(all)/onboarding/layout.tsx similarity index 100% rename from web/app/onboarding/layout.tsx rename to web/app/(all)/onboarding/layout.tsx diff --git a/web/app/onboarding/page.tsx b/web/app/(all)/onboarding/page.tsx similarity index 97% rename from web/app/onboarding/page.tsx rename to web/app/(all)/onboarding/page.tsx index a26bef3a6..078d6fefa 100644 --- a/web/app/onboarding/page.tsx +++ b/web/app/(all)/onboarding/page.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; // types -import { USER_ONBOARDING_COMPLETED } from "@plane/constants"; +import { USER_TRACKER_EVENTS } from "@plane/constants"; import { TOnboardingSteps, TUserProfile } from "@plane/types"; // ui import { TOAST_TYPE, setToast } from "@plane/ui"; @@ -73,8 +73,7 @@ const OnboardingPage = observer(() => { await finishUserOnboarding() .then(() => { - captureEvent(USER_ONBOARDING_COMPLETED, { - // user_role: user.role, + captureEvent(USER_TRACKER_EVENTS.onboarding_complete, { email: user.email, user_id: user.id, status: "SUCCESS", diff --git a/web/app/profile/activity/page.tsx b/web/app/(all)/profile/activity/page.tsx similarity index 100% rename from web/app/profile/activity/page.tsx rename to web/app/(all)/profile/activity/page.tsx diff --git a/web/app/profile/appearance/page.tsx b/web/app/(all)/profile/appearance/page.tsx similarity index 95% rename from web/app/profile/appearance/page.tsx rename to web/app/(all)/profile/appearance/page.tsx index c89bcdf3c..877dfd511 100644 --- a/web/app/profile/appearance/page.tsx +++ b/web/app/(all)/profile/appearance/page.tsx @@ -9,14 +9,14 @@ import { useTranslation } from "@plane/i18n"; import { IUserTheme } from "@plane/types"; import { setPromiseToast } from "@plane/ui"; // components +import { applyTheme, unsetCustomCssVariables } from "@plane/utils"; import { LogoSpinner } from "@/components/common"; -import { CustomThemeSelector, ThemeSwitch, PageHead } from "@/components/core"; +import { ThemeSwitch, PageHead, CustomThemeSelector } from "@/components/core"; import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile"; -// constants // helpers -import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper"; // hooks import { useUserProfile } from "@/hooks/store"; + const ProfileAppearancePage = observer(() => { const { t } = useTranslation(); const { setTheme } = useTheme(); diff --git a/web/app/profile/layout.tsx b/web/app/(all)/profile/layout.tsx similarity index 100% rename from web/app/profile/layout.tsx rename to web/app/(all)/profile/layout.tsx diff --git a/web/app/profile/notifications/page.tsx b/web/app/(all)/profile/notifications/page.tsx similarity index 100% rename from web/app/profile/notifications/page.tsx rename to web/app/(all)/profile/notifications/page.tsx diff --git a/web/app/profile/page.tsx b/web/app/(all)/profile/page.tsx similarity index 100% rename from web/app/profile/page.tsx rename to web/app/(all)/profile/page.tsx diff --git a/web/app/profile/security/page.tsx b/web/app/(all)/profile/security/page.tsx similarity index 97% rename from web/app/profile/security/page.tsx rename to web/app/(all)/profile/security/page.tsx index 8477d70d9..eec52f994 100644 --- a/web/app/profile/security/page.tsx +++ b/web/app/(all)/profile/security/page.tsx @@ -4,16 +4,17 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; import { Eye, EyeOff } from "lucide-react"; +import { E_PASSWORD_STRENGTH } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // ui import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; // components -import { PasswordStrengthMeter } from "@/components/account"; -import { PageHead } from "@/components/core"; +import { getPasswordStrength } from "@plane/utils"; +import { PasswordStrengthMeter } from "@/components/account/password-strength-meter"; +import { PageHead } from "@/components/core/page-title"; import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile"; // helpers import { authErrorHandler } from "@/helpers/authentication.helper"; -import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper"; // hooks import { useUser } from "@/hooks/store"; // services diff --git a/web/app/profile/sidebar.tsx b/web/app/(all)/profile/sidebar.tsx similarity index 97% rename from web/app/profile/sidebar.tsx rename to web/app/(all)/profile/sidebar.tsx index 59e3daa48..be70e1e13 100644 --- a/web/app/profile/sidebar.tsx +++ b/web/app/(all)/profile/sidebar.tsx @@ -22,12 +22,11 @@ import { PROFILE_ACTION_LINKS } from "@plane/constants"; import { useOutsideClickDetector } from "@plane/hooks"; import { useTranslation } from "@plane/i18n"; import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; +import { cn, getFileURL } from "@plane/utils"; // components import { SidebarNavItem } from "@/components/sidebar"; // constants // helpers -import { cn } from "@/helpers/common.helper"; -import { getFileURL } from "@/helpers/file.helper"; // hooks import { useAppTheme, useUser, useUserSettings, useWorkspace } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -47,17 +46,19 @@ const WORKSPACE_ACTION_LINKS = [ }, ]; -export const ProjectActionIcons = ({ type, size, className }: { type: string; size?: number; className?: string }) => { +const ProjectActionIcons = ({ type, size, className = "" }: { type: string; size?: number; className?: string }) => { const icons = { profile: CircleUser, security: KeyRound, activity: Activity, - appearance: Settings2, + preferences: Settings2, notifications: Bell, + "api-tokens": KeyRound, }; if (type === undefined) return null; const Icon = icons[type as keyof typeof icons]; + if (!Icon) return null; return ; }; export const ProfileLayoutSidebar = observer(() => { diff --git a/web/app/sign-up/layout.tsx b/web/app/(all)/sign-up/layout.tsx similarity index 79% rename from web/app/sign-up/layout.tsx rename to web/app/(all)/sign-up/layout.tsx index f7f405c27..3ae097721 100644 --- a/web/app/sign-up/layout.tsx +++ b/web/app/(all)/sign-up/layout.tsx @@ -2,6 +2,10 @@ import { Metadata } from "next"; export const metadata: Metadata = { title: "Sign up - Plane", + robots: { + index: true, + follow: false, + } }; export default function SignUpLayout({ children }: { children: React.ReactNode }) { diff --git a/web/app/sign-up/page.tsx b/web/app/(all)/sign-up/page.tsx similarity index 95% rename from web/app/sign-up/page.tsx rename to web/app/(all)/sign-up/page.tsx index aa18cf085..786b97384 100644 --- a/web/app/sign-up/page.tsx +++ b/web/app/(all)/sign-up/page.tsx @@ -6,7 +6,7 @@ import Link from "next/link"; // ui import { useTheme } from "next-themes"; // components -import { NAVIGATE_TO_SIGNIN } from "@plane/constants"; +import { AUTH_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { AuthRoot } from "@/components/account"; // constants @@ -54,7 +54,7 @@ const SignInPage = observer(() => { {t("auth.common.already_have_an_account")} captureEvent(NAVIGATE_TO_SIGNIN, {})} + onClick={() => captureEvent(AUTH_TRACKER_EVENTS.navigate.sign_in, {})} className="font-semibold text-custom-primary-100 hover:underline" > {t("auth.common.login")} diff --git a/web/app/workspace-invitations/layout.tsx b/web/app/(all)/workspace-invitations/layout.tsx similarity index 100% rename from web/app/workspace-invitations/layout.tsx rename to web/app/(all)/workspace-invitations/layout.tsx diff --git a/web/app/workspace-invitations/page.tsx b/web/app/(all)/workspace-invitations/page.tsx similarity index 100% rename from web/app/workspace-invitations/page.tsx rename to web/app/(all)/workspace-invitations/page.tsx diff --git a/web/app/(home)/layout.tsx b/web/app/(home)/layout.tsx new file mode 100644 index 000000000..0ed40f86b --- /dev/null +++ b/web/app/(home)/layout.tsx @@ -0,0 +1,21 @@ +import { Metadata, Viewport } from "next"; + +export const metadata: Metadata = { + robots: { + index: true, + follow: false, + }, +}; + +export const viewport: Viewport = { + minimumScale: 1, + initialScale: 1, + width: "device-width", + viewportFit: "cover", +}; + +export default function HomeLayout({ children }: { children: React.ReactNode }) { + return ( + <>{children} + ); +} diff --git a/web/app/page.tsx b/web/app/(home)/page.tsx similarity index 95% rename from web/app/page.tsx rename to web/app/(home)/page.tsx index 8d52af80c..21e5c12d0 100644 --- a/web/app/page.tsx +++ b/web/app/(home)/page.tsx @@ -1,5 +1,4 @@ "use client"; - import React from "react"; import { observer } from "mobx-react"; import Image from "next/image"; @@ -7,7 +6,7 @@ import Link from "next/link"; // ui import { useTheme } from "next-themes"; // components -import { NAVIGATE_TO_SIGNUP } from "@plane/constants"; +import { AUTH_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { AuthRoot } from "@/components/account"; import { PageHead } from "@/components/core"; @@ -64,7 +63,7 @@ const HomePage = observer(() => { {t("auth.common.new_to_plane")} captureEvent(NAVIGATE_TO_SIGNUP, {})} + onClick={() => captureEvent(AUTH_TRACKER_EVENTS.navigate.sign_up, {})} className="font-semibold text-custom-primary-100 hover:underline" > {t("auth.common.create_account")} diff --git a/web/app/[workspaceSlug]/(projects)/analytics/header.tsx b/web/app/[workspaceSlug]/(projects)/analytics/header.tsx deleted file mode 100644 index 2c3247bd7..000000000 --- a/web/app/[workspaceSlug]/(projects)/analytics/header.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { observer } from "mobx-react"; -import { useSearchParams } from "next/navigation"; -import { BarChart2, PanelRight } from "lucide-react"; -import { useTranslation } from "@plane/i18n"; -// ui -import { Breadcrumbs, Header } from "@plane/ui"; -// components -import { BreadcrumbLink } from "@/components/common"; -// helpers -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 - const { workspaceAnalyticsSidebarCollapsed, toggleWorkspaceAnalyticsSidebar } = useAppTheme(); - - useEffect(() => { - const handleToggleWorkspaceAnalyticsSidebar = () => { - if (window && window.innerWidth < 768) { - toggleWorkspaceAnalyticsSidebar(true); - } - if (window && workspaceAnalyticsSidebarCollapsed && window.innerWidth >= 768) { - toggleWorkspaceAnalyticsSidebar(false); - } - }; - - window.addEventListener("resize", handleToggleWorkspaceAnalyticsSidebar); - handleToggleWorkspaceAnalyticsSidebar(); - return () => window.removeEventListener("resize", handleToggleWorkspaceAnalyticsSidebar); - }, [toggleWorkspaceAnalyticsSidebar, workspaceAnalyticsSidebarCollapsed]); - - return ( -
    - - - } - /> - } - /> - - {analytics_tab === "custom" ? ( - - ) : ( - <> - )} - -
    - ); -}); diff --git a/web/app/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx b/web/app/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx deleted file mode 100644 index ec0d8b03a..000000000 --- a/web/app/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx +++ /dev/null @@ -1,96 +0,0 @@ -"use client"; - -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -import { Briefcase } from "lucide-react"; -// plane imports -import { useTranslation } from "@plane/i18n"; -import { Breadcrumbs, LayersIcon, Header, Logo } from "@plane/ui"; -// components -import { BreadcrumbLink } from "@/components/common"; -import { IssueDetailQuickActions } from "@/components/issues"; -// hooks -import { useIssueDetail, useProject } from "@/hooks/store"; -import { useAppRouter } from "@/hooks/use-app-router"; - -export const ProjectIssueDetailsHeader = observer(() => { - // router - const router = useAppRouter(); - const { workspaceSlug, workItem } = useParams(); - // store hooks - const { t } = useTranslation(); - const { getProjectById, loader } = useProject(); - const { - issue: { getIssueById, getIssueIdByIdentifier }, - } = useIssueDetail(); - // derived values - const issueId = getIssueIdByIdentifier(workItem?.toString()); - const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined; - const projectId = issueDetails ? issueDetails?.project_id : undefined; - const projectDetails = projectId ? getProjectById(projectId?.toString()) : undefined; - - if (!workspaceSlug || !projectId || !issueId) return null; - - return ( -
    - -
    - - - - - ) - ) : ( - - - - ) - } - /> - } - /> - - } - /> - } - /> - - - } - /> - -
    -
    - - {projectId && issueId && ( - - )} - -
    - ); -}); diff --git a/web/app/[workspaceSlug]/(projects)/notifications/page.tsx b/web/app/[workspaceSlug]/(projects)/notifications/page.tsx deleted file mode 100644 index 8afe768d8..000000000 --- a/web/app/[workspaceSlug]/(projects)/notifications/page.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client"; - -import { useCallback, useEffect } from "react"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -import useSWR from "swr"; -// plane imports -import { ENotificationLoader, ENotificationQueryParamType } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -// components -import { LogoSpinner } from "@/components/common"; -import { PageHead } from "@/components/core"; -import { SimpleEmptyState } from "@/components/empty-state"; -import { InboxContentRoot } from "@/components/inbox"; -import { IssuePeekOverview } from "@/components/issues"; -// hooks -import { useIssueDetail, useUserPermissions, useWorkspace, useWorkspaceNotifications } from "@/hooks/store"; -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; -import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties"; - -const WorkspaceDashboardPage = observer(() => { - const { workspaceSlug } = useParams(); - // plane hooks - const { t } = useTranslation(); - // hooks - const { currentWorkspace } = useWorkspace(); - const { - currentSelectedNotificationId, - setCurrentSelectedNotificationId, - notificationLiteByNotificationId, - notificationIdsByWorkspaceId, - getNotifications, - } = useWorkspaceNotifications(); - const { fetchUserProjectInfo } = useUserPermissions(); - const { setPeekIssue } = useIssueDetail(); - // derived values - const pageTitle = currentWorkspace?.name - ? t("notification.page_label", { workspace: currentWorkspace?.name }) - : undefined; - const { workspace_slug, project_id, issue_id, is_inbox_issue } = - notificationLiteByNotificationId(currentSelectedNotificationId); - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/intake/issue-detail" }); - - // fetching workspace work item properties - useWorkspaceIssueProperties(workspaceSlug); - - // fetch workspace notifications - const notificationMutation = - currentWorkspace && notificationIdsByWorkspaceId(currentWorkspace.id) - ? ENotificationLoader.MUTATION_LOADER - : ENotificationLoader.INIT_LOADER; - const notificationLoader = - currentWorkspace && notificationIdsByWorkspaceId(currentWorkspace.id) - ? ENotificationQueryParamType.CURRENT - : ENotificationQueryParamType.INIT; - useSWR( - currentWorkspace?.slug ? `WORKSPACE_NOTIFICATION` : null, - currentWorkspace?.slug - ? () => getNotifications(currentWorkspace?.slug, notificationMutation, notificationLoader) - : null - ); - - // fetching user project member info - const { isLoading: projectMemberInfoLoader } = useSWR( - workspace_slug && project_id && is_inbox_issue - ? `PROJECT_MEMBER_PERMISSION_INFO_${workspace_slug}_${project_id}` - : null, - workspace_slug && project_id && is_inbox_issue ? () => fetchUserProjectInfo(workspace_slug, project_id) : null - ); - - const embedRemoveCurrentNotification = useCallback( - () => setCurrentSelectedNotificationId(undefined), - [setCurrentSelectedNotificationId] - ); - - // clearing up the selected notifications when unmounting the page - useEffect( - () => () => { - setCurrentSelectedNotificationId(undefined); - setPeekIssue(undefined); - }, - [setCurrentSelectedNotificationId, setPeekIssue] - ); - - return ( - <> - -
    - {!currentSelectedNotificationId ? ( -
    - -
    - ) : ( - <> - {is_inbox_issue === true && workspace_slug && project_id && issue_id ? ( - <> - {projectMemberInfoLoader ? ( -
    - -
    - ) : ( - {}} - isMobileSidebar={false} - workspaceSlug={workspace_slug} - projectId={project_id} - inboxIssueId={issue_id} - isNotificationEmbed - embedRemoveCurrentNotification={embedRemoveCurrentNotification} - /> - )} - - ) : ( - - )} - - )} -
    - - ); -}); - -export default WorkspaceDashboardPage; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/layout.tsx deleted file mode 100644 index 221ecf442..000000000 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/layout.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import { FC, ReactNode } from "react"; -// components -import { AppHeader } from "@/components/core"; -// local components -import { ProjectSettingHeader } from "../header"; -import { ProjectSettingsSidebar } from "./sidebar"; - -export interface IProjectSettingLayout { - children: ReactNode; -} - -const ProjectSettingLayout: FC = (props) => { - const { children } = props; - return ( - <> - } /> -
    -
    - -
    -
    -
    - {children} -
    -
    -
    - - ); -}; - -export default ProjectSettingLayout; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/sidebar.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/sidebar.tsx deleted file mode 100644 index 7bb1984c8..000000000 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/sidebar.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"use client"; - -import React from "react"; -import range from "lodash/range"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { useParams, usePathname } from "next/navigation"; -import { EUserPermissionsLevel } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -// ui -import { Loader } from "@plane/ui"; -// components -import { SidebarNavItem } from "@/components/sidebar"; -// hooks -import { useUserPermissions } from "@/hooks/store"; -// plane web constants -import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project"; - -export const ProjectSettingsSidebar = observer(() => { - const { workspaceSlug, projectId } = useParams(); - const pathname = usePathname(); - // mobx store - const { allowPermissions, projectUserInfo } = useUserPermissions(); - - const { t } = useTranslation(); - - // derived values - const currentProjectRole = projectUserInfo?.[workspaceSlug?.toString()]?.[projectId?.toString()]?.role; - - if (!currentProjectRole) { - return ( -
    -
    - SETTINGS - - {range(8).map((index) => ( - - ))} - -
    -
    - ); - } - - return ( -
    -
    - SETTINGS -
    - {PROJECT_SETTINGS_LINKS.map( - (link) => - allowPermissions( - link.access, - EUserPermissionsLevel.PROJECT, - workspaceSlug.toString(), - projectId.toString() - ) && ( - - - {t(link.i18n_label)} - - - ) - )} -
    -
    -
    - ); -}); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx deleted file mode 100644 index 6fa36db34..000000000 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client"; - -import { FC } from "react"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// ui -import { Settings } from "lucide-react"; -import { EUserPermissionsLevel } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -import { Breadcrumbs, CustomMenu, Header } from "@plane/ui"; -// components -import { BreadcrumbLink } from "@/components/common"; -// hooks -import { useProject, useUserPermissions } from "@/hooks/store"; -import { useAppRouter } from "@/hooks/use-app-router"; -// plane web -import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; -import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project"; - -export const ProjectSettingHeader: FC = observer(() => { - // router - const router = useAppRouter(); - const { workspaceSlug, projectId } = useParams(); - // store hooks - const { allowPermissions } = useUserPermissions(); - const { loader } = useProject(); - - const { t } = useTranslation(); - - return ( -
    - -
    -
    - - -
    - } /> - } - /> -
    -
    -
    -
    - - Settings - - } - placement="bottom-start" - closeOnSelect - > - {PROJECT_SETTINGS_LINKS.map( - (item) => - allowPermissions( - item.access, - EUserPermissionsLevel.PROJECT, - workspaceSlug.toString(), - projectId.toString() - ) && ( - router.push(`/${workspaceSlug}/projects/${projectId}${item.href}`)} - > - {t(item.i18n_label)} - - ) - )} - -
    -
    - ); -}); diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/api-tokens/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/api-tokens/page.tsx deleted file mode 100644 index 21334ff23..000000000 --- a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/api-tokens/page.tsx +++ /dev/null @@ -1,99 +0,0 @@ -"use client"; - -import React, { useState } from "react"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -import useSWR from "swr"; -// plane imports -import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -import { Button } from "@plane/ui"; -// component -import { ApiTokenListItem, CreateApiTokenModal } from "@/components/api-token"; -import { NotAuthorizedView } from "@/components/auth-screens"; -import { PageHead } from "@/components/core"; -import { DetailedEmptyState } from "@/components/empty-state"; -import { APITokenSettingsLoader } from "@/components/ui"; -import { API_TOKENS_LIST } from "@/constants/fetch-keys"; -// store hooks -import { useUserPermissions, useWorkspace } from "@/hooks/store"; -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; -// services -import { APITokenService } from "@/services/api_token.service"; - -const apiTokenService = new APITokenService(); - -const ApiTokensPage = observer(() => { - // states - const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false); - // router - const { workspaceSlug } = useParams(); - // plane hooks - const { t } = useTranslation(); - // store hooks - const { currentWorkspace } = useWorkspace(); - const { workspaceUserInfo, allowPermissions } = useUserPermissions(); - // derived values - const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/api-tokens" }); - - const { data: tokens } = useSWR( - workspaceSlug && canPerformWorkspaceAdminActions ? API_TOKENS_LIST(workspaceSlug.toString()) : null, - () => - workspaceSlug && canPerformWorkspaceAdminActions ? apiTokenService.getApiTokens(workspaceSlug.toString()) : null - ); - - const pageTitle = currentWorkspace?.name - ? `${currentWorkspace.name} - ${t("workspace_settings.settings.api_tokens.title")}` - : undefined; - - if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { - return ; - } - - if (!tokens) { - return ; - } - - return ( - <> - - setIsCreateTokenModalOpen(false)} /> -
    - {tokens.length > 0 ? ( - <> -
    -

    {t("workspace_settings.settings.api_tokens.title")}

    - -
    -
    - {tokens.map((token) => ( - - ))} -
    - - ) : ( -
    -
    -

    {t("workspace_settings.settings.api_tokens.title")}

    - -
    -
    - -
    -
    - )} -
    - - ); -}); - -export default ApiTokensPage; diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx b/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx deleted file mode 100644 index e51106bfe..000000000 --- a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx +++ /dev/null @@ -1,63 +0,0 @@ -"use client"; - -import { FC, ReactNode } from "react"; -import { observer } from "mobx-react"; -// components -import { useParams, usePathname } from "next/navigation"; -import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_ACCESS } from "@plane/constants"; -import { NotAuthorizedView } from "@/components/auth-screens"; -import { AppHeader } from "@/components/core"; -// hooks -import { useUserPermissions } from "@/hooks/store"; -// plane web constants -// local components -import { WorkspaceSettingHeader } from "../header"; -import { MobileWorkspaceSettingsTabs } from "./mobile-header-tabs"; -import { WorkspaceSettingsSidebar } from "./sidebar"; - -export interface IWorkspaceSettingLayout { - children: ReactNode; -} - -const WorkspaceSettingLayout: FC = observer((props) => { - const { children } = props; - - const { workspaceUserInfo } = useUserPermissions(); - const pathname = usePathname(); - const [workspaceSlug, suffix, route] = pathname.replace(/^\/|\/$/g, "").split("/"); // Regex removes leading and trailing slashes - - // derived values - const userWorkspaceRole = workspaceUserInfo?.[workspaceSlug.toString()]?.role; - const isAuthorized = - pathname && - workspaceSlug && - userWorkspaceRole && - WORKSPACE_SETTINGS_ACCESS[route ? `/${suffix}/${route}` : `/${suffix}`]?.includes( - userWorkspaceRole as EUserWorkspaceRoles - ); - - return ( - <> - } /> - -
    - {workspaceUserInfo && !isAuthorized ? ( - - ) : ( - <> -
    - -
    -
    -
    - {children} -
    -
    - - )} -
    - - ); -}); - -export default WorkspaceSettingLayout; diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/sidebar.tsx b/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/sidebar.tsx deleted file mode 100644 index 95cb20c6c..000000000 --- a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/sidebar.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; - -import React from "react"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { useParams, usePathname } from "next/navigation"; -import { WORKSPACE_SETTINGS_LINKS, EUserPermissionsLevel } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -// components -import { SidebarNavItem } from "@/components/sidebar"; -// hooks -import { useUserPermissions } from "@/hooks/store"; -// plane web helpers -import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; - -export const WorkspaceSettingsSidebar = observer(() => { - // router - const { workspaceSlug } = useParams(); - const pathname = usePathname(); - // mobx store - const { t } = useTranslation(); - const { allowPermissions } = useUserPermissions(); - - return ( -
    -
    - {t("settings")} -
    - {WORKSPACE_SETTINGS_LINKS.map( - (link) => - shouldRenderSettingLink(workspaceSlug.toString(), link.key) && - allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && ( - - - {t(link.i18n_label)} - - - ) - )} -
    -
    -
    - ); -}); diff --git a/web/app/[workspaceSlug]/(projects)/settings/header.tsx b/web/app/[workspaceSlug]/(projects)/settings/header.tsx deleted file mode 100644 index 003e72743..000000000 --- a/web/app/[workspaceSlug]/(projects)/settings/header.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { FC } from "react"; -import { observer } from "mobx-react"; -import { Settings } from "lucide-react"; -// ui -import { useTranslation } from "@plane/i18n"; -import { Breadcrumbs, Header } from "@plane/ui"; -// components -import { BreadcrumbLink } from "@/components/common"; -// hooks -import { useWorkspace } from "@/hooks/store"; - -export const WorkspaceSettingHeader: FC = observer(() => { - const { currentWorkspace, loader } = useWorkspace(); - const { t } = useTranslation(); - - return ( -
    - - - } - /> - } - /> - } /> - - -
    - ); -}); diff --git a/web/app/error.tsx b/web/app/error.tsx index b176799b4..565c362d5 100644 --- a/web/app/error.tsx +++ b/web/app/error.tsx @@ -1,9 +1,10 @@ "use client"; -// ui -import { Button, TOAST_TYPE, setToast } from "@plane/ui"; -// helpers -import { API_BASE_URL } from "@/helpers/common.helper"; +import Link from "next/link"; +// plane imports +import { API_BASE_URL } from "@plane/constants"; +import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +import { cn } from "@plane/utils"; // hooks import { useAppRouter } from "@/hooks/use-app-router"; // layouts @@ -17,10 +18,6 @@ const authService = new AuthService(); export default function CustomErrorComponent() { const router = useAppRouter(); - const handleRefresh = () => { - window.location.reload(); - }; - const handleSignOut = async () => { await authService .signOut(API_BASE_URL) @@ -39,7 +36,7 @@ export default function CustomErrorComponent() {
    -
    +

    Yikes! That doesn{"'"}t look good.

    That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more @@ -60,9 +57,9 @@ export default function CustomErrorComponent() {

    - + + Go to home + diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 6024753df..fc454e54b 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,30 +1,47 @@ import { Metadata, Viewport } from "next"; import Script from "next/script"; + // styles import "@/styles/globals.css"; -import "@/styles/command-pallette.css"; -import "@/styles/emoji.css"; -import "@/styles/react-day-picker.css"; -// meta data info import { SITE_DESCRIPTION, SITE_NAME } from "@plane/constants"; + // helpers -import { API_BASE_URL, cn } from "@/helpers/common.helper"; +import { cn } from "@plane/utils"; + // local import { AppProvider } from "./provider"; export const metadata: Metadata = { title: "Plane | Simple, extensible, open-source project management tool.", description: SITE_DESCRIPTION, + metadataBase: new URL("https://app.plane.so"), openGraph: { title: "Plane | Simple, extensible, open-source project management tool.", description: "Open-source project management tool to manage work items, cycles, and product roadmaps easily", url: "https://app.plane.so/", + images: [ + { + url: "/og-image.png", + width: 1200, + height: 630, + alt: "Plane - Modern project management", + }, + ], }, keywords: "software development, plan, ship, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration", twitter: { site: "@planepowers", + card: "summary_large_image", + images: [ + { + url: "/og-image.png", + width: 1200, + height: 630, + alt: "Plane - Modern project management", + }, + ], }, }; @@ -60,17 +77,6 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - {/* preloading */} - - - - -
    @@ -81,7 +87,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) "app-container" )} > -
    {children}
    +
    {children}
    diff --git a/web/app/not-found.tsx b/web/app/not-found.tsx index ecc01b500..1f1ec0e2c 100644 --- a/web/app/not-found.tsx +++ b/web/app/not-found.tsx @@ -11,6 +11,10 @@ import Image404 from "@/public/404.svg"; export const metadata: Metadata = { title: "404 - Page Not Found", + robots: { + index: false, + follow: false, + }, }; const PageNotFound = () => ( diff --git a/web/app/provider.tsx b/web/app/provider.tsx index b120882f7..554bf796d 100644 --- a/web/app/provider.tsx +++ b/web/app/provider.tsx @@ -9,7 +9,7 @@ import { WEB_SWR_CONFIG } from "@plane/constants"; import { TranslationProvider } from "@plane/i18n"; import { Toast } from "@plane/ui"; //helpers -import { resolveGeneralTheme } from "@/helpers/theme.helper"; +import { resolveGeneralTheme } from "@plane/utils"; // nprogress import { AppProgressBar } from "@/lib/n-progress"; // polyfills diff --git a/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx b/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx index e8a13f036..b82fb019e 100644 --- a/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx +++ b/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx @@ -9,9 +9,9 @@ import { MARKETING_PRICING_PAGE_LINK } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { ContentWrapper, getButtonStyling } from "@plane/ui"; // components +import { cn } from "@plane/utils"; import { ProIcon } from "@/components/common"; // helper -import { cn } from "@/helpers/common.helper"; // hooks import { useUser } from "@/hooks/store"; diff --git a/web/ce/components/analytics/tabs.tsx b/web/ce/components/analytics/tabs.tsx new file mode 100644 index 000000000..eb8344c05 --- /dev/null +++ b/web/ce/components/analytics/tabs.tsx @@ -0,0 +1,8 @@ +import { AnalyticsTab } from "@plane/types"; +import { Overview } from "@/components/analytics/overview"; +import { WorkItems } from "@/components/analytics/work-items"; + +export const getAnalyticsTabs = (t: (key: string, params?: Record) => string): AnalyticsTab[] => [ + { key: "overview", label: t("common.overview"), content: Overview, isDisabled: false }, + { key: "work-items", label: t("sidebar.work_items"), content: WorkItems, isDisabled: false }, +]; diff --git a/web/ce/components/breadcrumbs/common.tsx b/web/ce/components/breadcrumbs/common.tsx new file mode 100644 index 000000000..5b2f573cb --- /dev/null +++ b/web/ce/components/breadcrumbs/common.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { FC } from "react"; +// plane imports +import { EProjectFeatureKey } from "@plane/constants"; +// local components +import { ProjectFeatureBreadcrumb } from "./project-feature"; +import { ProjectBreadcrumb } from "./project"; + +type TCommonProjectBreadcrumbProps = { + workspaceSlug: string; + projectId: string; + featureKey?: EProjectFeatureKey; + isLast?: boolean; +}; + +export const CommonProjectBreadcrumbs: FC = (props) => { + const { workspaceSlug, projectId, featureKey, isLast = false } = props; + return ( + <> + + {featureKey && ( + + )} + + ); +}; diff --git a/web/ce/components/breadcrumbs/index.ts b/web/ce/components/breadcrumbs/index.ts index 9ff8c7dff..aad2cb352 100644 --- a/web/ce/components/breadcrumbs/index.ts +++ b/web/ce/components/breadcrumbs/index.ts @@ -1 +1,3 @@ +export * from "./common"; +export * from "./project-feature"; export * from "./project"; diff --git a/web/ce/components/breadcrumbs/project-feature.tsx b/web/ce/components/breadcrumbs/project-feature.tsx new file mode 100644 index 000000000..c606a2d3f --- /dev/null +++ b/web/ce/components/breadcrumbs/project-feature.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +// ui +import { EProjectFeatureKey } from "@plane/constants"; +import { BreadcrumbNavigationDropdown, Breadcrumbs, ISvgIcons } from "@plane/ui"; +// components +import { SwitcherLabel } from "@/components/common"; +import { TNavigationItem } from "@/components/workspace"; +// hooks +import { useProject } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +// local components +import { getProjectFeatureNavigation } from "../projects/navigation"; + +type TProjectFeatureBreadcrumbProps = { + workspaceSlug: string; + projectId: string; + featureKey: EProjectFeatureKey; + isLast?: boolean; + additionalNavigationItems?: TNavigationItem[]; +}; + +export const ProjectFeatureBreadcrumb = observer((props: TProjectFeatureBreadcrumbProps) => { + const { workspaceSlug, projectId, featureKey, isLast = false, additionalNavigationItems } = props; + // router + const router = useAppRouter(); + // store hooks + const { getPartialProjectById } = useProject(); + // derived values + const project = getPartialProjectById(projectId); + + if (!project) return null; + + const navigationItems = getProjectFeatureNavigation(workspaceSlug, projectId, project); + + // if additional navigation items are provided, add them to the navigation items + const allNavigationItems = [...(additionalNavigationItems || []), ...navigationItems]; + + return ( + <> + item.shouldRender) + .map((item) => ({ + key: item.key, + title: item.name, + customContent: } />, + action: () => router.push(item.href), + icon: item.icon as FC, + }))} + handleOnClick={() => { + router.push( + `/${workspaceSlug}/projects/${projectId}/${featureKey === EProjectFeatureKey.WORK_ITEMS ? "issues" : featureKey}/` + ); + }} + isLast={isLast} + /> + } + showSeparator={false} + isLast={isLast} + /> + + ); +}); diff --git a/web/ce/components/breadcrumbs/project.tsx b/web/ce/components/breadcrumbs/project.tsx index 3b49bb211..e59f948df 100644 --- a/web/ce/components/breadcrumbs/project.tsx +++ b/web/ce/components/breadcrumbs/project.tsx @@ -2,38 +2,73 @@ import { observer } from "mobx-react"; import { Briefcase } from "lucide-react"; -// ui -import { Breadcrumbs, Logo } from "@plane/ui"; +// plane imports +import { ICustomSearchSelectOption } from "@plane/types"; +import { BreadcrumbNavigationSearchDropdown, Breadcrumbs, Logo } from "@plane/ui"; // components -import { BreadcrumbLink } from "@/components/common"; +import { SwitcherLabel } from "@/components/common"; // hooks import { useProject } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { TProject } from "@/plane-web/types"; -export const ProjectBreadcrumb = observer(() => { +type TProjectBreadcrumbProps = { + workspaceSlug: string; + projectId: string; + handleOnClick?: () => void; +}; + +export const ProjectBreadcrumb = observer((props: TProjectBreadcrumbProps) => { + const { workspaceSlug, projectId, handleOnClick } = props; + // router + const router = useAppRouter(); // store hooks - const { currentProjectDetails } = useProject(); + const { joinedProjectIds, getPartialProjectById } = useProject(); + const currentProjectDetails = getPartialProjectById(projectId); + + // store hooks + + if (!currentProjectDetails) return null; + + // derived values + const switcherOptions = joinedProjectIds + .map((projectId) => { + const project = getPartialProjectById(projectId); + return { + value: projectId, + query: project?.name, + content: , + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; + + // helpers + const renderIcon = (projectDetails: TProject) => ( + + + + ); return ( - - - - ) - ) : ( - - - - ) - } - /> - } - /> + <> + { + router.push(`/${workspaceSlug}/projects/${value}/issues`); + }} + title={currentProjectDetails?.name} + icon={renderIcon(currentProjectDetails)} + handleOnClick={() => { + if (handleOnClick) handleOnClick(); + else router.push(`/${workspaceSlug}/projects/${currentProjectDetails.id}/issues/`); + }} + /> + } + showSeparator={false} + /> + ); }); diff --git a/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx b/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx index 9f59d226b..1ec015bbd 100644 --- a/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx +++ b/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx @@ -2,6 +2,7 @@ import { Command } from "cmdk"; import { observer } from "mobx-react"; import { Check } from "lucide-react"; // plane imports +import { EIconSize } from "@plane/constants"; import { Spinner, StateGroupIcon } from "@plane/ui"; // store hooks import { useProjectState } from "@/hooks/store"; @@ -26,7 +27,12 @@ export const ChangeWorkItemStateList = observer((props: TChangeWorkItemStateList projectStates.map((state) => ( handleStateChange(state.id)} className="focus:outline-none">
    - +

    {state.name}

    {state.id === currentStateId && }
    diff --git a/web/ce/components/command-palette/helpers.tsx b/web/ce/components/command-palette/helpers.tsx index d846ebfa0..3788e8f2b 100644 --- a/web/ce/components/command-palette/helpers.tsx +++ b/web/ce/components/command-palette/helpers.tsx @@ -12,7 +12,7 @@ import { // ui import { ContrastIcon, DiceIcon } from "@plane/ui"; // helpers -import { generateWorkItemLink } from "@/helpers/issue.helper"; +import { generateWorkItemLink } from "@plane/utils"; // plane web components import { IssueIdentifier } from "@/plane-web/components/issues"; @@ -93,7 +93,9 @@ export const commandGroups: TCommandGroups = { path: (page: IWorkspacePageSearchResult, projectId: string | undefined) => { let redirectProjectId = page?.project_ids?.[0]; if (!!projectId && page?.project_ids?.includes(projectId)) redirectProjectId = projectId; - return `/${page?.workspace__slug}/projects/${redirectProjectId}/pages/${page?.id}`; + return redirectProjectId + ? `/${page?.workspace__slug}/projects/${redirectProjectId}/pages/${page?.id}` + : `/${page?.workspace__slug}/pages/${page?.id}`; }, title: "Pages", }, diff --git a/web/ce/components/command-palette/modals/issue-level.tsx b/web/ce/components/command-palette/modals/issue-level.tsx index 02eada571..f88908f25 100644 --- a/web/ce/components/command-palette/modals/issue-level.tsx +++ b/web/ce/components/command-palette/modals/issue-level.tsx @@ -39,6 +39,7 @@ export const IssueLevelModals: FC = observer((props) => toggleDeleteIssueModal, isBulkDeleteIssueModalOpen, toggleBulkDeleteIssueModal, + createWorkItemAllowedProjectIds, } = useCommandPalette(); // derived values const issueDetails = issueId ? getIssueById(issueId) : undefined; @@ -80,6 +81,7 @@ export const IssueLevelModals: FC = observer((props) => data={getCreateIssueModalData()} isDraft={isDraftIssue} onSubmit={handleCreateIssueSubmit} + allowedProjectIds={createWorkItemAllowedProjectIds} /> {workspaceSlug && projectId && issueId && issueDetails && ( <>; diff --git a/web/ce/components/cycles/analytics-sidebar/base.tsx b/web/ce/components/cycles/analytics-sidebar/base.tsx index 40f098a2d..518f98648 100644 --- a/web/ce/components/cycles/analytics-sidebar/base.tsx +++ b/web/ce/components/cycles/analytics-sidebar/base.tsx @@ -6,10 +6,10 @@ import { useTranslation } from "@plane/i18n"; import { TCycleEstimateType } from "@plane/types"; import { Loader } from "@plane/ui"; // components +import { getDate } from "@plane/utils"; import ProgressChart from "@/components/core/sidebar/progress-chart"; import { EstimateTypeDropdown, validateCycleSnapshot } from "@/components/cycles"; // helpers -import { getDate } from "@/helpers/date-time.helper"; // hooks import { useCycle } from "@/hooks/store"; @@ -65,22 +65,10 @@ export const SidebarChart: FC = observer((props) => {
    -
    -
    - - {t("ideal")} -
    -
    - - {t("current")} -
    -
    {cycleStartDate && cycleEndDate && completionChartDistributionData ? ( diff --git a/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx b/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx index ccb3780c5..cb1f33f79 100644 --- a/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx +++ b/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx @@ -1,5 +1,5 @@ import { RefObject } from "react"; -import { IGanttBlock } from "@/components/gantt-chart"; +import type { IGanttBlock } from "@plane/types"; type LeftDependencyDraggableProps = { block: IGanttBlock; diff --git a/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx b/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx index 3d5ac24e0..29c731c9d 100644 --- a/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx +++ b/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx @@ -1,5 +1,5 @@ import { RefObject } from "react"; -import { IGanttBlock } from "@/components/gantt-chart"; +import type { IGanttBlock } from "@plane/types"; type RightDependencyDraggableProps = { block: IGanttBlock; diff --git a/web/ce/components/global/product-updates-header.tsx b/web/ce/components/global/product-updates-header.tsx index 5274ab5c1..8a2a94c5b 100644 --- a/web/ce/components/global/product-updates-header.tsx +++ b/web/ce/components/global/product-updates-header.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react"; import Image from "next/image"; import { useTranslation } from "@plane/i18n"; // helpers -import { cn } from "@/helpers/common.helper"; +import { cn } from "@plane/utils"; // assets import PlaneLogo from "@/public/plane-logos/blue-without-text.png"; // package.json diff --git a/web/ce/components/instance/maintenance-message.tsx b/web/ce/components/instance/maintenance-message.tsx index a55d7d149..1f7efa79f 100644 --- a/web/ce/components/instance/maintenance-message.tsx +++ b/web/ce/components/instance/maintenance-message.tsx @@ -1,6 +1,17 @@ -export const MaintenanceMessage = () => ( -

    - Plane didn't start up. This could be because one or more Plane services failed to start.
    Choose View - Logs from setup.sh and Docker logs to be sure. -

    -); +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; + +export const MaintenanceMessage = observer(() => { + // hooks + const { t } = useTranslation(); + + return ( +

    + {t( + "self_hosted_maintenance_message.plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start" + )} +
    + {t("self_hosted_maintenance_message.choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure")} +

    + ); +}); diff --git a/web/ce/components/issues/header.tsx b/web/ce/components/issues/header.tsx index abe44d506..3250ad4d5 100644 --- a/web/ce/components/issues/header.tsx +++ b/web/ce/components/issues/header.tsx @@ -4,24 +4,30 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // icons import { Circle, ExternalLink } from "lucide-react"; +import { + EIssuesStoreType, + EProjectFeatureKey, + EUserPermissions, + EUserPermissionsLevel, + SPACE_BASE_PATH, + SPACE_BASE_URL, +} from "@plane/constants"; // plane constants -import { EIssuesStoreType, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // ui -import { Breadcrumbs, Button, LayersIcon, Tooltip, Header } from "@plane/ui"; +import { Breadcrumbs, Button, Tooltip, Header } from "@plane/ui"; // components -import { BreadcrumbLink, CountChip } from "@/components/common"; +import { CountChip } from "@/components/common"; // constants import HeaderFilters from "@/components/issues/filters"; // helpers -import { SPACE_BASE_PATH, SPACE_BASE_URL } from "@/helpers/common.helper"; // hooks import { useEventTracker, useProject, useCommandPalette, useUserPermissions } from "@/hooks/store"; import { useIssues } from "@/hooks/store/use-issues"; import { useAppRouter } from "@/hooks/use-app-router"; import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web -import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; +import { CommonProjectBreadcrumbs } from "../breadcrumbs/common"; export const IssuesHeader = observer(() => { // router @@ -53,18 +59,13 @@ export const IssuesHeader = observer(() => { return (
    -
    - router.back()} isLoading={loader === "init-loader"}> - - - } - /> - } +
    + router.back()} isLoading={loader === "init-loader"} className="flex-grow-0"> + {issuesCount && issuesCount > 0 ? ( diff --git a/web/ce/components/issues/issue-detail-widgets/action-buttons.tsx b/web/ce/components/issues/issue-detail-widgets/action-buttons.tsx new file mode 100644 index 000000000..1312c0839 --- /dev/null +++ b/web/ce/components/issues/issue-detail-widgets/action-buttons.tsx @@ -0,0 +1,14 @@ +import { FC } from "react"; +// plane types +import { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; + +export type TWorkItemAdditionalWidgetActionButtonsProps = { + disabled: boolean; + hideWidgets: TWorkItemWidgets[]; + issueServiceType: TIssueServiceType; + projectId: string; + workItemId: string; + workspaceSlug: string; +}; + +export const WorkItemAdditionalWidgetActionButtons: FC = () => null; diff --git a/web/ce/components/issues/issue-detail-widgets/additional-widgets.tsx b/web/ce/components/issues/issue-detail-widgets/additional-widgets.tsx deleted file mode 100644 index 04288603a..000000000 --- a/web/ce/components/issues/issue-detail-widgets/additional-widgets.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { FC } from "react"; - -export type TWorkItemAdditionalWidgets = { - workspaceSlug: string; - projectId: string; - workItemId: string; - disabled: boolean; -}; - -export const WorkItemAdditionalWidgets: FC = (props) => <>; diff --git a/web/ce/components/issues/issue-detail-widgets/collapsibles.tsx b/web/ce/components/issues/issue-detail-widgets/collapsibles.tsx new file mode 100644 index 000000000..a9a6a1b29 --- /dev/null +++ b/web/ce/components/issues/issue-detail-widgets/collapsibles.tsx @@ -0,0 +1,14 @@ +import { FC } from "react"; +// plane types +import { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; + +export type TWorkItemAdditionalWidgetCollapsiblesProps = { + disabled: boolean; + hideWidgets: TWorkItemWidgets[]; + issueServiceType: TIssueServiceType; + projectId: string; + workItemId: string; + workspaceSlug: string; +}; + +export const WorkItemAdditionalWidgetCollapsibles: FC = () => null; diff --git a/web/ce/components/issues/issue-detail-widgets/index.ts b/web/ce/components/issues/issue-detail-widgets/index.ts deleted file mode 100644 index a972c5053..000000000 --- a/web/ce/components/issues/issue-detail-widgets/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./additional-widgets"; diff --git a/web/ce/components/issues/issue-detail-widgets/modals.tsx b/web/ce/components/issues/issue-detail-widgets/modals.tsx new file mode 100644 index 000000000..2e9dfe40d --- /dev/null +++ b/web/ce/components/issues/issue-detail-widgets/modals.tsx @@ -0,0 +1,13 @@ +import { FC } from "react"; +// plane types +import { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; + +export type TWorkItemAdditionalWidgetModalsProps = { + hideWidgets: TWorkItemWidgets[]; + issueServiceType: TIssueServiceType; + projectId: string; + workItemId: string; + workspaceSlug: string; +}; + +export const WorkItemAdditionalWidgetModals: FC = () => null; diff --git a/web/ce/components/issues/issue-details/issue-identifier.tsx b/web/ce/components/issues/issue-details/issue-identifier.tsx index 1e87d387e..b806803f4 100644 --- a/web/ce/components/issues/issue-details/issue-identifier.tsx +++ b/web/ce/components/issues/issue-details/issue-identifier.tsx @@ -5,7 +5,7 @@ import { IIssueDisplayProperties } from "@plane/types"; // ui import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui"; // helpers -import { cn } from "@/helpers/common.helper"; +import { cn } from "@plane/utils"; // hooks import { useIssueDetail, useProject } from "@/hooks/store"; diff --git a/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx b/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx new file mode 100644 index 000000000..f9aed4040 --- /dev/null +++ b/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx @@ -0,0 +1,22 @@ +import { Copy } from "lucide-react"; +import { TContextMenuItem } from "@plane/ui"; + +export interface CopyMenuHelperProps { + baseItem: { + key: string; + title: string; + icon: typeof Copy; + action: () => void; + shouldRender: boolean; + }; + activeLayout: string; + setTrackElement: (element: string) => void; + setCreateUpdateIssueModal: (open: boolean) => void; + setDuplicateWorkItemModal?: (open: boolean) => void; +} + +export const createCopyMenuWithDuplication = (props: CopyMenuHelperProps): TContextMenuItem => { + const { baseItem } = props; + + return baseItem; +}; diff --git a/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx b/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx new file mode 100644 index 000000000..1ea30e26e --- /dev/null +++ b/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx @@ -0,0 +1,11 @@ +import { FC } from "react"; + +type TDuplicateWorkItemModalProps = { + workItemId: string; + onClose: () => void; + isOpen: boolean; + workspaceSlug: string; + projectId: string; +}; + +export const DuplicateWorkItemModal: FC = () => <>; diff --git a/web/ce/components/issues/issue-layouts/quick-action-dropdowns/index.ts b/web/ce/components/issues/issue-layouts/quick-action-dropdowns/index.ts new file mode 100644 index 000000000..470ae9181 --- /dev/null +++ b/web/ce/components/issues/issue-layouts/quick-action-dropdowns/index.ts @@ -0,0 +1,2 @@ +export * from "./duplicate-modal"; +export * from "./copy-menu-helper"; diff --git a/web/ce/components/issues/issue-layouts/utils.tsx b/web/ce/components/issues/issue-layouts/utils.tsx index 1c0eed554..62c8b35e6 100644 --- a/web/ce/components/issues/issue-layouts/utils.tsx +++ b/web/ce/components/issues/issue-layouts/utils.tsx @@ -13,7 +13,7 @@ import { Users, } from "lucide-react"; // types -import { IGroupByColumn, IIssueDisplayProperties, TSpreadsheetColumn } from "@plane/types"; +import { IGroupByColumn, IIssueDisplayProperties, TGetColumns, TSpreadsheetColumn } from "@plane/types"; import { DiceIcon, DoubleCircleIcon, ISvgIcons } from "@plane/ui"; // components import { @@ -32,6 +32,36 @@ import { SpreadsheetSubIssueColumn, SpreadsheetUpdatedOnColumn, } from "@/components/issues/issue-layouts/spreadsheet"; +// store +import { store } from "@/lib/store-context"; + +export type TGetScopeMemberIdsResult = { + memberIds: string[]; + includeNone: boolean; +}; + +export const getScopeMemberIds = ({ isWorkspaceLevel, projectId }: TGetColumns): TGetScopeMemberIdsResult => { + // store values + const { workspaceMemberIds } = store.memberRoot.workspace; + const { projectMemberIds } = store.memberRoot.project; + // derived values + const memberIds = workspaceMemberIds; + + if (isWorkspaceLevel) { + return { memberIds: memberIds ?? [], includeNone: true }; + } + + if (projectId || (projectMemberIds && projectMemberIds.length > 0)) { + const { getProjectMemberIds } = store.memberRoot.project; + const _projectMemberIds = projectId ? getProjectMemberIds(projectId, false) : projectMemberIds; + return { + memberIds: _projectMemberIds ?? [], + includeNone: true, + }; + } + + return { memberIds: [], includeNone: true }; +}; export const getTeamProjectColumns = (): IGroupByColumn[] | undefined => undefined; diff --git a/web/ce/components/issues/issue-modal/provider.tsx b/web/ce/components/issues/issue-modal/provider.tsx index 18e122d97..788f1ae86 100644 --- a/web/ce/components/issues/issue-modal/provider.tsx +++ b/web/ce/components/issues/issue-modal/provider.tsx @@ -4,21 +4,29 @@ import { observer } from "mobx-react-lite"; import { ISearchIssueResponse, TIssue } from "@plane/types"; // components import { IssueModalContext } from "@/components/issues"; +// hooks +import { useUser } from "@/hooks/store/user/user-user"; export type TIssueModalProviderProps = { templateId?: string; dataForPreload?: Partial; + allowedProjectIds?: string[]; children: React.ReactNode; }; export const IssueModalProvider = observer((props: TIssueModalProviderProps) => { - const { children } = props; + const { children, allowedProjectIds } = props; // states const [selectedParentIssue, setSelectedParentIssue] = useState(null); + // store hooks + const { projectsWithCreatePermissions } = useUser(); + // derived values + const projectIdsWithCreatePermissions = Object.keys(projectsWithCreatePermissions ?? {}); return ( {}, isApplyingTemplate: false, @@ -35,6 +43,7 @@ export const IssueModalProvider = observer((props: TIssueModalProviderProps) => handleCreateUpdatePropertyValues: () => Promise.resolve(), handleProjectEntitiesFetch: () => Promise.resolve(), handleTemplateChange: () => Promise.resolve(), + handleConvert: () => Promise.resolve(), }} > {children} diff --git a/web/ce/components/pages/editor/ai/ask-pi-menu.tsx b/web/ce/components/pages/editor/ai/ask-pi-menu.tsx index bd49942ef..93d0c998a 100644 --- a/web/ce/components/pages/editor/ai/ask-pi-menu.tsx +++ b/web/ce/components/pages/editor/ai/ask-pi-menu.tsx @@ -3,9 +3,9 @@ import { CircleArrowUp, CornerDownRight, RefreshCcw, Sparkles } from "lucide-rea // ui import { Tooltip } from "@plane/ui"; // components +import { cn } from "@plane/utils"; import { RichTextReadOnlyEditor } from "@/components/editor"; // helpers -import { cn } from "@/helpers/common.helper"; // hooks import { useWorkspace } from "@/hooks/store"; diff --git a/web/ce/components/pages/editor/ai/menu.tsx b/web/ce/components/pages/editor/ai/menu.tsx index 3c33d3f55..e9a98279f 100644 --- a/web/ce/components/pages/editor/ai/menu.tsx +++ b/web/ce/components/pages/editor/ai/menu.tsx @@ -7,9 +7,9 @@ import { EditorRefApi } from "@plane/editor"; // plane ui import { Tooltip } from "@plane/ui"; // components +import { cn } from "@plane/utils"; import { RichTextReadOnlyEditor } from "@/components/editor"; // helpers -import { cn } from "@/helpers/common.helper"; // plane web constants import { AI_EDITOR_TASKS, LOADING_TEXTS } from "@/plane-web/constants/ai"; // plane web services diff --git a/web/ce/components/pages/editor/embed/issue-embed-upgrade-card.tsx b/web/ce/components/pages/editor/embed/issue-embed-upgrade-card.tsx index 1f698f0a7..4ade46da1 100644 --- a/web/ce/components/pages/editor/embed/issue-embed-upgrade-card.tsx +++ b/web/ce/components/pages/editor/embed/issue-embed-upgrade-card.tsx @@ -1,9 +1,9 @@ // plane ui import { getButtonStyling } from "@plane/ui"; // components +import { cn } from "@plane/utils"; import { ProIcon } from "@/components/common"; // helpers -import { cn } from "@/helpers/common.helper"; export const IssueEmbedUpgradeCard: React.FC = (props) => (
    = () => null; diff --git a/web/ce/components/pages/header/share-control.tsx b/web/ce/components/pages/header/share-control.tsx new file mode 100644 index 000000000..bedd0322c --- /dev/null +++ b/web/ce/components/pages/header/share-control.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { type EPageStoreType } from "@/plane-web/hooks/store"; +// store +import { TPageInstance } from "@/store/pages/base-page"; + +export type TPageShareControlProps = { + page: TPageInstance; + storeType: EPageStoreType; +}; + +export const PageShareControl = ({}: TPageShareControlProps) => null; diff --git a/web/ce/components/preferences/config.ts b/web/ce/components/preferences/config.ts new file mode 100644 index 000000000..1a67ab7d3 --- /dev/null +++ b/web/ce/components/preferences/config.ts @@ -0,0 +1,7 @@ +import { StartOfWeekPreference } from "@/components/profile/start-of-week-preference"; +import { ThemeSwitcher } from "./theme-switcher"; + +export const PREFERENCE_COMPONENTS = { + theme: ThemeSwitcher, + start_of_week: StartOfWeekPreference, +}; diff --git a/web/ce/components/preferences/theme-switcher.tsx b/web/ce/components/preferences/theme-switcher.tsx new file mode 100644 index 000000000..5de3d729b --- /dev/null +++ b/web/ce/components/preferences/theme-switcher.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; +// plane imports +import { I_THEME_OPTION, THEME_OPTIONS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { IUserTheme } from "@plane/types"; +import { setPromiseToast } from "@plane/ui"; +import { applyTheme, unsetCustomCssVariables } from "@plane/utils"; +// components +import { CustomThemeSelector, ThemeSwitch } from "@/components/core"; +// helpers +import { PreferencesSection } from "@/components/preferences/section"; +// hooks +import { useUserProfile } from "@/hooks/store"; + +export const ThemeSwitcher = observer( + (props: { + option: { + id: string; + title: string; + description: string; + }; + }) => { + // hooks + const { setTheme } = useTheme(); + const { data: userProfile, updateUserTheme } = useUserProfile(); + + // states + const [currentTheme, setCurrentTheme] = useState(null); + + const { t } = useTranslation(); + + // initialize theme + useEffect(() => { + if (!userProfile?.theme?.theme) return; + + const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile.theme.theme); + + if (userThemeOption) { + setCurrentTheme(userThemeOption); + } + }, [userProfile?.theme?.theme]); + + // handlers + const applyThemeChange = useCallback( + (theme: Partial) => { + const themeValue = theme?.theme || "system"; + setTheme(themeValue); + + if (theme?.theme === "custom" && theme?.palette) { + const defaultPalette = "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5"; + const palette = theme.palette !== ",,,," ? theme.palette : defaultPalette; + applyTheme(palette, false); + } else { + unsetCustomCssVariables(); + } + }, + [setTheme] + ); + + const handleThemeChange = useCallback( + async (themeOption: I_THEME_OPTION) => { + try { + applyThemeChange({ theme: themeOption.value }); + + const updatePromise = updateUserTheme({ theme: themeOption.value }); + setPromiseToast(updatePromise, { + loading: "Updating theme...", + success: { + title: "Success!", + message: () => "Theme updated successfully!", + }, + error: { + title: "Error!", + message: () => "Failed to update the theme", + }, + }); + } catch (error) { + console.error("Error updating theme:", error); + } + }, + [applyThemeChange, updateUserTheme] + ); + + if (!userProfile) return null; + + return ( + <> + + +
    + } + /> + {userProfile.theme?.theme === "custom" && } + + ); + } +); diff --git a/web/ce/components/projects/create/attributes.tsx b/web/ce/components/projects/create/attributes.tsx index a40f12317..01c1b64c7 100644 --- a/web/ce/components/projects/create/attributes.tsx +++ b/web/ce/components/projects/create/attributes.tsx @@ -7,10 +7,10 @@ import { IProject } from "@plane/types"; // ui import { CustomSelect } from "@plane/ui"; // components +import { getTabIndex } from "@plane/utils"; import { MemberDropdown } from "@/components/dropdowns"; import { ProjectNetworkIcon } from "@/components/project"; // helpers -import { getTabIndex } from "@/helpers/tab-indices.helper"; type Props = { isMobile?: boolean; diff --git a/web/ce/components/projects/create/root.tsx b/web/ce/components/projects/create/root.tsx index 490d3c6b8..18f4878fb 100644 --- a/web/ce/components/projects/create/root.tsx +++ b/web/ce/components/projects/create/root.tsx @@ -3,7 +3,7 @@ import { useState, FC } from "react"; import { observer } from "mobx-react"; import { FormProvider, useForm } from "react-hook-form"; -import { PROJECT_CREATED, DEFAULT_PROJECT_FORM_VALUES } from "@plane/constants"; +import { DEFAULT_PROJECT_FORM_VALUES, PROJECT_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // ui import { setToast, TOAST_TYPE } from "@plane/ui"; @@ -49,7 +49,7 @@ export const CreateProjectForm: FC = observer((props) = addProjectToFavorites(workspaceSlug.toString(), projectId).catch(() => { setToast({ type: TOAST_TYPE.ERROR, - title: t("error"), + title: t("toast.error"), message: t("failed_to_remove_project_from_favorites"), }); }); @@ -75,7 +75,7 @@ export const CreateProjectForm: FC = observer((props) = state: "SUCCESS", }; captureProjectEvent({ - eventName: PROJECT_CREATED, + eventName: PROJECT_TRACKER_EVENTS.create, payload: newPayload, }); setToast({ @@ -89,20 +89,27 @@ export const CreateProjectForm: FC = observer((props) = handleNextStep(res.id); }) .catch((err) => { - Object.keys(err?.data ?? {}).map((key) => { + if (err?.data.code === "PROJECT_NAME_ALREADY_EXIST") { setToast({ type: TOAST_TYPE.ERROR, - title: t("error"), - message: err.data[key], + title: t("toast.error"), + message: t("project_name_already_taken"), }); - captureProjectEvent({ - eventName: PROJECT_CREATED, - payload: { - ...formData, - state: "FAILED", - }, + } else if (err?.data.code === "PROJECT_IDENTIFIER_ALREADY_EXIST") { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: t("project_identifier_already_taken"), }); - }); + } else { + Object.keys(err?.data ?? {}).map((key) => { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: err.data[key], + }); + }); + } }); }; diff --git a/web/ce/components/projects/mobile-header.tsx b/web/ce/components/projects/mobile-header.tsx index 9e51c6799..6c34c558c 100644 --- a/web/ce/components/projects/mobile-header.tsx +++ b/web/ce/components/projects/mobile-header.tsx @@ -9,10 +9,10 @@ import { useTranslation } from "@plane/i18n"; // types import { TProjectFilters } from "@plane/types"; // hooks +import { calculateTotalFilters } from "@plane/utils"; import { FiltersDropdown } from "@/components/issues/issue-layouts"; import { ProjectFiltersSelection, ProjectOrderByDropdown } from "@/components/project/dropdowns"; // helpers -import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks import { useMember, useProjectFilter } from "@/hooks/store"; diff --git a/web/ce/components/projects/navigation/helper.tsx b/web/ce/components/projects/navigation/helper.tsx new file mode 100644 index 000000000..1a99262c6 --- /dev/null +++ b/web/ce/components/projects/navigation/helper.tsx @@ -0,0 +1,77 @@ +import { FileText, Layers } from "lucide-react"; +import { EUserPermissions, EProjectFeatureKey } from "@plane/constants"; +import { ContrastIcon, DiceIcon, Intake, LayersIcon } from "@plane/ui"; +import { TNavigationItem } from "@/components/workspace"; + +export const getProjectFeatureNavigation = ( + workspaceSlug: string, + projectId: string, + project: { + cycle_view: boolean; + module_view: boolean; + issue_views_view: boolean; + page_view: boolean; + inbox_view: boolean; + } +): TNavigationItem[] => [ + { + i18n_key: "sidebar.work_items", + key: EProjectFeatureKey.WORK_ITEMS, + name: "Work items", + href: `/${workspaceSlug}/projects/${projectId}/issues`, + icon: LayersIcon, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + shouldRender: true, + sortOrder: 1, + }, + { + i18n_key: "sidebar.cycles", + key: EProjectFeatureKey.CYCLES, + name: "Cycles", + href: `/${workspaceSlug}/projects/${projectId}/cycles`, + icon: ContrastIcon, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + shouldRender: project.cycle_view, + sortOrder: 2, + }, + { + i18n_key: "sidebar.modules", + key: EProjectFeatureKey.MODULES, + name: "Modules", + href: `/${workspaceSlug}/projects/${projectId}/modules`, + icon: DiceIcon, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + shouldRender: project.module_view, + sortOrder: 3, + }, + { + i18n_key: "sidebar.views", + key: EProjectFeatureKey.VIEWS, + name: "Views", + href: `/${workspaceSlug}/projects/${projectId}/views`, + icon: Layers, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + shouldRender: project.issue_views_view, + sortOrder: 4, + }, + { + i18n_key: "sidebar.pages", + key: EProjectFeatureKey.PAGES, + name: "Pages", + href: `/${workspaceSlug}/projects/${projectId}/pages`, + icon: FileText, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + shouldRender: project.page_view, + sortOrder: 5, + }, + { + i18n_key: "sidebar.intake", + key: EProjectFeatureKey.INTAKE, + name: "Intake", + href: `/${workspaceSlug}/projects/${projectId}/intake`, + icon: Intake, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + shouldRender: project.inbox_view, + sortOrder: 6, + }, +]; diff --git a/web/ce/components/projects/navigation/index.ts b/web/ce/components/projects/navigation/index.ts new file mode 100644 index 000000000..b9755e783 --- /dev/null +++ b/web/ce/components/projects/navigation/index.ts @@ -0,0 +1 @@ +export * from "./helper"; diff --git a/web/ce/components/projects/settings/intake/header.tsx b/web/ce/components/projects/settings/intake/header.tsx index 32a93894f..0aa77e470 100644 --- a/web/ce/components/projects/settings/intake/header.tsx +++ b/web/ce/components/projects/settings/intake/header.tsx @@ -5,16 +5,15 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { RefreshCcw } from "lucide-react"; // ui -import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Breadcrumbs, Button, Intake, Header } from "@plane/ui"; +import { Breadcrumbs, Button, Header } from "@plane/ui"; // components -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 { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs"; export const ProjectInboxHeader: FC = observer(() => { // states @@ -37,13 +36,13 @@ export const ProjectInboxHeader: FC = observer(() => { return (
    -
    +
    - - - } />} + diff --git a/web/ce/components/projects/settings/useProjectColumns.tsx b/web/ce/components/projects/settings/useProjectColumns.tsx index bd9e589ad..a1ef7f3f2 100644 --- a/web/ce/components/projects/settings/useProjectColumns.tsx +++ b/web/ce/components/projects/settings/useProjectColumns.tsx @@ -1,34 +1,28 @@ import { useState } from "react"; -import { useParams } from "next/navigation"; +// plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -import { IWorkspaceMember } from "@plane/types"; +import { IWorkspaceMember, TProjectMembership } from "@plane/types"; +// components import { AccountTypeColumn, NameColumn } from "@/components/project/settings/member-columns"; +// hooks import { useUser, useUserPermissions } from "@/hooks/store"; -export interface RowData { +export interface RowData extends Pick { member: IWorkspaceMember; - role: EUserPermissions; } -export const useProjectColumns = () => { +type TUseProjectColumnsProps = { + projectId: string; + workspaceSlug: string; +}; + +export const useProjectColumns = (props: TUseProjectColumnsProps) => { + const { projectId, workspaceSlug } = props; // states const [removeMemberModal, setRemoveMemberModal] = useState(null); - - const { workspaceSlug, projectId } = useParams(); - + // store hooks const { data: currentUser } = useUser(); - const { allowPermissions, projectUserInfo } = useUserPermissions(); - - const currentProjectRole = - (projectUserInfo?.[workspaceSlug.toString()]?.[projectId.toString()]?.role as unknown as EUserPermissions) ?? - EUserPermissions.GUEST; - - const getFormattedDate = (dateStr: string) => { - const date = new Date(dateStr); - - const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "long", day: "numeric" }; - return date.toLocaleDateString("en-US", options); - }; + const { allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions(); // derived values const isAdmin = allowPermissions( [EUserPermissions.ADMIN], @@ -36,6 +30,15 @@ export const useProjectColumns = () => { workspaceSlug.toString(), projectId.toString() ); + const currentProjectRole = + getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug.toString(), projectId.toString()) ?? EUserPermissions.GUEST; + + const getFormattedDate = (dateStr: string) => { + const date = new Date(dateStr); + + const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "long", day: "numeric" }; + return date.toLocaleDateString("en-US", options); + }; const columns = [ { @@ -76,5 +79,5 @@ export const useProjectColumns = () => { tdRender: (rowData: RowData) =>
    {getFormattedDate(rowData?.member?.joining_date || "")}
    , }, ]; - return { columns, workspaceSlug, projectId, removeMemberModal, setRemoveMemberModal }; + return { columns, removeMemberModal, setRemoveMemberModal }; }; diff --git a/web/ce/components/projects/teamspaces/index.ts b/web/ce/components/projects/teamspaces/index.ts new file mode 100644 index 000000000..968205a9b --- /dev/null +++ b/web/ce/components/projects/teamspaces/index.ts @@ -0,0 +1 @@ +export * from "./teamspace-list"; diff --git a/web/ce/components/projects/teamspaces/teamspace-list.tsx b/web/ce/components/projects/teamspaces/teamspace-list.tsx new file mode 100644 index 000000000..05d401c57 --- /dev/null +++ b/web/ce/components/projects/teamspaces/teamspace-list.tsx @@ -0,0 +1,6 @@ +export type TProjectTeamspaceList = { + workspaceSlug: string; + projectId: string; +}; + +export const ProjectTeamspaceList: React.FC = () => null; diff --git a/web/ce/components/views/helper.tsx b/web/ce/components/views/helper.tsx new file mode 100644 index 000000000..889f634b7 --- /dev/null +++ b/web/ce/components/views/helper.tsx @@ -0,0 +1,12 @@ +import { EIssueLayoutTypes } from "@plane/constants"; +import { TWorkspaceLayoutProps } from "@/components/views/helper"; + +export type TLayoutSelectionProps = { + onChange: (layout: EIssueLayoutTypes) => void; + selectedLayout: EIssueLayoutTypes; + workspaceSlug: string; +}; + +export const GlobalViewLayoutSelection = (props: TLayoutSelectionProps) => <>; + +export const WorkspaceAdditionalLayouts = (props: TWorkspaceLayoutProps) => <>; diff --git a/web/ce/components/workspace-notifications/index.ts b/web/ce/components/workspace-notifications/index.ts index 18c4afa96..c12683ce6 100644 --- a/web/ce/components/workspace-notifications/index.ts +++ b/web/ce/components/workspace-notifications/index.ts @@ -1 +1,2 @@ export * from "./notification-card/root"; +export * from "./list-root"; diff --git a/web/ce/components/workspace-notifications/list-root.tsx b/web/ce/components/workspace-notifications/list-root.tsx new file mode 100644 index 000000000..55fd68c3f --- /dev/null +++ b/web/ce/components/workspace-notifications/list-root.tsx @@ -0,0 +1,8 @@ +import { NotificationCardListRoot } from "./notification-card/root"; + +export type TNotificationListRoot = { + workspaceSlug: string; + workspaceId: string; +}; + +export const NotificationListRoot = (props: TNotificationListRoot) => ; diff --git a/web/ce/components/workspace/billing/comparison/plan-detail.tsx b/web/ce/components/workspace/billing/comparison/plan-detail.tsx index 313b0e96e..5b22e79cc 100644 --- a/web/ce/components/workspace/billing/comparison/plan-detail.tsx +++ b/web/ce/components/workspace/billing/comparison/plan-detail.tsx @@ -11,10 +11,12 @@ import { useTranslation } from "@plane/i18n"; import { TBillingFrequency } from "@plane/types"; import { getButtonStyling } from "@plane/ui"; import { cn, getSubscriptionName } from "@plane/utils"; +// components +import { DiscountInfo } from "@/components/license/modal/card/discount-info"; // constants import { getUpgradeButtonStyle } from "@/components/workspace/billing/subscription"; import { TPlanDetail } from "@/constants/plans"; -// components +// local imports import { PlanFrequencyToggle } from "./frequency-toggle"; type TPlanDetailProps = { @@ -64,11 +66,17 @@ export const PlanDetail: FC = observer((props) => {
    {isSubscriptionActive && displayPrice !== undefined && ( - - {"$" + displayPrice} - +
    + +
    )} -
    +
    {pricingDescription &&
    {pricingDescription}
    } {pricingSecondaryDescription && (
    diff --git a/web/ce/components/workspace/billing/comparison/root.tsx b/web/ce/components/workspace/billing/comparison/root.tsx index 9070dd354..469b9806d 100644 --- a/web/ce/components/workspace/billing/comparison/root.tsx +++ b/web/ce/components/workspace/billing/comparison/root.tsx @@ -1,4 +1,3 @@ -import { forwardRef } from "react"; import { observer } from "mobx-react"; // plane imports import { EProductSubscriptionEnum } from "@plane/constants"; @@ -10,52 +9,40 @@ import { PLANE_PLANS, TPlanePlans } from "@/constants/plans"; import { PlanDetail } from "./plan-detail"; type TPlansComparisonProps = { - isScrolled: boolean; isCompareAllFeaturesSectionOpen: boolean; getBillingFrequency: (subscriptionType: EProductSubscriptionEnum) => TBillingFrequency | undefined; setBillingFrequency: (subscriptionType: EProductSubscriptionEnum, frequency: TBillingFrequency) => void; setIsCompareAllFeaturesSectionOpen: React.Dispatch>; - setIsScrolled: React.Dispatch>; }; -export const PlansComparison = observer( - forwardRef(function PlansComparison( - props: TPlansComparisonProps, - ref: React.Ref - ) { - const { - isScrolled, - isCompareAllFeaturesSectionOpen, - getBillingFrequency, - setBillingFrequency, - setIsCompareAllFeaturesSectionOpen, - setIsScrolled, - } = props; - // plan details - const { planDetails } = PLANE_PLANS; +export const PlansComparison = observer((props: TPlansComparisonProps) => { + const { + isCompareAllFeaturesSectionOpen, + getBillingFrequency, + setBillingFrequency, + setIsCompareAllFeaturesSectionOpen, + } = props; + // plan details + const { planDetails } = PLANE_PLANS; - return ( - { - const currentPlanKey = planKey as TPlanePlans; - if (!shouldRenderPlanDetail(currentPlanKey)) return null; - return ( - setBillingFrequency(plan.id, frequency)} - /> - ); - })} - isSelfManaged - isScrolled={isScrolled} - isCompareAllFeaturesSectionOpen={isCompareAllFeaturesSectionOpen} - setIsCompareAllFeaturesSectionOpen={setIsCompareAllFeaturesSectionOpen} - setIsScrolled={setIsScrolled} - /> - ); - }) -); + return ( + { + const currentPlanKey = planKey as TPlanePlans; + if (!shouldRenderPlanDetail(currentPlanKey)) return null; + return ( + setBillingFrequency(plan.id, frequency)} + /> + ); + })} + isSelfManaged + isCompareAllFeaturesSectionOpen={isCompareAllFeaturesSectionOpen} + setIsCompareAllFeaturesSectionOpen={setIsCompareAllFeaturesSectionOpen} + /> + ); +}); diff --git a/web/ce/components/workspace/billing/root.tsx b/web/ce/components/workspace/billing/root.tsx index f76052584..c61b19643 100644 --- a/web/ce/components/workspace/billing/root.tsx +++ b/web/ce/components/workspace/billing/root.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; // plane imports import { @@ -6,20 +6,21 @@ import { EProductSubscriptionEnum, SUBSCRIPTION_WITH_BILLING_FREQUENCY, } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { TBillingFrequency, TProductBillingFrequency } from "@plane/types"; import { cn } from "@plane/utils"; // components +import { SettingsHeading } from "@/components/settings"; import { getSubscriptionTextColor } from "@/components/workspace/billing/subscription"; // local imports import { PlansComparison } from "./comparison/root"; export const BillingRoot = observer(() => { - const [isScrolled, setIsScrolled] = useState(false); - const containerRef = useRef(null); const [isCompareAllFeaturesSectionOpen, setIsCompareAllFeaturesSectionOpen] = useState(false); const [productBillingFrequency, setProductBillingFrequency] = useState( DEFAULT_PRODUCT_BILLING_FREQUENCY ); + const { t } = useTranslation(); /** * Retrieves the billing frequency for a given subscription type @@ -40,34 +41,13 @@ export const BillingRoot = observer(() => { const setBillingFrequency = (subscriptionType: EProductSubscriptionEnum, frequency: TBillingFrequency): void => setProductBillingFrequency({ ...productBillingFrequency, [subscriptionType]: frequency }); - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - const handleScroll = () => { - const scrollTop = container.scrollTop; - const isScrolled = isCompareAllFeaturesSectionOpen ? scrollTop > 0 : false; - setIsScrolled(isScrolled); - }; - - container.addEventListener("scroll", handleScroll); - return () => container.removeEventListener("scroll", handleScroll); - }, [isCompareAllFeaturesSectionOpen]); - return (
    -
    -
    -

    Billing and plans

    -
    -
    - -
    + +
    @@ -87,13 +67,10 @@ export const BillingRoot = observer(() => {
    All plans
    ); diff --git a/web/ce/components/workspace/edition-badge.tsx b/web/ce/components/workspace/edition-badge.tsx index b32ce9e61..5cffdbe12 100644 --- a/web/ce/components/workspace/edition-badge.tsx +++ b/web/ce/components/workspace/edition-badge.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; import { observer } from "mobx-react"; -import packageJson from "package.json"; // ui +import packageJson from "package.json"; +import { useTranslation } from "@plane/i18n"; import { Button, Tooltip } from "@plane/ui"; // hooks import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -9,9 +10,12 @@ import { usePlatformOS } from "@/hooks/use-platform-os"; import { PaidPlanUpgradeModal } from "../license"; export const WorkspaceEditionBadge = observer(() => { - const { isMobile } = usePlatformOS(); // states const [isPaidPlanPurchaseModalOpen, setIsPaidPlanPurchaseModalOpen] = useState(false); + // translation + const { t } = useTranslation(); + // platform + const { isMobile } = usePlatformOS(); return ( <> @@ -25,6 +29,8 @@ export const WorkspaceEditionBadge = observer(() => { variant="accent-primary" 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)} + aria-haspopup="dialog" + aria-label={t("aria_labels.projects_sidebar.edition_badge")} > Community diff --git a/web/ce/components/workspace/sidebar/app-search.tsx b/web/ce/components/workspace/sidebar/app-search.tsx index 6ab92b996..01d7f0147 100644 --- a/web/ce/components/workspace/sidebar/app-search.tsx +++ b/web/ce/components/workspace/sidebar/app-search.tsx @@ -1,7 +1,9 @@ import { observer } from "mobx-react"; import { Search } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; // helpers -import { cn } from "@/helpers/common.helper"; +import { cn } from "@plane/utils"; // hooks import { useAppTheme, useCommandPalette } from "@/hooks/store"; @@ -9,9 +11,12 @@ export const AppSearch = observer(() => { // store hooks const { sidebarCollapsed } = useAppTheme(); const { toggleCommandPaletteModal } = useCommandPalette(); + // translation + const { t } = useTranslation(); return ( diff --git a/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx b/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx index 5bb2fe885..5e7343e0a 100644 --- a/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx +++ b/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx @@ -55,7 +55,7 @@ export const ExtendedSidebarItem: FC = observer((prop const sidebarPreference = getNavigationPreferences(workspaceSlug.toString()); const isPinned = sidebarPreference?.[item.key]?.is_pinned; - const handleLinkClick = () => toggleExtendedSidebar(); + const handleLinkClick = () => toggleExtendedSidebar(true); if (!allowPermissions(item.access as any, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())) { return null; diff --git a/web/ce/components/workspace/sidebar/sidebar-item.tsx b/web/ce/components/workspace/sidebar/sidebar-item.tsx index 51a5735de..3645bde3d 100644 --- a/web/ce/components/workspace/sidebar/sidebar-item.tsx +++ b/web/ce/components/workspace/sidebar/sidebar-item.tsx @@ -38,7 +38,7 @@ export const SidebarItem: FC = observer((props) => { if (window.innerWidth < 768) { toggleSidebar(); } - if (extendedSidebarCollapsed) toggleExtendedSidebar(); + if (!extendedSidebarCollapsed) toggleExtendedSidebar(); }; const staticItems = ["home", "inbox", "pi-chat", "projects"]; diff --git a/web/ce/components/workspace/upgrade-badge.tsx b/web/ce/components/workspace/upgrade-badge.tsx index 1abb731e7..8c198dd2e 100644 --- a/web/ce/components/workspace/upgrade-badge.tsx +++ b/web/ce/components/workspace/upgrade-badge.tsx @@ -1,7 +1,7 @@ import { FC } from "react"; // helpers import { useTranslation } from "@plane/i18n"; -import { cn } from "@/helpers/common.helper"; +import { cn } from "@plane/utils"; type TUpgradeBadge = { className?: string; diff --git a/web/ce/constants/index.ts b/web/ce/constants/index.ts index 1a86ad1e2..5612b3026 100644 --- a/web/ce/constants/index.ts +++ b/web/ce/constants/index.ts @@ -1,5 +1,4 @@ export * from "./ai"; -export * from "./estimates"; export * from "./gantt-chart"; export * from "./project"; export * from "./sidebar-favorites"; diff --git a/web/ce/constants/project/settings/tabs.ts b/web/ce/constants/project/settings/tabs.ts index 15869c186..5443c6424 100644 --- a/web/ce/constants/project/settings/tabs.ts +++ b/web/ce/constants/project/settings/tabs.ts @@ -9,57 +9,57 @@ export const PROJECT_SETTINGS = { general: { key: "general", i18n_label: "common.general", - href: `/settings`, + href: ``, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/`, Icon: SettingIcon, }, members: { key: "members", - i18n_label: "members", - href: `/settings/members`, + i18n_label: "common.members", + href: `/members`, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/members/`, Icon: SettingIcon, }, features: { key: "features", i18n_label: "common.features", - href: `/settings/features`, + href: `/features`, access: [EUserPermissions.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/features/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/features/`, Icon: SettingIcon, }, states: { key: "states", i18n_label: "common.states", - href: `/settings/states`, + href: `/states`, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/states/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/states/`, Icon: SettingIcon, }, labels: { key: "labels", i18n_label: "common.labels", - href: `/settings/labels`, + href: `/labels`, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/labels/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/labels/`, Icon: SettingIcon, }, estimates: { key: "estimates", i18n_label: "common.estimates", - href: `/settings/estimates`, + href: `/estimates`, access: [EUserPermissions.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/estimates/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/estimates/`, Icon: SettingIcon, }, automations: { key: "automations", i18n_label: "project_settings.automations.label", - href: `/settings/automations`, + href: `/automations`, access: [EUserPermissions.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/automations/`, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/automations/`, Icon: SettingIcon, }, }; diff --git a/web/ce/constants/sidebar-favorites.ts b/web/ce/constants/sidebar-favorites.ts index 9fa80e05f..a6f49f8aa 100644 --- a/web/ce/constants/sidebar-favorites.ts +++ b/web/ce/constants/sidebar-favorites.ts @@ -1,7 +1,7 @@ -import { Briefcase, ContrastIcon, FileText, Layers, LucideIcon } from "lucide-react"; +import { Briefcase, FileText, Layers, LucideIcon } from "lucide-react"; // plane imports import { IFavorite } from "@plane/types"; -import { DiceIcon, FavoriteFolderIcon, ISvgIcons } from "@plane/ui"; +import { ContrastIcon, DiceIcon, FavoriteFolderIcon, ISvgIcons } from "@plane/ui"; export const FAVORITE_ITEM_ICONS: Record | LucideIcon> = { page: FileText, diff --git a/web/ce/helpers/project-settings.ts b/web/ce/helpers/project-settings.ts new file mode 100644 index 000000000..dbe06507a --- /dev/null +++ b/web/ce/helpers/project-settings.ts @@ -0,0 +1,7 @@ +/** + * @description Get the i18n key for the project settings page label + * @param _settingsKey - The key of the project settings page + * @param defaultLabelKey - The default i18n key for the project settings page label + * @returns The i18n key for the project settings page label + */ +export const getProjectSettingsPageLabelI18nKey = (_settingsKey: string, defaultLabelKey: string) => defaultLabelKey; diff --git a/web/ce/hooks/use-editor-flagging.ts b/web/ce/hooks/use-editor-flagging.ts index 05fdff91f..7e04919ce 100644 --- a/web/ce/hooks/use-editor-flagging.ts +++ b/web/ce/hooks/use-editor-flagging.ts @@ -1,17 +1,35 @@ // editor import { TExtensions } from "@plane/editor"; +export type TEditorFlaggingHookReturnType = { + document: { + disabled: TExtensions[]; + flagged: TExtensions[]; + }; + liteText: { + disabled: TExtensions[]; + flagged: TExtensions[]; + }; + richText: { + disabled: TExtensions[]; + flagged: TExtensions[]; + }; +}; + /** * @description extensions disabled in various editors */ -export const useEditorFlagging = ( - workspaceSlug: string -): { - documentEditor: TExtensions[]; - liteTextEditor: TExtensions[]; - richTextEditor: TExtensions[]; -} => ({ - documentEditor: ["ai", "collaboration-cursor"], - liteTextEditor: ["ai", "collaboration-cursor"], - richTextEditor: ["ai", "collaboration-cursor"], +export const useEditorFlagging = (workspaceSlug: string): TEditorFlaggingHookReturnType => ({ + document: { + disabled: ["ai", "collaboration-cursor"], + flagged: [], + }, + liteText: { + disabled: ["ai", "collaboration-cursor"], + flagged: [], + }, + richText: { + disabled: ["ai", "collaboration-cursor"], + flagged: [], + }, }); diff --git a/web/ce/hooks/use-issue-properties.tsx b/web/ce/hooks/use-issue-properties.tsx new file mode 100644 index 000000000..c4d35d6ad --- /dev/null +++ b/web/ce/hooks/use-issue-properties.tsx @@ -0,0 +1,10 @@ +import { TIssueServiceType } from "@plane/types"; + +export const useWorkItemProperties = ( + projectId: string | null | undefined, + workspaceSlug: string | null | undefined, + workItemId: string | null | undefined, + issueServiceType: TIssueServiceType +) => { + if (!projectId || !workspaceSlug || !workItemId) return; +}; diff --git a/web/ce/hooks/use-notification-preview.tsx b/web/ce/hooks/use-notification-preview.tsx new file mode 100644 index 000000000..b0c18d554 --- /dev/null +++ b/web/ce/hooks/use-notification-preview.tsx @@ -0,0 +1,25 @@ +import { EIssueServiceType } from "@plane/constants"; +import { IWorkItemPeekOverview } from "@plane/types"; +import { IssuePeekOverview } from "@/components/issues"; +import { useIssueDetail } from "@/hooks/store"; +import { TPeekIssue } from "@/store/issue/issue-details/root.store"; + +export type TNotificationPreview = { + isWorkItem: boolean; + PeekOverviewComponent: React.ComponentType; + setPeekWorkItem: (peekIssue: TPeekIssue | undefined) => void; +}; + +/** + * This function returns if the current active notification is related to work item or an epic. + * @returns isWorkItem: boolean, peekOverviewComponent: IWorkItemPeekOverview, setPeekWorkItem + */ +export const useNotificationPreview = (): TNotificationPreview => { + const { peekIssue, setPeekIssue } = useIssueDetail(EIssueServiceType.ISSUES); + + return { + isWorkItem: Boolean(peekIssue), + PeekOverviewComponent: IssuePeekOverview, + setPeekWorkItem: setPeekIssue, + }; +}; diff --git a/web/ce/hooks/use-page-flag.ts b/web/ce/hooks/use-page-flag.ts index 84dc31c0d..94d72065a 100644 --- a/web/ce/hooks/use-page-flag.ts +++ b/web/ce/hooks/use-page-flag.ts @@ -4,11 +4,13 @@ export type TPageFlagHookArgs = { export type TPageFlagHookReturnType = { isMovePageEnabled: boolean; + isPageSharingEnabled: boolean; }; export const usePageFlag = (args: TPageFlagHookArgs): TPageFlagHookReturnType => { const {} = args; return { isMovePageEnabled: false, + isPageSharingEnabled: false, }; }; diff --git a/web/ce/services/project/estimate.service.ts b/web/ce/services/project/estimate.service.ts index 1659d9966..f33c15efb 100644 --- a/web/ce/services/project/estimate.service.ts +++ b/web/ce/services/project/estimate.service.ts @@ -1,9 +1,9 @@ /* eslint-disable no-useless-catch */ // types +import { API_BASE_URL } from "@plane/constants"; import { IEstimate, IEstimateFormData, IEstimatePoint } from "@plane/types"; // helpers -import { API_BASE_URL } from "@/helpers/common.helper"; // services import { APIService } from "@/services/api.service"; diff --git a/web/ce/services/project/view.service.ts b/web/ce/services/project/view.service.ts index 9a6ee12a2..475cb3124 100644 --- a/web/ce/services/project/view.service.ts +++ b/web/ce/services/project/view.service.ts @@ -1,6 +1,5 @@ -import { EViewAccess } from "@plane/constants"; +import { EViewAccess, API_BASE_URL } from "@plane/constants"; import { TPublishViewSettings } from "@plane/types"; -import { API_BASE_URL } from "@/helpers/common.helper"; import { ViewService as CoreViewService } from "@/services/view.service"; export class ViewService extends CoreViewService { diff --git a/web/ce/services/workspace.service.ts b/web/ce/services/workspace.service.ts index 9dcfb7f6c..e5e99b65d 100644 --- a/web/ce/services/workspace.service.ts +++ b/web/ce/services/workspace.service.ts @@ -1,5 +1,4 @@ -import { EViewAccess } from "@plane/constants"; -import { API_BASE_URL } from "@/helpers/common.helper"; +import { EViewAccess, API_BASE_URL } from "@plane/constants"; import { WorkspaceService as CoreWorkspaceService } from "@/services/workspace.service"; export class WorkspaceService extends CoreWorkspaceService { diff --git a/web/ce/store/analytics.store.ts b/web/ce/store/analytics.store.ts new file mode 100644 index 000000000..ef866f65a --- /dev/null +++ b/web/ce/store/analytics.store.ts @@ -0,0 +1,8 @@ +import { BaseAnalyticsStore, IBaseAnalyticsStore } from "@/store/analytics.store"; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface IAnalyticsStore extends IBaseAnalyticsStore { + //observables +} + +export class AnalyticsStore extends BaseAnalyticsStore {} diff --git a/web/ce/store/event-tracker.store.ts b/web/ce/store/event-tracker.store.ts new file mode 100644 index 000000000..4f5074dd9 --- /dev/null +++ b/web/ce/store/event-tracker.store.ts @@ -0,0 +1,11 @@ +import { RootStore } from "@/plane-web/store/root.store"; +import { CoreEventTrackerStore, ICoreEventTrackerStore } from "@/store/event-tracker.store"; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface IEventTrackerStore extends ICoreEventTrackerStore {} + +export class EventTrackerStore extends CoreEventTrackerStore implements IEventTrackerStore { + constructor(_rootStore: RootStore) { + super(_rootStore); + } +} diff --git a/web/ce/store/issue/issue-details/root.store.ts b/web/ce/store/issue/issue-details/root.store.ts new file mode 100644 index 000000000..bbea3f46b --- /dev/null +++ b/web/ce/store/issue/issue-details/root.store.ts @@ -0,0 +1,18 @@ +import { makeObservable } from "mobx"; +import { TIssueServiceType } from "@plane/types"; +import { + IssueDetail as IssueDetailCore, + IIssueDetail as IIssueDetailCore, +} from "@/store/issue/issue-details/root.store"; +import { IIssueRootStore } from "@/store/issue/root.store"; + +export type IIssueDetail = IIssueDetailCore; + +export class IssueDetail extends IssueDetailCore { + constructor(rootStore: IIssueRootStore, serviceType: TIssueServiceType) { + super(rootStore, serviceType); + makeObservable(this, { + // observables + }); + } +} diff --git a/web/ce/store/issue/workspace/issue.store.ts b/web/ce/store/issue/workspace/issue.store.ts new file mode 100644 index 000000000..7317da96d --- /dev/null +++ b/web/ce/store/issue/workspace/issue.store.ts @@ -0,0 +1 @@ +export * from "@/store/issue/workspace/issue.store"; diff --git a/web/ce/store/member/project-member.store.ts b/web/ce/store/member/project-member.store.ts new file mode 100644 index 000000000..1b90c9c11 --- /dev/null +++ b/web/ce/store/member/project-member.store.ts @@ -0,0 +1,43 @@ +import { computedFn } from "mobx-utils"; +// plane imports +import { EUserProjectRoles } from "@plane/constants"; +// plane web imports +import { RootStore } from "@/plane-web/store/root.store"; +// store +import { IMemberRootStore } from "@/store/member"; +import { BaseProjectMemberStore, IBaseProjectMemberStore } from "@/store/member/base-project-member.store"; + +export type IProjectMemberStore = IBaseProjectMemberStore; + +export class ProjectMemberStore extends BaseProjectMemberStore implements IProjectMemberStore { + constructor(_memberRoot: IMemberRootStore, rootStore: RootStore) { + super(_memberRoot, rootStore); + } + + /** + * @description Returns the highest role from the project membership + * @param { string } userId + * @param { string } projectId + * @returns { EUserProjectRoles | undefined } + */ + getUserProjectRole = computedFn((userId: string, projectId: string): EUserProjectRoles | undefined => + this.getRoleFromProjectMembership(userId, projectId) + ); + + /** + * @description Returns the role from the project membership + * @param projectId + * @param userId + * @param role + */ + getProjectMemberRoleForUpdate = (_projectId: string, _userId: string, role: EUserProjectRoles): EUserProjectRoles => + role; + + /** + * @description Processes the removal of a member from a project + * This method handles the cleanup of member data from the project member map + * @param projectId - The ID of the project to remove the member from + * @param userId - The ID of the user to remove from the project + */ + processMemberRemoval = (projectId: string, userId: string) => this.handleMemberRemoval(projectId, userId); +} diff --git a/web/ce/store/pages/extended-base-page.ts b/web/ce/store/pages/extended-base-page.ts new file mode 100644 index 000000000..a80e5e4e3 --- /dev/null +++ b/web/ce/store/pages/extended-base-page.ts @@ -0,0 +1,16 @@ +import { TPage, TPageExtended } from "@plane/types"; +import { RootStore } from "@/plane-web/store/root.store"; +import { TBasePageServices } from "@/store/pages/base-page"; + +export type TExtendedPageInstance = TPageExtended & { + asJSONExtended: TPageExtended; +}; + +export class ExtendedBasePage implements TExtendedPageInstance { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + constructor(store: RootStore, page: TPage, services: TBasePageServices) {} + + get asJSONExtended(): TExtendedPageInstance["asJSONExtended"] { + return {}; + } +} diff --git a/web/ce/store/timeline/base-timeline.store.ts b/web/ce/store/timeline/base-timeline.store.ts index 81de55fc4..c021fa93e 100644 --- a/web/ce/store/timeline/base-timeline.store.ts +++ b/web/ce/store/timeline/base-timeline.store.ts @@ -3,7 +3,8 @@ import set from "lodash/set"; import { action, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; // components -import { ChartDataType, IBlockUpdateDependencyData, IGanttBlock, TGanttViews } from "@/components/gantt-chart"; +import type { ChartDataType, IBlockUpdateDependencyData, IGanttBlock, TGanttViews } from "@plane/types"; +import { renderFormattedPayloadDate } from "@plane/utils"; import { currentViewDataWithView } from "@/components/gantt-chart/data"; import { getDateFromPositionOnGantt, @@ -11,7 +12,6 @@ import { getPositionFromDate, } from "@/components/gantt-chart/views/helpers"; // helpers -import { renderFormattedPayloadDate } from "@/helpers/date-time.helper"; // store import { RootStore } from "@/plane-web/store/root.store"; @@ -22,6 +22,7 @@ type BlockData = { sort_order: number | null; start_date?: string | undefined | null; target_date?: string | undefined | null; + project_id?: string | undefined | null; }; export interface IBaseTimelineStore { @@ -194,6 +195,7 @@ export class BaseTimeLineStore implements IBaseTimelineStore { sort_order: blockData?.sort_order ?? undefined, start_date: blockData?.start_date ?? undefined, target_date: blockData?.target_date ?? undefined, + project_id: blockData?.project_id ?? undefined, }; if (this.currentViewData && (this.currentViewData?.data?.startDate || this.currentViewData?.data?.dayWidth)) { block.position = getItemPositionWidth(this.currentViewData, block); diff --git a/web/ce/store/user/permission.store.ts b/web/ce/store/user/permission.store.ts new file mode 100644 index 000000000..00300cdf2 --- /dev/null +++ b/web/ce/store/user/permission.store.ts @@ -0,0 +1,23 @@ +import { computedFn } from "mobx-utils"; +import { EUserPermissions } from "@plane/constants"; +import { RootStore } from "@/plane-web/store/root.store"; +import { BaseUserPermissionStore, IBaseUserPermissionStore } from "@/store/user/base-permissions.store"; + +export type IUserPermissionStore = IBaseUserPermissionStore; + +export class UserPermissionStore extends BaseUserPermissionStore implements IUserPermissionStore { + constructor(store: RootStore) { + super(store); + } + + /** + * @description Returns the project role from the workspace + * @param { string } workspaceSlug + * @param { string } projectId + * @returns { EUserPermissions | undefined } + */ + getProjectRoleByWorkspaceSlugAndProjectId = computedFn( + (workspaceSlug: string, projectId: string): EUserPermissions | undefined => + this.getProjectRole(workspaceSlug, projectId) + ); +} diff --git a/web/core/components/account/auth-forms/auth-banner.tsx b/web/core/components/account/auth-forms/auth-banner.tsx index 191d7a0a7..da1c57c4a 100644 --- a/web/core/components/account/auth-forms/auth-banner.tsx +++ b/web/core/components/account/auth-forms/auth-banner.tsx @@ -1,5 +1,7 @@ import { FC } from "react"; import { Info, X } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; // helpers import { TAuthErrorInfo } from "@/helpers/authentication.helper"; @@ -10,20 +12,28 @@ type TAuthBanner = { export const AuthBanner: FC = (props) => { const { bannerData, handleBannerData } = props; + // translation + const { t } = useTranslation(); if (!bannerData) return <>; + return ( -
    -
    +
    +
    -
    {bannerData?.message}
    -
    handleBannerData && handleBannerData(undefined)} +

    {bannerData?.message}

    +
    + +
    ); }; diff --git a/web/core/components/account/auth-forms/auth-header.tsx b/web/core/components/account/auth-forms/auth-header.tsx index b33c694ba..c705c7edd 100644 --- a/web/core/components/account/auth-forms/auth-header.tsx +++ b/web/core/components/account/auth-forms/auth-header.tsx @@ -102,9 +102,9 @@ export const AuthHeader: FC = observer((props) => { return ( <>
    -

    +

    {typeof header === "string" ? t(header) : header} -

    +

    {t(subHeader)}

    {children} diff --git a/web/core/components/account/auth-forms/email.tsx b/web/core/components/account/auth-forms/email.tsx index 724f52442..586bff1f7 100644 --- a/web/core/components/account/auth-forms/email.tsx +++ b/web/core/components/account/auth-forms/email.tsx @@ -8,10 +8,8 @@ import { CircleAlert, XCircle } from "lucide-react"; import { useTranslation } from "@plane/i18n"; import { IEmailCheckData } from "@plane/types"; import { Button, Input, Spinner } from "@plane/ui"; +import { cn, checkEmailValidity } from "@plane/utils"; // helpers -import { cn } from "@/helpers/common.helper"; -import { checkEmailValidity } from "@/helpers/string.helper"; - type TAuthEmailForm = { defaultEmail: string; onSubmit: (data: IEmailCheckData) => Promise; @@ -47,7 +45,7 @@ export const AuthEmailForm: FC = observer((props) => { return (
    -
    )} diff --git a/web/core/components/account/auth-forms/password.tsx b/web/core/components/account/auth-forms/password.tsx index 979899679..3c2927418 100644 --- a/web/core/components/account/auth-forms/password.tsx +++ b/web/core/components/account/auth-forms/password.tsx @@ -6,16 +6,15 @@ import Link from "next/link"; // icons import { Eye, EyeOff, Info, X, XCircle } from "lucide-react"; // plane imports -import { FORGOT_PASSWORD, SIGN_IN_WITH_CODE, SIGN_IN_WITH_PASSWORD, SIGN_UP_WITH_PASSWORD } from "@plane/constants"; +import { API_BASE_URL, E_PASSWORD_STRENGTH, AUTH_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { Button, Input, Spinner } from "@plane/ui"; +import { getPasswordStrength } from "@plane/utils"; // components import { ForgotPasswordPopover, PasswordStrengthMeter } from "@/components/account"; // constants // helpers import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper"; -import { API_BASE_URL } from "@/helpers/common.helper"; -import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper"; // hooks import { useEventTracker } from "@/hooks/store"; // services @@ -78,7 +77,7 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { const redirectToUniqueCodeSignIn = async () => { handleAuthStep(EAuthSteps.UNIQUE_CODE); - captureEvent(SIGN_IN_WITH_CODE); + captureEvent(AUTH_TRACKER_EVENTS.sign_in_with_code); }; const passwordSupport = @@ -86,7 +85,7 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => {
    {isSMTPConfigured ? ( captureEvent(FORGOT_PASSWORD)} + onClick={() => captureEvent(AUTH_TRACKER_EVENTS.forgot_password)} href={`/accounts/forgot-password?email=${encodeURIComponent(email)}`} className="text-xs font-medium text-custom-primary-100" > @@ -155,7 +154,11 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { : true; if (isPasswordValid) { setIsSubmitting(true); - captureEvent(mode === EAuthModes.SIGN_IN ? SIGN_IN_WITH_PASSWORD : SIGN_UP_WITH_PASSWORD); + captureEvent( + mode === EAuthModes.SIGN_IN + ? AUTH_TRACKER_EVENTS.sign_in_with_password + : AUTH_TRACKER_EVENTS.sign_up_with_password + ); if (formRef.current) formRef.current.submit(); // Manually submit the form if the condition is met } else { setBannerMessage(true); @@ -167,7 +170,7 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { {nextPath && }
    -
    -
    {mode === EAuthModes.SIGN_UP && (
    -