diff --git a/.github/actions/build-push-ce/action.yml b/.github/actions/build-push-ce/action.yml deleted file mode 100644 index acf06982d..000000000 --- a/.github/actions/build-push-ce/action.yml +++ /dev/null @@ -1,126 +0,0 @@ -name: "Build and Push Docker Image" -description: "Reusable action for building and pushing Docker images" -inputs: - docker-username: - description: "The Dockerhub username" - required: true - docker-token: - description: "The Dockerhub Token" - required: true - - # Docker Image Options - docker-image-owner: - description: "The owner of the Docker image" - required: true - docker-image-name: - description: "The name of the Docker image" - required: true - build-context: - description: "The build context" - required: true - default: "." - dockerfile-path: - description: "The path to the Dockerfile" - required: true - build-args: - description: "The build arguments" - required: false - default: "" - - # Buildx Options - buildx-driver: - description: "Buildx driver" - required: true - default: "docker-container" - buildx-version: - description: "Buildx version" - required: true - default: "latest" - buildx-platforms: - description: "Buildx platforms" - required: true - default: "linux/amd64" - buildx-endpoint: - description: "Buildx endpoint" - required: true - default: "default" - - # Release Build Options - build-release: - description: "Flag to publish release" - required: false - default: "false" - build-prerelease: - description: "Flag to publish prerelease" - required: false - default: "false" - release-version: - description: "The release version" - required: false - default: "latest" - -runs: - using: "composite" - steps: - - name: Set Docker Tag - shell: bash - env: - IMG_OWNER: ${{ inputs.docker-image-owner }} - IMG_NAME: ${{ inputs.docker-image-name }} - BUILD_RELEASE: ${{ inputs.build-release }} - IS_PRERELEASE: ${{ inputs.build-prerelease }} - REL_VERSION: ${{ inputs.release-version }} - run: | - FLAT_BRANCH_VERSION=$(echo "${{ github.ref_name }}" | sed 's/[^a-zA-Z0-9.-]//g') - - if [ "${{ env.BUILD_RELEASE }}" == "true" ]; then - semver_regex="^v([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)?$" - if [[ ! ${{ env.REL_VERSION }} =~ $semver_regex ]]; then - echo "Invalid Release Version Format : ${{ env.REL_VERSION }}" - echo "Please provide a valid SemVer version" - echo "e.g. v1.2.3 or v1.2.3-alpha-1" - echo "Exiting the build process" - exit 1 # Exit with status 1 to fail the step - fi - - TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:${{ env.REL_VERSION }} - - if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then - TAG=${TAG},${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:stable - fi - elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:latest - else - TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:${FLAT_BRANCH_VERSION} - fi - - echo "DOCKER_TAGS=${TAG}" >> $GITHUB_ENV - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ inputs.docker-username }} - password: ${{ inputs.docker-token}} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - with: - driver: ${{ inputs.buildx-driver }} - version: ${{ inputs.buildx-version }} - endpoint: ${{ inputs.buildx-endpoint }} - - - name: Check out the repo - uses: actions/checkout@v4 - - - name: Build and Push Docker Image - uses: docker/build-push-action@v5.1.0 - with: - context: ${{ inputs.build-context }} - file: ${{ inputs.dockerfile-path }} - platforms: ${{ inputs.buildx-platforms }} - tags: ${{ env.DOCKER_TAGS }} - push: true - build-args: ${{ inputs.build-args }} - env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ inputs.docker-username }} - DOCKER_PASSWORD: ${{ inputs.docker-token }} \ No newline at end of file diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index 627c782f9..3599fc71e 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -25,6 +25,10 @@ on: required: false default: false type: boolean + push: + branches: + - preview + - canary env: TARGET_BRANCH: ${{ github.ref_name }} @@ -36,7 +40,7 @@ env: jobs: branch_build_setup: name: Build Setup - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 outputs: gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }} @@ -160,20 +164,17 @@ jobs: branch_build_push_admin: if: ${{ needs.branch_build_setup.outputs.build_admin == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push Admin Docker Image - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: [branch_build_setup] steps: - - id: checkout_files - name: Checkout Files - uses: actions/checkout@v4 - name: Admin Build and Push - uses: ./.github/actions/build-push-ce + uses: makeplane/actions/build-push@v1.0.0 with: build-release: ${{ needs.branch_build_setup.outputs.build_release }} build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} release-version: ${{ needs.branch_build_setup.outputs.release_version }} - docker-username: ${{ secrets.DOCKERHUB_USERNAME }} - docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} docker-image-owner: makeplane docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_admin }} build-context: . @@ -186,20 +187,17 @@ jobs: branch_build_push_web: if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push Web Docker Image - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: [branch_build_setup] steps: - - id: checkout_files - name: Checkout Files - uses: actions/checkout@v4 - name: Web Build and Push - uses: ./.github/actions/build-push-ce + uses: makeplane/actions/build-push@v1.0.0 with: build-release: ${{ needs.branch_build_setup.outputs.build_release }} build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} release-version: ${{ needs.branch_build_setup.outputs.release_version }} - docker-username: ${{ secrets.DOCKERHUB_USERNAME }} - docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} docker-image-owner: makeplane docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_web }} build-context: . @@ -212,20 +210,17 @@ jobs: branch_build_push_space: if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push Space Docker Image - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: [branch_build_setup] steps: - - id: checkout_files - name: Checkout Files - uses: actions/checkout@v4 - name: Space Build and Push - uses: ./.github/actions/build-push-ce + uses: makeplane/actions/build-push@v1.0.0 with: build-release: ${{ needs.branch_build_setup.outputs.build_release }} build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} release-version: ${{ needs.branch_build_setup.outputs.release_version }} - docker-username: ${{ secrets.DOCKERHUB_USERNAME }} - docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} docker-image-owner: makeplane docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_space }} build-context: . @@ -238,20 +233,17 @@ jobs: branch_build_push_live: if: ${{ needs.branch_build_setup.outputs.build_live == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push Live Collaboration Docker Image - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: [branch_build_setup] steps: - - id: checkout_files - name: Checkout Files - uses: actions/checkout@v4 - name: Live Build and Push - uses: ./.github/actions/build-push-ce + uses: makeplane/actions/build-push@v1.0.0 with: build-release: ${{ needs.branch_build_setup.outputs.build_release }} build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} release-version: ${{ needs.branch_build_setup.outputs.release_version }} - docker-username: ${{ secrets.DOCKERHUB_USERNAME }} - docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} docker-image-owner: makeplane docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_live }} build-context: . @@ -264,20 +256,17 @@ jobs: branch_build_push_apiserver: if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push API Server Docker Image - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: [branch_build_setup] steps: - - id: checkout_files - name: Checkout Files - uses: actions/checkout@v4 - name: Backend Build and Push - uses: ./.github/actions/build-push-ce + uses: makeplane/actions/build-push@v1.0.0 with: build-release: ${{ needs.branch_build_setup.outputs.build_release }} build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} release-version: ${{ needs.branch_build_setup.outputs.release_version }} - docker-username: ${{ secrets.DOCKERHUB_USERNAME }} - docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} docker-image-owner: makeplane docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_backend }} build-context: ./apiserver @@ -290,20 +279,17 @@ jobs: branch_build_push_proxy: if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push Proxy Docker Image - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: [branch_build_setup] steps: - - id: checkout_files - name: Checkout Files - uses: actions/checkout@v4 - name: Proxy Build and Push - uses: ./.github/actions/build-push-ce + uses: makeplane/actions/build-push@v1.0.0 with: build-release: ${{ needs.branch_build_setup.outputs.build_release }} build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} release-version: ${{ needs.branch_build_setup.outputs.release_version }} - docker-username: ${{ secrets.DOCKERHUB_USERNAME }} - docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} docker-image-owner: makeplane docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_proxy }} build-context: ./nginx @@ -316,7 +302,7 @@ jobs: attach_assets_to_build: if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }} name: Attach Assets to Release - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: [branch_build_setup] steps: - name: Checkout @@ -334,6 +320,7 @@ jobs: retention-days: 2 path: | ${{ github.workspace }}/deploy/selfhost/setup.sh + ${{ github.workspace }}/deploy/selfhost/swarm.sh ${{ github.workspace }}/deploy/selfhost/restore.sh ${{ github.workspace }}/deploy/selfhost/docker-compose.yml ${{ github.workspace }}/deploy/selfhost/variables.env @@ -341,7 +328,7 @@ jobs: publish_release: if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }} name: Build Release - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: [ branch_build_setup, @@ -376,6 +363,7 @@ jobs: generate_release_notes: true files: | ${{ github.workspace }}/deploy/selfhost/setup.sh + ${{ github.workspace }}/deploy/selfhost/swarm.sh ${{ github.workspace }}/deploy/selfhost/restore.sh ${{ github.workspace }}/deploy/selfhost/docker-compose.yml ${{ github.workspace }}/deploy/selfhost/variables.env diff --git a/.github/workflows/build-test-pull-request.yml b/.github/workflows/build-test-pull-request.yml index 2e6f9c642..29ed45dfe 100644 --- a/.github/workflows/build-test-pull-request.yml +++ b/.github/workflows/build-test-pull-request.yml @@ -71,7 +71,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 20.x - run: yarn install - run: yarn lint --filter=admin @@ -84,7 +84,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 20.x - run: yarn install - run: yarn lint --filter=space @@ -97,7 +97,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 20.x - run: yarn install - run: yarn lint --filter=web @@ -109,7 +109,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 20.x - run: yarn install - run: yarn build --filter=admin @@ -121,7 +121,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 20.x - run: yarn install - run: yarn build --filter=space @@ -133,6 +133,6 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 20.x - run: yarn install - run: yarn build --filter=web diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9d352cbab..0f5e99cb8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,17 +62,143 @@ To ensure consistency throughout the source code, please keep these rules in min - All features or bug fixes must be tested by one or more specs (unit-tests). - We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier. -## Need help? Questions and suggestions - -Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Discord Server](https://discord.com/invite/A92xrEGCge). - ## Ways to contribute - Try Plane Cloud and the self hosting platform and give feedback - Add new integrations +- Add or update translations - Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose) - Share your thoughts and suggestions with us - Help create tutorials and blog posts - Request a feature by submitting a proposal - Report a bug - **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. + +### Understanding translation structure + +#### File organization +Translations are organized by language in the locales directory. Each language has its own folder containing JSON files for translations. Here's how it looks: + +``` +packages/i18n/src/locales/ + ├── en/ + │ ├── core.json # Critical translations + │ └── translations.json + ├── fr/ + │ └── translations.json + └── [language]/ + └── 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: + +```json +{ + "issue": { + "label": "Work item", + "title": { + "label": "Work item title" + } + } +} +``` + +### Translation formatting guide +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** + ```json + { + "greeting": "Hello, {name}!" + } + ``` + +- **Pluralization** + ```json + { + "items": "{count, plural, one {Work item} other {Work items}}" + } + ``` + +### Contributing guidelines + +#### Updating existing translations +1. Locate the key in `locales//translations.json`. + +2. Update the value while ensuring the key structure remains intact. +3. Preserve any existing ICU formats (e.g., variables, pluralization). + +#### Adding new translation keys +1. When introducing a new key, ensure it is added to **all** language files, even if translations are not immediately available. Use English as a placeholder if needed. + +2. Keep the nesting structure consistent across all languages. + +3. If the new key requires dynamic content (e.g., variables or pluralization), ensure the ICU format is applied uniformly across all languages. + +### 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** +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** +Include the new language in the list of supported languages: + + ```typescript + // constants/language.ts + export const SUPPORTED_LANGUAGES: ILanguageOption[] = [ + { label: "English", value: "en" }, + { label: "Your Language", value: "your-lang" } + ]; + ``` + +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** +Modify the language import logic to include your new language: + + ```typescript + private importLanguageFile(language: TLanguage): Promise { + switch (language) { + case "your-lang": + return import("../locales/your-lang/translations.json"); + // ... + } + } + ``` + +### Quality checklist +Before submitting your contribution, please ensure the following: + +- All translation keys exist in every language file. +- Nested structures match across all language files. +- ICU message formats are correctly implemented. +- All languages load without errors in the application. +- Dynamic values and pluralization work as expected. +- There are no missing or untranslated keys. + +#### Pro tips +- When in doubt, refer to the English translations for context. +- Verify pluralization works with different numbers. +- Ensure dynamic values (e.g., `{name}`) are correctly interpolated. +- Double-check that nested key access paths are accurate. + +Happy translating! 🌍✨ + +## Need help? Questions and suggestions + +Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Discord Server](https://discord.com/invite/A92xrEGCge). diff --git a/README.md b/README.md index 38ead5f99..3588f20e2 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,7 @@ Plane Logo

- -

Plane

+

Plane

Open-source project management that unlocks customer value

@@ -44,79 +43,85 @@ Meet [Plane](https://dub.sh/plane-website-readme), an open-source project manage > Plane is evolving every day. Your suggestions, ideas, and reported bugs help us immensely. Do not hesitate to join in the conversation on [Discord](https://discord.com/invite/A92xrEGCge) or raise a GitHub issue. We read everything and respond to most. -## ⚡ Installation +## 🚀 Installation -The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. +Getting started with Plane is simple. Choose the setup that works best for you: -If you would like to self-host Plane, please see our [deployment guide](https://docs.plane.so/docker-compose). +- **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** +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 | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://docs.plane.so/self-hosting/methods/docker-compose) | -| Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://docs.plane.so/kubernetes) | +| Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://developers.plane.so/self-hosting/methods/docker-compose) | +| Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://developers.plane.so/self-hosting/methods/kubernetes) | -`Instance admins` can configure instance settings with [God-mode](https://docs.plane.so/instance-admin). +`Instance admins` can configure instance settings with [God mode](https://developers.plane.so/self-hosting/govern/instance-admin). -## 🚀 Features +## 🌟 Features -- **Issues**: Quickly create issues and add details using a powerful rich text editor that supports file uploads. Add sub-properties and references to problems for better organization and tracking. +- **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**: - Keep up your team's momentum with Cycles. Gain insights into your project's progress with burn-down charts and other valuable features. +- **Cycles** +Maintain your team’s momentum with Cycles. Track progress effortlessly using burn-down charts and other insightful tools. -- **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to track and plan your project's progress easily. +- **Modules** +Simplify complex projects by dividing them into smaller, manageable modules. -- **Views**: Create custom filters to display only the issues that matter to you. Save and share your filters in just a few clicks. +- **Views** +Customize your workflow by creating filters to display only the most relevant issues. Save and share these views with ease. -- **Pages**: Plane pages, equipped with AI and a rich text editor, let you jot down your thoughts on the fly. Format your text, upload images, hyperlink, or sync your existing ideas into an actionable item or issue. +- **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**: Get insights into all your Plane data in real-time. Visualize issue data to spot trends, remove blockers, and progress your work. +- **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. -## 🛠️ Quick start for contributors -> Development system must have docker engine installed and running. +## 🛠️ Local development -Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute - +### Pre-requisites +- Ensure Docker Engine is installed and running. -1. Clone the code locally using: +### 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. Switch to the code folder: +2. Navigate to the project folder: ``` cd plane ``` -3. Create your feature or fix branch you plan to work on using: +3. Create a new branch for your feature or fix: ``` git checkout -b ``` -4. Open terminal and run: +4. Run the setup script in the terminal: ``` ./setup.sh ``` -5. Open the code on VSCode or similar equivalent IDE. -6. Review the `.env` files available in various folders. - Visit [Environment Setup](./ENV_SETUP.md) to know about various environment variables used in system. -7. Run the docker command to initiate services: +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 ``` -You are ready to make changes to the code. Do not forget to refresh the browser (in case it does not auto-reload). +That’s it! You’re all set to begin coding. Remember to refresh your browser if changes don’t auto-reload. Happy contributing! 🎉 -Thats it! - -## ❤️ Community - -The Plane community can be found on [GitHub Discussions](https://github.com/orgs/makeplane/discussions), and our [Discord server](https://discord.com/invite/A92xrEGCge). Our [Code of conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community chanels. - -Ask questions, report bugs, join discussions, voice ideas, make feature requests, or share your projects. - -### Repo Activity - -![Plane Repo Activity](https://repobeats.axiom.co/api/embed/2523c6ed2f77c082b7908c33e2ab208981d76c39.svg "Repobeats analytics image") +## ⚙️ Built with +[![Next JS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/) +[![Django](https://img.shields.io/badge/Django-092E20?style=for-the-badge&logo=django&logoColor=green)](https://www.djangoproject.com/) +[![Node JS](https://img.shields.io/badge/node.js-339933?style=for-the-badge&logo=Node.js&logoColor=white)](https://nodejs.org/en) ## 📸 Screenshots @@ -165,7 +170,7 @@ Ask questions, report bugs, join discussions, voice ideas, make feature requests

-

+

-## ⛓️ Security +## 📝 Documentation +Explore Plane's [product documentation](https://docs.plane.so/) and [developer documentation](https://developers.plane.so/) to learn about features, setup, and usage. -If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. +## ❤️ Community -Email squawk@plane.so to disclose any security vulnerabilities. +Join the Plane community on [GitHub Discussions](https://github.com/orgs/makeplane/discussions) and our [Discord server](https://discord.com/invite/A92xrEGCge). We follow a [Code of conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) in all our community channels. -## ❤️ Contribute +Feel free to ask questions, report bugs, participate in discussions, share ideas, request features, or showcase your projects. We’d love to hear from you! -There are many ways to contribute to Plane, including: +## 🛡️ Security -- Submitting [bugs](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%F0%9F%90%9Bbug&projects=&template=--bug-report.yaml&title=%5Bbug%5D%3A+) and [feature requests](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%E2%9C%A8feature&projects=&template=--feature-request.yaml&title=%5Bfeature%5D%3A+) for various components. -- Reviewing [the documentation](https://docs.plane.so/) and submitting [pull requests](https://github.com/makeplane/plane), from fixing typos to adding new features. -- Speaking or writing about Plane or any other ecosystem integration and [letting us know](https://discord.com/invite/A92xrEGCge)! -- Upvoting [popular feature requests](https://github.com/makeplane/plane/issues) to show your support. +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. + +## 🤝 Contributing + +There are many ways you can contribute to Plane: + +- Report [bugs](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%F0%9F%90%9Bbug&projects=&template=--bug-report.yaml&title=%5Bbug%5D%3A+) or submit [feature requests](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%E2%9C%A8feature&projects=&template=--feature-request.yaml&title=%5Bfeature%5D%3A+). +- Review the [documentation](https://docs.plane.so/) and submit [pull requests](https://github.com/makeplane/docs) to improve it—whether it's fixing typos or adding new content. +- Talk or write about Plane or any other ecosystem integration and [let us know](https://discord.com/invite/A92xrEGCge)! +- Show your support by upvoting [popular feature requests](https://github.com/makeplane/plane/issues). + +Please read [CONTRIBUTING.md](https://github.com/makeplane/plane/blob/master/CONTRIBUTING.md) for details on the process for submitting pull requests to us. + +### Repo activity + +![Plane Repo Activity](https://repobeats.axiom.co/api/embed/2523c6ed2f77c082b7908c33e2ab208981d76c39.svg "Repobeats analytics image") ### We couldn't have done this without you. + + +## 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 diff --git a/admin/.eslintrc.js b/admin/.eslintrc.js index d8355bae8..666f5ab50 100644 --- a/admin/.eslintrc.js +++ b/admin/.eslintrc.js @@ -2,7 +2,4 @@ module.exports = { root: true, extends: ["@plane/eslint-config/next.js"], parser: "@typescript-eslint/parser", - parserOptions: { - project: true, - }, }; diff --git a/admin/Dockerfile.admin b/admin/Dockerfile.admin index ad9469110..8046bf329 100644 --- a/admin/Dockerfile.admin +++ b/admin/Dockerfile.admin @@ -1,7 +1,9 @@ +FROM node:20-alpine as base + # ***************************************************************************** # STAGE 1: Build the project # ***************************************************************************** -FROM node:18-alpine AS builder +FROM base AS builder RUN apk add --no-cache libc6-compat WORKDIR /app @@ -13,7 +15,7 @@ RUN turbo prune --scope=admin --docker # ***************************************************************************** # STAGE 2: Install dependencies & build the project # ***************************************************************************** -FROM node:18-alpine AS installer +FROM base AS installer RUN apk add --no-cache libc6-compat WORKDIR /app @@ -52,7 +54,7 @@ RUN yarn turbo run build --filter=admin # ***************************************************************************** # STAGE 3: Copy the project and start it # ***************************************************************************** -FROM node:18-alpine AS runner +FROM base AS runner WORKDIR /app COPY --from=installer /app/admin/next.config.js . diff --git a/admin/Dockerfile.dev b/admin/Dockerfile.dev index 1ed84e78e..3bdc71c16 100644 --- a/admin/Dockerfile.dev +++ b/admin/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM node:18-alpine +FROM node:20-alpine RUN apk add --no-cache libc6-compat # Set working directory WORKDIR /app diff --git a/admin/app/authentication/github/form.tsx b/admin/app/authentication/github/form.tsx index 23ba9ccd1..4842675cd 100644 --- a/admin/app/authentication/github/form.tsx +++ b/admin/app/authentication/github/form.tsx @@ -4,10 +4,11 @@ import { FC, useState } from "react"; import isEmpty from "lodash/isEmpty"; import Link from "next/link"; import { useForm } from "react-hook-form"; -// types +// plane internal packages +import { API_BASE_URL } from "@plane/constants"; import { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types"; -// ui import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +import { cn } from "@plane/utils"; // components import { CodeBlock, @@ -17,8 +18,6 @@ import { TControllerInputFormField, TCopyField, } from "@/components/common"; -// helpers -import { API_BASE_URL, cn } from "@/helpers/common.helper"; // hooks import { useInstance } from "@/hooks/store"; @@ -103,8 +102,7 @@ export const InstanceGithubConfigForm: FC = (props) => { url: originURL, description: ( <> - We will auto-generate this. Paste this into the{" "} - Authorized origin URL field{" "} + We will auto-generate this. Paste this into the Authorized origin URL field{" "} = (props) => { url: `${originURL}/auth/github/callback/`, description: ( <> - We will auto-generate this. Paste this into your{" "} - Authorized Callback URI field{" "} + We will auto-generate this. Paste this into your Authorized Callback URI{" "} + field{" "} { loading: "Saving Configuration...", success: { title: "Configuration saved", - message: () => `Github authentication is now ${value ? "active" : "disabled"}.`, + message: () => `GitHub authentication is now ${value ? "active" : "disabled"}.`, }, error: { title: "Error", @@ -67,8 +67,8 @@ const InstanceGithubAuthenticationPage = observer(() => {
= (props) => { url: `${originURL}/auth/gitlab/callback/`, description: ( <> - We will auto-generate this. Paste this into the{" "} - Redirect URI field of your{" "} + We will auto-generate this. Paste this into the Redirect URI field of your{" "} = observer( No PII is collected.This anonymized data is used to understand how you use Plane and build new features in line with{" "} { }; export default function RootLayout({ children }: { children: ReactNode }) { + const ASSET_PREFIX = ADMIN_BASE_PATH; return ( @@ -34,7 +35,7 @@ export default function RootLayout({ children }: { children: ReactNode }) { - + {children} diff --git a/admin/app/page.tsx b/admin/app/page.tsx index b402fc44d..1a274025a 100644 --- a/admin/app/page.tsx +++ b/admin/app/page.tsx @@ -7,15 +7,15 @@ import { DefaultLayout } from "@/layouts/default-layout"; export const metadata: Metadata = { title: "Plane | Simple, extensible, open-source project management tool.", description: - "Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.", + "Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.", openGraph: { title: "Plane | Simple, extensible, open-source project management tool.", description: - "Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.", + "Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.", url: "https://plane.so/", }, keywords: - "software development, customer feedback, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration", + "software development, customer feedback, software, accelerate, code management, release management, project management, work items tracking, agile, scrum, kanban, collaboration", twitter: { site: "@planepowers", }, diff --git a/admin/app/workspace/create/form.tsx b/admin/app/workspace/create/form.tsx index 958f53153..d086777fc 100644 --- a/admin/app/workspace/create/form.tsx +++ b/admin/app/workspace/create/form.tsx @@ -2,20 +2,16 @@ import { useState, useEffect } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Controller, useForm } from "react-hook-form"; -// constants -import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants"; -// types +// plane imports +import { WEB_BASE_URL, ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants"; +import { InstanceWorkspaceService } from "@plane/services"; import { IWorkspace } from "@plane/types"; // components import { Button, CustomSelect, getButtonStyling, Input, setToast, TOAST_TYPE } from "@plane/ui"; -// helpers -import { WEB_BASE_URL } from "@/helpers/common.helper"; // hooks import { useWorkspace } from "@/hooks/store"; -// services -import { WorkspaceService } from "@/services/workspace.service"; -const workspaceService = new WorkspaceService(); +const instanceWorkspaceService = new InstanceWorkspaceService(); export const WorkspaceCreateForm = () => { // router @@ -42,8 +38,8 @@ export const WorkspaceCreateForm = () => { const workspaceBaseURL = encodeURI(WEB_BASE_URL || window.location.origin + "/"); const handleCreateWorkspace = async (formData: IWorkspace) => { - await workspaceService - .workspaceSlugCheck(formData.slug) + await instanceWorkspaceService + .slugCheck(formData.slug) .then(async (res) => { if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) { setSlugError(false); diff --git a/admin/app/workspace/page.tsx b/admin/app/workspace/page.tsx index ef8a3c42d..3ca34b69e 100644 --- a/admin/app/workspace/page.tsx +++ b/admin/app/workspace/page.tsx @@ -7,12 +7,10 @@ import useSWR from "swr"; import { Loader as LoaderIcon } from "lucide-react"; // types import { TInstanceConfigurationKeys } from "@plane/types"; -// ui import { Button, getButtonStyling, Loader, setPromiseToast, ToggleSwitch } from "@plane/ui"; +import { cn } from "@plane/utils"; // components import { WorkspaceListItem } from "@/components/workspace"; -// helpers -import { cn } from "@/helpers/common.helper"; // hooks import { useInstance, useWorkspace } from "@/hooks/store"; diff --git a/admin/ce/components/authentication/authentication-modes.tsx b/admin/ce/components/authentication/authentication-modes.tsx index 84cde94d4..3c7ec111a 100644 --- a/admin/ce/components/authentication/authentication-modes.tsx +++ b/admin/ce/components/authentication/authentication-modes.tsx @@ -10,7 +10,7 @@ import { // components import { AuthenticationMethodCard } from "@/components/authentication"; // helpers -import { getBaseAuthenticationModes } from "@/helpers/authentication.helper"; +import { getBaseAuthenticationModes } from "@/lib/auth-helpers"; // plane admin components import { UpgradeButton } from "@/plane-admin/components/common"; // images diff --git a/admin/ce/components/common/upgrade-button.tsx b/admin/ce/components/common/upgrade-button.tsx index c2b264bae..208225e0c 100644 --- a/admin/ce/components/common/upgrade-button.tsx +++ b/admin/ce/components/common/upgrade-button.tsx @@ -3,10 +3,9 @@ import React from "react"; // icons import { SquareArrowOutUpRight } from "lucide-react"; -// ui +// plane internal packages import { getButtonStyling } from "@plane/ui"; -// helpers -import { cn } from "@/helpers/common.helper"; +import { cn } from "@plane/utils"; export const UpgradeButton: React.FC = () => ( diff --git a/admin/core/components/admin-sidebar/help-section.tsx b/admin/core/components/admin-sidebar/help-section.tsx index 10d5cbd0d..d77647749 100644 --- a/admin/core/components/admin-sidebar/help-section.tsx +++ b/admin/core/components/admin-sidebar/help-section.tsx @@ -5,13 +5,14 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react"; import { Transition } from "@headlessui/react"; -// ui +// plane internal packages +import { WEB_BASE_URL } from "@plane/constants"; import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui"; -// helpers -import { WEB_BASE_URL, cn } from "@/helpers/common.helper"; +import { cn } from "@plane/utils"; // hooks import { useTheme } from "@/hooks/store"; // assets +// eslint-disable-next-line import/order import packageJson from "package.json"; const helpOptions = [ diff --git a/admin/core/components/admin-sidebar/sidebar-dropdown.tsx b/admin/core/components/admin-sidebar/sidebar-dropdown.tsx index e0741f7c4..501d501d8 100644 --- a/admin/core/components/admin-sidebar/sidebar-dropdown.tsx +++ b/admin/core/components/admin-sidebar/sidebar-dropdown.tsx @@ -5,15 +5,13 @@ import { observer } from "mobx-react"; import { useTheme as useNextTheme } from "next-themes"; import { LogOut, UserCog2, Palette } from "lucide-react"; import { Menu, Transition } from "@headlessui/react"; -// plane ui +// plane internal packages +import { API_BASE_URL } from "@plane/constants"; +import {AuthService } from "@plane/services"; import { Avatar } from "@plane/ui"; -// helpers -import { API_BASE_URL, cn } from "@/helpers/common.helper"; -import { getFileURL } from "@/helpers/file.helper"; +import { getFileURL, cn } from "@plane/utils"; // hooks import { useTheme, useUser } from "@/hooks/store"; -// services -import { AuthService } from "@/services/auth.service"; // service initialization const authService = new AuthService(); diff --git a/admin/core/components/admin-sidebar/sidebar-menu.tsx b/admin/core/components/admin-sidebar/sidebar-menu.tsx index a985842e7..618551ae6 100644 --- a/admin/core/components/admin-sidebar/sidebar-menu.tsx +++ b/admin/core/components/admin-sidebar/sidebar-menu.tsx @@ -4,11 +4,11 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react"; +// plane internal packages import { Tooltip, WorkspaceIcon } from "@plane/ui"; +import { cn } from "@plane/utils"; // hooks -import { cn } from "@/helpers/common.helper"; import { useTheme } from "@/hooks/store"; -// helpers const INSTANCE_ADMIN_LINKS = [ { diff --git a/admin/core/components/auth-header.tsx b/admin/core/components/auth-header.tsx index 81365d7f0..5edcb6118 100644 --- a/admin/core/components/auth-header.tsx +++ b/admin/core/components/auth-header.tsx @@ -30,7 +30,7 @@ export const InstanceHeader: FC = observer(() => { case "google": return "Google"; case "github": - return "Github"; + return "GitHub"; case "gitlab": return "GitLab"; case "workspace": diff --git a/admin/core/components/authentication/auth-banner.tsx b/admin/core/components/authentication/auth-banner.tsx index 191d7a0a7..7c1e5ea29 100644 --- a/admin/core/components/authentication/auth-banner.tsx +++ b/admin/core/components/authentication/auth-banner.tsx @@ -1,7 +1,7 @@ import { FC } from "react"; import { Info, X } from "lucide-react"; -// helpers -import { TAuthErrorInfo } from "@/helpers/authentication.helper"; +// plane constants +import { TAuthErrorInfo } from "@plane/constants"; type TAuthBanner = { bannerData: TAuthErrorInfo | undefined; diff --git a/admin/core/components/authentication/authentication-method-card.tsx b/admin/core/components/authentication/authentication-method-card.tsx index 50895a459..897deb7c4 100644 --- a/admin/core/components/authentication/authentication-method-card.tsx +++ b/admin/core/components/authentication/authentication-method-card.tsx @@ -2,7 +2,7 @@ import { FC } from "react"; // helpers -import { cn } from "helpers/common.helper"; +import { cn } from "@plane/utils"; type Props = { name: string; diff --git a/admin/core/components/authentication/github-config.tsx b/admin/core/components/authentication/github-config.tsx index 07c566d68..57035580f 100644 --- a/admin/core/components/authentication/github-config.tsx +++ b/admin/core/components/authentication/github-config.tsx @@ -5,12 +5,10 @@ import { observer } from "mobx-react"; import Link from "next/link"; // icons import { Settings2 } from "lucide-react"; -// types +// plane internal packages import { TInstanceAuthenticationMethodKeys } from "@plane/types"; -// ui import { ToggleSwitch, getButtonStyling } from "@plane/ui"; -// helpers -import { cn } from "@/helpers/common.helper"; +import { cn } from "@plane/utils"; // hooks import { useInstance } from "@/hooks/store"; diff --git a/admin/core/components/authentication/gitlab-config.tsx b/admin/core/components/authentication/gitlab-config.tsx index 735201025..4181338d2 100644 --- a/admin/core/components/authentication/gitlab-config.tsx +++ b/admin/core/components/authentication/gitlab-config.tsx @@ -5,12 +5,10 @@ import { observer } from "mobx-react"; import Link from "next/link"; // icons import { Settings2 } from "lucide-react"; -// types +// plane internal packages import { TInstanceAuthenticationMethodKeys } from "@plane/types"; -// ui import { ToggleSwitch, getButtonStyling } from "@plane/ui"; -// helpers -import { cn } from "@/helpers/common.helper"; +import { cn } from "@plane/utils"; // hooks import { useInstance } from "@/hooks/store"; diff --git a/admin/core/components/authentication/google-config.tsx b/admin/core/components/authentication/google-config.tsx index 12d11a2f8..0f3cc98e3 100644 --- a/admin/core/components/authentication/google-config.tsx +++ b/admin/core/components/authentication/google-config.tsx @@ -5,12 +5,10 @@ import { observer } from "mobx-react"; import Link from "next/link"; // icons import { Settings2 } from "lucide-react"; -// types +// plane internal packages import { TInstanceAuthenticationMethodKeys } from "@plane/types"; -// ui import { ToggleSwitch, getButtonStyling } from "@plane/ui"; -// helpers -import { cn } from "@/helpers/common.helper"; +import { cn } from "@plane/utils"; // hooks import { useInstance } from "@/hooks/store"; diff --git a/admin/core/components/common/code-block.tsx b/admin/core/components/common/code-block.tsx index 55f8b4afb..88ad78a1e 100644 --- a/admin/core/components/common/code-block.tsx +++ b/admin/core/components/common/code-block.tsx @@ -1,4 +1,4 @@ -import { cn } from "@/helpers/common.helper"; +import { cn } from "@plane/utils"; type TProps = { children: React.ReactNode; diff --git a/admin/core/components/common/controller-input.tsx b/admin/core/components/common/controller-input.tsx index 4d0eade08..ca8f30162 100644 --- a/admin/core/components/common/controller-input.tsx +++ b/admin/core/components/common/controller-input.tsx @@ -4,10 +4,9 @@ import React, { useState } from "react"; import { Controller, Control } from "react-hook-form"; // icons import { Eye, EyeOff } from "lucide-react"; -// ui +// plane internal packages import { Input } from "@plane/ui"; -// helpers -import { cn } from "@/helpers/common.helper"; +import { cn } from "@plane/utils"; type Props = { control: Control; @@ -37,9 +36,7 @@ export const ControllerInput: React.FC = (props) => { return (
-

- {label} -

+

{label}

{ { useEffect(() => { if (errorCode) { - const errorDetail = authErrorHandler(errorCode?.toString() as EAuthenticationErrorCodes); + const errorDetail = authErrorHandler(errorCode?.toString() as EAdminAuthErrorCodes); if (errorDetail) { setErrorInfo(errorDetail); } diff --git a/admin/core/components/new-user-popup.tsx b/admin/core/components/new-user-popup.tsx index 8e1570781..0b974b38c 100644 --- a/admin/core/components/new-user-popup.tsx +++ b/admin/core/components/new-user-popup.tsx @@ -1,13 +1,13 @@ "use client"; import React from "react"; -import { resolveGeneralTheme } from "helpers/common.helper"; import { observer } from "mobx-react"; import Image from "next/image"; import Link from "next/link"; import { useTheme as nextUseTheme } from "next-themes"; // ui import { Button, getButtonStyling } from "@plane/ui"; +import { resolveGeneralTheme } from "@plane/utils"; // hooks import { useTheme } from "@/hooks/store"; // icons diff --git a/admin/core/components/workspace/list-item.tsx b/admin/core/components/workspace/list-item.tsx index e0a96a6ef..ae693eb72 100644 --- a/admin/core/components/workspace/list-item.tsx +++ b/admin/core/components/workspace/list-item.tsx @@ -1,9 +1,9 @@ import { observer } from "mobx-react"; import { ExternalLink } from "lucide-react"; -// helpers +// plane internal packages +import { WEB_BASE_URL } from "@plane/constants"; import { Tooltip } from "@plane/ui"; -import { WEB_BASE_URL } from "@/helpers/common.helper"; -import { getFileURL } from "@/helpers/file.helper"; +import { getFileURL } from "@plane/utils"; // hooks import { useWorkspace } from "@/hooks/store"; diff --git a/admin/core/constants/seo.ts b/admin/core/constants/seo.ts deleted file mode 100644 index aafd5f7a3..000000000 --- a/admin/core/constants/seo.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const SITE_NAME = "Plane | Simple, extensible, open-source project management tool."; -export const SITE_TITLE = "Plane | Simple, extensible, open-source project management tool."; -export const SITE_DESCRIPTION = - "Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind."; -export const SITE_KEYWORDS = - "software development, plan, ship, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration"; -export const SITE_URL = "https://app.plane.so/"; -export const TWITTER_USER_NAME = "Plane | Simple, extensible, open-source project management tool."; diff --git a/admin/core/constants/swr-config.ts b/admin/core/constants/swr-config.ts deleted file mode 100644 index 38478fcea..000000000 --- a/admin/core/constants/swr-config.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const SWR_CONFIG = { - refreshWhenHidden: false, - revalidateIfStale: false, - revalidateOnFocus: false, - revalidateOnMount: true, - refreshInterval: 600000, - errorRetryCount: 3, -}; diff --git a/admin/core/lib/auth-helpers.tsx b/admin/core/lib/auth-helpers.tsx new file mode 100644 index 000000000..582b56e29 --- /dev/null +++ b/admin/core/lib/auth-helpers.tsx @@ -0,0 +1,164 @@ +import { ReactNode } from "react"; +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 { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types"; +import { resolveGeneralTheme } from "@plane/utils"; +// components +import { + EmailCodesConfiguration, + GithubConfiguration, + GitlabConfiguration, + GoogleConfiguration, + PasswordLoginConfiguration, +} from "@/components/authentication"; +// images +import githubLightModeImage from "@/public/logos/github-black.png"; +import githubDarkModeImage from "@/public/logos/github-white.png"; +import GitlabLogo from "@/public/logos/gitlab-logo.svg"; +import GoogleLogo from "@/public/logos/google-logo.svg"; + +export enum EErrorAlertType { + BANNER_ALERT = "BANNER_ALERT", + INLINE_FIRST_NAME = "INLINE_FIRST_NAME", + INLINE_EMAIL = "INLINE_EMAIL", + INLINE_PASSWORD = "INLINE_PASSWORD", + INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE", +} + +const errorCodeMessages: { + [key in EAdminAuthErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode }; +} = { + // admin + [EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST]: { + title: `Admin already exists`, + message: () => `Admin already exists. Please try again.`, + }, + [EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: { + title: `Email, password and first name required`, + message: () => `Email, password and first name required. Please try again.`, + }, + [EAdminAuthErrorCodes.INVALID_ADMIN_EMAIL]: { + title: `Invalid admin email`, + message: () => `Invalid admin email. Please try again.`, + }, + [EAdminAuthErrorCodes.INVALID_ADMIN_PASSWORD]: { + title: `Invalid admin password`, + message: () => `Invalid admin password. Please try again.`, + }, + [EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: { + title: `Email and password required`, + message: () => `Email and password required. Please try again.`, + }, + [EAdminAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED]: { + title: `Authentication failed`, + message: () => `Authentication failed. Please try again.`, + }, + [EAdminAuthErrorCodes.ADMIN_USER_ALREADY_EXIST]: { + title: `Admin user already exists`, + message: () => ( +
+ Admin user already exists.  + + Sign In + +  now. +
+ ), + }, + [EAdminAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: { + title: `Admin user does not exist`, + message: () => ( +
+ Admin user does not exist.  + + Sign In + +  now. +
+ ), + }, + [EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED]: { + title: `User account deactivated`, + message: () => `User account deactivated. Please contact ${!!SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`, + }, +}; + +export const authErrorHandler = ( + errorCode: EAdminAuthErrorCodes, + email?: string | undefined +): TAuthErrorInfo | undefined => { + const bannerAlertErrorCodes = [ + EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST, + EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME, + EAdminAuthErrorCodes.INVALID_ADMIN_EMAIL, + EAdminAuthErrorCodes.INVALID_ADMIN_PASSWORD, + EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD, + EAdminAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED, + EAdminAuthErrorCodes.ADMIN_USER_ALREADY_EXIST, + EAdminAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST, + EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED, + ]; + + if (bannerAlertErrorCodes.includes(errorCode)) + return { + type: EErrorAlertType.BANNER_ALERT, + code: errorCode, + title: errorCodeMessages[errorCode]?.title || "Error", + message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.", + }; + + return undefined; +}; + +export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({ + disabled, + updateConfig, + resolvedTheme, +}) => [ + { + key: "unique-codes", + name: "Unique codes", + description: + "Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.", + icon: , + config: , + }, + { + key: "passwords-login", + name: "Passwords", + description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.", + icon: , + config: , + }, + { + key: "google", + name: "Google", + description: "Allow members to log in or sign up for Plane with their Google accounts.", + icon: Google Logo, + config: , + }, + { + key: "github", + name: "GitHub", + description: "Allow members to log in or sign up for Plane with their GitHub accounts.", + icon: ( + GitHub Logo + ), + config: , + }, + { + key: "gitlab", + name: "GitLab", + description: "Allow members to log in or sign up to plane with their GitLab accounts.", + icon: GitLab Logo, + config: , + }, +]; diff --git a/admin/core/services/api.service.ts b/admin/core/services/api.service.ts deleted file mode 100644 index fa45c10b7..000000000 --- a/admin/core/services/api.service.ts +++ /dev/null @@ -1,53 +0,0 @@ -import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; -// store -// import { rootStore } from "@/lib/store-context"; - -export abstract class APIService { - protected baseURL: string; - private axiosInstance: AxiosInstance; - - constructor(baseURL: string) { - this.baseURL = baseURL; - this.axiosInstance = axios.create({ - baseURL, - withCredentials: true, - }); - - this.setupInterceptors(); - } - - private setupInterceptors() { - // this.axiosInstance.interceptors.response.use( - // (response) => response, - // (error) => { - // const store = rootStore; - // if (error.response && error.response.status === 401 && store.user.currentUser) store.user.reset(); - // return Promise.reject(error); - // } - // ); - } - - get(url: string, params = {}): Promise> { - return this.axiosInstance.get(url, { params }); - } - - post(url: string, data: RequestType, config = {}): Promise> { - return this.axiosInstance.post(url, data, config); - } - - put(url: string, data: RequestType, config = {}): Promise> { - return this.axiosInstance.put(url, data, config); - } - - patch(url: string, data: RequestType, config = {}): Promise> { - return this.axiosInstance.patch(url, data, config); - } - - delete(url: string, data?: RequestType, config = {}) { - return this.axiosInstance.delete(url, { data, ...config }); - } - - request(config: AxiosRequestConfig = {}): Promise> { - return this.axiosInstance(config); - } -} diff --git a/admin/core/services/auth.service.ts b/admin/core/services/auth.service.ts deleted file mode 100644 index 2cea01bee..000000000 --- a/admin/core/services/auth.service.ts +++ /dev/null @@ -1,22 +0,0 @@ -// helpers -import { API_BASE_URL } from "@/helpers/common.helper"; -// services -import { APIService } from "@/services/api.service"; - -type TCsrfTokenResponse = { - csrf_token: string; -}; - -export class AuthService extends APIService { - constructor() { - super(API_BASE_URL); - } - - async requestCSRFToken(): Promise { - return this.get("/auth/get-csrf-token/") - .then((response) => response.data) - .catch((error) => { - throw error; - }); - } -} diff --git a/admin/core/services/instance.service.ts b/admin/core/services/instance.service.ts deleted file mode 100644 index feb94ceea..000000000 --- a/admin/core/services/instance.service.ts +++ /dev/null @@ -1,72 +0,0 @@ -// types -import type { - IFormattedInstanceConfiguration, - IInstance, - IInstanceAdmin, - IInstanceConfiguration, - IInstanceInfo, -} from "@plane/types"; -// helpers -import { API_BASE_URL } from "@/helpers/common.helper"; -import { APIService } from "@/services/api.service"; - -export class InstanceService extends APIService { - constructor() { - super(API_BASE_URL); - } - - async getInstanceInfo(): Promise { - return this.get("/api/instances/") - .then((response) => response.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async getInstanceAdmins(): Promise { - return this.get("/api/instances/admins/") - .then((response) => response.data) - .catch((error) => { - throw error; - }); - } - - async updateInstanceInfo(data: Partial): Promise { - return this.patch, IInstance>("/api/instances/", data) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async getInstanceConfigurations() { - return this.get("/api/instances/configurations/") - .then((response) => response.data) - .catch((error) => { - throw error; - }); - } - - async updateInstanceConfigurations( - data: Partial - ): Promise { - return this.patch, IInstanceConfiguration[]>( - "/api/instances/configurations/", - data - ) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async sendTestEmail(receiverEmail: string): Promise { - return this.post<{ receiver_email: string }, undefined>("/api/instances/email-credentials-check/", { - receiver_email: receiverEmail, - }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } -} diff --git a/admin/core/services/user.service.ts b/admin/core/services/user.service.ts deleted file mode 100644 index 42eb6eb22..000000000 --- a/admin/core/services/user.service.ts +++ /dev/null @@ -1,30 +0,0 @@ -// types -import type { IUser } from "@plane/types"; -// helpers -import { API_BASE_URL } from "@/helpers/common.helper"; -// services -import { APIService } from "@/services/api.service"; - -interface IUserSession extends IUser { - isAuthenticated: boolean; -} - -export class UserService extends APIService { - constructor() { - super(API_BASE_URL); - } - - async authCheck(): Promise { - return this.get("/api/instances/admins/me/") - .then((response) => ({ ...response?.data, isAuthenticated: true })) - .catch(() => ({ isAuthenticated: false })); - } - - async currentUser(): Promise { - return this.get("/api/instances/admins/me/") - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } -} diff --git a/admin/core/services/workspace.service.ts b/admin/core/services/workspace.service.ts deleted file mode 100644 index 81ba36a6f..000000000 --- a/admin/core/services/workspace.service.ts +++ /dev/null @@ -1,53 +0,0 @@ -// types -import type { IWorkspace, TWorkspacePaginationInfo } from "@plane/types"; -// helpers -import { API_BASE_URL } from "@/helpers/common.helper"; -// services -import { APIService } from "@/services/api.service"; - -export class WorkspaceService extends APIService { - constructor() { - super(API_BASE_URL); - } - - /** - * @description Fetches all workspaces - * @returns Promise - */ - async getWorkspaces(nextPageCursor?: string): Promise { - return this.get("/api/instances/workspaces/", { - cursor: nextPageCursor, - }) - .then((response) => response.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - /** - * @description Checks if a slug is available - * @param slug - string - * @returns Promise - */ - async workspaceSlugCheck(slug: string): Promise { - const params = new URLSearchParams({ slug }); - return this.get(`/api/instances/workspace-slug-check/?${params.toString()}`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - /** - * @description Creates a new workspace - * @param data - IWorkspace - * @returns Promise - */ - async createWorkspace(data: IWorkspace): Promise { - return this.post("/api/instances/workspaces/", data) - .then((response) => response.data) - .catch((error) => { - throw error?.response?.data; - }); - } -} diff --git a/admin/core/store/instance.store.ts b/admin/core/store/instance.store.ts index 01ab55284..9b25a2469 100644 --- a/admin/core/store/instance.store.ts +++ b/admin/core/store/instance.store.ts @@ -1,5 +1,8 @@ import set from "lodash/set"; import { observable, action, computed, makeObservable, runInAction } from "mobx"; +// plane internal packages +import { EInstanceStatus, TInstanceStatus } from "@plane/constants"; +import {InstanceService} from "@plane/services"; import { IInstance, IInstanceAdmin, @@ -8,10 +11,6 @@ import { IInstanceInfo, IInstanceConfig, } from "@plane/types"; -// helpers -import { EInstanceStatus, TInstanceStatus } from "@/helpers/instance.helper"; -// services -import { InstanceService } from "@/services/instance.service"; // root store import { CoreRootStore } from "@/store/root.store"; @@ -96,7 +95,7 @@ export class InstanceStore implements IInstanceStore { try { if (this.instance === undefined) this.isLoading = true; this.error = undefined; - const instanceInfo = await this.instanceService.getInstanceInfo(); + const instanceInfo = await this.instanceService.info(); // handling the new user popup toggle if (this.instance === undefined && !instanceInfo?.instance?.workspaces_exist) this.store.theme.toggleNewUserPopup(); @@ -125,7 +124,7 @@ export class InstanceStore implements IInstanceStore { */ updateInstanceInfo = async (data: Partial) => { try { - const instanceResponse = await this.instanceService.updateInstanceInfo(data); + const instanceResponse = await this.instanceService.update(data); if (instanceResponse) { runInAction(() => { if (this.instance) set(this.instance, "instance", instanceResponse); @@ -144,7 +143,7 @@ export class InstanceStore implements IInstanceStore { */ fetchInstanceAdmins = async () => { try { - const instanceAdmins = await this.instanceService.getInstanceAdmins(); + const instanceAdmins = await this.instanceService.admins(); if (instanceAdmins) runInAction(() => (this.instanceAdmins = instanceAdmins)); return instanceAdmins; } catch (error) { @@ -159,7 +158,7 @@ export class InstanceStore implements IInstanceStore { */ fetchInstanceConfigurations = async () => { try { - const instanceConfigurations = await this.instanceService.getInstanceConfigurations(); + const instanceConfigurations = await this.instanceService.configurations(); if (instanceConfigurations) runInAction(() => (this.instanceConfigurations = instanceConfigurations)); return instanceConfigurations; } catch (error) { @@ -174,7 +173,7 @@ export class InstanceStore implements IInstanceStore { */ updateInstanceConfigurations = async (data: Partial) => { try { - const response = await this.instanceService.updateInstanceConfigurations(data); + const response = await this.instanceService.updateConfigurations(data); runInAction(() => { this.instanceConfigurations = this.instanceConfigurations?.map((config) => { const item = response.find((item) => item.key === config.key); diff --git a/admin/core/store/user.store.ts b/admin/core/store/user.store.ts index df17c9b00..85c56495b 100644 --- a/admin/core/store/user.store.ts +++ b/admin/core/store/user.store.ts @@ -1,10 +1,8 @@ import { action, observable, runInAction, makeObservable } from "mobx"; +// plane internal packages +import { EUserStatus, TUserStatus } from "@plane/constants"; +import { AuthService, UserService } from "@plane/services"; import { IUser } from "@plane/types"; -// helpers -import { EUserStatus, TUserStatus } from "@/helpers/user.helper"; -// services -import { AuthService } from "@/services/auth.service"; -import { UserService } from "@/services/user.service"; // root store import { CoreRootStore } from "@/store/root.store"; @@ -58,7 +56,7 @@ export class UserStore implements IUserStore { fetchCurrentUser = async () => { try { if (this.currentUser === undefined) this.isLoading = true; - const currentUser = await this.userService.currentUser(); + const currentUser = await this.userService.adminDetails(); if (currentUser) { await this.store.instance.fetchInstanceAdmins(); runInAction(() => { diff --git a/admin/core/store/workspace.store.ts b/admin/core/store/workspace.store.ts index f892e14f0..64f7501d3 100644 --- a/admin/core/store/workspace.store.ts +++ b/admin/core/store/workspace.store.ts @@ -1,8 +1,8 @@ import set from "lodash/set"; import { action, observable, runInAction, makeObservable, computed } from "mobx"; +// plane imports +import { InstanceWorkspaceService } from "@plane/services"; import { IWorkspace, TLoader, TPaginationInfo } from "@plane/types"; -// services -import { WorkspaceService } from "@/services/workspace.service"; // root store import { CoreRootStore } from "@/store/root.store"; @@ -29,7 +29,7 @@ export class WorkspaceStore implements IWorkspaceStore { workspaces: Record = {}; paginationInfo: TPaginationInfo | undefined = undefined; // services - workspaceService; + instanceWorkspaceService; constructor(private store: CoreRootStore) { makeObservable(this, { @@ -48,7 +48,7 @@ export class WorkspaceStore implements IWorkspaceStore { // curd actions createWorkspace: action, }); - this.workspaceService = new WorkspaceService(); + this.instanceWorkspaceService = new InstanceWorkspaceService(); } // computed @@ -84,7 +84,7 @@ export class WorkspaceStore implements IWorkspaceStore { } else { this.loader = "init-loader"; } - const paginatedWorkspaceData = await this.workspaceService.getWorkspaces(); + const paginatedWorkspaceData = await this.instanceWorkspaceService.list(); runInAction(() => { const { results, ...paginationInfo } = paginatedWorkspaceData; results.forEach((workspace: IWorkspace) => { @@ -109,7 +109,7 @@ export class WorkspaceStore implements IWorkspaceStore { if (!this.paginationInfo || this.paginationInfo.next_page_results === false) return []; try { this.loader = "pagination"; - const paginatedWorkspaceData = await this.workspaceService.getWorkspaces(this.paginationInfo.next_cursor); + const paginatedWorkspaceData = await this.instanceWorkspaceService.list(this.paginationInfo.next_cursor); runInAction(() => { const { results, ...paginationInfo } = paginatedWorkspaceData; results.forEach((workspace: IWorkspace) => { @@ -135,7 +135,7 @@ export class WorkspaceStore implements IWorkspaceStore { createWorkspace = async (data: IWorkspace): Promise => { try { this.loader = "mutation"; - const workspace = await this.workspaceService.createWorkspace(data); + const workspace = await this.instanceWorkspaceService.create(data); runInAction(() => { set(this.workspaces, [workspace.id], workspace); }); diff --git a/admin/helpers/authentication.helper.tsx b/admin/helpers/authentication.helper.tsx deleted file mode 100644 index 627ff182c..000000000 --- a/admin/helpers/authentication.helper.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { ReactNode } from "react"; -import Image from "next/image"; -import Link from "next/link"; -import { KeyRound, Mails } from "lucide-react"; -// types -import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types"; -// components -import { - EmailCodesConfiguration, - GithubConfiguration, - GitlabConfiguration, - GoogleConfiguration, - PasswordLoginConfiguration, -} from "@/components/authentication"; -// helpers -import { SUPPORT_EMAIL, resolveGeneralTheme } from "@/helpers/common.helper"; -// images -import githubLightModeImage from "@/public/logos/github-black.png"; -import githubDarkModeImage from "@/public/logos/github-white.png"; -import GitlabLogo from "@/public/logos/gitlab-logo.svg"; -import GoogleLogo from "@/public/logos/google-logo.svg"; - -export enum EPageTypes { - PUBLIC = "PUBLIC", - NON_AUTHENTICATED = "NON_AUTHENTICATED", - SET_PASSWORD = "SET_PASSWORD", - ONBOARDING = "ONBOARDING", - AUTHENTICATED = "AUTHENTICATED", -} - -export enum EAuthModes { - SIGN_IN = "SIGN_IN", - SIGN_UP = "SIGN_UP", -} - -export enum EAuthSteps { - EMAIL = "EMAIL", - PASSWORD = "PASSWORD", - UNIQUE_CODE = "UNIQUE_CODE", -} - -export enum EErrorAlertType { - BANNER_ALERT = "BANNER_ALERT", - INLINE_FIRST_NAME = "INLINE_FIRST_NAME", - INLINE_EMAIL = "INLINE_EMAIL", - INLINE_PASSWORD = "INLINE_PASSWORD", - INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE", -} - -export enum EAuthenticationErrorCodes { - // Admin - ADMIN_ALREADY_EXIST = "5150", - REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155", - INVALID_ADMIN_EMAIL = "5160", - INVALID_ADMIN_PASSWORD = "5165", - REQUIRED_ADMIN_EMAIL_PASSWORD = "5170", - ADMIN_AUTHENTICATION_FAILED = "5175", - ADMIN_USER_ALREADY_EXIST = "5180", - ADMIN_USER_DOES_NOT_EXIST = "5185", - ADMIN_USER_DEACTIVATED = "5190", -} - -export type TAuthErrorInfo = { - type: EErrorAlertType; - code: EAuthenticationErrorCodes; - title: string; - message: ReactNode; -}; - -const errorCodeMessages: { - [key in EAuthenticationErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode }; -} = { - // admin - [EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST]: { - title: `Admin already exists`, - message: () => `Admin already exists. Please try again.`, - }, - [EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: { - title: `Email, password and first name required`, - message: () => `Email, password and first name required. Please try again.`, - }, - [EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL]: { - title: `Invalid admin email`, - message: () => `Invalid admin email. Please try again.`, - }, - [EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD]: { - title: `Invalid admin password`, - message: () => `Invalid admin password. Please try again.`, - }, - [EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: { - title: `Email and password required`, - message: () => `Email and password required. Please try again.`, - }, - [EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED]: { - title: `Authentication failed`, - message: () => `Authentication failed. Please try again.`, - }, - [EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST]: { - title: `Admin user already exists`, - message: () => ( -
- Admin user already exists.  - - Sign In - -  now. -
- ), - }, - [EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: { - title: `Admin user does not exist`, - message: () => ( -
- Admin user does not exist.  - - Sign In - -  now. -
- ), - }, - [EAuthenticationErrorCodes.ADMIN_USER_DEACTIVATED]: { - title: `User account deactivated`, - message: () => `User account deactivated. Please contact ${!!SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`, - }, -}; - -export const authErrorHandler = ( - errorCode: EAuthenticationErrorCodes, - email?: string | undefined -): TAuthErrorInfo | undefined => { - const bannerAlertErrorCodes = [ - EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST, - EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME, - EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL, - EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD, - EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD, - EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED, - EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST, - EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST, - EAuthenticationErrorCodes.ADMIN_USER_DEACTIVATED, - ]; - - if (bannerAlertErrorCodes.includes(errorCode)) - return { - type: EErrorAlertType.BANNER_ALERT, - code: errorCode, - title: errorCodeMessages[errorCode]?.title || "Error", - message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.", - }; - - return undefined; -}; - -export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({ - disabled, - updateConfig, - resolvedTheme, -}) => [ - { - key: "unique-codes", - name: "Unique codes", - description: - "Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.", - icon: , - config: , - }, - { - key: "passwords-login", - name: "Passwords", - description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.", - icon: , - config: , - }, - { - key: "google", - name: "Google", - description: "Allow members to log in or sign up for Plane with their Google accounts.", - icon: Google Logo, - config: , - }, - { - key: "github", - name: "GitHub", - description: "Allow members to log in or sign up for Plane with their GitHub accounts.", - icon: ( - GitHub Logo - ), - config: , - }, - { - key: "gitlab", - name: "GitLab", - description: "Allow members to log in or sign up to plane with their GitLab accounts.", - icon: GitLab Logo, - config: , - }, - ]; diff --git a/admin/helpers/common.helper.ts b/admin/helpers/common.helper.ts deleted file mode 100644 index e282e5792..000000000 --- a/admin/helpers/common.helper.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { clsx, type ClassValue } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || ""; - -export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || ""; - -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 WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || ""; - -export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || ""; - -export const ASSET_PREFIX = ADMIN_BASE_PATH; - -export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); - -export const resolveGeneralTheme = (resolvedTheme: string | undefined) => - resolvedTheme?.includes("light") ? "light" : resolvedTheme?.includes("dark") ? "dark" : "system"; diff --git a/admin/helpers/file.helper.ts b/admin/helpers/file.helper.ts deleted file mode 100644 index 6e1f54636..000000000 --- a/admin/helpers/file.helper.ts +++ /dev/null @@ -1,14 +0,0 @@ -// helpers -import { API_BASE_URL } from "@/helpers/common.helper"; - -/** - * @description combine the file path with the base URL - * @param {string} path - * @returns {string} final URL with the base URL - */ -export const getFileURL = (path: string): string | undefined => { - if (!path) return undefined; - const isValidURL = path.startsWith("http"); - if (isValidURL) return path; - return `${API_BASE_URL}${path}`; -}; diff --git a/admin/helpers/index.ts b/admin/helpers/index.ts deleted file mode 100644 index ae6aab829..000000000 --- a/admin/helpers/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./instance.helper"; -export * from "./user.helper"; diff --git a/admin/helpers/password.helper.ts b/admin/helpers/password.helper.ts deleted file mode 100644 index dfe9a5c65..000000000 --- a/admin/helpers/password.helper.ts +++ /dev/null @@ -1,67 +0,0 @@ -import zxcvbn from "zxcvbn"; - -export enum E_PASSWORD_STRENGTH { - EMPTY = "empty", - LENGTH_NOT_VALID = "length_not_valid", - STRENGTH_NOT_VALID = "strength_not_valid", - STRENGTH_VALID = "strength_valid", -} - -const PASSWORD_MIN_LENGTH = 8; -// const PASSWORD_NUMBER_REGEX = /\d/; -// const PASSWORD_CHAR_CAPS_REGEX = /[A-Z]/; -// const PASSWORD_SPECIAL_CHAR_REGEX = /[`!@#$%^&*()_\-+=\[\]{};':"\\|,.<>\/?~ ]/; - -export const PASSWORD_CRITERIA = [ - { - key: "min_8_char", - label: "Min 8 characters", - isCriteriaValid: (password: string) => password.length >= PASSWORD_MIN_LENGTH, - }, - // { - // key: "min_1_upper_case", - // label: "Min 1 upper-case letter", - // isCriteriaValid: (password: string) => PASSWORD_NUMBER_REGEX.test(password), - // }, - // { - // key: "min_1_number", - // label: "Min 1 number", - // isCriteriaValid: (password: string) => PASSWORD_CHAR_CAPS_REGEX.test(password), - // }, - // { - // key: "min_1_special_char", - // label: "Min 1 special character", - // isCriteriaValid: (password: string) => PASSWORD_SPECIAL_CHAR_REGEX.test(password), - // }, -]; - -export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => { - let passwordStrength: E_PASSWORD_STRENGTH = E_PASSWORD_STRENGTH.EMPTY; - - if (!password || password === "" || password.length <= 0) { - return passwordStrength; - } - - if (password.length >= PASSWORD_MIN_LENGTH) { - passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID; - } else { - passwordStrength = E_PASSWORD_STRENGTH.LENGTH_NOT_VALID; - return passwordStrength; - } - - const passwordCriteriaValidation = PASSWORD_CRITERIA.map((criteria) => criteria.isCriteriaValid(password)).every( - (criterion) => criterion - ); - const passwordStrengthScore = zxcvbn(password).score; - - if (passwordCriteriaValidation === false || passwordStrengthScore <= 2) { - passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID; - return passwordStrength; - } - - if (passwordCriteriaValidation === true && passwordStrengthScore >= 3) { - passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_VALID; - } - - return passwordStrength; -}; diff --git a/admin/helpers/string.helper.ts b/admin/helpers/string.helper.ts deleted file mode 100644 index a48508118..000000000 --- a/admin/helpers/string.helper.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @description - * This function test whether a URL is valid or not. - * - * It accepts URLs with or without the protocol. - * @param {string} url - * @returns {boolean} - * @example - * checkURLValidity("https://example.com") => true - * checkURLValidity("example.com") => true - * checkURLValidity("example") => false - */ -export const checkURLValidity = (url: string): boolean => { - if (!url) return false; - - // regex to support complex query parameters and fragments - const urlPattern = - /^(https?:\/\/)?((([a-z\d-]+\.)*[a-z\d-]+\.[a-z]{2,6})|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(:\d+)?(\/[\w.-]*)*(\?[^#\s]*)?(#[\w-]*)?$/i; - - return urlPattern.test(url); -}; diff --git a/admin/helpers/user.helper.ts b/admin/helpers/user.helper.ts deleted file mode 100644 index 5c6a89a17..000000000 --- a/admin/helpers/user.helper.ts +++ /dev/null @@ -1,21 +0,0 @@ -export enum EAuthenticationPageType { - STATIC = "STATIC", - NOT_AUTHENTICATED = "NOT_AUTHENTICATED", - AUTHENTICATED = "AUTHENTICATED", -} - -export enum EInstancePageType { - PRE_SETUP = "PRE_SETUP", - POST_SETUP = "POST_SETUP", -} - -export enum EUserStatus { - ERROR = "ERROR", - AUTHENTICATION_NOT_DONE = "AUTHENTICATION_NOT_DONE", - NOT_YET_READY = "NOT_YET_READY", -} - -export type TUserStatus = { - status: EUserStatus | undefined; - message?: string; -}; diff --git a/admin/package.json b/admin/package.json index e2fe4cf33..f4c6f2cbc 100644 --- a/admin/package.json +++ b/admin/package.json @@ -18,13 +18,14 @@ "@plane/types": "*", "@plane/ui": "*", "@plane/utils": "*", - "@sentry/nextjs": "^8.32.0", + "@plane/services": "*", + "@sentry/nextjs": "^8.54.0", "@tailwindcss/typography": "^0.5.9", "@types/lodash": "^4.17.0", "autoprefixer": "10.4.14", - "axios": "^1.7.4", + "axios": "^1.7.9", "lodash": "^4.17.21", - "lucide-react": "^0.356.0", + "lucide-react": "^0.469.0", "mobx": "^6.12.0", "mobx-react": "^9.1.1", "next": "^14.2.20", @@ -34,19 +35,18 @@ "react-dom": "^18.3.1", "react-hook-form": "7.51.5", "swr": "^2.2.4", - "tailwindcss": "3.3.2", "uuid": "^9.0.1", "zxcvbn": "^4.4.2" }, "devDependencies": { "@plane/eslint-config": "*", + "@plane/tailwind-config": "*", "@plane/typescript-config": "*", "@types/node": "18.16.1", "@types/react": "^18.3.11", "@types/react-dom": "^18.2.18", "@types/uuid": "^9.0.8", "@types/zxcvbn": "^4.4.4", - "tailwind-config-custom": "*", "typescript": "5.3.3" } } diff --git a/admin/tailwind.config.js b/admin/tailwind.config.js index 05bc93bdc..a05d9dcd8 100644 --- a/admin/tailwind.config.js +++ b/admin/tailwind.config.js @@ -1,4 +1,5 @@ -const sharedConfig = require("tailwind-config-custom/tailwind.config.js"); +/* eslint-disable @typescript-eslint/no-require-imports */ +const sharedConfig = require("@plane/tailwind-config/tailwind.config.js"); module.exports = { presets: [sharedConfig], diff --git a/admin/tsconfig.json b/admin/tsconfig.json index 174843581..f9bb7cf10 100644 --- a/admin/tsconfig.json +++ b/admin/tsconfig.json @@ -5,7 +5,6 @@ "baseUrl": ".", "paths": { "@/*": ["core/*"], - "@/helpers/*": ["helpers/*"], "@/public/*": ["public/*"], "@/plane-admin/*": ["ce/*"] } diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index b7780a90c..4eb1457ce 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -15,3 +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 diff --git a/apiserver/plane/api/serializers/base.py b/apiserver/plane/api/serializers/base.py index d51434554..4b1e54707 100644 --- a/apiserver/plane/api/serializers/base.py +++ b/apiserver/plane/api/serializers/base.py @@ -72,6 +72,7 @@ class BaseSerializer(serializers.ModelSerializer): StateLiteSerializer, UserLiteSerializer, WorkspaceLiteSerializer, + EstimatePointSerializer, ) # Expansion mapper @@ -88,6 +89,7 @@ class BaseSerializer(serializers.ModelSerializer): "owned_by": UserLiteSerializer, "members": UserLiteSerializer, "parent": IssueLiteSerializer, + "estimate_point": EstimatePointSerializer, } # Check if field in expansion then expand the field if expand in expansion: diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index f4f06c324..ea3c4eb3d 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -4,6 +4,7 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer from plane.db.models import Cycle, CycleIssue +from plane.utils.timezone_converter import convert_to_utc class CycleSerializer(BaseSerializer): @@ -24,6 +25,27 @@ class CycleSerializer(BaseSerializer): and data.get("start_date", None) > data.get("end_date", None) ): raise serializers.ValidationError("Start date cannot exceed end date") + + if ( + data.get("start_date", None) is not None + and data.get("end_date", None) is not None + ): + project_id = self.initial_data.get("project_id") or self.instance.project_id + is_start_date_end_date_equal = ( + True + if str(data.get("start_date")) == str(data.get("end_date")) + else False + ) + data["start_date"] = convert_to_utc( + date=str(data.get("start_date").date()), + project_id=project_id, + is_start_date=True, + ) + 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 class Meta: diff --git a/apiserver/plane/api/serializers/estimate.py b/apiserver/plane/api/serializers/estimate.py new file mode 100644 index 000000000..0d9235dad --- /dev/null +++ b/apiserver/plane/api/serializers/estimate.py @@ -0,0 +1,10 @@ +# Module imports +from plane.db.models import EstimatePoint +from .base import BaseSerializer + + +class EstimatePointSerializer(BaseSerializer): + class Meta: + model = EstimatePoint + fields = ["id", "value"] + read_only_fields = fields diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 72918b268..275ebeb07 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -1,6 +1,7 @@ # Django imports from django.utils import timezone from lxml import html +from django.db import IntegrityError # Third party imports from rest_framework import serializers @@ -138,47 +139,56 @@ class IssueSerializer(BaseSerializer): updated_by_id = issue.updated_by_id if assignees is not None and len(assignees): - IssueAssignee.objects.bulk_create( - [ - IssueAssignee( - assignee_id=assignee_id, + try: + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee_id=assignee_id, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for assignee_id in assignees + ], + batch_size=10, + ) + except IntegrityError: + pass + else: + try: + # Then assign it to default assignee + if default_assignee_id is not None: + IssueAssignee.objects.create( + assignee_id=default_assignee_id, issue=issue, project_id=project_id, workspace_id=workspace_id, created_by_id=created_by_id, updated_by_id=updated_by_id, ) - for assignee_id in assignees - ], - batch_size=10, - ) - else: - # Then assign it to default assignee - if default_assignee_id is not None: - IssueAssignee.objects.create( - assignee_id=default_assignee_id, - issue=issue, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) + except IntegrityError: + pass if labels is not None and len(labels): - IssueLabel.objects.bulk_create( - [ - IssueLabel( - label_id=label_id, - issue=issue, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for label_id in labels - ], - batch_size=10, - ) + try: + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label_id=label_id, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label_id in labels + ], + batch_size=10, + ) + except IntegrityError: + pass return issue @@ -194,37 +204,45 @@ class IssueSerializer(BaseSerializer): if assignees is not None: IssueAssignee.objects.filter(issue=instance).delete() - IssueAssignee.objects.bulk_create( - [ - IssueAssignee( - assignee_id=assignee_id, - issue=instance, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for assignee_id in assignees - ], - batch_size=10, - ) + try: + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee_id=assignee_id, + issue=instance, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for assignee_id in assignees + ], + batch_size=10, + ignore_conflicts=True, + ) + except IntegrityError: + pass if labels is not None: IssueLabel.objects.filter(issue=instance).delete() - IssueLabel.objects.bulk_create( - [ - IssueLabel( - label_id=label_id, - issue=instance, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for label_id in labels - ], - batch_size=10, - ) + try: + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label_id=label_id, + issue=instance, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label_id in labels + ], + batch_size=10, + ignore_conflicts=True, + ) + except IntegrityError: + pass # Time updation occues even when other related models are updated instance.updated_at = timezone.now() @@ -237,17 +255,37 @@ class IssueSerializer(BaseSerializer): from .user import UserLiteSerializer data["assignees"] = UserLiteSerializer( - instance.assignees.all(), many=True + User.objects.filter( + pk__in=IssueAssignee.objects.filter(issue=instance).values_list( + "assignee_id", flat=True + ) + ), + many=True, ).data else: data["assignees"] = [ - str(assignee.id) for assignee in instance.assignees.all() + str(assignee) + for assignee in IssueAssignee.objects.filter( + issue=instance + ).values_list("assignee_id", flat=True) ] if "labels" in self.fields: if "labels" in self.expand: - data["labels"] = LabelSerializer(instance.labels.all(), many=True).data + data["labels"] = LabelSerializer( + Label.objects.filter( + pk__in=IssueLabel.objects.filter(issue=instance).values_list( + "label_id", flat=True + ) + ), + many=True, + ).data else: - data["labels"] = [str(label.id) for label in instance.labels.all()] + data["labels"] = [ + str(label) + for label in IssueLabel.objects.filter(issue=instance).values_list( + "label_id", flat=True + ) + ] return data diff --git a/apiserver/plane/api/urls/issue.py b/apiserver/plane/api/urls/issue.py index e9bf030a2..71ab39855 100644 --- a/apiserver/plane/api/urls/issue.py +++ b/apiserver/plane/api/urls/issue.py @@ -71,4 +71,9 @@ urlpatterns = [ IssueAttachmentEndpoint.as_view(), name="attachment", ), + path( + "workspaces//projects//issues//issue-attachments//", + IssueAttachmentEndpoint.as_view(), + name="issue-attachment", + ), ] diff --git a/apiserver/plane/api/views/intake.py b/apiserver/plane/api/views/intake.py index c2d0733ba..faefc3761 100644 --- a/apiserver/plane/api/views/intake.py +++ b/apiserver/plane/api/views/intake.py @@ -109,16 +109,6 @@ class IntakeIssueAPIEndpoint(BaseAPIView): {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST ) - # Create or get state - state, _ = State.objects.get_or_create( - name="Triage", - group="triage", - description="Default state for managing all Intake Issues", - project_id=project_id, - color="#ff7700", - is_triage=True, - ) - # create an issue issue = Issue.objects.create( name=request.data.get("issue", {}).get("name"), @@ -128,7 +118,6 @@ class IntakeIssueAPIEndpoint(BaseAPIView): ), priority=request.data.get("issue", {}).get("priority", "none"), project_id=project_id, - state=state, ) # create an intake issue diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index df7b9aec2..9f9b189ae 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -1,9 +1,10 @@ # Python imports import json - -from django.core.serializers.json import DjangoJSONEncoder +import uuid # Django imports +from django.core.serializers.json import DjangoJSONEncoder +from django.http import HttpResponseRedirect from django.db import IntegrityError from django.db.models import ( Case, @@ -19,11 +20,11 @@ from django.db.models import ( Subquery, ) from django.utils import timezone +from django.conf import settings # Third party imports from rest_framework import status from rest_framework.response import Response -from rest_framework.parsers import MultiPartParser, FormParser # Module imports from plane.api.serializers import ( @@ -50,8 +51,10 @@ from plane.db.models import ( Project, ProjectMember, CycleIssue, + Workspace, ) - +from plane.settings.storage import S3Storage +from plane.bgtasks.storage_metadata_task import get_asset_object_metadata from .base import BaseAPIView @@ -940,37 +943,162 @@ class IssueAttachmentEndpoint(BaseAPIView): serializer_class = IssueAttachmentSerializer permission_classes = [ProjectEntityPermission] model = FileAsset - parser_classes = (MultiPartParser, FormParser) def post(self, request, slug, project_id, issue_id): - serializer = IssueAttachmentSerializer(data=request.data) + name = request.data.get("name") + type = request.data.get("type", False) + size = request.data.get("size") + external_id = request.data.get("external_id") + external_source = request.data.get("external_source") + + # Check if the request is valid + if not name or not size: + return Response( + {"error": "Invalid request.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + size_limit = min(size, settings.FILE_SIZE_LIMIT) + + if not type or type not in settings.ATTACHMENT_MIME_TYPES: + return Response( + {"error": "Invalid file type.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the workspace + workspace = Workspace.objects.get(slug=slug) + + # asset key + asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" + if ( request.data.get("external_id") and request.data.get("external_source") and FileAsset.objects.filter( project_id=project_id, workspace__slug=slug, - issue_id=issue_id, external_source=request.data.get("external_source"), external_id=request.data.get("external_id"), + issue_id=issue_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ).exists() ): - issue_attachment = FileAsset.objects.filter( - workspace__slug=slug, + asset = FileAsset.objects.filter( project_id=project_id, - external_id=request.data.get("external_id"), + workspace__slug=slug, external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + issue_id=issue_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ).first() return Response( { - "error": "Issue attachment with the same external id and external source already exists", - "id": str(issue_attachment.id), + "error": "Issue with the same external id and external source already exists", + "id": str(asset.id), }, status=status.HTTP_409_CONFLICT, ) - if serializer.is_valid(): - serializer.save(project_id=project_id, issue_id=issue_id) + # Create a File Asset + asset = FileAsset.objects.create( + attributes={"name": name, "type": type, "size": size_limit}, + asset=asset_key, + size=size_limit, + workspace_id=workspace.id, + created_by=request.user, + issue_id=issue_id, + project_id=project_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + external_id=external_id, + external_source=external_source, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + presigned_url = storage.generate_presigned_post( + object_name=asset_key, file_type=type, file_size=size_limit + ) + # Return the presigned URL + return Response( + { + "upload_data": presigned_url, + "asset_id": str(asset.id), + "attachment": IssueAttachmentSerializer(asset).data, + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + + def delete(self, request, slug, project_id, issue_id, pk): + issue_attachment = FileAsset.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) + issue_attachment.is_deleted = True + issue_attachment.deleted_at = timezone.now() + issue_attachment.save() + + issue_activity.delay( + type="attachment.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + + # Get the storage metadata + if not issue_attachment.storage_metadata: + get_asset_object_metadata.delay(str(issue_attachment.id)) + issue_attachment.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + def get(self, request, slug, project_id, issue_id, pk=None): + if pk: + # Get the asset + asset = FileAsset.objects.get( + id=pk, workspace__slug=slug, project_id=project_id + ) + + # Check if the asset is uploaded + if not asset.is_uploaded: + return Response( + {"error": "The asset is not uploaded.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + storage = S3Storage(request=request) + presigned_url = storage.generate_presigned_url( + object_name=asset.asset.name, + disposition="attachment", + filename=asset.attributes.get("name"), + ) + return HttpResponseRedirect(presigned_url) + + # Get all the attachments + issue_attachments = FileAsset.objects.filter( + issue_id=issue_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + workspace__slug=slug, + project_id=project_id, + is_uploaded=True, + ) + # Serialize the attachments + serializer = IssueAttachmentSerializer(issue_attachments, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def patch(self, request, slug, project_id, issue_id, pk): + issue_attachment = FileAsset.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) + serializer = IssueAttachmentSerializer(issue_attachment) + + # Send this activity only if the attachment is not uploaded before + if not issue_attachment.is_uploaded: issue_activity.delay( type="attachment.activity.created", requested_data=None, @@ -982,30 +1110,13 @@ class IssueAttachmentEndpoint(BaseAPIView): notification=True, origin=request.META.get("HTTP_ORIGIN"), ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def delete(self, request, slug, project_id, issue_id, pk): - issue_attachment = FileAsset.objects.get(pk=pk) - issue_attachment.asset.delete(save=False) - issue_attachment.delete() - issue_activity.delay( - type="attachment.activity.deleted", - requested_data=None, - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=None, - epoch=int(timezone.now().timestamp()), - notification=True, - origin=request.META.get("HTTP_ORIGIN"), - ) + # Update the attachment + issue_attachment.is_uploaded = True + issue_attachment.created_by = request.user + # Get the storage metadata + if not issue_attachment.storage_metadata: + get_asset_object_metadata.delay(str(issue_attachment.id)) + issue_attachment.save() return Response(status=status.HTTP_204_NO_CONTENT) - - def get(self, request, slug, project_id, issue_id): - issue_attachments = FileAsset.objects.filter( - issue_id=issue_id, workspace__slug=slug, project_id=project_id - ) - serializer = IssueAttachmentSerializer(issue_attachments, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index fa645a63c..e98f35d57 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -28,7 +28,7 @@ from plane.db.models import ( Workspace, UserFavorite, ) -from plane.bgtasks.webhook_task import model_activity +from plane.bgtasks.webhook_task import model_activity, webhook_activity from .base import BaseAPIView @@ -258,7 +258,9 @@ class ProjectAPIEndpoint(BaseAPIView): ProjectSerializer(project).data, cls=DjangoJSONEncoder ) - intake_view = request.data.get("inbox_view", project.intake_view) + intake_view = request.data.get( + "inbox_view", request.data.get("intake_view", project.intake_view) + ) if project.archived_at: return Response( @@ -286,16 +288,6 @@ class ProjectAPIEndpoint(BaseAPIView): is_default=True, ) - # Create the triage state in Backlog group - State.objects.get_or_create( - name="Triage", - group="triage", - description="Default state for managing all Intake Issues", - project_id=pk, - color="#ff7700", - is_triage=True, - ) - project = self.get_queryset().filter(pk=serializer.data["id"]).first() model_activity.delay( @@ -334,6 +326,19 @@ class ProjectAPIEndpoint(BaseAPIView): entity_type="project", entity_identifier=pk, project_id=pk ).delete() project.delete() + webhook_activity.delay( + event="project", + verb="deleted", + field=None, + old_value=None, + new_value=None, + actor_id=request.user.id, + slug=slug, + current_site=request.META.get("HTTP_ORIGIN"), + event_id=project.id, + old_identifier=None, + new_identifier=None, + ) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index cd9adb939..479f08d5a 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -19,6 +19,10 @@ from .workspace import ( WorkspaceMemberAdminSerializer, WorkspaceMemberMeSerializer, WorkspaceUserPropertiesSerializer, + WorkspaceUserLinkSerializer, + WorkspaceRecentVisitSerializer, + WorkspaceHomePreferenceSerializer, + StickySerializer, ) from .project import ( ProjectSerializer, @@ -68,6 +72,8 @@ from .issue import ( IssueReactionLiteSerializer, IssueAttachmentLiteSerializer, IssueLinkLiteSerializer, + IssueVersionDetailSerializer, + IssueDescriptionVersionDetailSerializer, ) from .module import ( diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py index bf08de4fe..b56b08350 100644 --- a/apiserver/plane/app/serializers/cycle.py +++ b/apiserver/plane/app/serializers/cycle.py @@ -5,6 +5,7 @@ from rest_framework import serializers from .base import BaseSerializer from .issue import IssueStateSerializer from plane.db.models import Cycle, CycleIssue, CycleUserProperties +from plane.utils.timezone_converter import convert_to_utc class CycleWriteSerializer(BaseSerializer): @@ -15,6 +16,30 @@ class CycleWriteSerializer(BaseSerializer): and data.get("start_date", None) > data.get("end_date", None) ): raise serializers.ValidationError("Start date cannot exceed end date") + if ( + data.get("start_date", None) is not None + and data.get("end_date", None) is not None + ): + project_id = ( + self.initial_data.get("project_id", None) + or (self.instance and self.instance.project_id) + or self.context.get("project_id", None) + ) + is_start_date_end_date_equal = ( + True + if str(data.get("start_date")) == str(data.get("end_date")) + else False + ) + data["start_date"] = convert_to_utc( + date=str(data.get("start_date").date()), + project_id=project_id, + is_start_date=True, + ) + data["end_date"] = convert_to_utc( + date=str(data.get("end_date", None).date()), + project_id=project_id, + is_start_date_end_date_equal=is_start_date_end_date_equal, + ) return data class Meta: diff --git a/apiserver/plane/app/serializers/dashboard.py b/apiserver/plane/app/serializers/dashboard.py index b0ed8841b..d09fc3e35 100644 --- a/apiserver/plane/app/serializers/dashboard.py +++ b/apiserver/plane/app/serializers/dashboard.py @@ -1,6 +1,6 @@ # Module imports from .base import BaseSerializer -from plane.db.models import Dashboard, Widget +from plane.db.models import DeprecatedDashboard, DeprecatedWidget # Third party frameworks from rest_framework import serializers @@ -8,7 +8,7 @@ from rest_framework import serializers class DashboardSerializer(BaseSerializer): class Meta: - model = Dashboard + model = DeprecatedDashboard fields = "__all__" @@ -17,5 +17,5 @@ class WidgetSerializer(BaseSerializer): widget_filters = serializers.JSONField(read_only=True) class Meta: - model = Widget + model = DeprecatedWidget fields = ["id", "key", "is_visible", "widget_filters"] diff --git a/apiserver/plane/app/serializers/favorite.py b/apiserver/plane/app/serializers/favorite.py index 940b8ee82..18f92f3ea 100644 --- a/apiserver/plane/app/serializers/favorite.py +++ b/apiserver/plane/app/serializers/favorite.py @@ -53,7 +53,6 @@ def get_entity_model_and_serializer(entity_type): } return entity_map.get(entity_type, (None, None)) - class UserFavoriteSerializer(serializers.ModelSerializer): entity_data = serializers.SerializerMethodField() diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index 007f34849..2fb7c035d 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -2,6 +2,7 @@ from django.utils import timezone from django.core.validators import URLValidator from django.core.exceptions import ValidationError +from django.db import IntegrityError # Third Party imports from rest_framework import serializers @@ -33,6 +34,8 @@ from plane.db.models import ( IssueVote, IssueRelation, State, + IssueVersion, + IssueDescriptionVersion, ) @@ -132,47 +135,56 @@ class IssueCreateSerializer(BaseSerializer): updated_by_id = issue.updated_by_id if assignees is not None and len(assignees): - IssueAssignee.objects.bulk_create( - [ - IssueAssignee( - assignee=user, - issue=issue, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for user in assignees - ], - batch_size=10, - ) + try: + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee=user, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for user in assignees + ], + batch_size=10, + ) + except IntegrityError: + pass else: # Then assign it to default assignee if default_assignee_id is not None: - IssueAssignee.objects.create( - assignee_id=default_assignee_id, - issue=issue, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - - if labels is not None and len(labels): - IssueLabel.objects.bulk_create( - [ - IssueLabel( - label=label, + try: + IssueAssignee.objects.create( + assignee_id=default_assignee_id, issue=issue, project_id=project_id, workspace_id=workspace_id, created_by_id=created_by_id, updated_by_id=updated_by_id, ) - for label in labels - ], - batch_size=10, - ) + except IntegrityError: + pass + + if labels is not None and len(labels): + try: + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label=label, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + except IntegrityError: + pass return issue @@ -188,37 +200,45 @@ class IssueCreateSerializer(BaseSerializer): if assignees is not None: IssueAssignee.objects.filter(issue=instance).delete() - IssueAssignee.objects.bulk_create( - [ - IssueAssignee( - assignee=user, - issue=instance, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for user in assignees - ], - batch_size=10, - ) + try: + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee=user, + issue=instance, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for user in assignees + ], + batch_size=10, + ignore_conflicts=True, + ) + except IntegrityError: + pass if labels is not None: IssueLabel.objects.filter(issue=instance).delete() - IssueLabel.objects.bulk_create( - [ - IssueLabel( - label=label, - issue=instance, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for label in labels - ], - batch_size=10, - ) + try: + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label=label, + issue=instance, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label in labels + ], + batch_size=10, + ignore_conflicts=True, + ) + except IntegrityError: + pass # Time updation occues even when other related models are updated instance.updated_at = timezone.now() @@ -281,10 +301,26 @@ class IssueRelationSerializer(BaseSerializer): ) name = serializers.CharField(source="related_issue.name", read_only=True) relation_type = serializers.CharField(read_only=True) + state_id = serializers.UUIDField(source="related_issue.state.id", read_only=True) + priority = serializers.CharField(source="related_issue.priority", read_only=True) + assignee_ids = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) class Meta: model = IssueRelation - fields = ["id", "project_id", "sequence_id", "relation_type", "name"] + fields = [ + "id", + "project_id", + "sequence_id", + "relation_type", + "name", + "state_id", + "priority", + "assignee_ids", + ] read_only_fields = ["workspace", "project"] @@ -296,10 +332,26 @@ class RelatedIssueSerializer(BaseSerializer): sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True) name = serializers.CharField(source="issue.name", read_only=True) relation_type = serializers.CharField(read_only=True) + state_id = serializers.UUIDField(source="issue.state.id", read_only=True) + priority = serializers.CharField(source="issue.priority", read_only=True) + assignee_ids = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) class Meta: model = IssueRelation - fields = ["id", "project_id", "sequence_id", "relation_type", "name"] + fields = [ + "id", + "project_id", + "sequence_id", + "relation_type", + "name", + "state_id", + "priority", + "assignee_ids", + ] read_only_fields = ["workspace", "project"] @@ -470,6 +522,7 @@ class IssueAttachmentLiteSerializer(DynamicBaseSerializer): "asset", "attributes", # "issue_id", + "created_by", "updated_at", "updated_by", "asset_url", @@ -667,3 +720,64 @@ class IssueSubscriberSerializer(BaseSerializer): model = IssueSubscriber fields = "__all__" read_only_fields = ["workspace", "project", "issue"] + + +class IssueVersionDetailSerializer(BaseSerializer): + class Meta: + model = IssueVersion + fields = [ + "id", + "workspace", + "project", + "issue", + "parent", + "state", + "estimate_point", + "name", + "priority", + "start_date", + "target_date", + "assignees", + "sequence_id", + "labels", + "sort_order", + "completed_at", + "archived_at", + "is_draft", + "external_source", + "external_id", + "type", + "cycle", + "modules", + "meta", + "name", + "last_saved_at", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = ["workspace", "project", "issue"] + + +class IssueDescriptionVersionDetailSerializer(BaseSerializer): + class Meta: + model = IssueDescriptionVersion + fields = [ + "id", + "workspace", + "project", + "issue", + "description_binary", + "description_html", + "description_stripped", + "description_json", + "last_saved_at", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = ["workspace", "project", "issue"] diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py index b69221081..1fd2f4d3c 100644 --- a/apiserver/plane/app/serializers/page.py +++ b/apiserver/plane/app/serializers/page.py @@ -54,6 +54,8 @@ class PageSerializer(BaseSerializer): labels = validated_data.pop("labels", None) project_id = self.context["project_id"] owned_by_id = self.context["owned_by_id"] + description = self.context["description"] + description_binary = self.context["description_binary"] description_html = self.context["description_html"] # Get the workspace id from the project @@ -62,6 +64,8 @@ class PageSerializer(BaseSerializer): # Create the page page = Page.objects.create( **validated_data, + description=description, + description_binary=description_binary, description_html=description_html, owned_by_id=owned_by_id, workspace_id=project.workspace_id, diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py index cc7b7fbcb..73c8a85d9 100644 --- a/apiserver/plane/app/serializers/project.py +++ b/apiserver/plane/app/serializers/project.py @@ -90,17 +90,7 @@ class ProjectLiteSerializer(BaseSerializer): class ProjectListSerializer(DynamicBaseSerializer): - total_issues = serializers.IntegerField(read_only=True) - archived_issues = serializers.IntegerField(read_only=True) - archived_sub_issues = serializers.IntegerField(read_only=True) - draft_issues = serializers.IntegerField(read_only=True) - draft_sub_issues = serializers.IntegerField(read_only=True) - sub_issues = serializers.IntegerField(read_only=True) is_favorite = serializers.BooleanField(read_only=True) - total_members = serializers.IntegerField(read_only=True) - total_cycles = serializers.IntegerField(read_only=True) - total_modules = serializers.IntegerField(read_only=True) - is_member = serializers.BooleanField(read_only=True) sort_order = serializers.FloatField(read_only=True) member_role = serializers.IntegerField(read_only=True) anchor = serializers.CharField(read_only=True) @@ -113,14 +103,9 @@ class ProjectListSerializer(DynamicBaseSerializer): if project_members is not None: # Filter members by the project ID return [ - { - "id": member.id, - "member_id": member.member_id, - "member__display_name": member.member.display_name, - "member__avatar": member.member.avatar, - "member__avatar_url": member.member.avatar_url, - } + member.member_id for member in project_members + if member.is_active and not member.member.is_bot ] return [] @@ -134,10 +119,6 @@ class ProjectDetailSerializer(BaseSerializer): default_assignee = UserLiteSerializer(read_only=True) project_lead = UserLiteSerializer(read_only=True) is_favorite = serializers.BooleanField(read_only=True) - total_members = serializers.IntegerField(read_only=True) - total_cycles = serializers.IntegerField(read_only=True) - total_modules = serializers.IntegerField(read_only=True) - is_member = serializers.BooleanField(read_only=True) sort_order = serializers.FloatField(read_only=True) member_role = serializers.IntegerField(read_only=True) anchor = serializers.CharField(read_only=True) diff --git a/apiserver/plane/app/serializers/webhook.py b/apiserver/plane/app/serializers/webhook.py index fa4019f7a..1036b700c 100644 --- a/apiserver/plane/app/serializers/webhook.py +++ b/apiserver/plane/app/serializers/webhook.py @@ -116,7 +116,7 @@ class WebhookSerializer(DynamicBaseSerializer): class Meta: model = Webhook fields = "__all__" - read_only_fields = ["workspace", "secret_key"] + read_only_fields = ["workspace", "secret_key", "deleted_at"] class WebhookLogSerializer(DynamicBaseSerializer): diff --git a/apiserver/plane/app/serializers/workspace.py b/apiserver/plane/app/serializers/workspace.py index 49cd55bf7..52333c246 100644 --- a/apiserver/plane/app/serializers/workspace.py +++ b/apiserver/plane/app/serializers/workspace.py @@ -1,25 +1,40 @@ # Third party imports from rest_framework import serializers +from rest_framework import status +from rest_framework.response import Response # Module imports from .base import BaseSerializer, DynamicBaseSerializer from .user import UserLiteSerializer, UserAdminLiteSerializer + from plane.db.models import ( Workspace, WorkspaceMember, WorkspaceMemberInvite, WorkspaceTheme, WorkspaceUserProperties, + WorkspaceUserLink, + UserRecentVisit, + Issue, + Page, + Project, + ProjectMember, + WorkspaceHomePreference, + Sticky, + WorkspaceUserPreference, ) from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS +# Django imports +from django.core.validators import URLValidator +from django.core.exceptions import ValidationError + class WorkSpaceSerializer(DynamicBaseSerializer): - owner = UserLiteSerializer(read_only=True) total_members = serializers.IntegerField(read_only=True) - total_issues = serializers.IntegerField(read_only=True) logo_url = serializers.CharField(read_only=True) + role = serializers.IntegerField(read_only=True) def validate_slug(self, value): # Check if the slug is restricted @@ -44,7 +59,7 @@ class WorkSpaceSerializer(DynamicBaseSerializer): class WorkspaceLiteSerializer(BaseSerializer): class Meta: model = Workspace - fields = ["name", "slug", "id"] + fields = ["name", "slug", "id", "logo_url"] read_only_fields = fields @@ -75,9 +90,11 @@ class WorkspaceMemberAdminSerializer(DynamicBaseSerializer): class WorkSpaceMemberInviteSerializer(BaseSerializer): - workspace = WorkSpaceSerializer(read_only=True) - total_members = serializers.IntegerField(read_only=True) - created_by_detail = UserLiteSerializer(read_only=True, source="created_by") + workspace = WorkspaceLiteSerializer(read_only=True) + invite_link = serializers.SerializerMethodField() + + def get_invite_link(self, obj): + return f"/workspace-invitations/?invitation_id={obj.id}&email={obj.email}&slug={obj.workspace.slug}" class Meta: model = WorkspaceMemberInvite @@ -91,6 +108,7 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer): "responded_at", "created_at", "updated_at", + "invite_link", ] @@ -106,3 +124,183 @@ class WorkspaceUserPropertiesSerializer(BaseSerializer): model = WorkspaceUserProperties fields = "__all__" read_only_fields = ["workspace", "user"] + + +class WorkspaceUserLinkSerializer(BaseSerializer): + class Meta: + model = WorkspaceUserLink + fields = "__all__" + read_only_fields = ["workspace", "owner"] + + def to_internal_value(self, data): + url = data.get("url", "") + if url and not url.startswith(("http://", "https://")): + data["url"] = "http://" + url + + return super().to_internal_value(data) + + def validate_url(self, value): + url_validator = URLValidator() + try: + url_validator(value) + except ValidationError: + raise serializers.ValidationError({"error": "Invalid URL format."}) + + return value + + + def create(self, validated_data): + # Filtering the WorkspaceUserLink with the given url to check if the link already exists. + + url = validated_data.get("url") + + workspace_user_link = WorkspaceUserLink.objects.filter( + url=url, + workspace_id=validated_data.get("workspace_id"), + owner_id=validated_data.get("owner_id") + ) + + if workspace_user_link.exists(): + raise serializers.ValidationError( + {"error": "URL already exists for this workspace and owner"} + ) + + return super().create(validated_data) + + def update(self, instance, validated_data): + # Filtering the WorkspaceUserLink with the given url to check if the link already exists. + + url = validated_data.get("url") + + workspace_user_link = WorkspaceUserLink.objects.filter( + url=url, + workspace_id=instance.workspace_id, + owner=instance.owner + ) + + if workspace_user_link.exclude(pk=instance.id).exists(): + raise serializers.ValidationError( + {"error": "URL already exists for this workspace and owner"} + ) + + return super().update(instance, validated_data) + +class IssueRecentVisitSerializer(serializers.ModelSerializer): + project_identifier = serializers.SerializerMethodField() + + class Meta: + model = Issue + fields = [ + "id", + "name", + "state", + "priority", + "assignees", + "type", + "sequence_id", + "project_id", + "project_identifier", + ] + + def get_project_identifier(self, obj): + project = obj.project + + return project.identifier if project else None + + +class ProjectRecentVisitSerializer(serializers.ModelSerializer): + project_members = serializers.SerializerMethodField() + + class Meta: + model = Project + fields = ["id", "name", "logo_props", "project_members", "identifier"] + + def get_project_members(self, obj): + members = ProjectMember.objects.filter( + project_id=obj.id, member__is_bot=False, is_active=True + ).values_list("member", flat=True) + + return members + + +class PageRecentVisitSerializer(serializers.ModelSerializer): + project_id = serializers.SerializerMethodField() + project_identifier = serializers.SerializerMethodField() + + class Meta: + model = Page + fields = [ + "id", + "name", + "logo_props", + "project_id", + "owned_by", + "project_identifier", + ] + + def get_project_id(self, obj): + return ( + obj.project_id + if hasattr(obj, "project_id") + else obj.projects.values_list("id", flat=True).first() + ) + + def get_project_identifier(self, obj): + project = obj.projects.first() + + return project.identifier if project else None + + +def get_entity_model_and_serializer(entity_type): + entity_map = { + "issue": (Issue, IssueRecentVisitSerializer), + "page": (Page, PageRecentVisitSerializer), + "project": (Project, ProjectRecentVisitSerializer), + } + return entity_map.get(entity_type, (None, None)) + + +class WorkspaceRecentVisitSerializer(BaseSerializer): + entity_data = serializers.SerializerMethodField() + + class Meta: + model = UserRecentVisit + fields = ["id", "entity_name", "entity_identifier", "entity_data", "visited_at"] + read_only_fields = ["workspace", "owner", "created_by", "updated_by"] + + def get_entity_data(self, obj): + entity_name = obj.entity_name + entity_identifier = obj.entity_identifier + + entity_model, entity_serializer = get_entity_model_and_serializer(entity_name) + + if entity_model and entity_serializer: + try: + entity = entity_model.objects.get(pk=entity_identifier) + + return entity_serializer(entity).data + except entity_model.DoesNotExist: + return None + return None + + +class WorkspaceHomePreferenceSerializer(BaseSerializer): + class Meta: + model = WorkspaceHomePreference + fields = ["key", "is_enabled", "sort_order"] + read_only_fields = ["workspace", "created_by", "updated_by"] + + +class StickySerializer(BaseSerializer): + class Meta: + model = Sticky + fields = "__all__" + read_only_fields = ["workspace", "owner"] + extra_kwargs = {"name": {"required": False}} + + +class WorkspaceUserPreferenceSerializer(BaseSerializer): + class Meta: + model = WorkspaceUserPreference + fields = ["key", "is_pinned", "sort_order"] + read_only_fields = ["workspace", "created_by", "updated_by"] diff --git a/apiserver/plane/app/urls/__init__.py b/apiserver/plane/app/urls/__init__.py index 8798e8044..3be75536b 100644 --- a/apiserver/plane/app/urls/__init__.py +++ b/apiserver/plane/app/urls/__init__.py @@ -17,6 +17,7 @@ from .user import urlpatterns as user_urls from .views import urlpatterns as view_urls from .webhook import urlpatterns as webhook_urls from .workspace import urlpatterns as workspace_urls +from .timezone import urlpatterns as timezone_urls urlpatterns = [ *analytic_urls, @@ -38,4 +39,5 @@ urlpatterns = [ *workspace_urls, *api_urls, *webhook_urls, + *timezone_urls, ] diff --git a/apiserver/plane/app/urls/analytic.py b/apiserver/plane/app/urls/analytic.py index 668268350..abe18f2ad 100644 --- a/apiserver/plane/app/urls/analytic.py +++ b/apiserver/plane/app/urls/analytic.py @@ -7,6 +7,7 @@ from plane.app.views import ( SavedAnalyticEndpoint, ExportAnalyticsEndpoint, DefaultAnalyticsEndpoint, + ProjectStatsEndpoint, ) @@ -43,4 +44,9 @@ urlpatterns = [ DefaultAnalyticsEndpoint.as_view(), name="default-analytics", ), + path( + "workspaces//project-stats/", + ProjectStatsEndpoint.as_view(), + name="project-analytics", + ), ] diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py index 07dfa5112..6c5e45033 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -24,6 +24,10 @@ from plane.app.views import ( IssueDetailEndpoint, IssueAttachmentV2Endpoint, IssueBulkUpdateDateEndpoint, + IssueVersionEndpoint, + IssueDescriptionVersionEndpoint, + IssueMetaEndpoint, + IssueDetailIdentifierEndpoint, ) urlpatterns = [ @@ -256,4 +260,34 @@ urlpatterns = [ IssueBulkUpdateDateEndpoint.as_view(), name="project-issue-dates", ), + path( + "workspaces//projects//issues//versions/", + IssueVersionEndpoint.as_view(), + name="page-versions", + ), + path( + "workspaces//projects//issues//versions//", + IssueVersionEndpoint.as_view(), + name="page-versions", + ), + path( + "workspaces//projects//issues//description-versions/", + IssueDescriptionVersionEndpoint.as_view(), + name="page-versions", + ), + path( + "workspaces//projects//issues//description-versions//", + IssueDescriptionVersionEndpoint.as_view(), + name="page-versions", + ), + path( + "workspaces//projects//issues//meta/", + IssueMetaEndpoint.as_view(), + name="issue-meta", + ), + path( + "workspaces//work-items/-/", + IssueDetailIdentifierEndpoint.as_view(), + name="issue-detail-identifier", + ), ] diff --git a/apiserver/plane/app/urls/page.py b/apiserver/plane/app/urls/page.py index b49f1d4a2..f7eb7e424 100644 --- a/apiserver/plane/app/urls/page.py +++ b/apiserver/plane/app/urls/page.py @@ -8,6 +8,7 @@ from plane.app.views import ( SubPagesEndpoint, PagesDescriptionViewSet, PageVersionEndpoint, + PageDuplicateEndpoint, ) @@ -78,4 +79,9 @@ urlpatterns = [ PageVersionEndpoint.as_view(), name="page-versions", ), + path( + "workspaces//projects//pages//duplicate/", + PageDuplicateEndpoint.as_view(), + name="page-duplicate", + ), ] diff --git a/apiserver/plane/app/urls/project.py b/apiserver/plane/app/urls/project.py index 4037402ab..d673d191e 100644 --- a/apiserver/plane/app/urls/project.py +++ b/apiserver/plane/app/urls/project.py @@ -23,6 +23,11 @@ urlpatterns = [ ProjectViewSet.as_view({"get": "list", "post": "create"}), name="project", ), + path( + "workspaces//projects/details/", + ProjectViewSet.as_view({"get": "list_detail"}), + name="project", + ), path( "workspaces//projects//", ProjectViewSet.as_view( diff --git a/apiserver/plane/app/urls/search.py b/apiserver/plane/app/urls/search.py index bbea8093d..0bbbd9cf7 100644 --- a/apiserver/plane/app/urls/search.py +++ b/apiserver/plane/app/urls/search.py @@ -1,7 +1,7 @@ from django.urls import path -from plane.app.views import GlobalSearchEndpoint, IssueSearchEndpoint +from plane.app.views import GlobalSearchEndpoint, IssueSearchEndpoint, SearchEndpoint urlpatterns = [ @@ -15,4 +15,9 @@ urlpatterns = [ IssueSearchEndpoint.as_view(), name="project-issue-search", ), + path( + "workspaces//entity-search/", + SearchEndpoint.as_view(), + name="entity-search", + ), ] diff --git a/apiserver/plane/app/urls/timezone.py b/apiserver/plane/app/urls/timezone.py new file mode 100644 index 000000000..ff14d029f --- /dev/null +++ b/apiserver/plane/app/urls/timezone.py @@ -0,0 +1,8 @@ +from django.urls import path + +from plane.app.views import TimezoneEndpoint + +urlpatterns = [ + # timezone endpoint + path("timezones/", TimezoneEndpoint.as_view(), name="timezone-list") +] diff --git a/apiserver/plane/app/urls/workspace.py b/apiserver/plane/app/urls/workspace.py index d91fdb60b..f16fdb161 100644 --- a/apiserver/plane/app/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -27,6 +27,11 @@ from plane.app.views import ( WorkspaceFavoriteEndpoint, WorkspaceFavoriteGroupEndpoint, WorkspaceDraftIssueViewSet, + QuickLinkViewSet, + UserRecentVisitViewSet, + WorkspaceHomePreferenceViewSet, + WorkspaceStickyViewSet, + WorkspaceUserPreferenceViewSet, ) @@ -213,4 +218,56 @@ urlpatterns = [ WorkspaceDraftIssueViewSet.as_view({"post": "create_draft_to_issue"}), name="workspace-drafts-issues", ), + # quick link + path( + "workspaces//quick-links/", + QuickLinkViewSet.as_view({"get": "list", "post": "create"}), + name="workspace-quick-links", + ), + path( + "workspaces//quick-links//", + QuickLinkViewSet.as_view( + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} + ), + name="workspace-quick-links", + ), + # Widgets + path( + "workspaces//home-preferences/", + WorkspaceHomePreferenceViewSet.as_view(), + name="workspace-home-preference", + ), + path( + "workspaces//home-preferences//", + WorkspaceHomePreferenceViewSet.as_view(), + name="workspace-home-preference", + ), + path( + "workspaces//recent-visits/", + UserRecentVisitViewSet.as_view({"get": "list"}), + name="workspace-recent-visits", + ), + path( + "workspaces//stickies/", + WorkspaceStickyViewSet.as_view({"get": "list", "post": "create"}), + name="workspace-sticky", + ), + path( + "workspaces//stickies//", + WorkspaceStickyViewSet.as_view( + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} + ), + name="workspace-sticky", + ), + # User Preference + path( + "workspaces//sidebar-preferences/", + WorkspaceUserPreferenceViewSet.as_view(), + name="workspace-user-preference", + ), + path( + "workspaces//sidebar-preferences//", + WorkspaceUserPreferenceViewSet.as_view(), + name="workspace-user-preference", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 581a1065d..684179d90 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -41,10 +41,14 @@ from .workspace.base import ( from .workspace.draft import WorkspaceDraftIssueViewSet +from .workspace.home import WorkspaceHomePreferenceViewSet + from .workspace.favorite import ( WorkspaceFavoriteEndpoint, WorkspaceFavoriteGroupEndpoint, ) +from .workspace.recent_visit import UserRecentVisitViewSet +from .workspace.user_preference import WorkspaceUserPreferenceViewSet from .workspace.member import ( WorkSpaceMemberViewSet, @@ -72,6 +76,8 @@ from .workspace.user import ( from .workspace.estimate import WorkspaceEstimatesEndpoint from .workspace.module import WorkspaceModulesEndpoint from .workspace.cycle import WorkspaceCyclesEndpoint +from .workspace.quick_link import QuickLinkViewSet +from .workspace.sticky import WorkspaceStickyViewSet from .state.base import StateViewSet from .view.base import ( @@ -110,6 +116,8 @@ from .issue.base import ( IssuePaginatedViewSet, IssueDetailEndpoint, IssueBulkUpdateDateEndpoint, + IssueMetaEndpoint, + IssueDetailIdentifierEndpoint, ) from .issue.activity import IssueActivityEndpoint @@ -136,6 +144,8 @@ from .issue.sub_issue import SubIssuesEndpoint from .issue.subscriber import IssueSubscriberViewSet +from .issue.version import IssueVersionEndpoint, IssueDescriptionVersionEndpoint + from .module.base import ( ModuleViewSet, ModuleLinkViewSet, @@ -155,10 +165,11 @@ from .page.base import ( PageLogEndpoint, SubPagesEndpoint, PagesDescriptionViewSet, + PageDuplicateEndpoint, ) from .page.version import PageVersionEndpoint -from .search.base import GlobalSearchEndpoint +from .search.base import GlobalSearchEndpoint, SearchEndpoint from .search.issue import IssueSearchEndpoint @@ -181,6 +192,7 @@ from .analytic.base import ( SavedAnalyticEndpoint, ExportAnalyticsEndpoint, DefaultAnalyticsEndpoint, + ProjectStatsEndpoint, ) from .notification.base import ( @@ -204,3 +216,5 @@ from .error_404 import custom_404_view from .notification.base import MarkAllReadNotificationViewSet from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint + +from .timezone.base import TimezoneEndpoint diff --git a/apiserver/plane/app/views/analytic/base.py b/apiserver/plane/app/views/analytic/base.py index a36458406..631c6884a 100644 --- a/apiserver/plane/app/views/analytic/base.py +++ b/apiserver/plane/app/views/analytic/base.py @@ -3,7 +3,7 @@ from django.db.models import Count, F, Sum, Q from django.db.models.functions import ExtractMonth from django.utils import timezone from django.db.models.functions import Concat -from django.db.models import Case, When, Value +from django.db.models import Case, When, Value, OuterRef, Func from django.db import models # Third party imports @@ -15,7 +15,16 @@ from plane.app.permissions import WorkSpaceAdminPermission from plane.app.serializers import AnalyticViewSerializer from plane.app.views.base import BaseAPIView, BaseViewSet from plane.bgtasks.analytic_plot_export import analytic_export_task -from plane.db.models import AnalyticView, Issue, Workspace +from plane.db.models import ( + AnalyticView, + Issue, + Workspace, + Project, + ProjectMember, + Cycle, + Module, +) + from plane.utils.analytics_plot import build_graph_plot from plane.utils.issue_filters import issue_filters from plane.app.permissions import allow_permission, ROLE @@ -441,3 +450,74 @@ class DefaultAnalyticsEndpoint(BaseAPIView): }, status=status.HTTP_200_OK, ) + + +class ProjectStatsEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def get(self, request, slug): + fields = request.GET.get("fields", "").split(",") + project_ids = request.GET.get("project_ids", "") + + valid_fields = { + "total_issues", + "completed_issues", + "total_members", + "total_cycles", + "total_modules", + } + requested_fields = set(filter(None, fields)) & valid_fields + + if not requested_fields: + requested_fields = valid_fields + + projects = Project.objects.filter(workspace__slug=slug) + if project_ids: + projects = projects.filter(id__in=project_ids.split(",")) + + annotations = {} + if "total_issues" in requested_fields: + annotations["total_issues"] = ( + Issue.issue_objects.filter(project_id=OuterRef("pk")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + if "completed_issues" in requested_fields: + annotations["completed_issues"] = ( + Issue.issue_objects.filter( + project_id=OuterRef("pk"), state__group="completed" + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + if "total_cycles" in requested_fields: + annotations["total_cycles"] = ( + Cycle.objects.filter(project_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + if "total_modules" in requested_fields: + annotations["total_modules"] = ( + Module.objects.filter(project_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + if "total_members" in requested_fields: + annotations["total_members"] = ( + ProjectMember.objects.filter( + project_id=OuterRef("id"), member__is_bot=False, is_active=True + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + projects = projects.annotate(**annotations).values("id", *requested_fields) + return Response(projects, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/asset/v2.py b/apiserver/plane/app/views/asset/v2.py index 827c95908..da36b91a0 100644 --- a/apiserver/plane/app/views/asset/v2.py +++ b/apiserver/plane/app/views/asset/v2.py @@ -5,6 +5,7 @@ import uuid from django.conf import settings from django.http import HttpResponseRedirect from django.utils import timezone +from django.db import IntegrityError # Third party imports from rest_framework import status @@ -126,7 +127,13 @@ class UserAssetsV2Endpoint(BaseAPIView): ) # Check if the file type is allowed - allowed_types = ["image/jpeg", "image/png", "image/webp", "image/jpg"] + allowed_types = [ + "image/jpeg", + "image/png", + "image/webp", + "image/jpg", + "image/gif", + ] if type not in allowed_types: return Response( { @@ -673,15 +680,30 @@ class ProjectBulkAssetEndpoint(BaseAPIView): [self.save_project_cover(asset, project_id) for asset in assets] if asset.entity_type == FileAsset.EntityTypeContext.ISSUE_DESCRIPTION: - assets.update(issue_id=entity_id) + # For some cases, the bulk api is called after the issue is deleted creating + # an integrity error + try: + assets.update(issue_id=entity_id) + except IntegrityError: + pass if asset.entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: - assets.update(comment_id=entity_id) + # For some cases, the bulk api is called after the comment is deleted + # creating an integrity error + try: + assets.update(comment_id=entity_id) + except IntegrityError: + pass if asset.entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION: assets.update(page_id=entity_id) if asset.entity_type == FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION: - assets.update(draft_issue_id=entity_id) + # For some cases, the bulk api is called after the draft issue is deleted + # creating an integrity error + try: + assets.update(draft_issue_id=entity_id) + except IntegrityError: + pass return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 61ea9eed4..84a161619 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -1,5 +1,7 @@ # Python imports import json +import pytz + # Django imports from django.contrib.postgres.aggregates import ArrayAgg @@ -45,6 +47,7 @@ from plane.db.models import ( User, Project, ProjectMember, + UserRecentVisit, ) from plane.utils.analytics_plot import burndown_plot from plane.bgtasks.recent_visited_task import recent_visited_task @@ -52,6 +55,7 @@ from plane.bgtasks.recent_visited_task import recent_visited_task # Module imports from .. import BaseAPIView, BaseViewSet from plane.bgtasks.webhook_task import model_activity +from plane.utils.timezone_converter import convert_to_utc, user_timezone_converter class CycleViewSet(BaseViewSet): @@ -67,6 +71,19 @@ class CycleViewSet(BaseViewSet): project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), ) + + project = Project.objects.get(id=self.kwargs.get("project_id")) + + # Fetch project for the specific record or pass project_id dynamically + project_timezone = project.timezone + + # Convert the current time (timezone.now()) to the project's timezone + local_tz = pytz.timezone(project_timezone) + current_time_in_project_tz = timezone.now().astimezone(local_tz) + + # Convert project local time back to UTC for comparison (start_date is stored in UTC) + current_time_in_utc = current_time_in_project_tz.astimezone(pytz.utc) + return self.filter_queryset( super() .get_queryset() @@ -116,15 +133,27 @@ class CycleViewSet(BaseViewSet): ), ) ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + issue_cycle__issue__state__group__in=["cancelled"], + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) .annotate( status=Case( When( - Q(start_date__lte=timezone.now()) - & Q(end_date__gte=timezone.now()), + Q(start_date__lte=current_time_in_utc) + & Q(end_date__gte=current_time_in_utc), then=Value("CURRENT"), ), - When(start_date__gt=timezone.now(), then=Value("UPCOMING")), - When(end_date__lt=timezone.now(), then=Value("COMPLETED")), + When(start_date__gt=current_time_in_utc, then=Value("UPCOMING")), + When(end_date__lt=current_time_in_utc, then=Value("COMPLETED")), When( Q(start_date__isnull=True) & Q(end_date__isnull=True), then=Value("DRAFT"), @@ -160,10 +189,22 @@ class CycleViewSet(BaseViewSet): # Update the order by queryset = queryset.order_by("-is_favorite", "-created_at") + project = Project.objects.get(id=self.kwargs.get("project_id")) + + # Fetch project for the specific record or pass project_id dynamically + project_timezone = project.timezone + + # Convert the current time (timezone.now()) to the project's timezone + local_tz = pytz.timezone(project_timezone) + current_time_in_project_tz = timezone.now().astimezone(local_tz) + + # Convert project local time back to UTC for comparison (start_date is stored in UTC) + current_time_in_utc = current_time_in_project_tz.astimezone(pytz.utc) + # Current Cycle if cycle_view == "current": queryset = queryset.filter( - start_date__lte=timezone.now(), end_date__gte=timezone.now() + start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc ) data = queryset.values( @@ -186,11 +227,14 @@ class CycleViewSet(BaseViewSet): "is_favorite", "total_issues", "completed_issues", + "cancelled_issues", "assignee_ids", "status", "version", "created_by", ) + datetime_fields = ["start_date", "end_date"] + data = user_timezone_converter(data, datetime_fields, project_timezone) if data: return Response(data, status=status.HTTP_200_OK) @@ -215,12 +259,17 @@ class CycleViewSet(BaseViewSet): # meta fields "is_favorite", "total_issues", + "cancelled_issues", "completed_issues", "assignee_ids", "status", "version", "created_by", ) + datetime_fields = ["start_date", "end_date"] + data = user_timezone_converter( + data, datetime_fields, request.user.user_timezone + ) return Response(data, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) @@ -232,7 +281,9 @@ class CycleViewSet(BaseViewSet): request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None ): - serializer = CycleWriteSerializer(data=request.data) + serializer = CycleWriteSerializer( + data=request.data, context={"project_id": project_id} + ) if serializer.is_valid(): serializer.save(project_id=project_id, owned_by=request.user) cycle = ( @@ -267,6 +318,11 @@ class CycleViewSet(BaseViewSet): .first() ) + datetime_fields = ["start_date", "end_date"] + cycle = user_timezone_converter( + cycle, datetime_fields, request.user.user_timezone + ) + # Send the model activity model_activity.delay( model_name="cycle", @@ -319,7 +375,9 @@ class CycleViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - serializer = CycleWriteSerializer(cycle, data=request.data, partial=True) + serializer = CycleWriteSerializer( + cycle, data=request.data, partial=True, context={"project_id": project_id} + ) if serializer.is_valid(): serializer.save() cycle = queryset.values( @@ -349,6 +407,11 @@ class CycleViewSet(BaseViewSet): "created_by", ).first() + datetime_fields = ["start_date", "end_date"] + cycle = user_timezone_converter( + cycle, datetime_fields, request.user.user_timezone + ) + # Send the model activity model_activity.delay( model_name="cycle", @@ -417,6 +480,10 @@ class CycleViewSet(BaseViewSet): ) queryset = queryset.first() + datetime_fields = ["start_date", "end_date"] + data = user_timezone_converter( + data, datetime_fields, request.user.user_timezone + ) recent_visited_task.delay( slug=slug, @@ -477,6 +544,13 @@ class CycleViewSet(BaseViewSet): entity_identifier=pk, project_id=project_id, ).delete() + # Delete the cycle from recent visits + UserRecentVisit.objects.filter( + project_id=project_id, + workspace__slug=slug, + entity_identifier=pk, + entity_name="cycle", + ).delete(soft=False) return Response(status=status.HTTP_204_NO_CONTENT) @@ -492,6 +566,18 @@ 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 cycles = Cycle.objects.filter( Q(workspace__slug=slug) diff --git a/apiserver/plane/app/views/dashboard/base.py b/apiserver/plane/app/views/dashboard/base.py index 8ccce092e..ff67bb73b 100644 --- a/apiserver/plane/app/views/dashboard/base.py +++ b/apiserver/plane/app/views/dashboard/base.py @@ -32,15 +32,15 @@ from plane.app.serializers import ( WidgetSerializer, ) from plane.db.models import ( - Dashboard, - DashboardWidget, + DeprecatedDashboard, + DeprecatedDashboardWidget, Issue, IssueActivity, FileAsset, IssueLink, IssueRelation, Project, - Widget, + DeprecatedWidget, WorkspaceMember, CycleIssue, ) @@ -53,10 +53,10 @@ from .. import BaseAPIView def dashboard_overview_stats(self, request, slug): assigned_issues = ( Issue.issue_objects.filter( + (Q(assignees__in=[request.user]) & Q(issue_assignee__deleted_at__isnull=True)), project__project_projectmember__is_active=True, project__project_projectmember__member=request.user, workspace__slug=slug, - assignees__in=[request.user], ) .filter( Q( @@ -133,10 +133,13 @@ def dashboard_overview_stats(self, request, slug): completed_issues_count = ( Issue.issue_objects.filter( + ( + Q(assignees__in=[request.user]) + & Q(issue_assignee__deleted_at__isnull=True) + ), workspace__slug=slug, project__project_projectmember__is_active=True, project__project_projectmember__member=request.user, - assignees__in=[request.user], state__group="completed", ) .filter( @@ -176,10 +179,13 @@ def dashboard_assigned_issues(self, request, slug): # get all the assigned issues assigned_issues = ( Issue.issue_objects.filter( + ( + Q(assignees__in=[request.user]) + & Q(issue_assignee__deleted_at__isnull=True) + ), workspace__slug=slug, project__project_projectmember__member=request.user, project__project_projectmember__is_active=True, - assignees__in=[request.user], ) .filter(**filters) .select_related("workspace", "project", "state", "parent") @@ -687,7 +693,7 @@ class DashboardEndpoint(BaseAPIView): if not dashboard_id: dashboard_type = request.GET.get("dashboard_type", None) if dashboard_type == "home": - dashboard, created = Dashboard.objects.get_or_create( + dashboard, created = DeprecatedDashboard.objects.get_or_create( type_identifier=dashboard_type, owned_by=request.user, is_default=True, @@ -707,24 +713,24 @@ class DashboardEndpoint(BaseAPIView): updated_dashboard_widgets = [] for widget_key in widgets_to_fetch: - widget = Widget.objects.filter(key=widget_key).values_list( - "id", flat=True - ) + widget = DeprecatedWidget.objects.filter( + key=widget_key + ).values_list("id", flat=True) if widget: updated_dashboard_widgets.append( - DashboardWidget( + DeprecatedDashboardWidget( widget_id=widget, dashboard_id=dashboard.id ) ) - DashboardWidget.objects.bulk_create( + DeprecatedDashboardWidget.objects.bulk_create( updated_dashboard_widgets, batch_size=100 ) widgets = ( - Widget.objects.annotate( + DeprecatedWidget.objects.annotate( is_visible=Exists( - DashboardWidget.objects.filter( + DeprecatedDashboardWidget.objects.filter( widget_id=OuterRef("pk"), dashboard_id=dashboard.id, is_visible=True, @@ -733,7 +739,7 @@ class DashboardEndpoint(BaseAPIView): ) .annotate( dashboard_filters=Subquery( - DashboardWidget.objects.filter( + DeprecatedDashboardWidget.objects.filter( widget_id=OuterRef("pk"), dashboard_id=dashboard.id, filters__isnull=False, @@ -792,7 +798,7 @@ class DashboardEndpoint(BaseAPIView): class WidgetsEndpoint(BaseAPIView): def patch(self, request, dashboard_id, widget_id): - dashboard_widget = DashboardWidget.objects.filter( + dashboard_widget = DeprecatedDashboardWidget.objects.filter( widget_id=widget_id, dashboard_id=dashboard_id ).first() dashboard_widget.is_visible = request.data.get( diff --git a/apiserver/plane/app/views/external/base.py b/apiserver/plane/app/views/external/base.py index 1dfbc421a..5643da226 100644 --- a/apiserver/plane/app/views/external/base.py +++ b/apiserver/plane/app/views/external/base.py @@ -1,71 +1,171 @@ -# Python imports -import requests +# Python import import os +from typing import List, Dict, Tuple -# Third party imports +# Third party import from openai import OpenAI -from rest_framework.response import Response +import requests + from rest_framework import status +from rest_framework.response import Response -# Django imports - -# Module imports -from ..base import BaseAPIView -from plane.app.permissions import allow_permission, ROLE -from plane.db.models import Workspace, Project -from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer +# Module import +from plane.app.permissions import ROLE, allow_permission +from plane.app.serializers import (ProjectLiteSerializer, + WorkspaceLiteSerializer) +from plane.db.models import Project, Workspace from plane.license.utils.instance_value import get_configuration_value +from plane.utils.exception_logger import log_exception +from ..base import BaseAPIView + + +class LLMProvider: + """Base class for LLM provider configurations""" + name: str = "" + models: List[str] = [] + default_model: str = "" + + @classmethod + def get_config(cls) -> Dict[str, str | List[str]]: + return { + "name": cls.name, + "models": cls.models, + "default_model": cls.default_model, + } + +class OpenAIProvider(LLMProvider): + name = "OpenAI" + models = ["gpt-3.5-turbo", "gpt-4o-mini", "gpt-4o", "o1-mini", "o1-preview"] + default_model = "gpt-4o-mini" + +class AnthropicProvider(LLMProvider): + name = "Anthropic" + models = [ + "claude-3-5-sonnet-20240620", + "claude-3-haiku-20240307", + "claude-3-opus-20240229", + "claude-3-sonnet-20240229", + "claude-2.1", + "claude-2", + "claude-instant-1.2", + "claude-instant-1" + ] + default_model = "claude-3-sonnet-20240229" + +class GeminiProvider(LLMProvider): + name = "Gemini" + models = ["gemini-pro", "gemini-1.5-pro-latest", "gemini-pro-vision"] + default_model = "gemini-pro" + +SUPPORTED_PROVIDERS = { + "openai": OpenAIProvider, + "anthropic": AnthropicProvider, + "gemini": GeminiProvider, +} + +def get_llm_config() -> Tuple[str | None, str | None, str | None]: + """ + Helper to get LLM configuration values, returns: + - api_key, model, provider + """ + api_key, provider_key, model = get_configuration_value([ + { + "key": "LLM_API_KEY", + "default": os.environ.get("LLM_API_KEY", None), + }, + { + "key": "LLM_PROVIDER", + "default": os.environ.get("LLM_PROVIDER", "openai"), + }, + { + "key": "LLM_MODEL", + "default": os.environ.get("LLM_MODEL", None), + }, + ]) + + provider = SUPPORTED_PROVIDERS.get(provider_key.lower()) + if not provider: + log_exception(ValueError(f"Unsupported provider: {provider_key}")) + return None, None, None + + if not api_key: + log_exception(ValueError(f"Missing API key for provider: {provider.name}")) + return None, None, None + + # If no model specified, use provider's default + if not model: + model = provider.default_model + + # Validate model is supported by provider + if model not in provider.models: + log_exception(ValueError( + f"Model {model} not supported by {provider.name}. " + f"Supported models: {', '.join(provider.models)}" + )) + return None, None, None + + return api_key, model, provider_key + + +def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> Tuple[str | None, str | None]: + """Helper to get LLM completion response""" + final_text = task + "\n" + prompt + try: + # For Gemini, prepend provider name to model + if provider.lower() == "gemini": + model = f"gemini/{model}" + + client = OpenAI(api_key=api_key) + chat_completion = client.chat.completions.create( + model=model, + messages=[ + {"role": "user", "content": final_text} + ] + ) + text = chat_completion.choices[0].message.content + return text, None + except Exception as e: + log_exception(e) + error_type = e.__class__.__name__ + if error_type == "AuthenticationError": + return None, f"Invalid API key for {provider}" + elif error_type == "RateLimitError": + return None, f"Rate limit exceeded for {provider}" + else: + return None, f"Error occurred while generating response from {provider}" class GPTIntegrationEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def post(self, request, slug, project_id): - OPENAI_API_KEY, GPT_ENGINE = get_configuration_value( - [ - { - "key": "OPENAI_API_KEY", - "default": os.environ.get("OPENAI_API_KEY", None), - }, - { - "key": "GPT_ENGINE", - "default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), - }, - ] - ) + api_key, model, provider = get_llm_config() - # Get the configuration value - # Check the keys - if not OPENAI_API_KEY or not GPT_ENGINE: + if not api_key or not model or not provider: return Response( - {"error": "OpenAI API key and engine is required"}, + {"error": "LLM provider API key and model are required"}, status=status.HTTP_400_BAD_REQUEST, ) - prompt = request.data.get("prompt", False) task = request.data.get("task", False) - if not task: return Response( {"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST ) - final_text = task + "\n" + prompt - - client = OpenAI(api_key=OPENAI_API_KEY) - - response = client.chat.completions.create( - model=GPT_ENGINE, messages=[{"role": "user", "content": final_text}] - ) + text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider) + if not text and error: + return Response( + {"error": "An internal error has occurred."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) workspace = Workspace.objects.get(slug=slug) project = Project.objects.get(pk=project_id) - text = response.choices[0].message.content.strip() - text_html = text.replace("\n", "
") return Response( { "response": text, - "response_html": text_html, + "response_html": text.replace("\n", "
"), "project_detail": ProjectLiteSerializer(project).data, "workspace_detail": WorkspaceLiteSerializer(workspace).data, }, @@ -76,47 +176,33 @@ class GPTIntegrationEndpoint(BaseAPIView): class WorkspaceGPTIntegrationEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def post(self, request, slug): - OPENAI_API_KEY, GPT_ENGINE = get_configuration_value( - [ - { - "key": "OPENAI_API_KEY", - "default": os.environ.get("OPENAI_API_KEY", None), - }, - { - "key": "GPT_ENGINE", - "default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), - }, - ] - ) + api_key, model, provider = get_llm_config() - # Get the configuration value - # Check the keys - if not OPENAI_API_KEY or not GPT_ENGINE: + if not api_key or not model or not provider: return Response( - {"error": "OpenAI API key and engine is required"}, + {"error": "LLM provider API key and model are required"}, status=status.HTTP_400_BAD_REQUEST, ) - prompt = request.data.get("prompt", False) task = request.data.get("task", False) - if not task: return Response( {"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST ) - final_text = task + "\n" + prompt + text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider) + if not text and error: + return Response( + {"error": "An internal error has occurred."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) - client = OpenAI(api_key=OPENAI_API_KEY) - - response = client.chat.completions.create( - model=GPT_ENGINE, messages=[{"role": "user", "content": final_text}] - ) - - text = response.choices[0].message.content.strip() - text_html = text.replace("\n", "
") return Response( - {"response": text, "response_html": text_html}, status=status.HTTP_200_OK + { + "response": text, + "response_html": text.replace("\n", "
"), + }, + status=status.HTTP_200_OK, ) diff --git a/apiserver/plane/app/views/intake/base.py b/apiserver/plane/app/views/intake/base.py index 8647117c5..631fe80da 100644 --- a/apiserver/plane/app/views/intake/base.py +++ b/apiserver/plane/app/views/intake/base.py @@ -174,14 +174,17 @@ class IntakeIssueViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def list(self, request, slug, project_id): - intake_id = Intake.objects.filter( + intake = Intake.objects.filter( workspace__slug=slug, project_id=project_id ).first() + if not intake: + return Response({"error": "Intake not found"}, status=status.HTTP_404_NOT_FOUND) + project = Project.objects.get(pk=project_id) filters = issue_filters(request.GET, "GET", "issue__") intake_issue = ( IntakeIssue.objects.filter( - intake_id=intake_id.id, project_id=project_id, **filters + intake_id=intake.id, project_id=project_id, **filters ) .select_related("issue") .prefetch_related("issue__labels") diff --git a/apiserver/plane/app/views/issue/attachment.py b/apiserver/plane/app/views/issue/attachment.py index 4427227f1..d519a5269 100644 --- a/apiserver/plane/app/views/issue/attachment.py +++ b/apiserver/plane/app/views/issue/attachment.py @@ -120,10 +120,12 @@ class IssueAttachmentV2Endpoint(BaseAPIView): # Get the presigned URL storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object presigned_url = storage.generate_presigned_post( object_name=asset_key, file_type=type, file_size=size_limit ) + # Return the presigned URL return Response( { diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index d0c614368..48ea2f6bc 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -44,6 +44,7 @@ from plane.db.models import ( Project, ProjectMember, CycleIssue, + UserRecentVisit, ) from plane.utils.grouper import ( issue_group_values, @@ -54,10 +55,11 @@ 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 .. import BaseAPIView, BaseViewSet -from plane.utils.user_timezone_converter import user_timezone_converter +from plane.utils.timezone_converter import user_timezone_converter from plane.bgtasks.recent_visited_task import recent_visited_task from plane.utils.global_paginator import paginate from plane.bgtasks.webhook_task import model_activity +from plane.bgtasks.issue_description_version_task import issue_description_version_task class IssueListEndpoint(BaseAPIView): @@ -428,6 +430,13 @@ class IssueViewSet(BaseViewSet): slug=slug, origin=request.META.get("HTTP_ORIGIN"), ) + # updated issue description version + issue_description_version_task.delay( + updated_issue=json.dumps(request.data, cls=DjangoJSONEncoder), + issue_id=str(serializer.data["id"]), + user_id=request.user.id, + is_creating=True, + ) return Response(issue, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -538,7 +547,7 @@ class IssueViewSet(BaseViewSet): ) """ - 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 issue """ @@ -649,6 +658,12 @@ class IssueViewSet(BaseViewSet): slug=slug, origin=request.META.get("HTTP_ORIGIN"), ) + # updated issue description version + issue_description_version_task.delay( + updated_issue=current_instance, + issue_id=str(serializer.data.get("id", None)), + user_id=request.user.id, + ) return Response(status=status.HTTP_204_NO_CONTENT) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -657,6 +672,13 @@ class IssueViewSet(BaseViewSet): issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) issue.delete() + # delete the issue from recent visits + UserRecentVisit.objects.filter( + project_id=project_id, + workspace__slug=slug, + entity_identifier=pk, + entity_name="issue", + ).delete(soft=False) issue_activity.delay( type="issue.activity.deleted", requested_data=json.dumps({"issue_id": str(pk)}), @@ -1074,3 +1096,192 @@ class IssueBulkUpdateDateEndpoint(BaseAPIView): return Response( {"message": "Issues updated successfully"}, status=status.HTTP_200_OK ) + + +class IssueMetaEndpoint(BaseAPIView): + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT") + def get(self, request, slug, project_id, issue_id): + issue = Issue.issue_objects.only("sequence_id", "project__identifier").get( + id=issue_id, project_id=project_id, workspace__slug=slug + ) + return Response( + { + "sequence_id": issue.sequence_id, + "project_identifier": issue.project.identifier, + }, + status=status.HTTP_200_OK, + ) + + +class IssueDetailIdentifierEndpoint(BaseAPIView): + + def strict_str_to_int(self, s): + if not s.isdigit() and not (s.startswith('-') and s[1:].isdigit()): + raise ValueError("Invalid integer string") + return int(s) + + def get(self, request, slug, project_identifier, issue_identifier): + + # Check if the issue identifier is a valid integer + try: + issue_identifier = self.strict_str_to_int(issue_identifier) + except ValueError: + return Response( + {"error": "Invalid issue identifier"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Fetch the project + project = Project.objects.get( + identifier__iexact=project_identifier, + workspace__slug=slug, + ) + + # Check if the user is a member of the project + if not ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project.id, + member=request.user, + is_active=True, + ).exists(): + return Response( + {"error": "You are not allowed to view this issue"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Fetch the issue + issue = ( + Issue.issue_objects.filter(project_id=project.id) + .filter(workspace__slug=slug) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate( + cycle_id=Subquery( + CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[ + :1 + ] + ) + ) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(sequence_id=issue_identifier) + .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())), + ), + ) + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("issue", "actor"), + ) + ) + .prefetch_related( + Prefetch( + "issue_link", + queryset=IssueLink.objects.select_related("created_by"), + ) + ) + .annotate( + is_subscribed=Exists( + IssueSubscriber.objects.filter( + workspace__slug=slug, + project_id=project.id, + issue__sequence_id=issue_identifier, + subscriber=request.user, + ) + ) + ) + ).first() + + # Check if the issue exists + if not issue: + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + """ + if the role is guest and guest_view_all_features is false and owned by is not + the requesting user then dont show the issue + """ + + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project.id, + member=request.user, + role=5, + is_active=True, + ).exists() + and not project.guest_view_all_features + and not issue.created_by == request.user + ): + return Response( + {"error": "You are not allowed to view this issue"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + recent_visited_task.delay( + slug=slug, + entity_name="issue", + entity_identifier=str(issue.id), + user_id=str(request.user.id), + project_id=str(project.id), + ) + + # Serialize the issue + serializer = IssueDetailSerializer(issue, expand=self.expand) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/issue/comment.py b/apiserver/plane/app/views/issue/comment.py index d072bb881..91d27bff2 100644 --- a/apiserver/plane/app/views/issue/comment.py +++ b/apiserver/plane/app/views/issue/comment.py @@ -5,6 +5,7 @@ import json from django.utils import timezone from django.db.models import Exists from django.core.serializers.json import DjangoJSONEncoder +from django.db import IntegrityError # Third Party imports from rest_framework.response import Response @@ -164,24 +165,32 @@ class CommentReactionViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def create(self, request, slug, project_id, comment_id): - serializer = CommentReactionSerializer(data=request.data) - if serializer.is_valid(): - serializer.save( - project_id=project_id, actor_id=request.user.id, comment_id=comment_id + try: + serializer = CommentReactionSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + project_id=project_id, + actor_id=request.user.id, + comment_id=comment_id, + ) + issue_activity.delay( + type="comment_reaction.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=None, + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError: + return Response( + {"error": "Reaction already exists for the user"}, + status=status.HTTP_400_BAD_REQUEST, ) - issue_activity.delay( - type="comment_reaction.activity.created", - requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), - actor_id=str(request.user.id), - issue_id=None, - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - notification=True, - origin=request.META.get("HTTP_ORIGIN"), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def destroy(self, request, slug, project_id, comment_id, reaction_code): diff --git a/apiserver/plane/app/views/issue/label.py b/apiserver/plane/app/views/issue/label.py index b8a960161..79a8a7770 100644 --- a/apiserver/plane/app/views/issue/label.py +++ b/apiserver/plane/app/views/issue/label.py @@ -35,7 +35,9 @@ class LabelViewSet(BaseViewSet): .order_by("sort_order") ) - @invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False) + @invalidate_cache( + path="/api/workspaces/:slug/labels/", url_params=True, user=False, multiple=True + ) @allow_permission([ROLE.ADMIN]) def create(self, request, slug, project_id): try: @@ -53,6 +55,20 @@ class LabelViewSet(BaseViewSet): @invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False) @allow_permission([ROLE.ADMIN]) def partial_update(self, request, *args, **kwargs): + # Check if the label name is unique within the project + if ( + "name" in request.data + and Label.objects.filter( + project_id=kwargs["project_id"], name=request.data["name"] + ) + .exclude(pk=kwargs["pk"]) + .exists() + ): + return Response( + {"error": "Label with the same name already exists in the project"}, + status=status.HTTP_400_BAD_REQUEST, + ) + # call the parent method to perform the update return super().partial_update(request, *args, **kwargs) @invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False) @@ -72,7 +88,7 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView): Label( name=label.get("name", "Migrated"), description=label.get("description", "Migrated Issue"), - color=f"#{random.randint(0, 0xFFFFFF+1):06X}", + color=f"#{random.randint(0, 0xFFFFFF + 1):06X}", project_id=project_id, workspace_id=project.workspace_id, created_by=request.user, diff --git a/apiserver/plane/app/views/issue/relation.py b/apiserver/plane/app/views/issue/relation.py index 35d88a54b..4b50e4b03 100644 --- a/apiserver/plane/app/views/issue/relation.py +++ b/apiserver/plane/app/views/issue/relation.py @@ -268,27 +268,19 @@ class IssueRelationViewSet(BaseViewSet): ) def remove_relation(self, request, slug, project_id, issue_id): - relation_type = request.data.get("relation_type", None) related_issue = request.data.get("related_issue", None) - if relation_type in ["blocking", "start_after", "finish_after"]: - issue_relation = IssueRelation.objects.get( - workspace__slug=slug, - project_id=project_id, - issue_id=related_issue, - related_issue_id=issue_id, - ) - else: - issue_relation = IssueRelation.objects.get( - workspace__slug=slug, - project_id=project_id, - issue_id=issue_id, - related_issue_id=related_issue, - ) - current_instance = json.dumps( - IssueRelationSerializer(issue_relation).data, cls=DjangoJSONEncoder + issue_relations = IssueRelation.objects.filter( + workspace__slug=slug, + ).filter( + Q(issue_id=related_issue, related_issue_id=issue_id) + | Q(issue_id=issue_id, related_issue_id=related_issue) ) - issue_relation.delete() + issue_relations = issue_relations.first() + current_instance = json.dumps( + IssueRelationSerializer(issue_relations).data, cls=DjangoJSONEncoder + ) + issue_relations.delete() issue_activity.delay( type="issue_relation.activity.deleted", requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index e461917fb..19e2522d2 100644 --- a/apiserver/plane/app/views/issue/sub_issue.py +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -20,7 +20,7 @@ from plane.app.serializers import IssueSerializer from plane.app.permissions import ProjectEntityPermission from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue from plane.bgtasks.issue_activities_task import issue_activity -from plane.utils.user_timezone_converter import user_timezone_converter +from plane.utils.timezone_converter import user_timezone_converter from collections import defaultdict diff --git a/apiserver/plane/app/views/issue/version.py b/apiserver/plane/app/views/issue/version.py new file mode 100644 index 000000000..ab26ca5a6 --- /dev/null +++ b/apiserver/plane/app/views/issue/version.py @@ -0,0 +1,118 @@ +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.db.models import IssueVersion, IssueDescriptionVersion +from ..base import BaseAPIView +from plane.app.serializers import ( + IssueVersionDetailSerializer, + IssueDescriptionVersionDetailSerializer, +) +from plane.app.permissions import allow_permission, ROLE +from plane.utils.global_paginator import paginate +from plane.utils.timezone_converter import user_timezone_converter + + +class IssueVersionEndpoint(BaseAPIView): + def process_paginated_result(self, fields, results, timezone): + paginated_data = results.values(*fields) + + datetime_fields = ["created_at", "updated_at"] + paginated_data = user_timezone_converter( + paginated_data, datetime_fields, timezone + ) + + return paginated_data + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id, issue_id, pk=None): + if pk: + issue_version = IssueVersion.objects.get( + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + ) + + serializer = IssueVersionDetailSerializer(issue_version) + return Response(serializer.data, status=status.HTTP_200_OK) + + cursor = request.GET.get("cursor", None) + + required_fields = [ + "id", + "workspace", + "project", + "issue", + "last_saved_at", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + + issue_versions_queryset = IssueVersion.objects.filter( + workspace__slug=slug, project_id=project_id, issue_id=issue_id + ) + + paginated_data = paginate( + base_queryset=issue_versions_queryset, + queryset=issue_versions_queryset, + cursor=cursor, + on_result=lambda results: self.process_paginated_result( + required_fields, results, request.user.user_timezone + ), + ) + + return Response(paginated_data, status=status.HTTP_200_OK) + + +class IssueDescriptionVersionEndpoint(BaseAPIView): + def process_paginated_result(self, fields, results, timezone): + paginated_data = results.values(*fields) + + datetime_fields = ["created_at", "updated_at"] + paginated_data = user_timezone_converter( + paginated_data, datetime_fields, timezone + ) + + return paginated_data + + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id, issue_id, pk=None): + if pk: + issue_description_version = IssueDescriptionVersion.objects.get( + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + ) + + serializer = IssueDescriptionVersionDetailSerializer( + issue_description_version + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + cursor = request.GET.get("cursor", None) + + required_fields = [ + "id", + "workspace", + "project", + "issue", + "last_saved_at", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + + issue_description_versions_queryset = IssueDescriptionVersion.objects.filter( + workspace__slug=slug, project_id=project_id, issue_id=issue_id + ) + paginated_data = paginate( + base_queryset=issue_description_versions_queryset, + queryset=issue_description_versions_queryset, + cursor=cursor, + on_result=lambda results: self.process_paginated_result( + required_fields, results, request.user.user_timezone + ), + ) + return Response(paginated_data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/module/archive.py b/apiserver/plane/app/views/module/archive.py index 82c1d47eb..d5c632f96 100644 --- a/apiserver/plane/app/views/module/archive.py +++ b/apiserver/plane/app/views/module/archive.py @@ -28,7 +28,7 @@ from plane.app.permissions import ProjectEntityPermission from plane.app.serializers import ModuleDetailSerializer from plane.db.models import Issue, Module, ModuleLink, UserFavorite, Project from plane.utils.analytics_plot import burndown_plot -from plane.utils.user_timezone_converter import user_timezone_converter +from plane.utils.timezone_converter import user_timezone_converter # Module imports diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index 8f9839b71..d9118de0a 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -54,9 +54,10 @@ from plane.db.models import ( ModuleLink, ModuleUserProperties, Project, + UserRecentVisit, ) from plane.utils.analytics_plot import burndown_plot -from plane.utils.user_timezone_converter import user_timezone_converter +from plane.utils.timezone_converter import user_timezone_converter from plane.bgtasks.webhook_task import model_activity from .. import BaseAPIView, BaseViewSet from plane.bgtasks.recent_visited_task import recent_visited_task @@ -808,6 +809,13 @@ class ModuleViewSet(BaseViewSet): entity_identifier=pk, project_id=project_id, ).delete() + # delete the module from recent visits + UserRecentVisit.objects.filter( + project_id=project_id, + workspace__slug=slug, + entity_identifier=pk, + entity_name="module", + ).delete(soft=False) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/module/issue.py b/apiserver/plane/app/views/module/issue.py index 06b0a2fb1..832e9a5cd 100644 --- a/apiserver/plane/app/views/module/issue.py +++ b/apiserver/plane/app/views/module/issue.py @@ -280,7 +280,7 @@ class ModuleIssueViewSet(BaseViewSet): issue_id=str(issue_id), project_id=str(project_id), current_instance=json.dumps( - {"module_name": module_issue.first().module.name} + {"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 46ce81ce1..e8a3c3ffd 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -33,13 +33,14 @@ from plane.db.models import ( ProjectMember, ProjectPage, Project, + UserRecentVisit, ) from plane.utils.error_codes import ERROR_CODES from ..base import BaseAPIView, BaseViewSet from plane.bgtasks.page_transaction_task import page_transaction 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 @@ -121,6 +122,8 @@ class PageViewSet(BaseViewSet): context={ "project_id": project_id, "owned_by_id": request.user.id, + "description": request.data.get("description", {}), + "description_binary": request.data.get("description_binary", None), "description_html": request.data.get("description_html", "

"), }, ) @@ -385,6 +388,13 @@ class PageViewSet(BaseViewSet): entity_identifier=pk, entity_type="page", ).delete() + # Delete the page from recent visit + UserRecentVisit.objects.filter( + project_id=project_id, + workspace__slug=slug, + entity_identifier=pk, + entity_name="page", + ).delete(soft=False) return Response(status=status.HTTP_204_NO_CONTENT) @@ -553,3 +563,61 @@ class PagesDescriptionViewSet(BaseViewSet): return Response({"message": "Updated successfully"}) else: return Response({"error": "No binary data provided"}) + + +class PageDuplicateEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def post(self, request, slug, project_id, page_id): + page = Page.objects.filter( + pk=page_id, workspace__slug=slug, projects__id=project_id + ).first() + + # get all the project ids where page is present + project_ids = ProjectPage.objects.filter(page_id=page_id).values_list( + "project_id", flat=True + ) + + page.pk = None + page.name = f"{page.name} (Copy)" + page.description_binary = None + page.owned_by = request.user + page.created_by = request.user + page.updated_by = request.user + page.save() + + for project_id in project_ids: + ProjectPage.objects.create( + workspace_id=page.workspace_id, + project_id=project_id, + page_id=page.id, + created_by_id=page.created_by_id, + updated_by_id=page.updated_by_id, + ) + + page_transaction.delay( + {"description_html": page.description_html}, None, page.id + ) + + # Copy the s3 objects uploaded in the page + copy_s3_objects.delay( + entity_name="PAGE", + entity_identifier=page.id, + project_id=project_id, + slug=slug, + user_id=request.user.id, + ) + + page = ( + Page.objects.filter(pk=page.id) + .annotate( + project_ids=Coalesce( + ArrayAgg( + "projects__id", distinct=True, filter=~Q(projects__id=True) + ), + Value([], output_field=ArrayField(UUIDField())), + ) + ) + .first() + ) + serializer = PageDetailSerializer(page) + return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 16b2a6e77..2b9d65e10 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -6,7 +6,7 @@ import json # Django imports from django.db import IntegrityError -from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery +from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery from django.core.serializers.json import DjangoJSONEncoder # Third Party imports @@ -25,12 +25,9 @@ from plane.app.serializers import ( from plane.app.permissions import ProjectMemberPermission, allow_permission, ROLE from plane.db.models import ( UserFavorite, - Cycle, Intake, DeployBoard, IssueUserProperty, - Issue, - Module, Project, ProjectIdentifier, ProjectMember, @@ -39,7 +36,7 @@ from plane.db.models import ( WorkspaceMember, ) from plane.utils.cache import cache_response -from plane.bgtasks.webhook_task import model_activity +from plane.bgtasks.webhook_task import model_activity, webhook_activity from plane.bgtasks.recent_visited_task import recent_visited_task from plane.utils.exception_logger import log_exception @@ -73,36 +70,6 @@ class ProjectViewSet(BaseViewSet): ) ) ) - .annotate( - is_member=Exists( - ProjectMember.objects.filter( - member=self.request.user, - project_id=OuterRef("pk"), - workspace__slug=self.kwargs.get("slug"), - is_active=True, - ) - ) - ) - .annotate( - total_members=ProjectMember.objects.filter( - project_id=OuterRef("id"), member__is_bot=False, is_active=True - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - total_cycles=Cycle.objects.filter(project_id=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - total_modules=Module.objects.filter(project_id=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) .annotate( member_role=ProjectMember.objects.filter( project_id=OuterRef("pk"), @@ -133,7 +100,7 @@ class ProjectViewSet(BaseViewSet): @allow_permission( allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" ) - def list(self, request, slug): + def list_detail(self, request, slug): fields = [field for field in request.GET.get("fields", "").split(",") if field] projects = self.get_queryset().order_by("sort_order", "name") if WorkspaceMember.objects.filter( @@ -170,6 +137,73 @@ class ProjectViewSet(BaseViewSet): ).data return Response(projects, status=status.HTTP_200_OK) + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def list(self, request, slug): + sort_order = ProjectMember.objects.filter( + member=self.request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + is_active=True, + ).values("sort_order") + + projects = ( + Project.objects.filter(workspace__slug=self.kwargs.get("slug")) + .select_related( + "workspace", "workspace__owner", "default_assignee", "project_lead" + ) + .annotate( + member_role=ProjectMember.objects.filter( + project_id=OuterRef("pk"), + member_id=self.request.user.id, + is_active=True, + ).values("role") + ) + .annotate(inbox_view=F("intake_view")) + .annotate(sort_order=Subquery(sort_order)) + .distinct() + ).values( + "id", + "name", + "identifier", + "sort_order", + "logo_props", + "member_role", + "archived_at", + "workspace", + "cycle_view", + "issue_views_view", + "module_view", + "page_view", + "inbox_view", + "project_lead", + "created_at", + "updated_at", + "created_by", + "updated_by", + ) + + if WorkspaceMember.objects.filter( + member=request.user, workspace__slug=slug, is_active=True, role=5 + ).exists(): + projects = projects.filter( + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + ) + + if WorkspaceMember.objects.filter( + member=request.user, workspace__slug=slug, is_active=True, role=15 + ).exists(): + projects = projects.filter( + Q( + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + ) + | Q(network=2) + ) + return Response(projects, status=status.HTTP_200_OK) + @allow_permission( allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" ) @@ -182,58 +216,6 @@ class ProjectViewSet(BaseViewSet): ) .filter(archived_at__isnull=True) .filter(pk=pk) - .annotate( - total_issues=Issue.issue_objects.filter( - project_id=self.kwargs.get("pk") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - sub_issues=Issue.issue_objects.filter( - project_id=self.kwargs.get("pk"), parent__isnull=False - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - archived_issues=Issue.objects.filter( - project_id=self.kwargs.get("pk"), archived_at__isnull=False - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - archived_sub_issues=Issue.objects.filter( - project_id=self.kwargs.get("pk"), - archived_at__isnull=False, - parent__isnull=False, - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - draft_issues=Issue.objects.filter( - project_id=self.kwargs.get("pk"), is_draft=True - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - draft_sub_issues=Issue.objects.filter( - project_id=self.kwargs.get("pk"), - is_draft=True, - parent__isnull=False, - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) ).first() if project is None: @@ -416,16 +398,6 @@ class ProjectViewSet(BaseViewSet): is_default=True, ) - # Create the triage state in Backlog group - State.objects.get_or_create( - name="Triage", - group="triage", - description="Default state for managing all Intake Issues", - project_id=pk, - color="#ff7700", - is_triage=True, - ) - project = self.get_queryset().filter(pk=serializer.data["id"]).first() model_activity.delay( @@ -472,7 +444,19 @@ class ProjectViewSet(BaseViewSet): ): project = Project.objects.get(pk=pk) project.delete() - + webhook_activity.delay( + event="project", + verb="deleted", + field=None, + old_value=None, + new_value=None, + actor_id=request.user.id, + slug=slug, + current_site=request.META.get("HTTP_ORIGIN"), + event_id=project.id, + old_identifier=None, + new_identifier=None, + ) # Delete the project members DeployBoard.objects.filter(project_id=pk, workspace__slug=slug).delete() diff --git a/apiserver/plane/app/views/search/base.py b/apiserver/plane/app/views/search/base.py index 5161103f5..b98e2855f 100644 --- a/apiserver/plane/app/views/search/base.py +++ b/apiserver/plane/app/views/search/base.py @@ -2,10 +2,21 @@ import re # Django imports -from django.db.models import Q, OuterRef, Subquery, Value, UUIDField, CharField +from django.db import models +from django.db.models import ( + Q, + OuterRef, + Subquery, + Value, + UUIDField, + CharField, + When, + Case, +) from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField -from django.db.models.functions import Coalesce +from django.db.models.functions import Coalesce, Concat +from django.utils import timezone # Third party imports from rest_framework import status @@ -21,7 +32,9 @@ from plane.db.models import ( Module, Page, IssueView, + ProjectMember, ProjectPage, + WorkspaceMember, ) @@ -237,3 +250,466 @@ class GlobalSearchEndpoint(BaseAPIView): func = MODELS_MAPPER.get(model, None) results[model] = func(query, slug, project_id, workspace_search) return Response({"results": results}, status=status.HTTP_200_OK) + + +class SearchEndpoint(BaseAPIView): + def get(self, request, slug): + query = request.query_params.get("query", False) + query_types = request.query_params.get("query_type", "user_mention").split(",") + query_types = [qt.strip() for qt in query_types] + count = int(request.query_params.get("count", 5)) + project_id = request.query_params.get("project_id", None) + issue_id = request.query_params.get("issue_id", None) + + response_data = {} + + if project_id: + for query_type in query_types: + if query_type == "user_mention": + fields = [ + "member__first_name", + "member__last_name", + "member__display_name", + ] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + users = ( + ProjectMember.objects.filter( + q, + is_active=True, + workspace__slug=slug, + member__is_bot=False, + project_id=project_id, + ) + .annotate( + member__avatar_url=Case( + When( + member__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "member__avatar_asset", + Value("/"), + ), + ), + When( + member__avatar_asset__isnull=True, + then="member__avatar", + ), + default=Value(None), + output_field=CharField(), + ) + ) + .order_by("-created_at") + ) + + if issue_id: + issue_created_by = ( + Issue.objects.filter(id=issue_id) + .values_list("created_by_id", flat=True) + .first() + ) + users = ( + users.filter(Q(role__gt=10) | Q(member_id=issue_created_by)) + .distinct() + .values( + "member__avatar_url", + "member__display_name", + "member__id", + ) + ) + else: + users = ( + users.filter(Q(role__gt=10)) + .distinct() + .values( + "member__avatar_url", + "member__display_name", + "member__id", + ) + ) + + response_data["user_mention"] = list(users[:count]) + + elif query_type == "project": + fields = ["name", "identifier"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + projects = ( + Project.objects.filter( + q, + Q(project_projectmember__member=self.request.user) + | Q(network=2), + workspace__slug=slug, + ) + .order_by("-created_at") + .distinct() + .values( + "name", "id", "identifier", "logo_props", "workspace__slug" + )[:count] + ) + response_data["project"] = list(projects) + + elif query_type == "issue": + fields = ["name", "sequence_id", "project__identifier"] + q = Q() + + if query: + for field in fields: + if field == "sequence_id": + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) + + issues = ( + Issue.issue_objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + project_id=project_id, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "sequence_id", + "project__identifier", + "project_id", + "priority", + "state_id", + "type_id", + )[:count] + ) + response_data["issue"] = list(issues) + + elif query_type == "cycle": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + cycles = ( + Cycle.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + project_id=project_id, + ) + .annotate( + status=Case( + When( + Q(start_date__lte=timezone.now()) + & Q(end_date__gte=timezone.now()), + then=Value("CURRENT"), + ), + When( + start_date__gt=timezone.now(), + then=Value("UPCOMING"), + ), + When( + end_date__lt=timezone.now(), then=Value("COMPLETED") + ), + When( + Q(start_date__isnull=True) + & Q(end_date__isnull=True), + then=Value("DRAFT"), + ), + default=Value("DRAFT"), + output_field=CharField(), + ) + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "status", + "workspace__slug", + )[:count] + ) + response_data["cycle"] = list(cycles) + + elif query_type == "module": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + modules = ( + Module.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + project_id=project_id, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "status", + "workspace__slug", + )[:count] + ) + response_data["module"] = list(modules) + + elif query_type == "page": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + pages = ( + Page.objects.filter( + q, + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + projects__id=project_id, + workspace__slug=slug, + access=0, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "logo_props", + "projects__id", + "workspace__slug", + )[:count] + ) + response_data["page"] = list(pages) + return Response(response_data, status=status.HTTP_200_OK) + + else: + for query_type in query_types: + if query_type == "user_mention": + fields = [ + "member__first_name", + "member__last_name", + "member__display_name", + ] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + users = ( + WorkspaceMember.objects.filter( + q, + is_active=True, + workspace__slug=slug, + member__is_bot=False, + ) + .annotate( + member__avatar_url=Case( + When( + member__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "member__avatar_asset", + Value("/"), + ), + ), + When( + member__avatar_asset__isnull=True, + then="member__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) + .order_by("-created_at") + .values( + "member__avatar_url", "member__display_name", "member__id" + )[:count] + ) + response_data["user_mention"] = list(users) + + elif query_type == "project": + fields = ["name", "identifier"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + projects = ( + Project.objects.filter( + q, + Q(project_projectmember__member=self.request.user) + | Q(network=2), + workspace__slug=slug, + ) + .order_by("-created_at") + .distinct() + .values( + "name", "id", "identifier", "logo_props", "workspace__slug" + )[:count] + ) + response_data["project"] = list(projects) + + elif query_type == "issue": + fields = ["name", "sequence_id", "project__identifier"] + q = Q() + + if query: + for field in fields: + if field == "sequence_id": + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) + + issues = ( + Issue.issue_objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "sequence_id", + "project__identifier", + "project_id", + "priority", + "state_id", + "type_id", + )[:count] + ) + response_data["issue"] = list(issues) + + elif query_type == "cycle": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + cycles = ( + Cycle.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + ) + .annotate( + status=Case( + When( + Q(start_date__lte=timezone.now()) + & Q(end_date__gte=timezone.now()), + then=Value("CURRENT"), + ), + When( + start_date__gt=timezone.now(), + then=Value("UPCOMING"), + ), + When( + end_date__lt=timezone.now(), then=Value("COMPLETED") + ), + When( + Q(start_date__isnull=True) + & Q(end_date__isnull=True), + then=Value("DRAFT"), + ), + default=Value("DRAFT"), + output_field=CharField(), + ) + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "status", + "workspace__slug", + )[:count] + ) + response_data["cycle"] = list(cycles) + + elif query_type == "module": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + modules = ( + Module.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "status", + "workspace__slug", + )[:count] + ) + response_data["module"] = list(modules) + + elif query_type == "page": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + pages = ( + Page.objects.filter( + q, + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + workspace__slug=slug, + access=0, + is_global=True, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "logo_props", + "projects__id", + "workspace__slug", + )[:count] + ) + response_data["page"] = list(pages) + return Response(response_data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/search/issue.py b/apiserver/plane/app/views/search/issue.py index 13fdc4eff..3db9e1cba 100644 --- a/apiserver/plane/app/views/search/issue.py +++ b/apiserver/plane/app/views/search/issue.py @@ -1,5 +1,3 @@ -# Python imports - # Django imports from django.db.models import Q @@ -9,7 +7,7 @@ from rest_framework.response import Response # Module imports from .base import BaseAPIView -from plane.db.models import Issue, ProjectMember +from plane.db.models import Issue, ProjectMember, IssueRelation from plane.utils.issue_search import search_issues @@ -47,17 +45,18 @@ class IssueSearchEndpoint(BaseAPIView): ) if issue_relation == "true" and issue_id: issue = Issue.issue_objects.filter(pk=issue_id).first() + related_issue_ids = IssueRelation.objects.filter( + Q(related_issue=issue) | Q(issue=issue) + ).values_list( + "issue_id", "related_issue_id" + ).distinct() + + related_issue_ids = [item for sublist in related_issue_ids for item in sublist] + if issue: issues = issues.filter( ~Q(pk=issue_id), - ~( - Q(issue_related__issue=issue) - & Q(issue_related__deleted_at__isnull=True) - ), - ~( - Q(issue_relation__related_issue=issue) - & Q(issue_relation__deleted_at__isnull=True) - ), + ~Q(pk__in=related_issue_ids), ) if sub_issue == "true" and issue_id: issue = Issue.issue_objects.filter(pk=issue_id).first() diff --git a/apiserver/plane/app/views/state/base.py b/apiserver/plane/app/views/state/base.py index 00f4813e6..419cd5a35 100644 --- a/apiserver/plane/app/views/state/base.py +++ b/apiserver/plane/app/views/state/base.py @@ -1,6 +1,9 @@ # Python imports from itertools import groupby +# Django imports +from django.db.utils import IntegrityError + # Third party imports from rest_framework.response import Response from rest_framework import status @@ -37,11 +40,36 @@ class StateViewSet(BaseViewSet): @invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False) @allow_permission([ROLE.ADMIN]) def create(self, request, slug, project_id): - serializer = StateSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(project_id=project_id) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + try: + serializer = StateSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"name": "The state name is already taken"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def partial_update(self, request, slug, project_id, pk): + try: + state = State.objects.get( + pk=pk, project_id=project_id, workspace__slug=slug + ) + serializer = StateSerializer(state, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"name": "The state name is already taken"}, + status=status.HTTP_400_BAD_REQUEST, + ) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def list(self, request, slug, project_id): diff --git a/apiserver/plane/app/views/timezone/base.py b/apiserver/plane/app/views/timezone/base.py new file mode 100644 index 000000000..77c877047 --- /dev/null +++ b/apiserver/plane/app/views/timezone/base.py @@ -0,0 +1,247 @@ +# Python imports +import pytz +from datetime import datetime + +# Django imports +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_page + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from rest_framework.permissions import AllowAny +from rest_framework.views import APIView + +# Module imports +from plane.authentication.rate_limit import AuthenticationThrottle + + +class TimezoneEndpoint(APIView): + permission_classes = [AllowAny] + + throttle_classes = [AuthenticationThrottle] + + @method_decorator(cache_page(60 * 60 * 24)) + def get(self, request): + timezone_mapping = { + "-1100": [ + ("Midway Island", "Pacific/Midway"), + ("American Samoa", "Pacific/Pago_Pago"), + ], + "-1000": [ + ("Hawaii", "Pacific/Honolulu"), + ("Aleutian Islands", "America/Adak"), + ], + "-0930": [("Marquesas Islands", "Pacific/Marquesas")], + "-0900": [ + ("Alaska", "America/Anchorage"), + ("Gambier Islands", "Pacific/Gambier"), + ], + "-0800": [ + ("Pacific Time (US and Canada)", "America/Los_Angeles"), + ("Baja California", "America/Tijuana"), + ], + "-0700": [ + ("Mountain Time (US and Canada)", "America/Denver"), + ("Arizona", "America/Phoenix"), + ("Chihuahua, Mazatlan", "America/Chihuahua"), + ], + "-0600": [ + ("Central Time (US and Canada)", "America/Chicago"), + ("Saskatchewan", "America/Regina"), + ("Guadalajara, Mexico City, Monterrey", "America/Mexico_City"), + ("Tegucigalpa, Honduras", "America/Tegucigalpa"), + ("Costa Rica", "America/Costa_Rica"), + ], + "-0500": [ + ("Eastern Time (US and Canada)", "America/New_York"), + ("Lima", "America/Lima"), + ("Bogota", "America/Bogota"), + ("Quito", "America/Guayaquil"), + ("Chetumal", "America/Cancun"), + ], + "-0430": [("Caracas (Old Venezuela Time)", "America/Caracas")], + "-0400": [ + ("Atlantic Time (Canada)", "America/Halifax"), + ("Caracas", "America/Caracas"), + ("Santiago", "America/Santiago"), + ("La Paz", "America/La_Paz"), + ("Manaus", "America/Manaus"), + ("Georgetown", "America/Guyana"), + ("Bermuda", "Atlantic/Bermuda"), + ], + "-0330": [("Newfoundland Time (Canada)", "America/St_Johns")], + "-0300": [ + ("Buenos Aires", "America/Argentina/Buenos_Aires"), + ("Brasilia", "America/Sao_Paulo"), + ("Greenland", "America/Godthab"), + ("Montevideo", "America/Montevideo"), + ("Falkland Islands", "Atlantic/Stanley"), + ], + "-0200": [ + ( + "South Georgia and the South Sandwich Islands", + "Atlantic/South_Georgia", + ) + ], + "-0100": [ + ("Azores", "Atlantic/Azores"), + ("Cape Verde Islands", "Atlantic/Cape_Verde"), + ], + "+0000": [ + ("Dublin", "Europe/Dublin"), + ("Reykjavik", "Atlantic/Reykjavik"), + ("Lisbon", "Europe/Lisbon"), + ("Monrovia", "Africa/Monrovia"), + ("Casablanca", "Africa/Casablanca"), + ], + "+0100": [ + ("Central European Time (Berlin, Rome, Paris)", "Europe/Paris"), + ("West Central Africa", "Africa/Lagos"), + ("Algiers", "Africa/Algiers"), + ("Lagos", "Africa/Lagos"), + ("Tunis", "Africa/Tunis"), + ], + "+0200": [ + ("Eastern European Time (Cairo, Helsinki, Kyiv)", "Europe/Kiev"), + ("Athens", "Europe/Athens"), + ("Jerusalem", "Asia/Jerusalem"), + ("Johannesburg", "Africa/Johannesburg"), + ("Harare, Pretoria", "Africa/Harare"), + ], + "+0300": [ + ("Moscow Time", "Europe/Moscow"), + ("Baghdad", "Asia/Baghdad"), + ("Nairobi", "Africa/Nairobi"), + ("Kuwait, Riyadh", "Asia/Riyadh"), + ], + "+0330": [("Tehran", "Asia/Tehran")], + "+0400": [ + ("Abu Dhabi", "Asia/Dubai"), + ("Baku", "Asia/Baku"), + ("Yerevan", "Asia/Yerevan"), + ("Astrakhan", "Europe/Astrakhan"), + ("Tbilisi", "Asia/Tbilisi"), + ("Mauritius", "Indian/Mauritius"), + ], + "+0500": [ + ("Islamabad", "Asia/Karachi"), + ("Karachi", "Asia/Karachi"), + ("Tashkent", "Asia/Tashkent"), + ("Yekaterinburg", "Asia/Yekaterinburg"), + ("Maldives", "Indian/Maldives"), + ("Chagos", "Indian/Chagos"), + ], + "+0530": [ + ("Chennai", "Asia/Kolkata"), + ("Kolkata", "Asia/Kolkata"), + ("Mumbai", "Asia/Kolkata"), + ("New Delhi", "Asia/Kolkata"), + ("Sri Jayawardenepura", "Asia/Colombo"), + ], + "+0545": [("Kathmandu", "Asia/Kathmandu")], + "+0600": [ + ("Dhaka", "Asia/Dhaka"), + ("Almaty", "Asia/Almaty"), + ("Bishkek", "Asia/Bishkek"), + ("Thimphu", "Asia/Thimphu"), + ], + "+0630": [ + ("Yangon (Rangoon)", "Asia/Yangon"), + ("Cocos Islands", "Indian/Cocos"), + ], + "+0700": [ + ("Bangkok", "Asia/Bangkok"), + ("Hanoi", "Asia/Ho_Chi_Minh"), + ("Jakarta", "Asia/Jakarta"), + ("Novosibirsk", "Asia/Novosibirsk"), + ("Krasnoyarsk", "Asia/Krasnoyarsk"), + ], + "+0800": [ + ("Beijing", "Asia/Shanghai"), + ("Singapore", "Asia/Singapore"), + ("Perth", "Australia/Perth"), + ("Hong Kong", "Asia/Hong_Kong"), + ("Ulaanbaatar", "Asia/Ulaanbaatar"), + ("Palau", "Pacific/Palau"), + ], + "+0845": [("Eucla", "Australia/Eucla")], + "+0900": [ + ("Tokyo", "Asia/Tokyo"), + ("Seoul", "Asia/Seoul"), + ("Yakutsk", "Asia/Yakutsk"), + ], + "+0930": [ + ("Adelaide", "Australia/Adelaide"), + ("Darwin", "Australia/Darwin"), + ], + "+1000": [ + ("Sydney", "Australia/Sydney"), + ("Brisbane", "Australia/Brisbane"), + ("Guam", "Pacific/Guam"), + ("Vladivostok", "Asia/Vladivostok"), + ("Tahiti", "Pacific/Tahiti"), + ], + "+1030": [("Lord Howe Island", "Australia/Lord_Howe")], + "+1100": [ + ("Solomon Islands", "Pacific/Guadalcanal"), + ("Magadan", "Asia/Magadan"), + ("Norfolk Island", "Pacific/Norfolk"), + ("Bougainville Island", "Pacific/Bougainville"), + ("Chokurdakh", "Asia/Srednekolymsk"), + ], + "+1200": [ + ("Auckland", "Pacific/Auckland"), + ("Wellington", "Pacific/Auckland"), + ("Fiji Islands", "Pacific/Fiji"), + ("Anadyr", "Asia/Anadyr"), + ], + "+1245": [("Chatham Islands", "Pacific/Chatham")], + "+1300": [("Nuku'alofa", "Pacific/Tongatapu"), ("Samoa", "Pacific/Apia")], + "+1400": [("Kiritimati Island", "Pacific/Kiritimati")], + } + + timezone_list = [] + now = datetime.now() + + # Process timezone mapping + for offset, locations in timezone_mapping.items(): + sign = "-" if offset.startswith("-") else "+" + hours = offset[1:3] + minutes = offset[3:] if len(offset) > 3 else "00" + + for friendly_name, tz_identifier in locations: + try: + tz = pytz.timezone(tz_identifier) + current_offset = now.astimezone(tz).strftime("%z") + + # converting and formatting UTC offset to GMT offset + current_utc_offset = now.astimezone(tz).utcoffset() + total_seconds = int(current_utc_offset.total_seconds()) + hours_offset = total_seconds // 3600 + minutes_offset = abs(total_seconds % 3600) // 60 + gmt_offset = ( + f"GMT{'+' if hours_offset >= 0 else '-'}" + f"{abs(hours_offset):02}:{minutes_offset:02}" + ) + + timezone_value = { + "offset": int(current_offset), + "utc_offset": f"UTC{sign}{hours}:{minutes}", + "gmt_offset": gmt_offset, + "value": tz_identifier, + "label": f"{friendly_name}", + } + + timezone_list.append(timezone_value) + except pytz.exceptions.UnknownTimeZoneError: + continue + + # Sort by offset and then by label + timezone_list.sort(key=lambda x: (x["offset"], x["label"])) + + # Remove offset from final output + for tz in timezone_list: + del tz["offset"] + + return Response({"timezones": timezone_list}, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py index ca4048b70..c2b401abf 100644 --- a/apiserver/plane/app/views/view/base.py +++ b/apiserver/plane/app/views/view/base.py @@ -24,6 +24,7 @@ from plane.db.models import ( ProjectMember, Project, CycleIssue, + UserRecentVisit, ) from plane.utils.grouper import ( issue_group_values, @@ -495,6 +496,13 @@ class IssueViewViewSet(BaseViewSet): entity_identifier=pk, entity_type="view", ).delete() + # Delete the page from recent visit + UserRecentVisit.objects.filter( + project_id=project_id, + workspace__slug=slug, + entity_identifier=pk, + entity_name="view", + ).delete(soft=False) else: return Response( {"error": "Only admin or owner can delete the view"}, diff --git a/apiserver/plane/app/views/webhook/base.py b/apiserver/plane/app/views/webhook/base.py index d62838fab..d7ff5cf3a 100644 --- a/apiserver/plane/app/views/webhook/base.py +++ b/apiserver/plane/app/views/webhook/base.py @@ -120,7 +120,7 @@ class WebhookLogsEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") def get(self, request, slug, webhook_id): webhook_logs = WebhookLog.objects.filter( - workspace__slug=slug, webhook_id=webhook_id + workspace__slug=slug, webhook=webhook_id ) serializer = WebhookLogSerializer(webhook_logs, many=True) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/workspace/base.py b/apiserver/plane/app/views/workspace/base.py index 058f7702a..5be9a7558 100644 --- a/apiserver/plane/app/views/workspace/base.py +++ b/apiserver/plane/app/views/workspace/base.py @@ -7,9 +7,11 @@ from datetime import date from dateutil.relativedelta import relativedelta from django.db import IntegrityError from django.db.models import Count, F, Func, OuterRef, Prefetch, Q + from django.db.models.fields import DateField from django.db.models.functions import Cast, ExtractDay, ExtractWeek + # Django imports from django.http import HttpResponse from django.utils import timezone @@ -62,12 +64,6 @@ class WorkSpaceViewSet(BaseViewSet): .values("count") ) - issue_count = ( - Issue.issue_objects.filter(workspace=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) return ( self.filter_queryset(super().get_queryset().select_related("owner")) .order_by("name") @@ -76,8 +72,6 @@ class WorkSpaceViewSet(BaseViewSet): workspace_member__is_active=True, ) .annotate(total_members=member_count) - .annotate(total_issues=issue_count) - .select_related("owner") ) def create(self, request): @@ -123,7 +117,14 @@ class WorkSpaceViewSet(BaseViewSet): role=20, company_role=request.data.get("company_role", ""), ) - return Response(serializer.data, status=status.HTTP_201_CREATED) + + # Get total members and role + total_members=WorkspaceMember.objects.filter(workspace_id=serializer.data["id"]).count() + data = serializer.data + data["total_members"] = total_members + data["role"] = 20 + + return Response(data, status=status.HTTP_201_CREATED) return Response( [serializer.errors[error][0] for error in serializer.errors], status=status.HTTP_400_BAD_REQUEST, @@ -166,11 +167,9 @@ class UserWorkSpacesEndpoint(BaseAPIView): .values("count") ) - issue_count = ( - Issue.issue_objects.filter(workspace=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") + role = ( + WorkspaceMember.objects.filter(workspace=OuterRef("id"), member=request.user, is_active=True) + .values("role") ) workspace = ( @@ -182,19 +181,19 @@ class UserWorkSpacesEndpoint(BaseAPIView): ), ) ) - .select_related("owner") - .annotate(total_members=member_count) - .annotate(total_issues=issue_count) + .annotate(role=role, total_members=member_count) .filter( workspace_member__member=request.user, workspace_member__is_active=True ) .distinct() ) + workspaces = WorkSpaceSerializer( self.filter_queryset(workspace), fields=fields if fields else None, many=True, ).data + return Response(workspaces, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/workspace/cycle.py b/apiserver/plane/app/views/workspace/cycle.py index ec08f47c9..a9398a91d 100644 --- a/apiserver/plane/app/views/workspace/cycle.py +++ b/apiserver/plane/app/views/workspace/cycle.py @@ -10,7 +10,7 @@ from plane.app.views.base import BaseAPIView from plane.db.models import Cycle 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] diff --git a/apiserver/plane/app/views/workspace/favorite.py b/apiserver/plane/app/views/workspace/favorite.py index 38fa1bdef..055f4d678 100644 --- a/apiserver/plane/app/views/workspace/favorite.py +++ b/apiserver/plane/app/views/workspace/favorite.py @@ -4,6 +4,7 @@ from rest_framework.response import Response # Django modules from django.db.models import Q +from django.db import IntegrityError # Module imports from plane.app.views.base import BaseAPIView @@ -31,16 +32,21 @@ class WorkspaceFavoriteEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def post(self, request, slug): - workspace = Workspace.objects.get(slug=slug) - serializer = UserFavoriteSerializer(data=request.data) - if serializer.is_valid(): - serializer.save( - user_id=request.user.id, - workspace=workspace, - project_id=request.data.get("project_id", None), + try: + workspace = Workspace.objects.get(slug=slug) + serializer = UserFavoriteSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + user_id=request.user.id, + workspace=workspace, + project_id=request.data.get("project_id", None), + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError: + return Response( + {"error": "Favorite already exists"}, status=status.HTTP_400_BAD_REQUEST ) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def patch(self, request, slug, favorite_id): diff --git a/apiserver/plane/app/views/workspace/home.py b/apiserver/plane/app/views/workspace/home.py new file mode 100644 index 000000000..5ee9b0a39 --- /dev/null +++ b/apiserver/plane/app/views/workspace/home.py @@ -0,0 +1,85 @@ +# Module imports +from ..base import BaseAPIView +from plane.db.models.workspace import WorkspaceHomePreference +from plane.app.permissions import allow_permission, ROLE +from plane.db.models import Workspace +from plane.app.serializers.workspace import WorkspaceHomePreferenceSerializer + +# Third party imports +from rest_framework.response import Response +from rest_framework import status + + +class WorkspaceHomePreferenceViewSet(BaseAPIView): + model = WorkspaceHomePreference + + def get_serializer_class(self): + return WorkspaceHomePreferenceSerializer + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def get(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + + get_preference = WorkspaceHomePreference.objects.filter( + user=request.user, workspace_id=workspace.id + ) + + create_preference_keys = [] + + keys = [ + key + for key, _ in WorkspaceHomePreference.HomeWidgetKeys.choices + if key not in ["quick_tutorial", "new_at_plane"] + ] + + sort_order_counter = 1 + + for preference in keys: + if preference not in get_preference.values_list("key", flat=True): + create_preference_keys.append(preference) + + sort_order = 1000 - sort_order_counter + + preference = WorkspaceHomePreference.objects.bulk_create( + [ + WorkspaceHomePreference( + key=key, + user=request.user, + workspace=workspace, + sort_order=sort_order, + ) + for key in create_preference_keys + ], + batch_size=10, + ignore_conflicts=True, + ) + sort_order_counter += 1 + + preference = WorkspaceHomePreference.objects.filter( + user=request.user, workspace_id=workspace.id + ) + + return Response( + preference.values("key", "is_enabled", "config", "sort_order"), + status=status.HTTP_200_OK, + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def patch(self, request, slug, key): + preference = WorkspaceHomePreference.objects.filter( + key=key, workspace__slug=slug, user=request.user + ).first() + + if preference: + serializer = WorkspaceHomePreferenceSerializer( + preference, data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + return Response( + {"detail": "Preference not found"}, status=status.HTTP_400_BAD_REQUEST + ) diff --git a/apiserver/plane/app/views/workspace/invite.py b/apiserver/plane/app/views/workspace/invite.py index 486a3c93b..fd3f97c19 100644 --- a/apiserver/plane/app/views/workspace/invite.py +++ b/apiserver/plane/app/views/workspace/invite.py @@ -251,8 +251,7 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet): super() .get_queryset() .filter(email=self.request.user.email) - .select_related("workspace", "workspace__owner", "created_by") - .annotate(total_members=Count("workspace__workspace_member")) + .select_related("workspace") ) @invalidate_cache(path="/api/workspaces/", user=False) diff --git a/apiserver/plane/app/views/workspace/quick_link.py b/apiserver/plane/app/views/workspace/quick_link.py new file mode 100644 index 000000000..b7decea95 --- /dev/null +++ b/apiserver/plane/app/views/workspace/quick_link.py @@ -0,0 +1,74 @@ +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.db.models import WorkspaceUserLink, Workspace +from plane.app.serializers import WorkspaceUserLinkSerializer +from ..base import BaseViewSet +from plane.app.permissions import allow_permission, ROLE + + +class QuickLinkViewSet(BaseViewSet): + model = WorkspaceUserLink + + def get_serializer_class(self): + return WorkspaceUserLinkSerializer + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def create(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + serializer = WorkspaceUserLinkSerializer(data=request.data) + + if serializer.is_valid(): + serializer.save(workspace_id=workspace.id, owner_id=request.user.id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def partial_update(self, request, slug, pk): + quick_link = WorkspaceUserLink.objects.filter( + pk=pk, workspace__slug=slug, owner=request.user + ).first() + + if quick_link: + serializer = WorkspaceUserLinkSerializer( + quick_link, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"detail": "Quick link not found."}, status=status.HTTP_404_NOT_FOUND + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def retrieve(self, request, slug, pk): + try: + quick_link = WorkspaceUserLink.objects.get( + pk=pk, workspace__slug=slug, owner=request.user + ) + serializer = WorkspaceUserLinkSerializer(quick_link) + return Response(serializer.data, status=status.HTTP_200_OK) + except WorkspaceUserLink.DoesNotExist: + return Response( + {"error": "Quick link not found."}, status=status.HTTP_404_NOT_FOUND + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def destroy(self, request, slug, pk): + quick_link = WorkspaceUserLink.objects.get( + pk=pk, workspace__slug=slug, owner=request.user + ) + quick_link.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def list(self, request, slug): + quick_links = WorkspaceUserLink.objects.filter( + workspace__slug=slug, owner=request.user + ) + + serializer = WorkspaceUserLinkSerializer(quick_links, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/workspace/recent_visit.py b/apiserver/plane/app/views/workspace/recent_visit.py new file mode 100644 index 000000000..4fe15b513 --- /dev/null +++ b/apiserver/plane/app/views/workspace/recent_visit.py @@ -0,0 +1,35 @@ +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +from plane.db.models import UserRecentVisit +from plane.app.serializers import WorkspaceRecentVisitSerializer + +# Modules imports +from ..base import BaseViewSet +from plane.app.permissions import allow_permission, ROLE + + +class UserRecentVisitViewSet(BaseViewSet): + model = UserRecentVisit + + def get_serializer_class(self): + return WorkspaceRecentVisitSerializer + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def list(self, request, slug): + user_recent_visits = UserRecentVisit.objects.filter( + workspace__slug=slug, user=request.user + ) + + entity_name = request.query_params.get("entity_name") + + if entity_name: + user_recent_visits = user_recent_visits.filter(entity_name=entity_name) + + user_recent_visits = user_recent_visits.filter( + entity_name__in=["issue", "page", "project"] + ) + + serializer = WorkspaceRecentVisitSerializer(user_recent_visits[:20], many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/workspace/sticky.py b/apiserver/plane/app/views/workspace/sticky.py new file mode 100644 index 000000000..4870a6abe --- /dev/null +++ b/apiserver/plane/app/views/workspace/sticky.py @@ -0,0 +1,59 @@ +# Third party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from plane.app.views.base import BaseViewSet +from plane.app.permissions import ROLE, allow_permission +from plane.db.models import Sticky, Workspace +from plane.app.serializers import StickySerializer + + +class WorkspaceStickyViewSet(BaseViewSet): + serializer_class = StickySerializer + model = Sticky + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(owner_id=self.request.user.id) + .select_related("workspace", "owner") + .distinct() + ) + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def create(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + serializer = StickySerializer(data=request.data) + if serializer.is_valid(): + serializer.save(workspace_id=workspace.id, owner_id=request.user.id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def list(self, request, slug): + query = request.query_params.get("query", False) + stickies = self.get_queryset().order_by("-sort_order") + if query: + stickies = stickies.filter(description_stripped__icontains=query) + + return self.paginate( + request=request, + queryset=(stickies), + on_results=lambda stickies: StickySerializer(stickies, many=True).data, + default_per_page=20, + ) + + @allow_permission(allowed_roles=[], creator=True, model=Sticky, level="WORKSPACE") + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) + + @allow_permission(allowed_roles=[], creator=True, model=Sticky, level="WORKSPACE") + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) diff --git a/apiserver/plane/app/views/workspace/user.py b/apiserver/plane/app/views/workspace/user.py index 0010b4ae7..cc1caa92c 100644 --- a/apiserver/plane/app/views/workspace/user.py +++ b/apiserver/plane/app/views/workspace/user.py @@ -375,8 +375,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): state_distribution = ( Issue.issue_objects.filter( + ( + Q(assignees__in=[user_id]) + & Q(issue_assignee__deleted_at__isnull=True) + ), workspace__slug=slug, - assignees__in=[user_id], project__project_projectmember__member=request.user, project__project_projectmember__is_active=True, ) @@ -391,8 +394,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): priority_distribution = ( Issue.issue_objects.filter( + ( + Q(assignees__in=[user_id]) + & Q(issue_assignee__deleted_at__isnull=True) + ), workspace__slug=slug, - assignees__in=[user_id], project__project_projectmember__member=request.user, project__project_projectmember__is_active=True, ) @@ -426,8 +432,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): assigned_issues_count = ( Issue.issue_objects.filter( + ( + Q(assignees__in=[user_id]) + & Q(issue_assignee__deleted_at__isnull=True) + ), workspace__slug=slug, - assignees__in=[user_id], project__project_projectmember__member=request.user, project__project_projectmember__is_active=True, ) @@ -438,8 +447,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): pending_issues_count = ( Issue.issue_objects.filter( ~Q(state__group__in=["completed", "cancelled"]), + ( + Q(assignees__in=[user_id]) + & Q(issue_assignee__deleted_at__isnull=True) + ), workspace__slug=slug, - assignees__in=[user_id], project__project_projectmember__member=request.user, project__project_projectmember__is_active=True, ) @@ -449,8 +461,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): completed_issues_count = ( Issue.issue_objects.filter( + ( + Q(assignees__in=[user_id]) + & Q(issue_assignee__deleted_at__isnull=True) + ), workspace__slug=slug, - assignees__in=[user_id], state__group="completed", project__project_projectmember__member=request.user, project__project_projectmember__is_active=True, diff --git a/apiserver/plane/app/views/workspace/user_preference.py b/apiserver/plane/app/views/workspace/user_preference.py new file mode 100644 index 000000000..07ae70ac0 --- /dev/null +++ b/apiserver/plane/app/views/workspace/user_preference.py @@ -0,0 +1,85 @@ +# Module imports +from ..base import BaseAPIView +from plane.db.models.workspace import WorkspaceUserPreference +from plane.app.serializers.workspace import WorkspaceUserPreferenceSerializer +from plane.app.permissions import allow_permission, ROLE +from plane.db.models import Workspace + + +# Third party imports +from rest_framework.response import Response +from rest_framework import status + + +class WorkspaceUserPreferenceViewSet(BaseAPIView): + model = WorkspaceUserPreference + + def get_serializer_class(self): + return WorkspaceUserPreferenceSerializer + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def get(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + + get_preference = WorkspaceUserPreference.objects.filter( + user=request.user, workspace_id=workspace.id + ) + + create_preference_keys = [] + + keys = [ + key + for key, _ in WorkspaceUserPreference.UserPreferenceKeys.choices + ] + + for preference in keys: + if preference not in get_preference.values_list("key", flat=True): + create_preference_keys.append(preference) + + preference = WorkspaceUserPreference.objects.bulk_create( + [ + WorkspaceUserPreference( + key=key, user=request.user, workspace=workspace, sort_order=(65535 + (i*10000)) + ) + for i, key in enumerate(create_preference_keys) + ], + batch_size=10, + ignore_conflicts=True, + ) + + preferences = WorkspaceUserPreference.objects.filter( + user=request.user, workspace_id=workspace.id + ).order_by("sort_order").values("key", "is_pinned", "sort_order") + + + user_preferences = {} + + for preference in preferences: + user_preferences[(str(preference["key"]))] = { + "is_pinned": preference["is_pinned"], + "sort_order": preference["sort_order"], + } + return Response( + user_preferences, + status=status.HTTP_200_OK, + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def patch(self, request, slug, key): + preference = WorkspaceUserPreference.objects.filter( + key=key, workspace__slug=slug, user=request.user + ).first() + + if preference: + serializer = WorkspaceUserPreferenceSerializer( + preference, data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + return Response( + {"detail": "Preference not found"}, status=status.HTTP_404_NOT_FOUND + ) diff --git a/apiserver/plane/authentication/urls.py b/apiserver/plane/authentication/urls.py index b52cf2a21..d474fe4df 100644 --- a/apiserver/plane/authentication/urls.py +++ b/apiserver/plane/authentication/urls.py @@ -53,7 +53,6 @@ urlpatterns = [ path("magic-generate/", MagicGenerateEndpoint.as_view(), name="magic-generate"), path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"), path("magic-sign-up/", MagicSignUpEndpoint.as_view(), name="magic-sign-up"), - path("get-csrf-token/", CSRFTokenEndpoint.as_view(), name="get_csrf_token"), path( "spaces/magic-generate/", MagicGenerateSpaceEndpoint.as_view(), diff --git a/apiserver/plane/authentication/views/app/password_management.py b/apiserver/plane/authentication/views/app/password_management.py index cad498e63..5b8d383c7 100644 --- a/apiserver/plane/authentication/views/app/password_management.py +++ b/apiserver/plane/authentication/views/app/password_management.py @@ -100,8 +100,20 @@ class ResetPasswordEndpoint(View): def post(self, request, uidb64, token): try: # Decode the id from the uidb64 - id = smart_str(urlsafe_base64_decode(uidb64)) - user = User.objects.get(id=id) + try: + id = smart_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(id=id) + except (ValueError, User.DoesNotExist): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"], + error_message="INVALID_PASSWORD_TOKEN", + ) + params = exc.get_error_dict() + url = urljoin( + base_host(request=request, is_app=True), + "accounts/reset-password?" + urlencode(params), + ) + return HttpResponseRedirect(url) # check if the token is valid for the user if not PasswordResetTokenGenerator().check_token(user, token): diff --git a/apiserver/plane/bgtasks/copy_s3_object.py b/apiserver/plane/bgtasks/copy_s3_object.py new file mode 100644 index 000000000..d73b96454 --- /dev/null +++ b/apiserver/plane/bgtasks/copy_s3_object.py @@ -0,0 +1,150 @@ +# Python imports +import uuid +import base64 +import requests +from bs4 import BeautifulSoup + +# Django imports +from django.conf import settings + +# Module imports +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 + + +def get_entity_id_field(entity_type, entity_id): + entity_mapping = { + FileAsset.EntityTypeContext.WORKSPACE_LOGO: {"workspace_id": entity_id}, + FileAsset.EntityTypeContext.PROJECT_COVER: {"project_id": entity_id}, + FileAsset.EntityTypeContext.USER_AVATAR: {"user_id": entity_id}, + FileAsset.EntityTypeContext.USER_COVER: {"user_id": entity_id}, + FileAsset.EntityTypeContext.ISSUE_ATTACHMENT: {"issue_id": entity_id}, + FileAsset.EntityTypeContext.ISSUE_DESCRIPTION: {"issue_id": entity_id}, + FileAsset.EntityTypeContext.PAGE_DESCRIPTION: {"page_id": entity_id}, + FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: {"comment_id": entity_id}, + FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION: { + "draft_issue_id": entity_id + }, + } + return entity_mapping.get(entity_type, {}) + + +def extract_asset_ids(html, tag): + try: + soup = BeautifulSoup(html, "html.parser") + return [tag.get("src") for tag in soup.find_all(tag) if tag.get("src")] + except Exception as e: + log_exception(e) + return [] + + +def replace_asset_ids(html, tag, duplicated_assets): + try: + soup = BeautifulSoup(html, "html.parser") + for mention_tag in soup.find_all(tag): + for asset in duplicated_assets: + if mention_tag.get("src") == asset["old_asset_id"]: + mention_tag["src"] = asset["new_asset_id"] + return str(soup) + except Exception as e: + log_exception(e) + return html + + +def update_description(entity, duplicated_assets, tag): + updated_html = replace_asset_ids(entity.description_html, tag, duplicated_assets) + entity.description_html = updated_html + entity.save() + return updated_html + + +# Get the description binary and description from the live server +def sync_with_external_service(entity_name, description_html): + try: + data = { + "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, + ) + if response.status_code == 200: + return response.json() + except requests.RequestException as e: + log_exception(e) + return {} + + +@shared_task +def copy_s3_objects(entity_name, entity_identifier, project_id, slug, user_id): + """ + Step 1: Extract asset ids from the description_html of the entity + Step 2: Duplicate the assets + Step 3: Update the description_html of the entity with the new asset ids (change the src of img tag) + Step 4: Request the live server to generate the description_binary and description for the entity + + """ + try: + model_class = {"PAGE": Page, "ISSUE": Issue}.get(entity_name) + if not model_class: + raise ValueError(f"Unsupported entity_name: {entity_name}") + + entity = model_class.objects.get(id=entity_identifier) + asset_ids = extract_asset_ids(entity.description_html, "image-component") + + duplicated_assets = [] + workspace = entity.workspace + storage = S3Storage() + original_assets = FileAsset.objects.filter( + workspace=workspace, project_id=project_id, id__in=asset_ids + ) + + for original_asset in original_assets: + destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}" + duplicated_asset = FileAsset.objects.create( + attributes={ + "name": original_asset.attributes.get("name"), + "type": original_asset.attributes.get("type"), + "size": original_asset.attributes.get("size"), + }, + asset=destination_key, + size=original_asset.size, + workspace=workspace, + created_by_id=user_id, + entity_type=original_asset.entity_type, + project_id=project_id, + storage_metadata=original_asset.storage_metadata, + **get_entity_id_field(original_asset.entity_type, entity_identifier), + ) + storage.copy_object(original_asset.asset, destination_key) + duplicated_assets.append( + { + "new_asset_id": str(duplicated_asset.id), + "old_asset_id": str(original_asset.id), + } + ) + + if duplicated_assets: + FileAsset.objects.filter( + pk__in=[item["new_asset_id"] for item in duplicated_assets] + ).update(is_uploaded=True) + updated_html = update_description( + entity, duplicated_assets, "image-component" + ) + external_data = sync_with_external_service(entity_name, updated_html) + + if external_data: + entity.description = external_data.get("description") + entity.description_binary = base64.b64decode( + external_data.get("description_binary") + ) + entity.save() + + return + except Exception as e: + log_exception(e) + return [] diff --git a/apiserver/plane/bgtasks/deletion_task.py b/apiserver/plane/bgtasks/deletion_task.py index 30ff7e8bd..ef57873cf 100644 --- a/apiserver/plane/bgtasks/deletion_task.py +++ b/apiserver/plane/bgtasks/deletion_task.py @@ -82,7 +82,10 @@ def soft_delete_related_objects(app_label, model_name, instance_pk, using=None): ) else: # Handle other relationships - related_queryset = getattr(instance, related_name).all() + related_queryset = getattr(instance, related_name)( + manager="objects" + ).all() + for related_obj in related_queryset: if hasattr(related_obj, "deleted_at"): if not related_obj.deleted_at: diff --git a/apiserver/plane/bgtasks/issue_activities_task.py b/apiserver/plane/bgtasks/issue_activities_task.py index e0e2ea84e..dacb39236 100644 --- a/apiserver/plane/bgtasks/issue_activities_task.py +++ b/apiserver/plane/bgtasks/issue_activities_task.py @@ -9,10 +9,10 @@ from celery import shared_task from django.core.serializers.json import DjangoJSONEncoder from django.utils import timezone -from plane.app.serializers import IssueActivitySerializer -from plane.bgtasks.notification_task import notifications # Module imports +from plane.app.serializers import IssueActivitySerializer +from plane.bgtasks.notification_task import notifications from plane.db.models import ( CommentReaction, Cycle, @@ -32,7 +32,7 @@ from plane.settings.redis import redis_instance from plane.utils.exception_logger import log_exception from plane.bgtasks.webhook_task import webhook_activity from plane.utils.issue_relation_mapper import get_inverse_relation - +from plane.utils.valid_uuid import is_valid_uuid # Track Changes in name def track_name( @@ -738,8 +738,10 @@ def delete_comment_activity( issue_activities, epoch, ): + requested_data = json.loads(requested_data) if requested_data is not None else None issue_activities.append( IssueActivity( + issue_comment_id=requested_data.get("comment_id", None), issue_id=issue_id, project_id=project_id, workspace_id=workspace_id, @@ -788,14 +790,15 @@ def create_cycle_issue_activity( issue_id=updated_record.get("issue_id"), actor_id=actor_id, verb="updated", - old_value=old_cycle.name, - new_value=new_cycle.name, + old_value=old_cycle.name if old_cycle else "", + new_value=new_cycle.name if new_cycle else "", field="cycles", project_id=project_id, workspace_id=workspace_id, - comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}", - old_identifier=old_cycle.id, - new_identifier=new_cycle.id, + comment=f"""updated cycle from {old_cycle.name if old_cycle else ""} + to {new_cycle.name if new_cycle else ""}""", + old_identifier=old_cycle.id if old_cycle else None, + new_identifier=new_cycle.id if new_cycle else None, epoch=epoch, ) ) @@ -891,11 +894,11 @@ def create_module_issue_activity( actor_id=actor_id, verb="created", old_value="", - new_value=module.name, + new_value=module.name if module else "", field="modules", project_id=project_id, workspace_id=workspace_id, - comment=f"added module {module.name}", + comment=f"added module {module.name if module else ''}", new_identifier=requested_data.get("module_id"), epoch=epoch, ) @@ -1411,7 +1414,7 @@ def delete_issue_relation_activity( ), project_id=project_id, workspace_id=workspace_id, - comment=f'deleted {requested_data.get("relation_type")} relation', + comment=f"deleted {requested_data.get('relation_type')} relation", old_identifier=requested_data.get("related_issue"), epoch=epoch, ) @@ -1565,9 +1568,14 @@ def issue_activity( try: issue_activities = [] + # check if project_id is valid + if not is_valid_uuid(project_id): + return + project = Project.objects.get(pk=project_id) workspace_id = project.workspace_id + if issue_id is not None: if origin: ri = redis_instance() diff --git a/apiserver/plane/bgtasks/issue_description_version_sync.py b/apiserver/plane/bgtasks/issue_description_version_sync.py new file mode 100644 index 000000000..14956cb50 --- /dev/null +++ b/apiserver/plane/bgtasks/issue_description_version_sync.py @@ -0,0 +1,125 @@ +# Python imports +from typing import Optional +import logging + +# Django imports +from django.utils import timezone +from django.db import transaction + +# Third party imports +from celery import shared_task + +# Module imports +from plane.db.models import Issue, IssueDescriptionVersion, ProjectMember +from plane.utils.exception_logger import log_exception + + +def get_owner_id(issue: Issue) -> Optional[int]: + """Get the owner ID of the issue""" + + if issue.updated_by_id: + return issue.updated_by_id + + if issue.created_by_id: + return issue.created_by_id + + # Find project admin as fallback + project_member = ProjectMember.objects.filter( + project_id=issue.project_id, + role=20, # Admin role + ).first() + + return project_member.member_id if project_member else None + + +@shared_task +def sync_issue_description_version(batch_size=5000, offset=0, countdown=300): + """Task to create IssueDescriptionVersion records for existing Issues in batches""" + try: + with transaction.atomic(): + base_query = Issue.objects + total_issues_count = base_query.count() + + if total_issues_count == 0: + return + + # Calculate batch range + end_offset = min(offset + batch_size, total_issues_count) + + # Fetch issues with related data + issues_batch = ( + base_query.order_by("created_at") + .select_related("workspace", "project") + .only( + "id", + "workspace_id", + "project_id", + "created_by_id", + "updated_by_id", + "description_binary", + "description_html", + "description_stripped", + "description", + )[offset:end_offset] + ) + + if not issues_batch: + return + + version_objects = [] + for issue in issues_batch: + # Validate required fields + if not issue.workspace_id or not issue.project_id: + logging.warning( + f"Skipping {issue.id} - missing workspace_id or project_id" + ) + continue + + # Determine owned_by_id + owned_by_id = get_owner_id(issue) + if owned_by_id is None: + logging.warning(f"Skipping issue {issue.id} - missing owned_by") + continue + + # Create version object + version_objects.append( + IssueDescriptionVersion( + workspace_id=issue.workspace_id, + project_id=issue.project_id, + created_by_id=issue.created_by_id, + updated_by_id=issue.updated_by_id, + owned_by_id=owned_by_id, + last_saved_at=timezone.now(), + issue_id=issue.id, + description_binary=issue.description_binary, + description_html=issue.description_html, + description_stripped=issue.description_stripped, + description_json=issue.description, + ) + ) + + # Bulk create version objects + if version_objects: + IssueDescriptionVersion.objects.bulk_create(version_objects) + + # Schedule next batch if needed + if end_offset < total_issues_count: + sync_issue_description_version.apply_async( + kwargs={ + "batch_size": batch_size, + "offset": end_offset, + "countdown": countdown, + }, + countdown=countdown, + ) + return + except Exception as e: + log_exception(e) + return + + +@shared_task +def schedule_issue_description_version(batch_size=5000, countdown=300): + sync_issue_description_version.delay( + batch_size=int(batch_size), countdown=countdown + ) diff --git a/apiserver/plane/bgtasks/issue_description_version_task.py b/apiserver/plane/bgtasks/issue_description_version_task.py new file mode 100644 index 000000000..a29fb6c57 --- /dev/null +++ b/apiserver/plane/bgtasks/issue_description_version_task.py @@ -0,0 +1,84 @@ +from celery import shared_task +from django.db import transaction +from django.utils import timezone +from typing import Optional, Dict +import json + +from plane.db.models import Issue, IssueDescriptionVersion +from plane.utils.exception_logger import log_exception + + +def should_update_existing_version( + version: IssueDescriptionVersion, user_id: str, max_time_difference: int = 600 +) -> bool: + if not version: + return + + time_difference = (timezone.now() - version.last_saved_at).total_seconds() + return ( + str(version.owned_by_id) == str(user_id) + and time_difference <= max_time_difference + ) + + +def update_existing_version(version: IssueDescriptionVersion, issue) -> None: + version.description_json = issue.description + version.description_html = issue.description_html + version.description_binary = issue.description_binary + version.description_stripped = issue.description_stripped + version.last_saved_at = timezone.now() + + version.save( + update_fields=[ + "description_json", + "description_html", + "description_binary", + "description_stripped", + "last_saved_at", + ] + ) + + +@shared_task +def issue_description_version_task( + updated_issue, issue_id, user_id, is_creating=False +) -> Optional[bool]: + try: + # Parse updated issue data + current_issue: Dict = json.loads(updated_issue) if updated_issue else {} + + # Get current issue + issue = Issue.objects.get(id=issue_id) + + # Check if description has changed + if ( + current_issue.get("description_html") == issue.description_html + and not is_creating + ): + return + + with transaction.atomic(): + # Get latest version + latest_version = ( + IssueDescriptionVersion.objects.filter(issue_id=issue_id) + .order_by("-last_saved_at") + .first() + ) + + # Determine whether to update existing or create new version + if should_update_existing_version(version=latest_version, user_id=user_id): + update_existing_version(latest_version, issue) + else: + IssueDescriptionVersion.log_issue_description_version(issue, user_id) + + return + + except Issue.DoesNotExist: + # Issue no longer exists, skip processing + return + except json.JSONDecodeError as e: + log_exception(f"Invalid JSON for updated_issue: {e}") + return + except Exception as e: + log_exception(f"Error processing issue description version: {e}") + return diff --git a/apiserver/plane/bgtasks/issue_version_sync.py b/apiserver/plane/bgtasks/issue_version_sync.py new file mode 100644 index 000000000..698cedf15 --- /dev/null +++ b/apiserver/plane/bgtasks/issue_version_sync.py @@ -0,0 +1,254 @@ +# Python imports +import json +from typing import Optional, List, Dict +from uuid import UUID +from itertools import groupby +import logging + +# Django imports +from django.utils import timezone +from django.db import transaction + +# Third party imports +from celery import shared_task + +# Module imports +from plane.db.models import ( + Issue, + IssueVersion, + ProjectMember, + CycleIssue, + ModuleIssue, + IssueActivity, + IssueAssignee, + IssueLabel, +) +from plane.utils.exception_logger import log_exception + + +@shared_task +def issue_task(updated_issue, issue_id, user_id): + try: + current_issue = json.loads(updated_issue) if updated_issue else {} + issue = Issue.objects.get(id=issue_id) + + updated_current_issue = {} + for key, value in current_issue.items(): + if getattr(issue, key) != value: + updated_current_issue[key] = value + + if updated_current_issue: + issue_version = ( + IssueVersion.objects.filter(issue_id=issue_id) + .order_by("-last_saved_at") + .first() + ) + + if ( + issue_version + and str(issue_version.owned_by) == str(user_id) + and (timezone.now() - issue_version.last_saved_at).total_seconds() + <= 600 + ): + for key, value in updated_current_issue.items(): + setattr(issue_version, key, value) + issue_version.last_saved_at = timezone.now() + issue_version.save( + update_fields=list(updated_current_issue.keys()) + ["last_saved_at"] + ) + else: + IssueVersion.log_issue_version(issue, user_id) + + return + except Issue.DoesNotExist: + return + except Exception as e: + log_exception(e) + return + + +def get_owner_id(issue: Issue) -> Optional[int]: + """Get the owner ID of the issue""" + + if issue.updated_by_id: + return issue.updated_by_id + + if issue.created_by_id: + return issue.created_by_id + + # Find project admin as fallback + project_member = ProjectMember.objects.filter( + project_id=issue.project_id, + role=20, # Admin role + ).first() + + return project_member.member_id if project_member else None + + +def get_related_data(issue_ids: List[UUID]) -> Dict: + """Get related data for the given issue IDs""" + + cycle_issues = { + ci.issue_id: ci.cycle_id + for ci in CycleIssue.objects.filter(issue_id__in=issue_ids) + } + + # Get assignees with proper grouping + assignee_records = list( + IssueAssignee.objects.filter(issue_id__in=issue_ids) + .values_list("issue_id", "assignee_id") + .order_by("issue_id") + ) + assignees = {} + for issue_id, group in groupby(assignee_records, key=lambda x: x[0]): + assignees[issue_id] = [str(g[1]) for g in group] + + # Get labels with proper grouping + label_records = list( + IssueLabel.objects.filter(issue_id__in=issue_ids) + .values_list("issue_id", "label_id") + .order_by("issue_id") + ) + labels = {} + for issue_id, group in groupby(label_records, key=lambda x: x[0]): + labels[issue_id] = [str(g[1]) for g in group] + + # Get modules with proper grouping + module_records = list( + ModuleIssue.objects.filter(issue_id__in=issue_ids) + .values_list("issue_id", "module_id") + .order_by("issue_id") + ) + modules = {} + for issue_id, group in groupby(module_records, key=lambda x: x[0]): + modules[issue_id] = [str(g[1]) for g in group] + + # Get latest activities + latest_activities = {} + activities = IssueActivity.objects.filter(issue_id__in=issue_ids).order_by( + "issue_id", "-created_at" + ) + for issue_id, activities_group in groupby(activities, key=lambda x: x.issue_id): + first_activity = next(activities_group, None) + if first_activity: + latest_activities[issue_id] = first_activity.id + + return { + "cycle_issues": cycle_issues, + "assignees": assignees, + "labels": labels, + "modules": modules, + "activities": latest_activities, + } + + +def create_issue_version(issue: Issue, related_data: Dict) -> Optional[IssueVersion]: + """Create IssueVersion object from the given issue and related data""" + + try: + if not issue.workspace_id or not issue.project_id: + logging.warning( + f"Skipping issue {issue.id} - missing workspace_id or project_id" + ) + return None + + owned_by_id = get_owner_id(issue) + if owned_by_id is None: + logging.warning(f"Skipping issue {issue.id} - missing owned_by") + return None + + return IssueVersion( + workspace_id=issue.workspace_id, + project_id=issue.project_id, + created_by_id=issue.created_by_id, + updated_by_id=issue.updated_by_id, + owned_by_id=owned_by_id, + last_saved_at=timezone.now(), + activity_id=related_data["activities"].get(issue.id), + properties=getattr(issue, "properties", {}), + meta=getattr(issue, "meta", {}), + issue_id=issue.id, + parent=issue.parent_id, + state=issue.state_id, + estimate_point=issue.estimate_point_id, + name=issue.name, + priority=issue.priority, + start_date=issue.start_date, + target_date=issue.target_date, + assignees=related_data["assignees"].get(issue.id, []), + sequence_id=issue.sequence_id, + labels=related_data["labels"].get(issue.id, []), + sort_order=issue.sort_order, + completed_at=issue.completed_at, + archived_at=issue.archived_at, + is_draft=issue.is_draft, + external_source=issue.external_source, + external_id=issue.external_id, + type=issue.type_id, + cycle=related_data["cycle_issues"].get(issue.id), + modules=related_data["modules"].get(issue.id, []), + ) + except Exception as e: + log_exception(e) + return None + + +@shared_task +def sync_issue_version(batch_size=5000, offset=0, countdown=300): + """Task to create IssueVersion records for existing Issues in batches""" + + try: + with transaction.atomic(): + base_query = Issue.objects + total_issues_count = base_query.count() + + if total_issues_count == 0: + return + + end_offset = min(offset + batch_size, total_issues_count) + + # Get issues batch with optimized queries + issues_batch = list( + base_query.order_by("created_at") + .select_related("workspace", "project") + .all()[offset:end_offset] + ) + + if not issues_batch: + return + + # Get all related data in bulk + issue_ids = [issue.id for issue in issues_batch] + related_data = get_related_data(issue_ids) + + issue_versions = [] + for issue in issues_batch: + version = create_issue_version(issue, related_data) + if version: + issue_versions.append(version) + + # Bulk create versions + if issue_versions: + IssueVersion.objects.bulk_create(issue_versions, batch_size=1000) + + # Schedule the next batch if there are more workspaces to process + if end_offset < total_issues_count: + sync_issue_version.apply_async( + kwargs={ + "batch_size": batch_size, + "offset": end_offset, + "countdown": countdown, + }, + countdown=countdown, + ) + + logging.info(f"Processed Issues: {end_offset}") + return + except Exception as e: + log_exception(e) + return + + +@shared_task +def schedule_issue_version(batch_size=5000, countdown=300): + sync_issue_version.delay(batch_size=int(batch_size), countdown=countdown) diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py index f61c2e3c3..e58344bbf 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -32,7 +32,6 @@ from bs4 import BeautifulSoup def update_mentions_for_issue(issue, project, new_mentions, removed_mention): aggregated_issue_mentions = [] - for mention_id in new_mentions: aggregated_issue_mentions.append( IssueMention( @@ -125,7 +124,9 @@ def extract_mentions(issue_instance): data = json.loads(issue_instance) html = data.get("description_html") soup = BeautifulSoup(html, "html.parser") - mention_tags = soup.find_all("mention-component", attrs={"target": "users"}) + mention_tags = soup.find_all( + "mention-component", attrs={"entity_name": "user_mention"} + ) mentions = [mention_tag["entity_identifier"] for mention_tag in mention_tags] @@ -139,7 +140,9 @@ def extract_comment_mentions(comment_value): try: mentions = [] soup = BeautifulSoup(comment_value, "html.parser") - mentions_tags = soup.find_all("mention-component", attrs={"target": "users"}) + mentions_tags = soup.find_all( + "mention-component", attrs={"entity_name": "user_mention"} + ) for mention_tag in mentions_tags: mentions.append(mention_tag["entity_identifier"]) return list(set(mentions)) @@ -255,12 +258,9 @@ def notifications( new_mentions = get_new_mentions( requested_instance=requested_data, current_instance=current_instance ) - - new_mentions = [ - str(mention) - for mention in new_mentions - if mention in set(project_members) - ] + new_mentions = list( + set(new_mentions) & {str(member) for member in project_members} + ) removed_mention = get_removed_mentions( requested_instance=requested_data, current_instance=current_instance ) diff --git a/apiserver/plane/bgtasks/recent_visited_task.py b/apiserver/plane/bgtasks/recent_visited_task.py index e8e3eb60f..4203867da 100644 --- a/apiserver/plane/bgtasks/recent_visited_task.py +++ b/apiserver/plane/bgtasks/recent_visited_task.py @@ -1,5 +1,6 @@ # Python imports from django.utils import timezone +from django.db import DatabaseError # Third party imports from celery import shared_task @@ -22,8 +23,12 @@ def recent_visited_task(entity_name, entity_identifier, user_id, project_id, slu ).first() if recent_visited: - recent_visited.visited_at = timezone.now() - recent_visited.save(update_fields=["visited_at"]) + # Check if the database is available + try: + recent_visited.visited_at = timezone.now() + recent_visited.save(update_fields=["visited_at"]) + except DatabaseError: + pass else: recent_visited_count = UserRecentVisit.objects.filter( user_id=user_id, workspace_id=workspace.id diff --git a/apiserver/plane/bgtasks/webhook_task.py b/apiserver/plane/bgtasks/webhook_task.py index d5d8b0043..c1ea01a4d 100644 --- a/apiserver/plane/bgtasks/webhook_task.py +++ b/apiserver/plane/bgtasks/webhook_task.py @@ -136,7 +136,7 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site): # Log the webhook request WebhookLog.objects.create( workspace_id=str(webhook.workspace_id), - webhook_id=str(webhook.id), + webhook=str(webhook.id), event_type=str(event), request_method=str(action), request_headers=str(headers), @@ -153,7 +153,7 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site): # Log the failed webhook request WebhookLog.objects.create( workspace_id=str(webhook.workspace_id), - webhook_id=str(webhook.id), + webhook=str(webhook.id), event_type=str(event), request_method=str(action), request_headers=str(headers), @@ -304,7 +304,7 @@ def webhook_send_task( # Log the webhook request WebhookLog.objects.create( workspace_id=str(webhook.workspace_id), - webhook_id=str(webhook.id), + webhook=str(webhook.id), event_type=str(event), request_method=str(action), request_headers=str(headers), @@ -319,7 +319,7 @@ def webhook_send_task( # Log the failed webhook request WebhookLog.objects.create( workspace_id=str(webhook.workspace_id), - webhook_id=str(webhook.id), + webhook=str(webhook.id), event_type=str(event), request_method=str(action), request_headers=str(headers), @@ -387,7 +387,11 @@ def webhook_activity( webhook=webhook.id, slug=slug, event=event, - event_data=get_model_data(event=event, event_id=event_id), + event_data=( + {"id": event_id} + if verb == "deleted" + else get_model_data(event=event, event_id=event_id) + ), action=verb, current_site=current_site, activity={ diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index c0b945e62..a8bd0d7d0 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -16,9 +16,9 @@ from plane.utils.exception_logger import log_exception @shared_task -def workspace_invitation(email, workspace_id, token, current_site, invitor): +def workspace_invitation(email, workspace_id, token, current_site, inviter): try: - user = User.objects.get(email=invitor) + user = User.objects.get(email=inviter) workspace = Workspace.objects.get(pk=workspace_id) workspace_member_invite = WorkspaceMemberInvite.objects.get( @@ -26,7 +26,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): ) # Relative link - relative_link = f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" + relative_link = f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" # noqa: E501 # The complete url including the domain abs_url = str(current_site) + relative_link @@ -42,7 +42,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): ) = get_email_configuration() # Subject of the email - subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane" + subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane" # noqa: E501 context = { "email": email, @@ -78,11 +78,9 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): ) msg.attach_alternative(html_content, "text/html") msg.send() - logging.getLogger("plane").info("Email sent succesfully") - + logging.getLogger("plane").info("Email sent successfully") return - except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e: - log_exception(e) + except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist): return except Exception as e: log_exception(e) diff --git a/apiserver/plane/db/management/commands/sync_issue_description_version.py b/apiserver/plane/db/management/commands/sync_issue_description_version.py new file mode 100644 index 000000000..7ff2fc391 --- /dev/null +++ b/apiserver/plane/db/management/commands/sync_issue_description_version.py @@ -0,0 +1,23 @@ +# Django imports +from django.core.management.base import BaseCommand + +# Module imports +from plane.bgtasks.issue_description_version_sync import ( + schedule_issue_description_version, +) + + +class Command(BaseCommand): + help = "Creates IssueDescriptionVersion records for existing Issues in batches" + + def handle(self, *args, **options): + batch_size = input("Enter the batch size: ") + batch_countdown = input("Enter the batch countdown: ") + + schedule_issue_description_version.delay( + batch_size=batch_size, countdown=int(batch_countdown) + ) + + self.stdout.write( + self.style.SUCCESS("Successfully created issue description version task") + ) diff --git a/apiserver/plane/db/management/commands/sync_issue_version.py b/apiserver/plane/db/management/commands/sync_issue_version.py new file mode 100644 index 000000000..2b6632f26 --- /dev/null +++ b/apiserver/plane/db/management/commands/sync_issue_version.py @@ -0,0 +1,19 @@ +# Django imports +from django.core.management.base import BaseCommand + +# Module imports +from plane.bgtasks.issue_version_sync import schedule_issue_version + + +class Command(BaseCommand): + help = "Creates IssueVersion records for existing Issues in batches" + + def handle(self, *args, **options): + batch_size = input("Enter the batch size: ") + batch_countdown = input("Enter the batch countdown: ") + + schedule_issue_version.delay( + batch_size=batch_size, countdown=int(batch_countdown) + ) + + self.stdout.write(self.style.SUCCESS("Successfully created issue version task")) diff --git a/apiserver/plane/db/migrations/0087_remove_issueversion_description_and_more.py b/apiserver/plane/db/migrations/0087_remove_issueversion_description_and_more.py new file mode 100644 index 000000000..086f52316 --- /dev/null +++ b/apiserver/plane/db/migrations/0087_remove_issueversion_description_and_more.py @@ -0,0 +1,117 @@ +# Generated by Django 4.2.17 on 2024-12-13 10:09 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import plane.db.models.user +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0086_issueversion_alter_teampage_unique_together_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='issueversion', + name='description', + ), + migrations.RemoveField( + model_name='issueversion', + name='description_binary', + ), + migrations.RemoveField( + model_name='issueversion', + name='description_html', + ), + migrations.RemoveField( + model_name='issueversion', + name='description_stripped', + ), + migrations.AddField( + model_name='issueversion', + name='activity', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='versions', to='db.issueactivity'), + ), + migrations.AddField( + model_name='profile', + name='is_mobile_onboarded', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='profile', + name='mobile_onboarding_step', + field=models.JSONField(default=plane.db.models.user.get_mobile_default_onboarding), + ), + migrations.AddField( + model_name='profile', + name='mobile_timezone_auto_set', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='profile', + name='language', + field=models.CharField(default='en', max_length=255), + ), + migrations.AlterField( + model_name='issueversion', + name='owned_by', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_versions', to=settings.AUTH_USER_MODEL), + ), + migrations.CreateModel( + name='Sticky', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.TextField()), + ('description', models.JSONField(blank=True, default=dict)), + ('description_html', models.TextField(blank=True, default='

')), + ('description_stripped', models.TextField(blank=True, null=True)), + ('description_binary', models.BinaryField(null=True)), + ('logo_props', models.JSONField(default=dict)), + ('color', models.CharField(blank=True, max_length=255, null=True)), + ('background_color', models.CharField(blank=True, max_length=255, null=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stickies', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stickies', to='db.workspace')), + ], + options={ + 'verbose_name': 'Sticky', + 'verbose_name_plural': 'Stickies', + 'db_table': 'stickies', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='IssueDescriptionVersion', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('description_binary', models.BinaryField(null=True)), + ('description_html', models.TextField(blank=True, default='

')), + ('description_stripped', models.TextField(blank=True, null=True)), + ('description_json', models.JSONField(blank=True, default=dict)), + ('last_saved_at', models.DateTimeField(default=django.utils.timezone.now)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='description_versions', to='db.issue')), + ('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_description_versions', to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Description Version', + 'verbose_name_plural': 'Issue Description Versions', + 'db_table': 'issue_description_versions', + }, + ), + ] diff --git a/apiserver/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py b/apiserver/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py new file mode 100644 index 000000000..1b3122157 --- /dev/null +++ b/apiserver/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py @@ -0,0 +1,124 @@ +# Generated by Django 4.2.15 on 2024-12-24 14:57 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0087_remove_issueversion_description_and_more'), + ] + + operations = [ + migrations.AddField( + model_name="sticky", + name="sort_order", + field=models.FloatField(default=65535), + ), + migrations.CreateModel( + name="WorkspaceUserLink", + fields=[ + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("title", models.CharField(blank=True, max_length=255, null=True)), + ("url", models.TextField()), + ("metadata", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="owner_workspace_user_link", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Workspace User Link", + "verbose_name_plural": "Workspace User Links", + "db_table": "workspace_user_links", + "ordering": ("-created_at",), + }, + ), + migrations.AlterField( + model_name="pagelog", + name="entity_name", + field=models.CharField(max_length=30, verbose_name="Transaction Type"), + ), + migrations.AlterUniqueTogether( + name="webhook", + unique_together={("workspace", "url", "deleted_at")}, + ), + migrations.AddConstraint( + model_name="webhook", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("workspace", "url"), + name="webhook_url_unique_url_when_deleted_at_null", + ), + ), + ] diff --git a/apiserver/plane/db/migrations/0089_workspacehomepreference_and_more.py b/apiserver/plane/db/migrations/0089_workspacehomepreference_and_more.py new file mode 100644 index 000000000..b13f65070 --- /dev/null +++ b/apiserver/plane/db/migrations/0089_workspacehomepreference_and_more.py @@ -0,0 +1,120 @@ +# Generated by Django 4.2.17 on 2025-01-02 07:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0088_sticky_sort_order_workspaceuserlink"), + ] + + operations = [ + migrations.CreateModel( + name="WorkspaceHomePreference", + fields=[ + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("key", models.CharField(max_length=255)), + ("is_enabled", models.BooleanField(default=True)), + ("config", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_user_home_preferences", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_user_home_preferences", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Workspace Home Preference", + "verbose_name_plural": "Workspace Home Preferences", + "db_table": "workspace_home_preferences", + "ordering": ("-created_at",), + }, + ), + migrations.AddConstraint( + model_name="workspacehomepreference", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("workspace", "user", "key"), + name="workspace_user_home_preferences_unique_workspace_user_key_when_deleted_at_null", + ), + ), + migrations.AlterUniqueTogether( + name="workspacehomepreference", + unique_together={("workspace", "user", "key", "deleted_at")}, + ), + migrations.AlterField( + model_name="page", + name="name", + field=models.TextField(blank=True), + ), + migrations.AlterField( + model_name="sticky", + name="name", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='workspacehomepreference', + name='sort_order', + field=models.PositiveIntegerField(default=65535), + ), + ] diff --git a/apiserver/plane/db/migrations/0090_rename_dashboard_deprecateddashboard_and_more.py b/apiserver/plane/db/migrations/0090_rename_dashboard_deprecateddashboard_and_more.py new file mode 100644 index 000000000..e0d16d5a6 --- /dev/null +++ b/apiserver/plane/db/migrations/0090_rename_dashboard_deprecateddashboard_and_more.py @@ -0,0 +1,87 @@ + # Generated by Django 4.2.17 on 2025-01-09 14:43 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0089_workspacehomepreference_and_more'), + ] + + operations = [ + migrations.RenameModel( + old_name='Dashboard', + new_name='DeprecatedDashboard', + ), + migrations.RenameModel( + old_name='DashboardWidget', + new_name='DeprecatedDashboardWidget', + ), + migrations.RenameModel( + old_name='Widget', + new_name='DeprecatedWidget', + ), + migrations.AlterModelOptions( + name='deprecateddashboard', + options={'ordering': ('-created_at',), 'verbose_name': 'DeprecatedDashboard', 'verbose_name_plural': 'DeprecatedDashboards'}, + ), + migrations.AlterModelOptions( + name='deprecateddashboardwidget', + options={'ordering': ('-created_at',), 'verbose_name': 'Deprecated Dashboard Widget', 'verbose_name_plural': 'Deprecated Dashboard Widgets'}, + ), + migrations.AlterModelOptions( + name='deprecatedwidget', + options={'ordering': ('-created_at',), 'verbose_name': 'DeprecatedWidget', 'verbose_name_plural': 'DeprecatedWidgets'}, + ), + migrations.AlterField( + model_name='workspacehomepreference', + name='sort_order', + field=models.FloatField(default=65535), + ), + migrations.AlterModelTable( + name='deprecateddashboard', + table='deprecated_dashboards', + ), + migrations.AlterModelTable( + name='deprecateddashboardwidget', + table='deprecated_dashboard_widgets', + ), + migrations.AlterModelTable( + name='deprecatedwidget', + table='deprecated_widgets', + ), + migrations.CreateModel( + name='WorkspaceUserPreference', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('key', models.CharField(max_length=255)), + ('is_pinned', models.BooleanField(default=False)), + ('sort_order', models.FloatField(default=65535)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_user_preferences', to=settings.AUTH_USER_MODEL)), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_user_preferences', to='db.workspace')), + ], + options={ + 'verbose_name': 'Workspace User Preference', + 'verbose_name_plural': 'Workspace User Preferences', + 'db_table': 'workspace_user_preferences', + 'ordering': ('-created_at',), + }, + ), + migrations.AddConstraint( + model_name='workspaceuserpreference', + constraint=models.UniqueConstraint(condition=models.Q(('deleted_at__isnull', True)), fields=('workspace', 'user', 'key'), name='workspace_user_preferences_unique_workspace_user_key_when_deleted_at_null'), + ), + migrations.AlterUniqueTogether( + name='workspaceuserpreference', + unique_together={('workspace', 'user', 'key', 'deleted_at')}, + ), + ] diff --git a/apiserver/plane/db/migrations/0091_issuecomment_edited_at_and_more.py b/apiserver/plane/db/migrations/0091_issuecomment_edited_at_and_more.py new file mode 100644 index 000000000..c6fb825a7 --- /dev/null +++ b/apiserver/plane/db/migrations/0091_issuecomment_edited_at_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.17 on 2025-01-30 16:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0090_rename_dashboard_deprecateddashboard_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='issuecomment', + name='edited_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='profile', + name='is_smooth_cursor_enabled', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='userrecentvisit', + name='entity_name', + field=models.CharField(max_length=30), + ), + migrations.AlterField( + model_name='webhooklog', + name='webhook', + field=models.UUIDField(), + ) + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index e3a9df254..8e20a8d67 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -3,7 +3,7 @@ from .api import APIActivityLog, APIToken from .asset import FileAsset from .base import BaseModel from .cycle import Cycle, CycleIssue, CycleUserProperties -from .dashboard import Dashboard, DashboardWidget, Widget +from .dashboard import DeprecatedDashboard, DeprecatedDashboardWidget, DeprecatedWidget from .deploy_board import DeployBoard from .draft import ( DraftIssue, @@ -41,6 +41,8 @@ from .issue import ( IssueSequence, IssueSubscriber, IssueVote, + IssueVersion, + IssueDescriptionVersion, ) from .module import Module, ModuleIssue, ModuleLink, ModuleMember, ModuleUserProperties from .notification import EmailNotificationLog, Notification, UserNotificationPreference @@ -66,17 +68,11 @@ from .workspace import ( WorkspaceMemberInvite, WorkspaceTheme, WorkspaceUserProperties, + WorkspaceUserLink, + WorkspaceHomePreference, + WorkspaceUserPreference, ) - - - - - - - - - from .favorite import UserFavorite from .issue_type import IssueType @@ -86,3 +82,5 @@ from .recent_visit import UserRecentVisit from .label import Label from .device import Device, DeviceSession + +from .sticky import Sticky diff --git a/apiserver/plane/db/models/dashboard.py b/apiserver/plane/db/models/dashboard.py index e0f401753..d1101e352 100644 --- a/apiserver/plane/db/models/dashboard.py +++ b/apiserver/plane/db/models/dashboard.py @@ -8,7 +8,7 @@ from ..mixins import TimeAuditModel from .base import BaseModel -class Dashboard(BaseModel): +class DeprecatedDashboard(BaseModel): DASHBOARD_CHOICES = ( ("workspace", "Workspace"), ("project", "Project"), @@ -36,13 +36,13 @@ class Dashboard(BaseModel): return f"{self.name}" class Meta: - verbose_name = "Dashboard" - verbose_name_plural = "Dashboards" - db_table = "dashboards" + verbose_name = "DeprecatedDashboard" + verbose_name_plural = "DeprecatedDashboards" + db_table = "deprecated_dashboards" ordering = ("-created_at",) -class Widget(TimeAuditModel): +class DeprecatedWidget(TimeAuditModel): id = models.UUIDField( default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True ) @@ -55,18 +55,18 @@ class Widget(TimeAuditModel): return f"{self.key}" class Meta: - verbose_name = "Widget" - verbose_name_plural = "Widgets" - db_table = "widgets" + verbose_name = "DeprecatedWidget" + verbose_name_plural = "DeprecatedWidgets" + db_table = "deprecated_widgets" ordering = ("-created_at",) -class DashboardWidget(BaseModel): +class DeprecatedDashboardWidget(BaseModel): widget = models.ForeignKey( - Widget, on_delete=models.CASCADE, related_name="dashboard_widgets" + DeprecatedWidget, on_delete=models.CASCADE, related_name="dashboard_widgets" ) dashboard = models.ForeignKey( - Dashboard, on_delete=models.CASCADE, related_name="dashboard_widgets" + DeprecatedDashboard, on_delete=models.CASCADE, related_name="dashboard_widgets" ) is_visible = models.BooleanField(default=True) sort_order = models.FloatField(default=65535) @@ -86,7 +86,7 @@ class DashboardWidget(BaseModel): name="dashboard_widget_unique_widget_dashboard_when_deleted_at_null", ) ] - verbose_name = "Dashboard Widget" - verbose_name_plural = "Dashboard Widgets" - db_table = "dashboard_widgets" + verbose_name = "Deprecated Dashboard Widget" + verbose_name_plural = "Deprecated Dashboard Widgets" + db_table = "deprecated_dashboard_widgets" ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 9ea1d3b26..fe5e9937c 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -15,6 +15,7 @@ from django import apps from plane.utils.html_processor import strip_tags from plane.db.mixins import SoftDeletionManager from plane.utils.exception_logger import log_exception +from .base import BaseModel from .project import ProjectBaseModel @@ -466,6 +467,7 @@ class IssueComment(ProjectBaseModel): ) external_source = models.CharField(max_length=255, null=True, blank=True) external_id = models.CharField(max_length=255, blank=True, null=True) + edited_at = models.DateTimeField(null=True, blank=True) def save(self, *args, **kwargs): self.comment_stripped = ( @@ -660,9 +662,6 @@ class IssueVote(ProjectBaseModel): class IssueVersion(ProjectBaseModel): - issue = models.ForeignKey( - "db.Issue", on_delete=models.CASCADE, related_name="versions" - ) PRIORITY_CHOICES = ( ("urgent", "Urgent"), ("high", "High"), @@ -670,14 +669,11 @@ class IssueVersion(ProjectBaseModel): ("low", "Low"), ("none", "None"), ) + parent = models.UUIDField(blank=True, null=True) state = models.UUIDField(blank=True, null=True) estimate_point = models.UUIDField(blank=True, null=True) name = models.CharField(max_length=255, verbose_name="Issue Name") - description = models.JSONField(blank=True, default=dict) - description_html = models.TextField(blank=True, default="

") - description_stripped = models.TextField(blank=True, null=True) - description_binary = models.BinaryField(null=True) priority = models.CharField( max_length=30, choices=PRIORITY_CHOICES, @@ -686,7 +682,9 @@ class IssueVersion(ProjectBaseModel): ) start_date = models.DateField(null=True, blank=True) target_date = models.DateField(null=True, blank=True) + assignees = ArrayField(models.UUIDField(), blank=True, default=list) sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID") + labels = ArrayField(models.UUIDField(), blank=True, default=list) sort_order = models.FloatField(default=65535) completed_at = models.DateTimeField(null=True) archived_at = models.DateField(null=True) @@ -694,14 +692,26 @@ class IssueVersion(ProjectBaseModel): external_source = models.CharField(max_length=255, null=True, blank=True) external_id = models.CharField(max_length=255, blank=True, null=True) type = models.UUIDField(blank=True, null=True) - last_saved_at = models.DateTimeField(default=timezone.now) - owned_by = models.UUIDField() - assignees = ArrayField(models.UUIDField(), blank=True, default=list) - labels = ArrayField(models.UUIDField(), blank=True, default=list) cycle = models.UUIDField(null=True, blank=True) modules = ArrayField(models.UUIDField(), blank=True, default=list) - properties = models.JSONField(default=dict) - meta = models.JSONField(default=dict) + properties = models.JSONField(default=dict) # issue properties + meta = models.JSONField(default=dict) # issue meta + last_saved_at = models.DateTimeField(default=timezone.now) + + issue = models.ForeignKey( + "db.Issue", on_delete=models.CASCADE, related_name="versions" + ) + activity = models.ForeignKey( + "db.IssueActivity", + on_delete=models.SET_NULL, + null=True, + related_name="versions", + ) + owned_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_versions", + ) class Meta: verbose_name = "Issue Version" @@ -721,39 +731,93 @@ class IssueVersion(ProjectBaseModel): Module = apps.get_model("db.Module") CycleIssue = apps.get_model("db.CycleIssue") + IssueAssignee = apps.get_model("db.IssueAssignee") + IssueLabel = apps.get_model("db.IssueLabel") cycle_issue = CycleIssue.objects.filter(issue=issue).first() cls.objects.create( issue=issue, - parent=issue.parent, - state=issue.state, - point=issue.point, - estimate_point=issue.estimate_point, + parent=issue.parent_id, + state=issue.state_id, + estimate_point=issue.estimate_point_id, name=issue.name, - description=issue.description, - description_html=issue.description_html, - description_stripped=issue.description_stripped, - description_binary=issue.description_binary, priority=issue.priority, start_date=issue.start_date, target_date=issue.target_date, + assignees=list( + IssueAssignee.objects.filter(issue=issue).values_list( + "assignee_id", flat=True + ) + ), sequence_id=issue.sequence_id, + labels=list( + IssueLabel.objects.filter(issue=issue).values_list( + "label_id", flat=True + ) + ), sort_order=issue.sort_order, completed_at=issue.completed_at, archived_at=issue.archived_at, is_draft=issue.is_draft, external_source=issue.external_source, external_id=issue.external_id, - type=issue.type, - last_saved_at=issue.last_saved_at, - assignees=issue.assignees, - labels=issue.labels, - cycle=cycle_issue.cycle if cycle_issue else None, - modules=Module.objects.filter(issue=issue).values_list("id", flat=True), + type=issue.type_id, + cycle=cycle_issue.cycle_id if cycle_issue else None, + modules=list( + Module.objects.filter(issue=issue).values_list("id", flat=True) + ), + properties={}, + meta={}, + last_saved_at=timezone.now(), owned_by=user, ) return True except Exception as e: log_exception(e) return False + + +class IssueDescriptionVersion(ProjectBaseModel): + issue = models.ForeignKey( + "db.Issue", on_delete=models.CASCADE, related_name="description_versions" + ) + description_binary = models.BinaryField(null=True) + description_html = models.TextField(blank=True, default="

") + description_stripped = models.TextField(blank=True, null=True) + description_json = models.JSONField(default=dict, blank=True) + last_saved_at = models.DateTimeField(default=timezone.now) + owned_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_description_versions", + ) + + class Meta: + verbose_name = "Issue Description Version" + verbose_name_plural = "Issue Description Versions" + db_table = "issue_description_versions" + + @classmethod + def log_issue_description_version(cls, issue, user): + try: + """ + Log the issue description version + """ + cls.objects.create( + workspace_id=issue.workspace_id, + project_id=issue.project_id, + created_by_id=issue.created_by_id, + updated_by_id=issue.updated_by_id, + owned_by_id=user, + last_saved_at=timezone.now(), + issue_id=issue.id, + description_binary=issue.description_binary, + description_html=issue.description_html, + description_stripped=issue.description_stripped, + description_json=issue.description, + ) + return True + except Exception as e: + log_exception(e) + return False diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index 81e2b15a0..91ffcf023 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -20,7 +20,7 @@ class Page(BaseModel): workspace = models.ForeignKey( "db.Workspace", on_delete=models.CASCADE, related_name="pages" ) - name = models.CharField(max_length=255, blank=True) + name = models.TextField(blank=True) description = models.JSONField(default=dict, blank=True) description_binary = models.BinaryField(null=True) description_html = models.TextField(blank=True, default="

") @@ -90,7 +90,7 @@ class PageLog(BaseModel): page = models.ForeignKey(Page, related_name="page_log", on_delete=models.CASCADE) entity_identifier = models.UUIDField(null=True) entity_name = models.CharField( - max_length=30, choices=TYPE_CHOICES, verbose_name="Transaction Type" + max_length=30, verbose_name="Transaction Type" ) workspace = models.ForeignKey( "db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log" diff --git a/apiserver/plane/db/models/recent_visit.py b/apiserver/plane/db/models/recent_visit.py index 14737eba5..42855081b 100644 --- a/apiserver/plane/db/models/recent_visit.py +++ b/apiserver/plane/db/models/recent_visit.py @@ -17,7 +17,7 @@ class EntityNameEnum(models.TextChoices): class UserRecentVisit(WorkspaceBaseModel): entity_identifier = models.UUIDField(null=True) - entity_name = models.CharField(max_length=30, choices=EntityNameEnum.choices) + entity_name = models.CharField(max_length=30) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, diff --git a/apiserver/plane/db/models/sticky.py b/apiserver/plane/db/models/sticky.py new file mode 100644 index 000000000..34f37b81e --- /dev/null +++ b/apiserver/plane/db/models/sticky.py @@ -0,0 +1,57 @@ +# Django imports +from django.conf import settings +from django.db import models + +# Module imports +from .base import BaseModel + +# Third party imports +from plane.utils.html_processor import strip_tags + + +class Sticky(BaseModel): + name = models.TextField(null=True, blank=True) + + description = models.JSONField(blank=True, default=dict) + description_html = models.TextField(blank=True, default="

") + description_stripped = models.TextField(blank=True, null=True) + description_binary = models.BinaryField(null=True) + + logo_props = models.JSONField(default=dict) + color = models.CharField(max_length=255, blank=True, null=True) + background_color = models.CharField(max_length=255, blank=True, null=True) + + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="stickies" + ) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="stickies" + ) + sort_order = models.FloatField(default=65535) + + class Meta: + verbose_name = "Sticky" + verbose_name_plural = "Stickies" + db_table = "stickies" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + # Strip the html tags using html parser + self.description_stripped = ( + None + if (self.description_html == "" or self.description_html is None) + else strip_tags(self.description_html) + ) + if self._state.adding: + # Get the maximum sequence value from the database + last_id = Sticky.objects.filter(workspace=self.workspace).aggregate( + largest=models.Max("sort_order") + )["largest"] + # if last_id is not None + if last_id is not None: + self.sort_order = last_id + 10000 + + super(Sticky, self).save(*args, **kwargs) + + def __str__(self): + return str(self.name) diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index 34a86a251..a7ac5251e 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -26,6 +26,14 @@ def get_default_onboarding(): } +def get_mobile_default_onboarding(): + return { + "profile_complete": False, + "workspace_create": False, + "workspace_join": False, + } + + class User(AbstractBaseUser, PermissionsMixin): id = models.UUIDField( default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True @@ -179,6 +187,14 @@ class Profile(TimeAuditModel): has_billing_address = models.BooleanField(default=False) company_name = models.CharField(max_length=255, blank=True) + is_smooth_cursor_enabled = models.BooleanField(default=False) + # mobile + is_mobile_onboarded = models.BooleanField(default=False) + mobile_onboarding_step = models.JSONField(default=get_mobile_default_onboarding) + mobile_timezone_auto_set = models.BooleanField(default=False) + # language + language = models.CharField(max_length=255, default="en") + class Meta: verbose_name = "Profile" verbose_name_plural = "Profiles" diff --git a/apiserver/plane/db/models/webhook.py b/apiserver/plane/db/models/webhook.py index ec8fcda3a..b1428523b 100644 --- a/apiserver/plane/db/models/webhook.py +++ b/apiserver/plane/db/models/webhook.py @@ -47,11 +47,18 @@ class Webhook(BaseModel): return f"{self.workspace.slug} {self.url}" class Meta: - unique_together = ["workspace", "url"] + unique_together = ["workspace", "url", "deleted_at"] verbose_name = "Webhook" verbose_name_plural = "Webhooks" db_table = "webhooks" ordering = ("-created_at",) + constraints = [ + models.UniqueConstraint( + fields=["workspace", "url"], + condition=models.Q(deleted_at__isnull=True), + name="webhook_url_unique_url_when_deleted_at_null", + ) + ] class WebhookLog(BaseModel): @@ -59,7 +66,7 @@ class WebhookLog(BaseModel): "db.Workspace", on_delete=models.CASCADE, related_name="webhook_logs" ) # Associated webhook - webhook = models.ForeignKey(Webhook, on_delete=models.CASCADE, related_name="logs") + webhook = models.UUIDField() # Basic request details event_type = models.CharField(max_length=255, blank=True, null=True) @@ -82,4 +89,4 @@ class WebhookLog(BaseModel): ordering = ("-created_at",) def __str__(self): - return f"{self.event_type} {str(self.webhook.url)}" + return f"{self.event_type} {str(self.webhook)}" diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index f8082e492..2c0370a61 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -1,4 +1,5 @@ # Python imports +from django.db.models.functions import Ln import pytz # Django imports @@ -322,3 +323,106 @@ class WorkspaceUserProperties(BaseModel): def __str__(self): return f"{self.workspace.name} {self.user.email}" + + +class WorkspaceUserLink(WorkspaceBaseModel): + title = models.CharField(max_length=255, null=True, blank=True) + url = models.TextField() + metadata = models.JSONField(default=dict) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="owner_workspace_user_link", + ) + + class Meta: + verbose_name = "Workspace User Link" + verbose_name_plural = "Workspace User Links" + db_table = "workspace_user_links" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.workspace.id} {self.url}" + + +class WorkspaceHomePreference(BaseModel): + """Preference for the home page of a workspace for a user""" + + class HomeWidgetKeys(models.TextChoices): + QUICK_LINKS = "quick_links", "Quick Links" + RECENTS = "recents", "Recents" + MY_STICKIES = "my_stickies", "My Stickies" + NEW_AT_PLANE = "new_at_plane", "New at Plane" + QUICK_TUTORIAL = "quick_tutorial", "Quick Tutorial" + + workspace = models.ForeignKey( + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_user_home_preferences", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="workspace_user_home_preferences", + ) + key = models.CharField(max_length=255) + is_enabled = models.BooleanField(default=True) + config = models.JSONField(default=dict) + sort_order = models.FloatField(default=65535) + + class Meta: + unique_together = ["workspace", "user", "key", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["workspace", "user", "key"], + condition=models.Q(deleted_at__isnull=True), + name="workspace_user_home_preferences_unique_workspace_user_key_when_deleted_at_null", + ) + ] + verbose_name = "Workspace Home Preference" + verbose_name_plural = "Workspace Home Preferences" + db_table = "workspace_home_preferences" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.workspace.name} {self.user.email} {self.key}" + + +class WorkspaceUserPreference(BaseModel): + """Preference for the workspace for a user""" + + class UserPreferenceKeys(models.TextChoices): + VIEWS = "views", "Views" + ACTIVE_CYCLES = "active_cycles", "Active Cycles" + ANALYTICS = "analytics", "Analytics" + DRAFTS = "drafts", "Drafts" + YOUR_WORK = "your_work", "Your Work" + ARCHIVES = "archives", "Archives" + + workspace = models.ForeignKey( + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_user_preferences", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="workspace_user_preferences", + ) + key = models.CharField(max_length=255) + is_pinned = models.BooleanField(default=False) + sort_order = models.FloatField(default=65535) + + class Meta: + unique_together = ["workspace", "user", "key", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["workspace", "user", "key"], + condition=models.Q(deleted_at__isnull=True), + name="workspace_user_preferences_unique_workspace_user_key_when_deleted_at_null", + ) + ] + verbose_name = "Workspace User Preference" + verbose_name_plural = "Workspace User Preferences" + db_table = "workspace_user_preferences" + ordering = ("-created_at",) diff --git a/apiserver/plane/license/api/views/admin.py b/apiserver/plane/license/api/views/admin.py index 10c6df5c3..97f0e446e 100644 --- a/apiserver/plane/license/api/views/admin.py +++ b/apiserver/plane/license/api/views/admin.py @@ -290,11 +290,12 @@ class InstanceAdminSignInEndpoint(View): # Fetch the user user = User.objects.filter(email=email).first() - # is_active - if not user.is_active: + # Error out if the user is not present + if not user: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"], - error_message="ADMIN_USER_DEACTIVATED", + error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DOES_NOT_EXIST"], + error_message="ADMIN_USER_DOES_NOT_EXIST", + payload={"email": email}, ) url = urljoin( base_host(request=request, is_admin=True), @@ -302,12 +303,11 @@ class InstanceAdminSignInEndpoint(View): ) return HttpResponseRedirect(url) - # Error out if the user is not present - if not user: + # is_active + if not user.is_active: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DOES_NOT_EXIST"], - error_message="ADMIN_USER_DOES_NOT_EXIST", - payload={"email": email}, + error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"], + error_message="ADMIN_USER_DEACTIVATED", ) url = urljoin( base_host(request=request, is_admin=True), diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py index 548c9c77e..8458df5df 100644 --- a/apiserver/plane/license/management/commands/configure_instance.py +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -132,20 +132,33 @@ class Command(BaseCommand): "is_encrypted": False, }, { - "key": "OPENAI_API_KEY", - "value": os.environ.get("OPENAI_API_KEY"), - "category": "OPENAI", + "key": "LLM_API_KEY", + "value": os.environ.get("LLM_API_KEY"), + "category": "AI", "is_encrypted": True, }, { - "key": "GPT_ENGINE", + "key": "LLM_PROVIDER", + "value": os.environ.get("LLM_PROVIDER", "openai"), + "category": "AI", + "is_encrypted": False, + }, + { + "key": "LLM_MODEL", + "value": os.environ.get("LLM_MODEL", "gpt-4o-mini"), + "category": "AI", + "is_encrypted": False, + }, + # Deprecated, use LLM_MODEL + { + "key": "GPT_ENGINE", "value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), "category": "SMTP", "is_encrypted": False, }, { "key": "UNSPLASH_ACCESS_KEY", - "value": os.environ.get("UNSPLASH_ACESS_KEY", ""), + "value": os.environ.get("UNSPLASH_ACCESS_KEY", ""), "category": "UNSPLASH", "is_encrypted": True, }, diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index ed42dfe19..10776f9af 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -262,6 +262,9 @@ CELERY_IMPORTS = ( "plane.license.bgtasks.tracer", # management tasks "plane.bgtasks.dummy_data_task", + # issue version tasks + "plane.bgtasks.issue_version_sync", + "plane.bgtasks.issue_description_version_sync", ) # Sentry Settings @@ -333,6 +336,8 @@ CSRF_FAILURE_VIEW = "plane.authentication.views.common.csrf_failure" ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None) 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") + HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60)) @@ -358,6 +363,18 @@ ATTACHMENT_MIME_TYPES = [ "application/vnd.openxmlformats-officedocument.presentationml.presentation", "text/plain", "application/rtf", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.presentation", + "application/vnd.oasis.opendocument.graphics", + # Microsoft Visio + "application/vnd.visio", + # Netpbm format + "image/x-portable-graymap", + "image/x-portable-bitmap", + "image/x-portable-pixmap", + # Open Office Bae + "application/vnd.oasis.opendocument.database", # Audio "audio/mpeg", "audio/wav", diff --git a/apiserver/plane/settings/storage.py b/apiserver/plane/settings/storage.py index d82d1c912..c843e9af4 100644 --- a/apiserver/plane/settings/storage.py +++ b/apiserver/plane/settings/storage.py @@ -151,3 +151,17 @@ class S3Storage(S3Boto3Storage): "ETag": response.get("ETag"), "Metadata": response.get("Metadata", {}), } + + def copy_object(self, object_name, new_object_name): + """Copy an S3 object to a new location""" + try: + response = self.s3_client.copy_object( + Bucket=self.aws_storage_bucket_name, + CopySource={"Bucket": self.aws_storage_bucket_name, "Key": object_name}, + Key=new_object_name, + ) + except ClientError as e: + log_exception(e) + return None + + return response diff --git a/apiserver/plane/space/utils/grouper.py b/apiserver/plane/space/utils/grouper.py index 250b54e89..274058842 100644 --- a/apiserver/plane/space/utils/grouper.py +++ b/apiserver/plane/space/utils/grouper.py @@ -91,6 +91,7 @@ def issue_on_results(issues, group_by, sub_group_by): Case( When( votes__isnull=False, + votes__deleted_at__isnull=True, then=JSONObject( vote=F("votes__vote"), actor_details=JSONObject( @@ -117,13 +118,14 @@ def issue_on_results(issues, group_by, sub_group_by): default=None, output_field=JSONField(), ), - filter=Q(votes__isnull=False), + filter=Q(votes__isnull=False,votes__deleted_at__isnull=True), distinct=True, ), reaction_items=ArrayAgg( Case( When( issue_reactions__isnull=False, + issue_reactions__deleted_at__isnull=True, then=JSONObject( reaction=F("issue_reactions__reaction"), actor_details=JSONObject( @@ -150,7 +152,7 @@ def issue_on_results(issues, group_by, sub_group_by): default=None, output_field=JSONField(), ), - filter=Q(issue_reactions__isnull=False), + 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/asset.py b/apiserver/plane/space/views/asset.py index 2c6722038..3e1d4d6f7 100644 --- a/apiserver/plane/space/views/asset.py +++ b/apiserver/plane/space/views/asset.py @@ -86,7 +86,13 @@ class EntityAssetEndpoint(BaseAPIView): ) # Check if the file type is allowed - allowed_types = ["image/jpeg", "image/png", "image/webp"] + allowed_types = [ + "image/jpeg", + "image/png", + "image/webp", + "image/jpg", + "image/gif", + ] if type not in allowed_types: return Response( { diff --git a/apiserver/plane/space/views/intake.py b/apiserver/plane/space/views/intake.py index 0d39dd276..bfce3a8bb 100644 --- a/apiserver/plane/space/views/intake.py +++ b/apiserver/plane/space/views/intake.py @@ -130,15 +130,6 @@ class IntakeIssuePublicViewSet(BaseViewSet): {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST ) - # Create or get state - state, _ = State.objects.get_or_create( - name="Triage", - group="backlog", - description="Default state for managing all Intake Issues", - project_id=project_deploy_board.project_id, - color="#ff7700", - ) - # create an issue issue = Issue.objects.create( name=request.data.get("issue", {}).get("name"), @@ -148,7 +139,6 @@ class IntakeIssuePublicViewSet(BaseViewSet): ), priority=request.data.get("issue", {}).get("priority", "low"), project_id=project_deploy_board.project_id, - state=state, ) # Create an Issue Activity diff --git a/apiserver/plane/space/views/issue.py b/apiserver/plane/space/views/issue.py index a1ab332f9..699253ae5 100644 --- a/apiserver/plane/space/views/issue.py +++ b/apiserver/plane/space/views/issue.py @@ -701,6 +701,7 @@ class IssueRetrievePublicEndpoint(BaseAPIView): Case( When( votes__isnull=False, + votes__deleted_at__isnull=True, then=JSONObject( vote=F("votes__vote"), actor_details=JSONObject( @@ -732,7 +733,11 @@ class IssueRetrievePublicEndpoint(BaseAPIView): output_field=JSONField(), ), filter=Case( - When(votes__isnull=False, then=True), + When( + votes__isnull=False, + votes__deleted_at__isnull=True, + then=True, + ), default=False, output_field=JSONField(), ), @@ -742,6 +747,7 @@ class IssueRetrievePublicEndpoint(BaseAPIView): Case( When( issue_reactions__isnull=False, + issue_reactions__deleted_at__isnull=True, then=JSONObject( reaction=F("issue_reactions__reaction"), actor_details=JSONObject( @@ -775,7 +781,11 @@ class IssueRetrievePublicEndpoint(BaseAPIView): output_field=JSONField(), ), filter=Case( - When(issue_reactions__isnull=False, then=True), + When( + issue_reactions__isnull=False, + issue_reactions__deleted_at__isnull=True, + then=True, + ), default=False, output_field=JSONField(), ), diff --git a/apiserver/plane/space/views/meta.py b/apiserver/plane/space/views/meta.py index fa4413599..d092e7e58 100644 --- a/apiserver/plane/space/views/meta.py +++ b/apiserver/plane/space/views/meta.py @@ -14,9 +14,9 @@ class ProjectMetaDataEndpoint(BaseAPIView): def get(self, request, anchor): try: - deploy_board = DeployBoard.objects.filter( + deploy_board = DeployBoard.objects.get( anchor=anchor, entity_name="project" - ).first() + ) except DeployBoard.DoesNotExist: return Response( {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND diff --git a/apiserver/plane/utils/timezone_converter.py b/apiserver/plane/utils/timezone_converter.py new file mode 100644 index 000000000..40480b4f6 --- /dev/null +++ b/apiserver/plane/utils/timezone_converter.py @@ -0,0 +1,125 @@ +# Python imports +import pytz +from datetime import datetime, time +from datetime import timedelta + +# Django imports +from django.utils import timezone + +# Module imports +from plane.db.models import Project + + +def user_timezone_converter(queryset, datetime_fields, user_timezone): + # Create a timezone object for the user's timezone + user_tz = pytz.timezone(user_timezone) + + # Check if queryset is a dictionary (single item) or a list of dictionaries + if isinstance(queryset, dict): + queryset_values = [queryset] + else: + queryset_values = list(queryset) + + # Iterate over the dictionaries in the list + for item in queryset_values: + # Iterate over the datetime fields + for field in datetime_fields: + # Convert the datetime field to the user's timezone + if field in item and item[field]: + item[field] = item[field].astimezone(user_tz) + + # If queryset was a single item, return a single item + if isinstance(queryset, dict): + return queryset_values[0] + else: + return queryset_values + + +def convert_to_utc( + date, project_id, is_start_date=False, is_start_date_end_date_equal=False +): + """ + Converts a start date string to the project's local timezone at 12:00 AM + and then converts it to UTC for storage. + + Args: + date (str): The date string in "YYYY-MM-DD" format. + project_id (int): The project's ID to fetch the associated timezone. + + Returns: + datetime: The UTC datetime. + """ + # Retrieve the project's timezone using the project ID + project = Project.objects.get(id=project_id) + project_timezone = project.timezone + if not date or not project_timezone: + raise ValueError("Both date and timezone must be provided.") + + # Parse the string into a date object + start_date = datetime.strptime(date, "%Y-%m-%d").date() + + # Get the project's timezone + local_tz = pytz.timezone(project_timezone) + + # Combine the date with 12:00 AM time + local_datetime = datetime.combine(start_date, time.min) + + # Localize the datetime to the project's timezone + localized_datetime = local_tz.localize(local_datetime) + + # If it's an start date, add one minute + if is_start_date: + localized_datetime += timedelta(minutes=0, seconds=1) + + # Convert the localized datetime to UTC + utc_datetime = localized_datetime.astimezone(pytz.utc) + + current_datetime_in_project_tz = timezone.now().astimezone(local_tz) + current_datetime_in_utc = current_datetime_in_project_tz.astimezone(pytz.utc) + + if localized_datetime.date() == current_datetime_in_project_tz.date(): + return current_datetime_in_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) + + # Convert the localized datetime to UTC + utc_datetime = localized_datetime.astimezone(pytz.utc) + + # Return the UTC datetime for storage + return utc_datetime + + +def convert_utc_to_project_timezone(utc_datetime, project_id): + """ + Converts a UTC datetime (stored in the database) to the project's local timezone. + + Args: + utc_datetime (datetime): The UTC datetime to be converted. + project_id (int): The project's ID to fetch the associated timezone. + + Returns: + datetime: The datetime in the project's local timezone. + """ + # Retrieve the project's timezone using the project ID + project = Project.objects.get(id=project_id) + project_timezone = project.timezone + if not project_timezone: + raise ValueError("Project timezone must be provided.") + + # Get the timezone object for the project's timezone + local_tz = pytz.timezone(project_timezone) + + # Convert the UTC datetime to the project's local timezone + if utc_datetime.tzinfo is None: + # Localize UTC datetime if it's naive (i.e., without timezone info) + utc_datetime = pytz.utc.localize(utc_datetime) + + # Convert to the project's local timezone + local_datetime = utc_datetime.astimezone(local_tz) + + return local_datetime diff --git a/apiserver/plane/utils/user_timezone_converter.py b/apiserver/plane/utils/user_timezone_converter.py deleted file mode 100644 index 550abfe99..000000000 --- a/apiserver/plane/utils/user_timezone_converter.py +++ /dev/null @@ -1,26 +0,0 @@ -import pytz - - -def user_timezone_converter(queryset, datetime_fields, user_timezone): - # Create a timezone object for the user's timezone - user_tz = pytz.timezone(user_timezone) - - # Check if queryset is a dictionary (single item) or a list of dictionaries - if isinstance(queryset, dict): - queryset_values = [queryset] - else: - queryset_values = list(queryset) - - # Iterate over the dictionaries in the list - for item in queryset_values: - # Iterate over the datetime fields - for field in datetime_fields: - # Convert the datetime field to the user's timezone - if field in item and item[field]: - item[field] = item[field].astimezone(user_tz) - - # If queryset was a single item, return a single item - if isinstance(queryset, dict): - return queryset_values[0] - else: - return queryset_values diff --git a/apiserver/plane/utils/valid_uuid.py b/apiserver/plane/utils/valid_uuid.py new file mode 100644 index 000000000..a44105136 --- /dev/null +++ b/apiserver/plane/utils/valid_uuid.py @@ -0,0 +1,8 @@ +import uuid + +def is_valid_uuid(uuid_str): + try: + uuid.UUID(uuid_str, version=4) + return True + except ValueError: + return False diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 40e90aedf..41a5e0f13 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -1,10 +1,10 @@ # base requirements # django -Django==4.2.17 +Django==4.2.18 # rest framework djangorestframework==3.15.2 -# postgres +# postgres psycopg==3.1.18 psycopg-binary==3.1.18 psycopg-c==3.1.18 @@ -37,7 +37,7 @@ uvicorn==0.29.0 # sockets channels==4.1.0 # ai -openai==1.25.0 +openai==1.63.2 # slack slack-sdk==3.27.1 # apm @@ -51,7 +51,7 @@ beautifulsoup4==4.12.3 # analytics posthog==3.5.0 # crypto -cryptography==43.0.1 +cryptography==44.0.1 # html validator lxml==5.2.1 # s3 @@ -66,4 +66,4 @@ PyJWT==2.8.0 opentelemetry-api==1.28.1 opentelemetry-sdk==1.28.1 opentelemetry-instrumentation-django==0.49b1 -opentelemetry-exporter-otlp==1.28.1 \ No newline at end of file +opentelemetry-exporter-otlp==1.28.1 diff --git a/apiserver/requirements/local.txt b/apiserver/requirements/local.txt index 02792201b..2146554f6 100644 --- a/apiserver/requirements/local.txt +++ b/apiserver/requirements/local.txt @@ -2,4 +2,4 @@ # debug toolbar django-debug-toolbar==4.3.0 # formatter -ruff==0.4.2 \ No newline at end of file +ruff==0.9.7 diff --git a/app.json b/app.json index bc5789078..600b524d2 100644 --- a/app.json +++ b/app.json @@ -70,7 +70,7 @@ "value": "" }, "GITHUB_CLIENT_SECRET": { - "description": "Github Client Secret", + "description": "GitHub Client Secret", "value": "" }, "NEXT_PUBLIC_API_BASE_URL": { diff --git a/deploy/selfhost/README.md b/deploy/selfhost/README.md index d93d85ca1..e5a8089e6 100644 --- a/deploy/selfhost/README.md +++ b/deploy/selfhost/README.md @@ -55,14 +55,26 @@ Installing plane is a very easy and minimal step process. - User context used must have access to docker services. In most cases, use sudo su to switch as root user - Use the terminal (or gitbash) window to run all the future steps -### Downloading Latest Stable Release +### Downloading Latest Release ``` mkdir plane-selfhost cd plane-selfhost +``` -curl -fsSL -o setup.sh https://raw.githubusercontent.com/makeplane/plane/master/deploy/selfhost/install.sh +#### For *Docker Compose* based setup + +``` +curl -fsSL -o setup.sh https://github.com/makeplane/plane/releases/latest/download/setup.sh + +chmod +x setup.sh +``` + +#### For *Docker Swarm* based setup + +``` +curl -fsSL -o setup.sh https://github.com/makeplane/plane/releases/latest/download/swarm.sh chmod +x setup.sh ``` @@ -77,8 +89,9 @@ Lets get started by running the `./setup.sh` command. This will prompt you with the below options. +#### Docker Compose ```bash -Select a Action you want to perform: +Select an Action you want to perform: 1) Install (x86_64) 2) Start 3) Stop @@ -87,17 +100,42 @@ Select a Action you want to perform: 6) View Logs 7) Backup Data 8) Exit + Action [2]: 1 ``` For the 1st time setup, type "1" as action input. -This will create a create a folder `plane-app` or `plane-app-preview` (in case of preview deployment) and will download 2 files inside that +This will create a folder `plane-app` and will download 2 files inside that - `docker-compose.yaml` - `plane.env` -Again the `options [1-8]` will be popped up and this time hit `8` to exit. +Again the `options [1-8]` will be popped up, and this time hit `8` to exit. + +#### Docker Swarm + +```bash +Select an Action you want to perform: + 1) Deploy Stack + 2) Remove Stack + 3) View Stack Status + 4) Redeploy Stack + 5) Upgrade + 6) View Logs + 7) Exit + +Action [3]: 1 +``` + +For the 1st time setup, type "1" as action input. + +This will create a create a folder `plane-app` and will download 2 files inside that + +- `docker-compose.yaml` +- `plane.env` + +Again the `options [1-7]` will be popped up, and this time hit `7` to exit. --- @@ -116,7 +154,7 @@ There are many other settings you can play with, but we suggest you configure `E --- -### Continue with setup - Start Server +### Continue with setup - Start Server (Docker Compose) Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `2` to start the sevices @@ -147,9 +185,11 @@ You have successfully self hosted `Plane` instance. Access the application by go --- -### Stopping the Server +### Stopping the Server / Remove Stack -In case you want to make changes to `.env` variables, we suggest you to stop the services before doing that. +In case you want to make changes to `plane.env` variables, we suggest you to stop the services before doing that. + +#### Docker Compose Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `3` to stop the sevices @@ -171,14 +211,34 @@ If all goes well, you must see something like this ![Stop Services](images/stopped.png) +#### Docker Swarm + +Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `2` to stop the sevices + +```bash +Select an Action you want to perform: + 1) Deploy Stack + 2) Remove Stack + 3) View Stack Status + 4) Redeploy Stack + 5) Upgrade + 6) View Logs + 7) Exit + +Action [3]: 2 +``` + +If all goes well, you will see the confirmation from docker cli + --- -### Restarting the Server +### Restarting the Server / Redeploy Stack -In case you want to make changes to `.env` variables, without stopping the server or you noticed some abnormalies in services, you can restart the services with RESTART option. +In case you want to make changes to `plane.env` variables, without stopping the server or you noticed some abnormalies in services, you can restart the services with `RESTART` / `REDEPLOY` option. Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `4` to restart the sevices +#### Docker Compose ```bash Select a Action you want to perform: 1) Install (x86_64) @@ -197,14 +257,32 @@ If all goes well, you must see something like this ![Restart Services](images/restart.png) +#### Docker Swarm + +```bash + 1) Deploy Stack + 2) Remove Stack + 3) View Stack Status + 4) Redeploy Stack + 5) Upgrade + 6) View Logs + 7) Exit + +Action [3]: 4 +``` + +If all goes well, you will see the confirmation from docker cli + --- -### Upgrading Plane Version +### Upgrading Plane Version It is always advised to keep Plane up to date with the latest release. Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `5` to upgrade the release. +#### Docker Compose + ```bash Select a Action you want to perform: 1) Install (x86_64) @@ -231,13 +309,41 @@ Once done, choose `8` to exit from prompt. Once done with making changes in `plane.env` file, jump on to `Start Server` +#### Docker Swarm + +Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `5` to upgrade the release. + +```bash + 1) Deploy Stack + 2) Remove Stack + 3) View Stack Status + 4) Redeploy Stack + 5) Upgrade + 6) View Logs + 7) Exit + +Action [3]: 5 +``` + +By choosing this, it will stop the services and then will download the latest `docker-compose.yaml` and `plane.env`. + +Once done, choose `7` to exit from prompt. + +> It is very important for you to validate the `plane.env` for the new changes. + +Once done with making changes in `plane.env` file, jump on to `Redeploy Stack` + --- ### View Logs There would a time when you might want to check what is happening inside the API, Worker or any other container. -Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `6` to view logs. +Lets again run the `./setup.sh` command. You will again be prompted with the below options. + +This time select `6` to view logs. + +#### Docker Compose ```bash Select a Action you want to perform: @@ -253,7 +359,22 @@ Select a Action you want to perform: Action [2]: 6 ``` +#### Docker Swarm + +```bash + 1) Deploy Stack + 2) Remove Stack + 3) View Stack Status + 4) Redeploy Stack + 5) Upgrade + 6) View Logs + 7) Exit + +Action [3]: 6 +``` + +#### Service Menu Options for Logs This will further open sub-menu with list of services ```bash Select a Service you want to view the logs for: @@ -267,9 +388,10 @@ Select a Service you want to view the logs for: 8) Redis 9) Postgres 10) Minio + 11) RabbitMQ 0) Back to Main Menu -Service: +Service: 3 ``` Select any of the service to view the logs e.g. `3`. Expect something similar to this @@ -323,7 +445,7 @@ Similarly, you can view the logs of other services. --- -### Backup Data +### Backup Data (Docker Compose) There would a time when you might want to backup your data from docker volumes to external storage like S3 or drives. @@ -355,7 +477,7 @@ Backup completed successfully. Backup files are stored in /....../plane-app/back --- -### Restore Data +### Restore Data (Docker Compose) When you want to restore the previously backed-up data, follow the instructions below. diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index fe47e625f..49123c73c 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -1,131 +1,131 @@ -x-app-env: &app-env - environment: - - NGINX_PORT=${NGINX_PORT:-80} - - WEB_URL=${WEB_URL:-http://localhost} - - DEBUG=${DEBUG:-0} - - SENTRY_DSN=${SENTRY_DSN:-""} - - SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"} - - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-} - # Gunicorn Workers - - GUNICORN_WORKERS=${GUNICORN_WORKERS:-1} - #DB SETTINGS - - PGHOST=${PGHOST:-plane-db} - - PGDATABASE=${PGDATABASE:-plane} - - POSTGRES_USER=${POSTGRES_USER:-plane} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-plane} - - POSTGRES_DB=${POSTGRES_DB:-plane} - - POSTGRES_PORT=${POSTGRES_PORT:-5432} - - PGDATA=${PGDATA:-/var/lib/postgresql/data} - - DATABASE_URL=${DATABASE_URL:-postgresql://plane:plane@plane-db/plane} - # REDIS SETTINGS - - REDIS_HOST=${REDIS_HOST:-plane-redis} - - REDIS_PORT=${REDIS_PORT:-6379} - - REDIS_URL=${REDIS_URL:-redis://plane-redis:6379/} +x-db-env: &db-env + PGHOST: ${PGHOST:-plane-db} + PGDATABASE: ${PGDATABASE:-plane} + POSTGRES_USER: ${POSTGRES_USER:-plane} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-plane} + POSTGRES_DB: ${POSTGRES_DB:-plane} + POSTGRES_PORT: ${POSTGRES_PORT:-5432} + PGDATA: ${PGDATA:-/var/lib/postgresql/data} - # RabbitMQ Settings - - RABBITMQ_HOST=${RABBITMQ_HOST:-plane-mq} - - RABBITMQ_PORT=${RABBITMQ_PORT:-5672} - - RABBITMQ_DEFAULT_USER=${RABBITMQ_USER:-plane} - - RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD:-plane} - - RABBITMQ_DEFAULT_VHOST=${RABBITMQ_VHOST:-plane} - - RABBITMQ_VHOST=${RABBITMQ_VHOST:-plane} - - AMQP_URL=${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane} - # Application secret - - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} - # DATA STORE SETTINGS - - USE_MINIO=${USE_MINIO:-1} - - AWS_REGION=${AWS_REGION:-} - - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-"access-key"} - - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-"secret-key"} - - AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000} - - AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads} - - MINIO_ROOT_USER=${MINIO_ROOT_USER:-"access-key"} - - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-"secret-key"} - - BUCKET_NAME=${BUCKET_NAME:-uploads} - - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} - # Live server env - - API_BASE_URL=${API_BASE_URL:-http://api:8000} +x-redis-env: &redis-env + REDIS_HOST: ${REDIS_HOST:-plane-redis} + REDIS_PORT: ${REDIS_PORT:-6379} + REDIS_URL: ${REDIS_URL:-redis://plane-redis:6379/} + +x-minio-env: &minio-env + MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID:-access-key} + MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY:-secret-key} + +x-aws-s3-env: &aws-s3-env + AWS_REGION: ${AWS_REGION:-} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-access-key} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-secret-key} + AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000} + AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} + +x-proxy-env: &proxy-env + NGINX_PORT: ${NGINX_PORT:-80} + BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} + FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880} + +x-mq-env: &mq-env # RabbitMQ Settings + RABBITMQ_HOST: ${RABBITMQ_HOST:-plane-mq} + RABBITMQ_PORT: ${RABBITMQ_PORT:-5672} + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-plane} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-plane} + RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:-plane} + RABBITMQ_VHOST: ${RABBITMQ_VHOST:-plane} + +x-live-env: &live-env + API_BASE_URL: ${API_BASE_URL:-http://api:8000} + +x-app-env: &app-env + WEB_URL: ${WEB_URL:-http://localhost} + DEBUG: ${DEBUG:-0} + SENTRY_DSN: ${SENTRY_DSN} + SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-production} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} + GUNICORN_WORKERS: 1 + USE_MINIO: ${USE_MINIO:-1} + DATABASE_URL: ${DATABASE_URL:-postgresql://plane:plane@plane-db/plane} + SECRET_KEY: ${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} + AMQP_URL: ${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane} services: web: - <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-frontend:${APP_RELEASE:-stable} - platform: ${DOCKER_PLATFORM:-} - pull_policy: if_not_present - restart: unless-stopped command: node web/server.js web deploy: replicas: ${WEB_REPLICAS:-1} + restart_policy: + condition: on-failure depends_on: - api - worker space: - <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable} - platform: ${DOCKER_PLATFORM:-} - pull_policy: if_not_present - restart: unless-stopped command: node space/server.js space deploy: replicas: ${SPACE_REPLICAS:-1} + restart_policy: + condition: on-failure depends_on: - api - worker - web admin: - <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-admin:${APP_RELEASE:-stable} - platform: ${DOCKER_PLATFORM:-} - pull_policy: if_not_present - restart: unless-stopped command: node admin/server.js admin deploy: replicas: ${ADMIN_REPLICAS:-1} + restart_policy: + condition: on-failure depends_on: - api - web live: - <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-live:${APP_RELEASE:-stable} - platform: ${DOCKER_PLATFORM:-} - pull_policy: if_not_present - restart: unless-stopped command: node live/dist/server.js live + environment: + <<: [*live-env] deploy: replicas: ${LIVE_REPLICAS:-1} + restart_policy: + condition: on-failure depends_on: - api - web api: - <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} - platform: ${DOCKER_PLATFORM:-} - pull_policy: if_not_present - restart: unless-stopped command: ./bin/docker-entrypoint-api.sh deploy: replicas: ${API_REPLICAS:-1} + restart_policy: + condition: on-failure volumes: - logs_api:/code/plane/logs + environment: + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env] depends_on: - plane-db - plane-redis - plane-mq - worker: - <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} - platform: ${DOCKER_PLATFORM:-} - pull_policy: if_not_present - restart: unless-stopped command: ./bin/docker-entrypoint-worker.sh + deploy: + replicas: ${WORKER_REPLICAS:-1} + restart_policy: + condition: on-failure volumes: - logs_worker:/code/plane/logs + environment: + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env] depends_on: - api - plane-db @@ -133,14 +133,16 @@ services: - plane-mq beat-worker: - <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} - platform: ${DOCKER_PLATFORM:-} - pull_policy: if_not_present - restart: unless-stopped command: ./bin/docker-entrypoint-beat.sh + deploy: + replicas: ${BEAT_WORKER_REPLICAS:-1} + restart_policy: + condition: on-failure volumes: - logs_beat-worker:/code/plane/logs + environment: + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env] depends_on: - api - plane-db @@ -148,60 +150,80 @@ services: - plane-mq migrator: - <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} - platform: ${DOCKER_PLATFORM:-} - pull_policy: if_not_present - restart: "no" command: ./bin/docker-entrypoint-migrator.sh + deploy: + replicas: 1 + restart_policy: + condition: on-failure volumes: - logs_migrator:/code/plane/logs + environment: + <<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env] depends_on: - plane-db - plane-redis + # Comment this if you already have a database running plane-db: - <<: *app-env image: postgres:15.7-alpine - pull_policy: if_not_present - restart: unless-stopped command: postgres -c 'max_connections=1000' + deploy: + replicas: 1 + restart_policy: + condition: on-failure + environment: + <<: *db-env volumes: - pgdata:/var/lib/postgresql/data plane-redis: - <<: *app-env image: valkey/valkey:7.2.5-alpine - pull_policy: if_not_present - restart: unless-stopped + deploy: + replicas: 1 + restart_policy: + condition: on-failure volumes: - redisdata:/data plane-mq: - <<: *app-env image: rabbitmq:3.13.6-management-alpine - restart: always + deploy: + replicas: 1 + restart_policy: + condition: on-failure + environment: + <<: *mq-env volumes: - rabbitmq_data:/var/lib/rabbitmq + # Comment this if you using any external s3 compatible storage plane-minio: - <<: *app-env image: minio/minio:latest - pull_policy: if_not_present - restart: unless-stopped command: server /export --console-address ":9090" + deploy: + replicas: 1 + restart_policy: + condition: on-failure + environment: + <<: *minio-env volumes: - uploads:/export # Comment this if you already have a reverse proxy running proxy: - <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-stable} - platform: ${DOCKER_PLATFORM:-} - pull_policy: if_not_present - restart: unless-stopped ports: - - ${NGINX_PORT}:80 + - target: 80 + published: ${NGINX_PORT:-80} + protocol: tcp + mode: host + environment: + <<: *proxy-env + deploy: + replicas: 1 + restart_policy: + condition: on-failure depends_on: - web - api @@ -210,7 +232,6 @@ services: volumes: pgdata: redisdata: - uploads: logs_api: logs_worker: diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh index 08cd4d916..1ee0d6f35 100755 --- a/deploy/selfhost/install.sh +++ b/deploy/selfhost/install.sh @@ -4,9 +4,12 @@ BRANCH=${BRANCH:-master} SCRIPT_DIR=$PWD SERVICE_FOLDER=plane-app PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER -export APP_RELEASE="stable" +export APP_RELEASE=stable export DOCKERHUB_USER=makeplane export PULL_POLICY=${PULL_POLICY:-if_not_present} +export GH_REPO=makeplane/plane +export RELEASE_DOWNLOAD_URL="https://github.com/$GH_REPO/releases/download" +export FALLBACK_DOWNLOAD_URL="https://raw.githubusercontent.com/$GH_REPO/$BRANCH/deploy/selfhost" CPU_ARCH=$(uname -m) OS_NAME=$(uname) @@ -16,13 +19,6 @@ mkdir -p $PLANE_INSTALL_DIR/archive DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yaml DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/plane.env -SED_PREFIX=() -if [ "$OS_NAME" == "Darwin" ]; then - SED_PREFIX=("-i" "") -else - SED_PREFIX=("-i") -fi - function print_header() { clear @@ -59,6 +55,17 @@ function spinner() { printf " \b\b\b\b" >&2 } +function checkLatestRelease(){ + echo "Checking for the latest release..." >&2 + local latest_release=$(curl -s https://api.github.com/repos/$GH_REPO/releases/latest | grep -o '"tag_name": "[^"]*"' | sed 's/"tag_name": "//;s/"//g') + if [ -z "$latest_release" ]; then + echo "Failed to check for the latest release. Exiting..." >&2 + exit 1 + fi + + echo $latest_release +} + function initialize(){ printf "Please wait while we check the availability of Docker images for the selected release ($APP_RELEASE) with ${UPPER_CPU_ARCH} support." >&2 @@ -130,8 +137,12 @@ function updateEnvFile() { echo "$key=$value" >> "$file" return else - # if key exists, update the value - sed "${SED_PREFIX[@]}" "s/^$key=.*/$key=$value/g" "$file" + if [ "$OS_NAME" == "Darwin" ]; then + value=$(echo "$value" | sed 's/|/\\|/g') + sed -i '' "s|^$key=.*|$key=$value|g" "$file" + else + sed -i "s/^$key=.*/$key=$value/g" "$file" + fi fi else echo "File not found: $file" @@ -182,7 +193,7 @@ function buildYourOwnImage(){ local PLANE_TEMP_CODE_DIR=~/tmp/plane rm -rf $PLANE_TEMP_CODE_DIR mkdir -p $PLANE_TEMP_CODE_DIR - REPO=https://github.com/makeplane/plane.git + REPO=https://github.com/$GH_REPO.git git clone "$REPO" "$PLANE_TEMP_CODE_DIR" --branch "$BRANCH" --single-branch --depth 1 cp "$PLANE_TEMP_CODE_DIR/deploy/selfhost/build.yml" "$PLANE_TEMP_CODE_DIR/build.yml" @@ -204,6 +215,10 @@ function install() { echo "Begin Installing Plane" echo "" + if [ "$APP_RELEASE" == "stable" ]; then + export APP_RELEASE=$(checkLatestRelease) + fi + local build_image=$(initialize) if [ "$build_image" == "build" ]; then @@ -232,8 +247,49 @@ function download() { mv $PLANE_INSTALL_DIR/docker-compose.yaml $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yaml fi - curl -H 'Cache-Control: no-cache, no-store' -s -o $PLANE_INSTALL_DIR/docker-compose.yaml https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/docker-compose.yml?$(date +%s) - curl -H 'Cache-Control: no-cache, no-store' -s -o $PLANE_INSTALL_DIR/variables-upgrade.env https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/variables.env?$(date +%s) + RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$RELEASE_DOWNLOAD_URL/$APP_RELEASE/docker-compose.yml?$(date +%s)") + BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g') + STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://') + + if [ "$STATUS" -eq 200 ]; then + echo "$BODY" > $PLANE_INSTALL_DIR/docker-compose.yaml + else + # Fallback to download from the raw github url + RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$FALLBACK_DOWNLOAD_URL/docker-compose.yml?$(date +%s)") + BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g') + STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://') + + if [ "$STATUS" -eq 200 ]; then + echo "$BODY" > $PLANE_INSTALL_DIR/docker-compose.yaml + else + echo "Failed to download docker-compose.yml. HTTP Status: $STATUS" + echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/docker-compose.yml" + mv $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yaml $PLANE_INSTALL_DIR/docker-compose.yaml + exit 1 + fi + fi + + RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$RELEASE_DOWNLOAD_URL/$APP_RELEASE/variables.env?$(date +%s)") + BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g') + STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://') + + if [ "$STATUS" -eq 200 ]; then + echo "$BODY" > $PLANE_INSTALL_DIR/variables-upgrade.env + else + # Fallback to download from the raw github url + RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$FALLBACK_DOWNLOAD_URL/variables.env?$(date +%s)") + BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g') + STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://') + + if [ "$STATUS" -eq 200 ]; then + echo "$BODY" > $PLANE_INSTALL_DIR/variables-upgrade.env + else + echo "Failed to download variables.env. HTTP Status: $STATUS" + echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/variables.env" + mv $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yaml $PLANE_INSTALL_DIR/docker-compose.yaml + exit 1 + fi + fi if [ -f "$DOCKER_ENV_PATH" ]; then @@ -335,6 +391,34 @@ function restartServices() { startServices } function upgrade() { + local latest_release=$(checkLatestRelease) + + echo "" + echo "Current release: $APP_RELEASE" + + if [ "$latest_release" == "$APP_RELEASE" ]; then + echo "" + echo "You are already using the latest release" + exit 0 + fi + + echo "Latest release: $latest_release" + echo "" + + # Check for confirmation to upgrade + echo "Do you want to upgrade to the latest release ($latest_release)?" + read -p "Continue? [y/N]: " confirm + + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + echo "Exiting..." + exit 0 + fi + + export APP_RELEASE=$latest_release + + echo "Upgrading Plane to the latest release..." + echo "" + echo "***** STOPPING SERVICES ****" stopServices @@ -373,12 +457,13 @@ function viewLogs(){ echo " 8) Redis" echo " 9) Postgres" echo " 10) Minio" + echo " 11) RabbitMQ" echo " 0) Back to Main Menu" echo read -p "Service: " DOCKER_SERVICE_NAME - until (( DOCKER_SERVICE_NAME >= 0 && DOCKER_SERVICE_NAME <= 10 )); do - echo "Invalid selection. Please enter a number between 1 and 11." + until (( DOCKER_SERVICE_NAME >= 0 && DOCKER_SERVICE_NAME <= 11 )); do + echo "Invalid selection. Please enter a number between 0 and 11." read -p "Service: " DOCKER_SERVICE_NAME done @@ -397,6 +482,7 @@ function viewLogs(){ 8) viewSpecificLogs "plane-redis";; 9) viewSpecificLogs "plane-db";; 10) viewSpecificLogs "plane-minio";; + 11) viewSpecificLogs "plane-mq";; 0) askForAction;; *) echo "INVALID SERVICE NAME SUPPLIED";; esac @@ -415,6 +501,7 @@ function viewLogs(){ redis) viewSpecificLogs "plane-redis";; postgres) viewSpecificLogs "plane-db";; minio) viewSpecificLogs "plane-minio";; + rabbitmq) viewSpecificLogs "plane-mq";; *) echo "INVALID SERVICE NAME SUPPLIED";; esac else diff --git a/deploy/selfhost/swarm.sh b/deploy/selfhost/swarm.sh new file mode 100755 index 000000000..49fe77576 --- /dev/null +++ b/deploy/selfhost/swarm.sh @@ -0,0 +1,612 @@ +#!/bin/bash + +BRANCH=${BRANCH:-master} +SERVICE_FOLDER=plane-app +SCRIPT_DIR=$PWD +PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER +export APP_RELEASE="stable" +export DOCKERHUB_USER=makeplane + +export GH_REPO=makeplane/plane +export RELEASE_DOWNLOAD_URL="https://github.com/$GH_REPO/releases/download" +export FALLBACK_DOWNLOAD_URL="https://raw.githubusercontent.com/$GH_REPO/$BRANCH/deploy/selfhost" + +OS_NAME=$(uname) + +# Create necessary directories +mkdir -p $PLANE_INSTALL_DIR/archive + +DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yml +DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/plane.env + +function print_header() { +clear + +cat <<"EOF" +-------------------------------------------- + ____ _ ///////// +| _ \| | __ _ _ __ ___ ///////// +| |_) | |/ _` | '_ \ / _ \ ///// ///// +| __/| | (_| | | | | __/ ///// ///// +|_| |_|\__,_|_| |_|\___| //// + //// +-------------------------------------------- +Project management tool from the future +-------------------------------------------- +EOF +} + +function checkLatestRelease(){ + echo "Checking for the latest release..." >&2 + local latest_release=$(curl -s https://api.github.com/repos/$GH_REPO/releases/latest | grep -o '"tag_name": "[^"]*"' | sed 's/"tag_name": "//;s/"//g') + if [ -z "$latest_release" ]; then + echo "Failed to check for the latest release. Exiting..." >&2 + exit 1 + fi + + echo $latest_release +} + +# Function to read stack name from env file +function readStackName() { + if [ -f "$DOCKER_ENV_PATH" ]; then + local saved_stack_name=$(grep "^STACK_NAME=" "$DOCKER_ENV_PATH" | cut -d'=' -f2) + if [ -n "$saved_stack_name" ]; then + stack_name=$saved_stack_name + return 1 + fi + fi + return 0 +} + +# Function to get stack name (either from env or user input) +function getStackName() { + read -p "Enter stack name [plane]: " input_stack_name + if [ -z "$input_stack_name" ]; then + input_stack_name="plane" + fi + stack_name=$input_stack_name + updateEnvFile "STACK_NAME" "$stack_name" "$DOCKER_ENV_PATH" + echo "Using stack name: $stack_name" +} + +function syncEnvFile(){ + echo "Syncing environment variables..." >&2 + if [ -f "$PLANE_INSTALL_DIR/plane.env.bak" ]; then + # READ keys of plane.env and update the values from plane.env.bak + while IFS= read -r line + do + # ignore if the line is empty or starts with # + if [ -z "$line" ] || [[ $line == \#* ]]; then + continue + fi + key=$(echo "$line" | cut -d'=' -f1) + value=$(getEnvValue "$key" "$PLANE_INSTALL_DIR/plane.env.bak") + if [ -n "$value" ]; then + updateEnvFile "$key" "$value" "$DOCKER_ENV_PATH" + fi + done < "$DOCKER_ENV_PATH" + + value=$(getEnvValue "STACK_NAME" "$PLANE_INSTALL_DIR/plane.env.bak") + if [ -n "$value" ]; then + updateEnvFile "STACK_NAME" "$value" "$DOCKER_ENV_PATH" + fi + fi + echo "Environment variables synced successfully" >&2 + rm -f $PLANE_INSTALL_DIR/plane.env.bak +} + +function getEnvValue() { + local key=$1 + local file=$2 + + if [ -z "$key" ] || [ -z "$file" ]; then + echo "Invalid arguments supplied" + exit 1 + fi + + if [ -f "$file" ]; then + grep -q "^$key=" "$file" + if [ $? -eq 0 ]; then + local value + value=$(grep "^$key=" "$file" | cut -d'=' -f2) + echo "$value" + else + echo "" + fi + fi +} + +function updateEnvFile() { + local key=$1 + local value=$2 + local file=$3 + + if [ -z "$key" ] || [ -z "$value" ] || [ -z "$file" ]; then + echo "Invalid arguments supplied" + exit 1 + fi + + if [ -f "$file" ]; then + # check if key exists in the file + grep -q "^$key=" "$file" + if [ $? -ne 0 ]; then + echo "$key=$value" >> "$file" + return + else + if [ "$OS_NAME" == "Darwin" ]; then + value=$(echo "$value" | sed 's/|/\\|/g') + sed -i '' "s|^$key=.*|$key=$value|g" "$file" + else + sed -i "s/^$key=.*/$key=$value/g" "$file" + fi + fi + else + echo "File not found: $file" + exit 1 + fi +} + +function download() { + cd $SCRIPT_DIR || exit 1 + TS=$(date +%s) + if [ -f "$PLANE_INSTALL_DIR/docker-compose.yml" ] + then + mv $PLANE_INSTALL_DIR/docker-compose.yml $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yml + fi + + echo $RELEASE_DOWNLOAD_URL + echo $FALLBACK_DOWNLOAD_URL + echo $APP_RELEASE + + RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$RELEASE_DOWNLOAD_URL/$APP_RELEASE/docker-compose.yml?$(date +%s)") + BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g') + STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://') + + if [ "$STATUS" -eq 200 ]; then + echo "$BODY" > $PLANE_INSTALL_DIR/docker-compose.yml + else + # Fallback to download from the raw github url + RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$FALLBACK_DOWNLOAD_URL/docker-compose.yml?$(date +%s)") + BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g') + STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://') + + if [ "$STATUS" -eq 200 ]; then + echo "$BODY" > $PLANE_INSTALL_DIR/docker-compose.yml + else + echo "Failed to download docker-compose.yml. HTTP Status: $STATUS" + echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/docker-compose.yml" + mv $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yml $PLANE_INSTALL_DIR/docker-compose.yml + exit 1 + fi + fi + + RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$RELEASE_DOWNLOAD_URL/$APP_RELEASE/variables.env?$(date +%s)") + BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g') + STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://') + + if [ "$STATUS" -eq 200 ]; then + echo "$BODY" > $PLANE_INSTALL_DIR/variables-upgrade.env + else + # Fallback to download from the raw github url + RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$FALLBACK_DOWNLOAD_URL/variables.env?$(date +%s)") + BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g') + STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://') + + if [ "$STATUS" -eq 200 ]; then + echo "$BODY" > $PLANE_INSTALL_DIR/variables-upgrade.env + else + echo "Failed to download variables.env. HTTP Status: $STATUS" + echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/variables.env" + mv $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yml $PLANE_INSTALL_DIR/docker-compose.yml + exit 1 + fi + fi + + if [ -f "$DOCKER_ENV_PATH" ]; + then + cp "$DOCKER_ENV_PATH" "$PLANE_INSTALL_DIR/archive/$TS.env" + cp "$DOCKER_ENV_PATH" "$PLANE_INSTALL_DIR/plane.env.bak" + fi + + mv $PLANE_INSTALL_DIR/variables-upgrade.env $DOCKER_ENV_PATH + + syncEnvFile + + updateEnvFile "APP_RELEASE" "$APP_RELEASE" "$DOCKER_ENV_PATH" + +} +function deployStack() { + # Check if docker compose file and env file exist + if [ ! -f "$DOCKER_FILE_PATH" ] || [ ! -f "$DOCKER_ENV_PATH" ]; then + echo "Configuration files not found" + echo "Downloading it now......" + APP_RELEASE=$(checkLatestRelease) + download + fi + if [ -z "$stack_name" ]; then + getStackName + fi + echo "Starting ${stack_name} stack..." + + # Pull envs + if [ -f "$DOCKER_ENV_PATH" ]; then + set -o allexport; source $DOCKER_ENV_PATH; set +o allexport; + else + echo "Environment file not found: $DOCKER_ENV_PATH" + exit 1 + fi + + # Deploy the stack + docker stack deploy -c $DOCKER_FILE_PATH $stack_name + + echo "Waiting for services to be deployed..." + sleep 10 + + # Check migrator service + local migrator_service=$(docker service ls --filter name=${stack_name}_migrator -q) + if [ -n "$migrator_service" ]; then + echo ">> Waiting for Data Migration to finish" + while docker service ls --filter name=${stack_name}_migrator | grep -q "running"; do + echo -n "." + sleep 1 + done + echo "" + + # Get the most recent container for the migrator service + local migrator_container=$(docker ps -a --filter name=${stack_name}_migrator --latest -q) + + if [ -n "$migrator_container" ]; then + # Get the exit code of the container + local exit_code=$(docker inspect --format='{{.State.ExitCode}}' $migrator_container) + + if [ "$exit_code" != "0" ]; then + echo "Server failed to start ❌" + echo "Migration failed with exit code: $exit_code" + echo "Please check the logs for the 'migrator' service and resolve the issue(s)." + echo "Stop the services by running the command: ./swarm.sh stop" + exit 1 + else + echo " Data Migration completed successfully ✅" + fi + else + echo "Warning: Could not find migrator container to check exit status" + fi + fi + + # Check API service + local api_service=$(docker service ls --filter name=${stack_name}_api -q) + while docker service ls --filter name=${stack_name}_api | grep -q "running"; do + local running_container=$(docker ps --filter "name=${stack_name}_api" --filter "status=running" -q) + if [ -n "$running_container" ]; then + if docker container logs $running_container 2>/dev/null | grep -q "Application Startup Complete"; then + break + fi + fi + sleep 2 + done + + if [ -z "$api_service" ]; then + echo "Plane Server failed to start ❌" + echo "Please check the logs for the 'api' service and resolve the issue(s)." + echo "Stop the services by running the command: ./swarm.sh stop" + exit 1 + fi + echo " Plane Server started successfully ✅" + echo "" + echo " You can access the application at $WEB_URL" + echo "" +} + +function removeStack() { + if [ -z "$stack_name" ]; then + echo "Stack name not found" + exit 1 + fi + echo "Removing ${stack_name} stack..." + docker stack rm "$stack_name" + echo "Waiting for services to be removed..." + while docker stack ls | grep -q "$stack_name"; do + sleep 1 + done + sleep 20 + echo "Services stopped successfully ✅" +} + +function viewStatus() { + echo "Checking status of ${stack_name} stack..." + if [ -z "$stack_name" ]; then + echo "Stack name not found" + exit 1 + fi + docker stack ps "$stack_name" +} + +function redeployStack() { + removeStack + echo "ReDeploying ${stack_name} stack..." + deployStack +} + +function upgrade() { + + echo "Checking status of ${stack_name} stack..." + if [ -z "$stack_name" ]; then + echo "Stack name not found" + exit 1 + fi + + local latest_release=$(checkLatestRelease) + + echo "" + echo "Current release: $APP_RELEASE" + + if [ "$latest_release" == "$APP_RELEASE" ]; then + echo "" + echo "You are already using the latest release" + exit 0 + fi + + echo "Latest release: $latest_release" + echo "" + + # Check for confirmation to upgrade + echo "Do you want to upgrade to the latest release ($latest_release)?" + read -p "Continue? [y/N]: " confirm + + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + echo "Exiting..." + exit 0 + fi + + export APP_RELEASE=$latest_release + + # check if stack exists + echo "Upgrading ${stack_name} stack..." + + # check env file and take backup + if [ -f "$DOCKER_ENV_PATH" ]; then + cp "$DOCKER_ENV_PATH" "${DOCKER_ENV_PATH}.bak" + fi + + download + redeployStack +} + +function viewSpecificLogs() { + local service=$1 + + # Input validation + if [ -z "$service" ]; then + echo "Error: Please specify a service name" + return 1 + fi + + # Main loop for service logs + while true; do + # Get all running containers for the service + local running_containers=$(docker ps --filter "name=${stack_name}_${service}" --filter "status=running" -q) + + # If no running containers found, try service logs + if [ -z "$running_containers" ]; then + echo "No running containers found for ${stack_name}_${service}, checking service logs..." + if docker service inspect ${stack_name}_${service} >/dev/null 2>&1; then + echo "Press Ctrl+C or 'q' to exit logs" + docker service logs ${stack_name}_${service} -f + break + else + echo "Error: No running containers or services found for ${stack_name}_${service}" + return 1 + fi + return + fi + + # If multiple containers are running, let user choose + if [ $(echo "$running_containers" | grep -v '^$' | wc -l) -gt 1 ]; then + clear + echo "Multiple containers found for ${stack_name}_${service}:" + local i=1 + # Use regular arrays instead of associative arrays + container_ids=() + container_names=() + + while read -r container_id; do + if [ -n "$container_id" ]; then + local container_name=$(docker inspect --format '{{.Name}}' "$container_id" | sed 's/\///') + container_ids[$i]=$container_id + container_names[$i]=$container_name + echo "[$i] ${container_names[$i]} (${container_ids[$i]})" + i=$((i+1)) + fi + done <<< "$running_containers" + + echo -e "\nPlease select a container number:" + read -r selection + + if [[ "$selection" =~ ^[0-9]+$ ]] && [ -n "${container_ids[$selection]}" ]; then + local selected_container=${container_ids[$selection]} + clear + echo "Showing logs for container: ${container_names[$selection]}" + echo "Press Ctrl+C or 'q' to return to container selection" + + # Start watching logs in the background + docker container logs -f "$selected_container" & + local log_pid=$! + + while true; do + read -r -n 1 input + if [[ $input == "q" ]]; then + kill $log_pid 2>/dev/null + wait $log_pid 2>/dev/null + break + fi + done + clear + else + echo "Error: Invalid selection" + sleep 2 + fi + else + # Single container case + local container_name=$(docker inspect --format '{{.Name}}' "$running_containers" | sed 's/\///') + echo "Showing logs for container: $container_name" + echo "Press Ctrl+C or 'q' to exit logs" + docker container logs -f "$running_containers" & + local log_pid=$! + + while true; do + read -r -n 1 input + if [[ $input == "q" ]]; then + kill $log_pid 2>/dev/null + wait $log_pid 2>/dev/null + break + fi + done + break + fi + done +} + +function viewLogs(){ + + ARG_SERVICE_NAME=$2 + if [ -z "$ARG_SERVICE_NAME" ]; + then + echo + echo "Select a Service you want to view the logs for:" + echo " 1) Web" + echo " 2) Space" + echo " 3) API" + echo " 4) Worker" + echo " 5) Beat-Worker" + echo " 6) Migrator" + echo " 7) Proxy" + echo " 8) Redis" + echo " 9) Postgres" + echo " 10) Minio" + echo " 11) RabbitMQ" + echo " 0) Back to Main Menu" + echo + read -p "Service: " DOCKER_SERVICE_NAME + + until (( DOCKER_SERVICE_NAME >= 0 && DOCKER_SERVICE_NAME <= 11 )); do + echo "Invalid selection. Please enter a number between 0 and 11." + read -p "Service: " DOCKER_SERVICE_NAME + done + + if [ -z "$DOCKER_SERVICE_NAME" ]; + then + echo "INVALID SERVICE NAME SUPPLIED" + else + case $DOCKER_SERVICE_NAME in + 1) viewSpecificLogs "web";; + 2) viewSpecificLogs "space";; + 3) viewSpecificLogs "api";; + 4) viewSpecificLogs "worker";; + 5) viewSpecificLogs "beat-worker";; + 6) viewSpecificLogs "migrator";; + 7) viewSpecificLogs "proxy";; + 8) viewSpecificLogs "plane-redis";; + 9) viewSpecificLogs "plane-db";; + 10) viewSpecificLogs "plane-minio";; + 11) viewSpecificLogs "plane-mq";; + 0) askForAction;; + *) echo "INVALID SERVICE NAME SUPPLIED";; + esac + fi + elif [ -n "$ARG_SERVICE_NAME" ]; + then + ARG_SERVICE_NAME=$(echo "$ARG_SERVICE_NAME" | tr '[:upper:]' '[:lower:]') + case $ARG_SERVICE_NAME in + web) viewSpecificLogs "web";; + space) viewSpecificLogs "space";; + api) viewSpecificLogs "api";; + worker) viewSpecificLogs "worker";; + beat-worker) viewSpecificLogs "beat-worker";; + migrator) viewSpecificLogs "migrator";; + proxy) viewSpecificLogs "proxy";; + redis) viewSpecificLogs "plane-redis";; + postgres) viewSpecificLogs "plane-db";; + minio) viewSpecificLogs "plane-minio";; + rabbitmq) viewSpecificLogs "plane-mq";; + *) echo "INVALID SERVICE NAME SUPPLIED";; + esac + else + echo "INVALID SERVICE NAME SUPPLIED" + fi +} + + + +function askForAction() { + # Rest of askForAction remains the same but use $stack_name instead of $STACK_NAME + local DEFAULT_ACTION=$1 + + if [ -z "$DEFAULT_ACTION" ]; then + echo + echo "Select an Action you want to perform:" + echo " 1) Deploy Stack" + echo " 2) Remove Stack" + echo " 3) View Stack Status" + echo " 4) Redeploy Stack" + echo " 5) Upgrade" + echo " 6) View Logs" + echo " 7) Exit" + echo + read -p "Action [3]: " ACTION + until [[ -z "$ACTION" || "$ACTION" =~ ^[1-6]$ ]]; do + echo "$ACTION: invalid selection." + read -p "Action [3]: " ACTION + done + + if [ -z "$ACTION" ]; then + ACTION=3 + fi + echo + fi + + if [ "$ACTION" == "1" ] || [ "$DEFAULT_ACTION" == "deploy" ]; then + deployStack + elif [ "$ACTION" == "2" ] || [ "$DEFAULT_ACTION" == "remove" ]; then + removeStack + elif [ "$ACTION" == "3" ] || [ "$DEFAULT_ACTION" == "status" ]; then + viewStatus + elif [ "$ACTION" == "4" ] || [ "$DEFAULT_ACTION" == "redeploy" ]; then + redeployStack + elif [ "$ACTION" == "5" ] || [ "$DEFAULT_ACTION" == "upgrade" ]; then + upgrade + elif [ "$ACTION" == "6" ] || [ "$DEFAULT_ACTION" == "logs" ]; then + viewLogs "$@" + elif [ "$ACTION" == "7" ] || [ "$DEFAULT_ACTION" == "exit" ]; then + exit 0 + else + echo "INVALID ACTION SUPPLIED" + fi +} + +# Initialize stack name at script start + +if [ -z "$stack_name" ]; then + readStackName +fi + +# Sync environment variables +if [ -f "$DOCKER_ENV_PATH" ]; then + DOCKERHUB_USER=$(getEnvValue "DOCKERHUB_USER" "$DOCKER_ENV_PATH") + APP_RELEASE=$(getEnvValue "APP_RELEASE" "$DOCKER_ENV_PATH") + + if [ -z "$DOCKERHUB_USER" ]; then + DOCKERHUB_USER=makeplane + updateEnvFile "DOCKERHUB_USER" "$DOCKERHUB_USER" "$DOCKER_ENV_PATH" + fi + + if [ -z "$APP_RELEASE" ]; then + APP_RELEASE=stable + updateEnvFile "APP_RELEASE" "$APP_RELEASE" "$DOCKER_ENV_PATH" + fi +fi + + +# Main execution +print_header +askForAction "$@" diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env index b5221c71a..b10cf11d0 100644 --- a/deploy/selfhost/variables.env +++ b/deploy/selfhost/variables.env @@ -5,6 +5,9 @@ WEB_REPLICAS=1 SPACE_REPLICAS=1 ADMIN_REPLICAS=1 API_REPLICAS=1 +WORKER_REPLICAS=1 +BEAT_WORKER_REPLICAS=1 +LIVE_REPLICAS=1 NGINX_PORT=80 WEB_URL=http://${APP_DOMAIN} @@ -47,9 +50,6 @@ AWS_ACCESS_KEY_ID=access-key AWS_SECRET_ACCESS_KEY=secret-key AWS_S3_ENDPOINT_URL=http://plane-minio:9000 AWS_S3_BUCKET_NAME=uploads -MINIO_ROOT_USER=access-key -MINIO_ROOT_PASSWORD=secret-key -BUCKET_NAME=uploads FILE_SIZE_LIMIT=5242880 # Gunicorn Workers diff --git a/live/.prettierignore b/live/.prettierignore new file mode 100644 index 000000000..8f6f9062d --- /dev/null +++ b/live/.prettierignore @@ -0,0 +1,6 @@ +.next +.turbo +out/ +dist/ +build/ +node_modules/ \ No newline at end of file diff --git a/live/.prettierrc b/live/.prettierrc new file mode 100644 index 000000000..87d988f1b --- /dev/null +++ b/live/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/live/Dockerfile.dev b/live/Dockerfile.dev index 92dee3e26..d893194ca 100644 --- a/live/Dockerfile.dev +++ b/live/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM node:18-alpine +FROM node:20-alpine RUN apk add --no-cache libc6-compat # Set working directory WORKDIR /app diff --git a/live/Dockerfile.live b/live/Dockerfile.live index 6664fee97..ae9eba9d1 100644 --- a/live/Dockerfile.live +++ b/live/Dockerfile.live @@ -1,4 +1,4 @@ -FROM node:18-alpine AS base +FROM node:20-alpine AS base # The web Dockerfile is copy-pasted into our main docs at /docs/handbook/deploying-with-docker. # Make sure you update this Dockerfile, the Dockerfile in the web workspace and copy that over to Dockerfile in the docs. diff --git a/live/package.json b/live/package.json index e098564e1..31d0b5149 100644 --- a/live/package.json +++ b/live/package.json @@ -16,17 +16,18 @@ "author": "", "license": "ISC", "dependencies": { - "@hocuspocus/extension-database": "^2.11.3", - "@hocuspocus/extension-logger": "^2.11.3", - "@hocuspocus/extension-redis": "^2.13.5", - "@hocuspocus/server": "^2.11.3", + "@hocuspocus/extension-database": "^2.15.0", + "@hocuspocus/extension-logger": "^2.15.0", + "@hocuspocus/extension-redis": "^2.15.0", + "@hocuspocus/server": "^2.15.0", + "@plane/constants": "*", "@plane/editor": "*", "@plane/types": "*", - "@sentry/node": "^8.28.0", + "@sentry/node": "^9.0.1", "@sentry/profiling-node": "^8.28.0", - "@tiptap/core": "^2.4.0", - "@tiptap/html": "^2.3.0", - "axios": "^1.7.2", + "@tiptap/core": "2.10.4", + "@tiptap/html": "2.11.0", + "axios": "^1.7.9", "compression": "^1.7.4", "cors": "^2.8.5", "dotenv": "^16.4.5", @@ -39,9 +40,9 @@ "pino-http": "^10.3.0", "pino-pretty": "^11.2.2", "uuid": "^10.0.0", - "y-prosemirror": "^1.2.9", + "y-prosemirror": "^1.2.15", "y-protocols": "^1.0.6", - "yjs": "^13.6.14" + "yjs": "^13.6.20" }, "devDependencies": { "@babel/cli": "^7.25.6", diff --git a/live/src/core/helpers/convert-document.ts b/live/src/core/helpers/convert-document.ts new file mode 100644 index 000000000..123989190 --- /dev/null +++ b/live/src/core/helpers/convert-document.ts @@ -0,0 +1,44 @@ +// plane editor +import { + getAllDocumentFormatsFromDocumentEditorBinaryData, + getAllDocumentFormatsFromRichTextEditorBinaryData, + getBinaryDataFromDocumentEditorHTMLString, + getBinaryDataFromRichTextEditorHTMLString, +} from "@plane/editor"; +// plane types +import { TDocumentPayload } from "@plane/types"; + +type TArgs = { + document_html: string; + variant: "rich" | "document"; +}; + +export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => { + const { document_html, variant } = args; + + let allFormats: TDocumentPayload; + + if (variant === "rich") { + const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html); + const { contentBinaryEncoded, contentHTML, contentJSON } = + getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary); + allFormats = { + description: contentJSON, + description_html: contentHTML, + description_binary: contentBinaryEncoded, + }; + } else if (variant === "document") { + const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html); + const { contentBinaryEncoded, contentHTML, contentJSON } = + getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary); + allFormats = { + description: contentJSON, + description_html: contentHTML, + description_binary: contentBinaryEncoded, + }; + } else { + throw new Error(`Invalid variant provided: ${variant}`); + } + + return allFormats; +}; diff --git a/live/src/core/types/common.d.ts b/live/src/core/types/common.d.ts index 3156060ef..90fd335ae 100644 --- a/live/src/core/types/common.d.ts +++ b/live/src/core/types/common.d.ts @@ -6,3 +6,8 @@ export type TDocumentTypes = "project_page" | TAdditionalDocumentTypes; export type HocusPocusServerContext = { cookie: string; }; + +export type TConvertDocumentRequestBody = { + description_html: string; + variant: "rich" | "document"; +}; diff --git a/live/src/server.ts b/live/src/server.ts index 1868b86c1..93f56bdb5 100644 --- a/live/src/server.ts +++ b/live/src/server.ts @@ -1,20 +1,19 @@ -import "@/core/config/sentry-config.js"; - -import express from "express"; -import expressWs from "express-ws"; import * as Sentry from "@sentry/node"; import compression from "compression"; -import helmet from "helmet"; - -// cors import cors from "cors"; - -// core hocuspocus server +import expressWs from "express-ws"; +import express from "express"; +import helmet from "helmet"; +// config +import "@/core/config/sentry-config.js"; +// hocuspocus server import { getHocusPocusServer } from "@/core/hocuspocus-server.js"; - // helpers +import { convertHTMLDocumentToAllFormats } from "@/core/helpers/convert-document.js"; import { logger, manualLogger } from "@/core/helpers/logger.js"; import { errorHandler } from "@/core/helpers/error-handler.js"; +// types +import { TConvertDocumentRequestBody } from "@/core/types/common.js"; const app = express(); expressWs(app); @@ -29,7 +28,7 @@ app.use( compression({ level: 6, threshold: 5 * 1000, - }), + }) ); // Logging middleware @@ -62,6 +61,31 @@ router.ws("/collaboration", (ws, req) => { } }); +router.post("/convert-document", (req, res) => { + const { description_html, variant } = req.body as TConvertDocumentRequestBody; + try { + if (description_html === undefined || variant === undefined) { + res.status(400).send({ + message: "Missing required fields", + }); + return; + } + const { description, description_binary } = convertHTMLDocumentToAllFormats({ + document_html: description_html, + variant, + }); + res.status(200).json({ + description, + description_binary, + }); + } catch (error) { + manualLogger.error("Error in /convert-document endpoint:", error); + res.status(500).send({ + message: `Internal server error. ${error}`, + }); + } +}); + app.use(process.env.LIVE_BASE_PATH || "/live", router); app.use((_req, res) => { @@ -82,9 +106,7 @@ const gracefulShutdown = async () => { try { // Close the HocusPocus server WebSocket connections await HocusPocusServer.destroy(); - manualLogger.info( - "HocusPocus server WebSocket connections closed gracefully.", - ); + manualLogger.info("HocusPocus server WebSocket connections closed gracefully."); // Close the Express server liveServer.close(() => { diff --git a/package.json b/package.json index f14aa4ac7..71f8fa0ee 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,11 @@ "devDependencies": { "prettier": "latest", "prettier-plugin-tailwindcss": "^0.5.4", - "turbo": "^2.3.3" + "turbo": "^2.4.2" + }, + "resolutions": { + "nanoid": "3.3.8", + "esbuild": "0.25.0" }, "packageManager": "yarn@1.22.22", "name": "plane" diff --git a/packages/constants/.prettierignore b/packages/constants/.prettierignore new file mode 100644 index 000000000..e841c6b32 --- /dev/null +++ b/packages/constants/.prettierignore @@ -0,0 +1,5 @@ +.next +.turbo +out/ +dist/ +build/ \ No newline at end of file diff --git a/packages/constants/.prettierrc b/packages/constants/.prettierrc new file mode 100644 index 000000000..87d988f1b --- /dev/null +++ b/packages/constants/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/packages/constants/src/ai.ts b/packages/constants/src/ai.ts new file mode 100644 index 000000000..812530244 --- /dev/null +++ b/packages/constants/src/ai.ts @@ -0,0 +1,3 @@ +export enum AI_EDITOR_TASKS { + ASK_ANYTHING = "ASK_ANYTHING", +} diff --git a/packages/constants/src/analytics.ts b/packages/constants/src/analytics.ts new file mode 100644 index 000000000..6c8211ae0 --- /dev/null +++ b/packages/constants/src/analytics.ts @@ -0,0 +1,81 @@ +// 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/auth.ts b/packages/constants/src/auth.ts index 59f08a37f..bcdda31b4 100644 --- a/packages/constants/src/auth.ts +++ b/packages/constants/src/auth.ts @@ -1,3 +1,36 @@ +export enum E_PASSWORD_STRENGTH { + EMPTY = "empty", + LENGTH_NOT_VALID = "length_not_valid", + STRENGTH_NOT_VALID = "strength_not_valid", + STRENGTH_VALID = "strength_valid", +} + +export const PASSWORD_MIN_LENGTH = 8; + +export const SPACE_PASSWORD_CRITERIA = [ + { + key: "min_8_char", + label: "Min 8 characters", + isCriteriaValid: (password: string) => + password.length >= PASSWORD_MIN_LENGTH, + }, + // { + // key: "min_1_upper_case", + // label: "Min 1 upper-case letter", + // isCriteriaValid: (password: string) => PASSWORD_NUMBER_REGEX.test(password), + // }, + // { + // key: "min_1_number", + // label: "Min 1 number", + // isCriteriaValid: (password: string) => PASSWORD_CHAR_CAPS_REGEX.test(password), + // }, + // { + // key: "min_1_special_char", + // label: "Min 1 special character", + // isCriteriaValid: (password: string) => PASSWORD_SPECIAL_CHAR_REGEX.test(password), + // }, +]; + export enum EAuthPageTypes { PUBLIC = "PUBLIC", NON_AUTHENTICATED = "NON_AUTHENTICATED", @@ -6,6 +39,14 @@ export enum EAuthPageTypes { AUTHENTICATED = "AUTHENTICATED", } +export enum EPageTypes { + INIT = "INIT", + PUBLIC = "PUBLIC", + NON_AUTHENTICATED = "NON_AUTHENTICATED", + ONBOARDING = "ONBOARDING", + AUTHENTICATED = "AUTHENTICATED", +} + export enum EAuthModes { SIGN_IN = "SIGN_IN", SIGN_UP = "SIGN_UP", @@ -17,15 +58,35 @@ export enum EAuthSteps { UNIQUE_CODE = "UNIQUE_CODE", } -// TODO: remove this export enum EErrorAlertType { BANNER_ALERT = "BANNER_ALERT", + TOAST_ALERT = "TOAST_ALERT", INLINE_FIRST_NAME = "INLINE_FIRST_NAME", INLINE_EMAIL = "INLINE_EMAIL", INLINE_PASSWORD = "INLINE_PASSWORD", INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE", } +export type TAuthErrorInfo = { + type: EErrorAlertType; + code: EAdminAuthErrorCodes; + title: string; + message: any; +}; + +export enum EAdminAuthErrorCodes { + // Admin + ADMIN_ALREADY_EXIST = "5150", + REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155", + INVALID_ADMIN_EMAIL = "5160", + INVALID_ADMIN_PASSWORD = "5165", + REQUIRED_ADMIN_EMAIL_PASSWORD = "5170", + ADMIN_AUTHENTICATION_FAILED = "5175", + ADMIN_USER_ALREADY_EXIST = "5180", + ADMIN_USER_DOES_NOT_EXIST = "5185", + ADMIN_USER_DEACTIVATED = "5190", +} + export enum EAuthErrorCodes { // Global INSTANCE_NOT_CONFIGURED = "5000", @@ -74,7 +135,7 @@ export enum EAuthErrorCodes { INCORRECT_OLD_PASSWORD = "5135", MISSING_PASSWORD = "5138", INVALID_NEW_PASSWORD = "5140", - // set passowrd + // set password PASSWORD_ALREADY_SET = "5145", // Admin ADMIN_ALREADY_EXIST = "5150", diff --git a/packages/constants/src/chart.ts b/packages/constants/src/chart.ts new file mode 100644 index 000000000..f921b8b37 --- /dev/null +++ b/packages/constants/src/chart.ts @@ -0,0 +1,2 @@ +export const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide"; +export const AXIS_LINE_CLASSNAME = "text-custom-text-400/70"; diff --git a/packages/constants/src/cycle.ts b/packages/constants/src/cycle.ts new file mode 100644 index 000000000..752f23dbe --- /dev/null +++ b/packages/constants/src/cycle.ts @@ -0,0 +1,42 @@ +// types +export const CYCLE_STATUS: { + i18n_label: string; + value: "current" | "upcoming" | "completed" | "draft"; + i18n_title: string; + color: string; + textColor: string; + bgColor: string; +}[] = [ + { + i18n_label: "project_cycles.status.days_left", + value: "current", + i18n_title: "project_cycles.status.in_progress", + color: "#F59E0B", + textColor: "text-amber-500", + bgColor: "bg-amber-50", + }, + { + i18n_label: "project_cycles.status.yet_to_start", + value: "upcoming", + i18n_title: "project_cycles.status.yet_to_start", + color: "#3F76FF", + textColor: "text-blue-500", + bgColor: "bg-indigo-50", + }, + { + i18n_label: "project_cycles.status.completed", + value: "completed", + i18n_title: "project_cycles.status.completed", + color: "#16A34A", + textColor: "text-green-600", + bgColor: "bg-green-50", + }, + { + i18n_label: "project_cycles.status.draft", + value: "draft", + i18n_title: "project_cycles.status.draft", + color: "#525252", + textColor: "text-custom-text-300", + bgColor: "bg-custom-background-90", + }, +]; diff --git a/packages/constants/src/dashboard.ts b/packages/constants/src/dashboard.ts new file mode 100644 index 000000000..d7a3ae611 --- /dev/null +++ b/packages/constants/src/dashboard.ts @@ -0,0 +1,92 @@ +// types +import { TIssuesListTypes } from "@plane/types"; + +export enum EDurationFilters { + NONE = "none", + TODAY = "today", + THIS_WEEK = "this_week", + THIS_MONTH = "this_month", + THIS_YEAR = "this_year", + CUSTOM = "custom", +} + +// filter duration options +export const DURATION_FILTER_OPTIONS: { + key: EDurationFilters; + label: string; +}[] = [ + { + key: EDurationFilters.NONE, + label: "All time", + }, + { + key: EDurationFilters.TODAY, + label: "Due today", + }, + { + key: EDurationFilters.THIS_WEEK, + label: "Due this week", + }, + { + key: EDurationFilters.THIS_MONTH, + label: "Due this month", + }, + { + key: EDurationFilters.THIS_YEAR, + label: "Due this year", + }, + { + key: EDurationFilters.CUSTOM, + label: "Custom", + }, +]; + +// random background colors for project cards +export const PROJECT_BACKGROUND_COLORS = [ + "bg-gray-500/20", + "bg-green-500/20", + "bg-red-500/20", + "bg-orange-500/20", + "bg-blue-500/20", + "bg-yellow-500/20", + "bg-pink-500/20", + "bg-purple-500/20", +]; + +// assigned and created issues widgets tabs list +export const FILTERED_ISSUES_TABS_LIST: { + key: TIssuesListTypes; + label: string; +}[] = [ + { + key: "upcoming", + label: "Upcoming", + }, + { + key: "overdue", + label: "Overdue", + }, + { + key: "completed", + label: "Marked completed", + }, +]; + +// assigned and created issues widgets tabs list +export const UNFILTERED_ISSUES_TABS_LIST: { + key: TIssuesListTypes; + label: string; +}[] = [ + { + key: "pending", + label: "Pending", + }, + { + key: "completed", + label: "Marked completed", + }, +]; + +export type TLinkOptions = { + userId: string | undefined; +}; diff --git a/packages/constants/src/emoji.ts b/packages/constants/src/emoji.ts new file mode 100644 index 000000000..48be02b01 --- /dev/null +++ b/packages/constants/src/emoji.ts @@ -0,0 +1,10 @@ +export const ISSUE_REACTION_EMOJI_CODES = [ + "128077", + "128078", + "128516", + "128165", + "128533", + "129505", + "9992", + "128064", +]; diff --git a/packages/constants/src/endpoints.ts b/packages/constants/src/endpoints.ts index fa6db6ec7..cd1c08d7a 100644 --- a/packages/constants/src/endpoints.ts +++ b/packages/constants/src/endpoints.ts @@ -1,18 +1,29 @@ export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || ""; -// PI Base Url -export const PI_BASE_URL = process.env.NEXT_PUBLIC_PI_BASE_URL || ""; +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 GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${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 SITES_URL = encodeURI(`${SPACE_BASE_URL}${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_URL = encodeURI(`${LIVE_BASE_URL}${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_URL = encodeURI(`${WEB_BASE_URL}${WEB_BASE_PATH}`); // plane website url 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"; +// marketing links +export const MARKETING_PRICING_PAGE_LINK = "https://plane.so/pricing"; +export const MARKETING_CONTACT_US_PAGE_LINK = "https://plane.so/contact"; +export const MARKETING_PLANE_ONE_PAGE_LINK = "https://plane.so/one"; diff --git a/web/core/constants/event-tracker.ts b/packages/constants/src/event-tracker.ts similarity index 94% rename from web/core/constants/event-tracker.ts rename to packages/constants/src/event-tracker.ts index c0ac86935..4fb1ea15c 100644 --- a/web/core/constants/event-tracker.ts +++ b/packages/constants/src/event-tracker.ts @@ -8,6 +8,8 @@ export type IssueEventProps = { export type EventProps = { eventName: string; payload: any; + updates?: any; + path?: string; }; export const getWorkspaceEventPayload = (payload: any) => ({ @@ -102,7 +104,10 @@ export const getIssueEventPayload = (props: IssueEventProps) => { 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() : "", + view_id: + path?.includes("workspace-views") || path?.includes("views") + ? path.split("/").pop() + : "", }; if (eventName === ISSUE_UPDATED) { @@ -164,12 +169,12 @@ 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 = "Issue created"; -export const ISSUE_UPDATED = "Issue updated"; -export const ISSUE_DELETED = "Issue deleted"; -export const ISSUE_ARCHIVED = "Issue archived"; -export const ISSUE_RESTORED = "Issue restored"; -export const ISSUE_OPENED = "Issue opened"; +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"; @@ -206,7 +211,7 @@ 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"; +export const GITHUB_REDIRECTED = "GitHub redirected"; // Sidebar Events export const SIDEBAR_CLICKED = "Sidenav clicked"; // Global View Events diff --git a/space/core/constants/common.ts b/packages/constants/src/file.ts similarity index 100% rename from space/core/constants/common.ts rename to packages/constants/src/file.ts diff --git a/web/core/constants/filters.ts b/packages/constants/src/filter.ts similarity index 90% rename from web/core/constants/filters.ts rename to packages/constants/src/filter.ts index 98189bc78..cc8d98c45 100644 --- a/web/core/constants/filters.ts +++ b/packages/constants/src/filter.ts @@ -1,3 +1,7 @@ +export enum E_SORT_ORDER { + ASC = "asc", + DESC = "desc", +} export const DATE_AFTER_FILTER_OPTIONS = [ { name: "1 week from now", @@ -28,6 +32,7 @@ export const DATE_BEFORE_FILTER_OPTIONS = [ }, { name: "1 month ago", + i18n_name: "date_filters.1_month_ago", value: "1_months;before;fromnow", }, ]; diff --git a/web/core/constants/graph.ts b/packages/constants/src/graph.ts similarity index 83% rename from web/core/constants/graph.ts rename to packages/constants/src/graph.ts index 0376a7f9f..1ed7e4f9d 100644 --- a/web/core/constants/graph.ts +++ b/packages/constants/src/graph.ts @@ -1,7 +1,4 @@ -// nivo -import { Theme } from "@nivo/core"; - -export const CHARTS_THEME: Theme = { +export const CHARTS_THEME = { background: "transparent", text: { color: "rgb(var(--color-text-200))", @@ -29,7 +26,7 @@ export const CHARTS_THEME: Theme = { }, }; -export const DEFAULT_MARGIN = { +export const CHART_DEFAULT_MARGIN = { top: 50, right: 50, bottom: 50, diff --git a/packages/constants/src/inbox.ts b/packages/constants/src/inbox.ts new file mode 100644 index 000000000..cf5270a04 --- /dev/null +++ b/packages/constants/src/inbox.ts @@ -0,0 +1,91 @@ +import { TInboxDuplicateIssueDetails, TIssue } from "@plane/types"; + +export enum EInboxIssueCurrentTab { + OPEN = "open", + CLOSED = "closed", +} + +export enum EInboxIssueStatus { + PENDING = -2, + DECLINED = -1, + SNOOZED = 0, + ACCEPTED = 1, + DUPLICATE = 2, +} + +export type TInboxIssueCurrentTab = EInboxIssueCurrentTab; +export type TInboxIssueStatus = EInboxIssueStatus; +export type TInboxIssue = { + id: string; + status: TInboxIssueStatus; + snoozed_till: Date | null; + duplicate_to: string | undefined; + source: string; + issue: TIssue; + created_by: string; + duplicate_issue_detail: TInboxDuplicateIssueDetails | undefined; +}; + +export const INBOX_STATUS: { + key: string; + status: TInboxIssueStatus; + i18n_title: string; + i18n_description: () => string; +}[] = [ + { + key: "pending", + i18n_title: "inbox_issue.status.pending.title", + status: EInboxIssueStatus.PENDING, + i18n_description: () => `inbox_issue.status.pending.description`, + }, + { + key: "declined", + i18n_title: "inbox_issue.status.declined.title", + status: EInboxIssueStatus.DECLINED, + i18n_description: () => `inbox_issue.status.declined.description`, + }, + { + key: "snoozed", + i18n_title: "inbox_issue.status.snoozed.title", + status: EInboxIssueStatus.SNOOZED, + i18n_description: () => `inbox_issue.status.snoozed.description`, + }, + { + key: "accepted", + i18n_title: "inbox_issue.status.accepted.title", + status: EInboxIssueStatus.ACCEPTED, + i18n_description: () => `inbox_issue.status.accepted.description`, + }, + { + key: "duplicate", + i18n_title: "inbox_issue.status.duplicate.title", + status: EInboxIssueStatus.DUPLICATE, + i18n_description: () => `inbox_issue.status.duplicate.description`, + }, +]; + +export const INBOX_ISSUE_ORDER_BY_OPTIONS = [ + { + key: "issue__created_at", + i18n_label: "inbox_issue.order_by.created_at", + }, + { + key: "issue__updated_at", + i18n_label: "inbox_issue.order_by.updated_at", + }, + { + key: "issue__sequence_id", + i18n_label: "inbox_issue.order_by.id", + }, +]; + +export const INBOX_ISSUE_SORT_BY_OPTIONS = [ + { + key: "asc", + i18n_label: "common.sort.asc", + }, + { + key: "desc", + i18n_label: "common.sort.desc", + }, +]; diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 418908622..7fedff05d 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -1,4 +1,31 @@ +export * from "./ai"; +export * from "./analytics"; export * from "./auth"; +export * from "./chart"; export * from "./endpoints"; +export * from "./file"; +export * from "./filter"; +export * from "./graph"; +export * from "./instance"; export * from "./issue"; +export * from "./metadata"; +export * from "./notification"; +export * from "./state"; +export * from "./swr"; +export * from "./tab-indices"; +export * from "./user"; export * from "./workspace"; +export * from "./stickies"; +export * from "./cycle"; +export * from "./module"; +export * from "./project"; +export * from "./views"; +export * from "./themes"; +export * from "./inbox"; +export * from "./profile"; +export * from "./workspace-drafts"; +export * from "./label"; +export * from "./event-tracker"; +export * from "./spreadsheet"; +export * from "./dashboard"; +export * from "./page"; diff --git a/admin/helpers/instance.helper.ts b/packages/constants/src/instance.ts similarity index 100% rename from admin/helpers/instance.helper.ts rename to packages/constants/src/instance.ts diff --git a/packages/constants/src/issue.ts b/packages/constants/src/issue.ts deleted file mode 100644 index 5db398c76..000000000 --- a/packages/constants/src/issue.ts +++ /dev/null @@ -1,40 +0,0 @@ -export const ALL_ISSUES = "All Issues"; - -export enum EIssueGroupByToServerOptions { - "state" = "state_id", - "priority" = "priority", - "labels" = "labels__id", - "state_detail.group" = "state__group", - "assignees" = "assignees__id", - "cycle" = "cycle_id", - "module" = "issue_module__module_id", - "target_date" = "target_date", - "project" = "project_id", - "created_by" = "created_by", -} - -export enum EIssueGroupBYServerToProperty { - "state_id" = "state_id", - "priority" = "priority", - "labels__id" = "label_ids", - "state__group" = "state__group", - "assignees__id" = "assignee_ids", - "cycle_id" = "cycle_id", - "issue_module__module_id" = "module_ids", - "target_date" = "target_date", - "project_id" = "project_id", - "created_by" = "created_by", -} - -export enum EServerGroupByToFilterOptions { - "state_id" = "state", - "priority" = "priority", - "labels__id" = "labels", - "state__group" = "state_group", - "assignees__id" = "assignees", - "cycle_id" = "cycle", - "issue_module__module_id" = "module", - "target_date" = "target_date", - "project_id" = "project", - "created_by" = "created_by", -} diff --git a/packages/constants/src/issue/common.ts b/packages/constants/src/issue/common.ts new file mode 100644 index 000000000..a63dd110d --- /dev/null +++ b/packages/constants/src/issue/common.ts @@ -0,0 +1,217 @@ +import { + TIssueGroupByOptions, + TIssueOrderByOptions, + IIssueDisplayProperties, +} from "@plane/types"; + +export const ALL_ISSUES = "All Issues"; + +export type TIssuePriorities = "urgent" | "high" | "medium" | "low" | "none"; + +export type TIssueFilterPriorityObject = { + key: TIssuePriorities; + titleTranslationKey: string; + className: string; + icon: string; +}; + +export enum EIssueGroupByToServerOptions { + "state" = "state_id", + "priority" = "priority", + "labels" = "labels__id", + "state_detail.group" = "state__group", + "assignees" = "assignees__id", + "cycle" = "cycle_id", + "module" = "issue_module__module_id", + "target_date" = "target_date", + "project" = "project_id", + "created_by" = "created_by", + "team_project" = "project_id", +} + +export enum EIssueGroupBYServerToProperty { + "state_id" = "state_id", + "priority" = "priority", + "labels__id" = "label_ids", + "state__group" = "state__group", + "assignees__id" = "assignee_ids", + "cycle_id" = "cycle_id", + "issue_module__module_id" = "module_ids", + "target_date" = "target_date", + "project_id" = "project_id", + "created_by" = "created_by", +} + +export enum EIssueServiceType { + ISSUES = "issues", + EPICS = "epics", +} + +export enum EIssuesStoreType { + GLOBAL = "GLOBAL", + PROFILE = "PROFILE", + TEAM = "TEAM", + PROJECT = "PROJECT", + CYCLE = "CYCLE", + MODULE = "MODULE", + TEAM_VIEW = "TEAM_VIEW", + PROJECT_VIEW = "PROJECT_VIEW", + ARCHIVED = "ARCHIVED", + DRAFT = "DRAFT", + DEFAULT = "DEFAULT", + WORKSPACE_DRAFT = "WORKSPACE_DRAFT", + EPIC = "EPIC", +} + +export enum EIssueCommentAccessSpecifier { + EXTERNAL = "EXTERNAL", + INTERNAL = "INTERNAL", +} + +export enum EIssueListRow { + HEADER = "HEADER", + ISSUE = "ISSUE", + NO_ISSUES = "NO_ISSUES", + QUICK_ADD = "QUICK_ADD", +} + +export const ISSUE_PRIORITIES: { + key: TIssuePriorities; + title: string; +}[] = [ + { + key: "urgent", + title: "Urgent", + }, + { + key: "high", + title: "High", + }, + { + key: "medium", + title: "Medium", + }, + { + key: "low", + title: "Low", + }, + { + key: "none", + title: "None", + }, +]; + +export const DRAG_ALLOWED_GROUPS: TIssueGroupByOptions[] = [ + "state", + "priority", + "assignees", + "labels", + "module", + "cycle", +]; + +export type TCreateModalStoreTypes = + | EIssuesStoreType.TEAM + | EIssuesStoreType.PROJECT + | EIssuesStoreType.TEAM_VIEW + | EIssuesStoreType.PROJECT_VIEW + | EIssuesStoreType.PROFILE + | EIssuesStoreType.CYCLE + | EIssuesStoreType.MODULE + | EIssuesStoreType.EPIC; + +export const ISSUE_GROUP_BY_OPTIONS: { + key: TIssueGroupByOptions; + titleTranslationKey: string; +}[] = [ + { key: "state", titleTranslationKey: "common.states" }, + { key: "state_detail.group", titleTranslationKey: "common.state_groups" }, + { key: "priority", titleTranslationKey: "common.priority" }, + { key: "team_project", titleTranslationKey: "common.team_project" }, // required this on team issues + { key: "project", titleTranslationKey: "common.project" }, // required this on my issues + { key: "cycle", titleTranslationKey: "common.cycle" }, // required this on my issues + { key: "module", titleTranslationKey: "common.module" }, // required this on my issues + { key: "labels", titleTranslationKey: "common.labels" }, + { key: "assignees", titleTranslationKey: "common.assignees" }, + { key: "created_by", titleTranslationKey: "common.created_by" }, + { key: null, titleTranslationKey: "common.none" }, +]; + +export const ISSUE_ORDER_BY_OPTIONS: { + key: TIssueOrderByOptions; + titleTranslationKey: string; +}[] = [ + { key: "sort_order", titleTranslationKey: "common.order_by.manual" }, + { key: "-created_at", titleTranslationKey: "common.order_by.last_created" }, + { key: "-updated_at", titleTranslationKey: "common.order_by.last_updated" }, + { key: "start_date", titleTranslationKey: "common.order_by.start_date" }, + { key: "target_date", titleTranslationKey: "common.order_by.due_date" }, + { key: "-priority", titleTranslationKey: "common.priority" }, +]; + +export const ISSUE_DISPLAY_PROPERTIES_KEYS: (keyof IIssueDisplayProperties)[] = + [ + "assignee", + "start_date", + "due_date", + "labels", + "key", + "priority", + "state", + "sub_issue_count", + "link", + "attachment_count", + "estimate", + "created_on", + "updated_on", + "modules", + "cycle", + "issue_type", + ]; + +export const ISSUE_DISPLAY_PROPERTIES: { + key: keyof IIssueDisplayProperties; + titleTranslationKey: string; +}[] = [ + { + key: "key", + titleTranslationKey: "issue.display.properties.id", + }, + { + key: "issue_type", + titleTranslationKey: "issue.display.properties.issue_type", + }, + { + key: "assignee", + titleTranslationKey: "common.assignee", + }, + { + key: "start_date", + titleTranslationKey: "common.order_by.start_date", + }, + { + key: "due_date", + titleTranslationKey: "common.order_by.due_date", + }, + { key: "labels", titleTranslationKey: "common.labels" }, + { + key: "priority", + titleTranslationKey: "common.priority", + }, + { key: "state", titleTranslationKey: "common.state" }, + { + key: "sub_issue_count", + titleTranslationKey: "issue.display.properties.sub_issue_count", + }, + { + key: "attachment_count", + titleTranslationKey: "issue.display.properties.attachment_count", + }, + { key: "link", titleTranslationKey: "common.link" }, + { + key: "estimate", + titleTranslationKey: "common.estimate", + }, + { key: "modules", titleTranslationKey: "common.module" }, + { key: "cycle", titleTranslationKey: "common.cycle" }, +]; diff --git a/packages/constants/src/issue/filter.ts b/packages/constants/src/issue/filter.ts new file mode 100644 index 000000000..5d1d694b7 --- /dev/null +++ b/packages/constants/src/issue/filter.ts @@ -0,0 +1,530 @@ +import { + ILayoutDisplayFiltersOptions, + TIssueActivityComment, +} from "@plane/types"; +import { + TIssueFilterPriorityObject, + ISSUE_DISPLAY_PROPERTIES_KEYS, + EIssuesStoreType, +} from "./common"; + +import { TIssueLayout } from "./layout"; + +export type TIssueFilterKeys = "priority" | "state" | "labels"; + +export enum EServerGroupByToFilterOptions { + "state_id" = "state", + "priority" = "priority", + "labels__id" = "labels", + "state__group" = "state_group", + "assignees__id" = "assignees", + "cycle_id" = "cycle", + "issue_module__module_id" = "module", + "target_date" = "target_date", + "project_id" = "project", + "created_by" = "created_by", +} + +export enum EIssueFilterType { + FILTERS = "filters", + DISPLAY_FILTERS = "display_filters", + DISPLAY_PROPERTIES = "display_properties", + KANBAN_FILTERS = "kanban_filters", +} + +export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { + [key in TIssueLayout]: Record<"filters", TIssueFilterKeys[]>; +} = { + list: { + filters: ["priority", "state", "labels"], + }, + kanban: { + filters: ["priority", "state", "labels"], + }, + calendar: { + filters: ["priority", "state", "labels"], + }, + spreadsheet: { + filters: ["priority", "state", "labels"], + }, + gantt: { + filters: ["priority", "state", "labels"], + }, +}; + +export const ISSUE_PRIORITY_FILTERS: TIssueFilterPriorityObject[] = [ + { + key: "urgent", + titleTranslationKey: "issue.priority.urgent", + className: "bg-red-500 border-red-500 text-white", + icon: "error", + }, + { + key: "high", + titleTranslationKey: "issue.priority.high", + className: "text-orange-500 border-custom-border-300", + icon: "signal_cellular_alt", + }, + { + key: "medium", + titleTranslationKey: "issue.priority.medium", + className: "text-yellow-500 border-custom-border-300", + icon: "signal_cellular_alt_2_bar", + }, + { + key: "low", + titleTranslationKey: "issue.priority.low", + className: "text-green-500 border-custom-border-300", + icon: "signal_cellular_alt_1_bar", + }, + { + key: "none", + titleTranslationKey: "common.none", + className: "text-gray-500 border-custom-border-300", + icon: "block", + }, +]; + +export type TFiltersByLayout = { + [layoutType: string]: ILayoutDisplayFiltersOptions; +}; + +export type TIssueFiltersToDisplayByPageType = { + [pageType: string]: TFiltersByLayout; +}; + +export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = { + profile_issues: { + list: { + 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", + ], + type: [null, "active", "backlog"], + }, + extra_options: { + access: true, + values: ["show_empty_groups", "sub_issue"], + }, + }, + kanban: { + 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", + ], + type: [null, "active", "backlog"], + }, + extra_options: { + access: true, + values: ["show_empty_groups"], + }, + }, + }, + archived_issues: { + list: { + filters: [ + "priority", + "state", + "cycle", + "module", + "assignees", + "created_by", + "labels", + "start_date", + "target_date", + "issue_type", + ], + 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", + ], + type: [null, "active", "backlog"], + }, + extra_options: { + access: true, + values: ["show_empty_groups"], + }, + }, + }, + my_issues: { + spreadsheet: { + filters: [ + "priority", + "state_group", + "labels", + "assignees", + "created_by", + "subscriber", + "project", + "start_date", + "target_date", + ], + display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, + display_filters: { + order_by: [], + type: [null, "active", "backlog"], + }, + extra_options: { + access: true, + values: ["sub_issue"], + }, + }, + list: { + filters: [ + "priority", + "state_group", + "labels", + "assignees", + "created_by", + "subscriber", + "project", + "start_date", + "target_date", + ], + display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, + display_filters: { + type: [null, "active", "backlog"], + }, + extra_options: { + access: false, + values: [], + }, + }, + }, + issues: { + list: { + filters: [ + "priority", + "state", + "cycle", + "module", + "assignees", + "mentions", + "created_by", + "labels", + "start_date", + "target_date", + "issue_type", + ], + 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", + ], + type: [null, "active", "backlog"], + }, + extra_options: { + access: true, + values: ["show_empty_groups", "sub_issue"], + }, + }, + kanban: { + filters: [ + "priority", + "state", + "cycle", + "module", + "assignees", + "mentions", + "created_by", + "labels", + "start_date", + "target_date", + "issue_type", + ], + 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", + ], + type: [null, "active", "backlog"], + }, + extra_options: { + access: true, + values: ["show_empty_groups", "sub_issue"], + }, + }, + calendar: { + filters: [ + "priority", + "state", + "cycle", + "module", + "assignees", + "mentions", + "created_by", + "labels", + "start_date", + "issue_type", + ], + display_properties: ["key", "issue_type"], + display_filters: { + type: [null, "active", "backlog"], + }, + extra_options: { + access: true, + values: ["sub_issue"], + }, + }, + spreadsheet: { + filters: [ + "priority", + "state", + "cycle", + "module", + "assignees", + "mentions", + "created_by", + "labels", + "start_date", + "target_date", + "issue_type", + ], + display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, + display_filters: { + order_by: [ + "sort_order", + "-created_at", + "-updated_at", + "start_date", + "-priority", + ], + type: [null, "active", "backlog"], + }, + extra_options: { + access: true, + values: ["sub_issue"], + }, + }, + gantt_chart: { + filters: [ + "priority", + "state", + "cycle", + "module", + "assignees", + "mentions", + "created_by", + "labels", + "start_date", + "target_date", + "issue_type", + ], + display_properties: ["key", "issue_type"], + display_filters: { + order_by: [ + "sort_order", + "-created_at", + "-updated_at", + "start_date", + "-priority", + ], + type: [null, "active", "backlog"], + }, + extra_options: { + access: true, + values: ["sub_issue"], + }, + }, + }, +}; + +export const ISSUE_STORE_TO_FILTERS_MAP: Partial< + Record +> = { + [EIssuesStoreType.PROJECT]: ISSUE_DISPLAY_FILTERS_BY_PAGE.issues, +}; + +export enum EActivityFilterType { + ACTIVITY = "ACTIVITY", + COMMENT = "COMMENT", +} + +export type TActivityFilters = EActivityFilterType; + +export const ACTIVITY_FILTER_TYPE_OPTIONS: Record< + TActivityFilters, + { labelTranslationKey: string } +> = { + [EActivityFilterType.ACTIVITY]: { + labelTranslationKey: "common.updates", + }, + [EActivityFilterType.COMMENT]: { + labelTranslationKey: "common.comments", + }, +}; + +export type TActivityFilterOption = { + key: TActivityFilters; + labelTranslationKey: string; + isSelected: boolean; + onClick: () => void; +}; + +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) + ); + +export const ENABLE_ISSUE_DEPENDENCIES = false; diff --git a/packages/constants/src/issue/index.ts b/packages/constants/src/issue/index.ts new file mode 100644 index 000000000..633203223 --- /dev/null +++ b/packages/constants/src/issue/index.ts @@ -0,0 +1,3 @@ +export * from "./common"; +export * from "./filter"; +export * from "./layout"; diff --git a/packages/constants/src/issue/layout.ts b/packages/constants/src/issue/layout.ts new file mode 100644 index 000000000..6dd62fd17 --- /dev/null +++ b/packages/constants/src/issue/layout.ts @@ -0,0 +1,76 @@ +export type TIssueLayout = + | "list" + | "kanban" + | "calendar" + | "spreadsheet" + | "gantt"; + +export enum EIssueLayoutTypes { + LIST = "list", + KANBAN = "kanban", + CALENDAR = "calendar", + GANTT = "gantt_chart", + SPREADSHEET = "spreadsheet", +} + +export type TIssueLayoutMap = Record< + EIssueLayoutTypes, + { + key: EIssueLayoutTypes; + i18n_title: string; + i18n_label: string; + } +>; + +export const SITES_ISSUE_LAYOUTS: { + key: TIssueLayout; + titleTranslationKey: string; + icon: any; +}[] = [ + { + key: "list", + icon: "List", + titleTranslationKey: "issue.layouts.list", + }, + { + key: "kanban", + icon: "Kanban", + titleTranslationKey: "issue.layouts.kanban", + }, + // { key: "calendar", title: "Calendar", icon: Calendar }, + // { key: "spreadsheet", title: "Spreadsheet", icon: Sheet }, + // { key: "gantt", title: "Gantt chart", icon: GanttChartSquare }, +]; + +export const ISSUE_LAYOUT_MAP: TIssueLayoutMap = { + [EIssueLayoutTypes.LIST]: { + key: EIssueLayoutTypes.LIST, + i18n_title: "issue.layouts.title.list", + i18n_label: "issue.layouts.list", + }, + [EIssueLayoutTypes.KANBAN]: { + key: EIssueLayoutTypes.KANBAN, + i18n_title: "issue.layouts.title.kanban", + i18n_label: "issue.layouts.kanban", + }, + [EIssueLayoutTypes.CALENDAR]: { + key: EIssueLayoutTypes.CALENDAR, + i18n_title: "issue.layouts.title.calendar", + i18n_label: "issue.layouts.calendar", + }, + [EIssueLayoutTypes.SPREADSHEET]: { + key: EIssueLayoutTypes.SPREADSHEET, + i18n_title: "issue.layouts.title.spreadsheet", + i18n_label: "issue.layouts.spreadsheet", + }, + [EIssueLayoutTypes.GANTT]: { + key: EIssueLayoutTypes.GANTT, + i18n_title: "issue.layouts.title.gantt", + i18n_label: "issue.layouts.gantt", + }, +}; + +export const ISSUE_LAYOUTS: { + key: EIssueLayoutTypes; + i18n_title: string; +}[] = Object.values(ISSUE_LAYOUT_MAP); diff --git a/web/core/constants/label.ts b/packages/constants/src/label.ts similarity index 100% rename from web/core/constants/label.ts rename to packages/constants/src/label.ts diff --git a/packages/constants/src/metadata.ts b/packages/constants/src/metadata.ts new file mode 100644 index 000000000..017cc84c2 --- /dev/null +++ b/packages/constants/src/metadata.ts @@ -0,0 +1,23 @@ +export const SITE_NAME = + "Plane | Simple, extensible, open-source project management tool."; +export const SITE_TITLE = + "Plane | Simple, extensible, open-source project management tool."; +export const SITE_DESCRIPTION = + "Open-source project management tool to manage work items, cycles, and product roadmaps easily"; +export const SITE_KEYWORDS = + "software development, plan, ship, software, accelerate, code management, release management, project management, work items tracking, agile, scrum, kanban, collaboration"; +export const SITE_URL = "https://app.plane.so/"; +export const TWITTER_USER_NAME = + "Plane | Simple, extensible, open-source project management tool."; + +// Plane Sites Metadata +export const SPACE_SITE_NAME = + "Plane Publish | Make your Plane boards and roadmaps pubic with just one-click. "; +export const SPACE_SITE_TITLE = + "Plane Publish | Make your Plane boards public with one-click"; +export const SPACE_SITE_DESCRIPTION = + "Plane Publish is a customer feedback management tool built on top of plane.so"; +export const SPACE_SITE_KEYWORDS = + "software development, customer feedback, software, accelerate, code management, release management, project management, work items tracking, agile, scrum, kanban, collaboration"; +export const SPACE_SITE_URL = "https://app.plane.so/"; +export const SPACE_TWITTER_USER_NAME = "planepowers"; diff --git a/web/core/constants/module.ts b/packages/constants/src/module.ts similarity index 51% rename from web/core/constants/module.ts rename to packages/constants/src/module.ts index 8d26ab5f4..6ce30f0dc 100644 --- a/web/core/constants/module.ts +++ b/packages/constants/src/module.ts @@ -1,51 +1,54 @@ -import { GanttChartSquare, LayoutGrid, List } from "lucide-react"; // types -import { TModuleLayoutOptions, TModuleOrderByOptions, TModuleStatus } from "@plane/types"; +import { + TModuleLayoutOptions, + TModuleOrderByOptions, + TModuleStatus, +} from "@plane/types"; export const MODULE_STATUS: { - label: string; + i18n_label: string; value: TModuleStatus; color: string; textColor: string; bgColor: string; }[] = [ { - label: "Backlog", + i18n_label: "project_modules.status.backlog", value: "backlog", color: "#a3a3a2", textColor: "text-custom-text-400", bgColor: "bg-custom-background-80", }, { - label: "Planned", + i18n_label: "project_modules.status.planned", value: "planned", color: "#3f76ff", textColor: "text-blue-500", bgColor: "bg-indigo-50", }, { - label: "In Progress", + i18n_label: "project_modules.status.in_progress", value: "in-progress", color: "#f39e1f", textColor: "text-amber-500", bgColor: "bg-amber-50", }, { - label: "Paused", + i18n_label: "project_modules.status.paused", value: "paused", color: "#525252", textColor: "text-custom-text-300", bgColor: "bg-custom-background-90", }, { - label: "Completed", + i18n_label: "project_modules.status.completed", value: "completed", color: "#16a34a", textColor: "text-green-600", bgColor: "bg-green-100", }, { - label: "Cancelled", + i18n_label: "project_modules.status.cancelled", value: "cancelled", color: "#ef4444", textColor: "text-red-500", @@ -53,47 +56,50 @@ export const MODULE_STATUS: { }, ]; -export const MODULE_VIEW_LAYOUTS: { key: TModuleLayoutOptions; icon: any; title: string }[] = [ +export const MODULE_VIEW_LAYOUTS: { + key: TModuleLayoutOptions; + i18n_title: string; +}[] = [ { key: "list", - icon: List, - title: "List layout", + i18n_title: "project_modules.layout.list", }, { key: "board", - icon: LayoutGrid, - title: "Gallery layout", + i18n_title: "project_modules.layout.board", }, { key: "gantt", - icon: GanttChartSquare, - title: "Timeline layout", + i18n_title: "project_modules.layout.timeline", }, ]; -export const MODULE_ORDER_BY_OPTIONS: { key: TModuleOrderByOptions; label: string }[] = [ +export const MODULE_ORDER_BY_OPTIONS: { + key: TModuleOrderByOptions; + i18n_label: string; +}[] = [ { key: "name", - label: "Name", + i18n_label: "project_modules.order_by.name", }, { key: "progress", - label: "Progress", + i18n_label: "project_modules.order_by.progress", }, { key: "issues_length", - label: "Number of issues", + i18n_label: "project_modules.order_by.issues", }, { key: "target_date", - label: "Due date", + i18n_label: "project_modules.order_by.due_date", }, { key: "created_at", - label: "Created date", + i18n_label: "project_modules.order_by.created_at", }, { key: "sort_order", - label: "Manual", + i18n_label: "project_modules.order_by.manual", }, ]; diff --git a/web/core/constants/notification.ts b/packages/constants/src/notification.ts similarity index 82% rename from web/core/constants/notification.ts rename to packages/constants/src/notification.ts index 36ab3c8ee..cb267c4ad 100644 --- a/web/core/constants/notification.ts +++ b/packages/constants/src/notification.ts @@ -29,12 +29,13 @@ export type TNotificationTab = ENotificationTab.ALL | ENotificationTab.MENTIONS; export const NOTIFICATION_TABS = [ { - label: "All", + i18n_label: "notification.tabs.all", value: ENotificationTab.ALL, - count: (unReadNotification: TUnreadNotificationsCount) => unReadNotification?.total_unread_notifications_count || 0, + count: (unReadNotification: TUnreadNotificationsCount) => + unReadNotification?.total_unread_notifications_count || 0, }, { - label: "Mentions", + i18n_label: "notification.tabs.mentions", value: ENotificationTab.MENTIONS, count: (unReadNotification: TUnreadNotificationsCount) => unReadNotification?.mention_unread_notifications_count || 0, @@ -43,15 +44,15 @@ export const NOTIFICATION_TABS = [ export const FILTER_TYPE_OPTIONS = [ { - label: "Assigned to me", + i18n_label: "notification.filter.assigned", value: ENotificationFilterType.ASSIGNED, }, { - label: "Created by me", + i18n_label: "notification.filter.created", value: ENotificationFilterType.CREATED, }, { - label: "Subscribed by me", + i18n_label: "notification.filter.subscribed", value: ENotificationFilterType.SUBSCRIBED, }, ]; @@ -59,7 +60,7 @@ export const FILTER_TYPE_OPTIONS = [ export const NOTIFICATION_SNOOZE_OPTIONS = [ { key: "1_day", - label: "1 day", + i18n_label: "notification.snooze.1_day", value: () => { const date = new Date(); return new Date(date.getTime() + 24 * 60 * 60 * 1000); @@ -67,7 +68,7 @@ export const NOTIFICATION_SNOOZE_OPTIONS = [ }, { key: "3_days", - label: "3 days", + i18n_label: "notification.snooze.3_days", value: () => { const date = new Date(); return new Date(date.getTime() + 3 * 24 * 60 * 60 * 1000); @@ -75,7 +76,7 @@ export const NOTIFICATION_SNOOZE_OPTIONS = [ }, { key: "5_days", - label: "5 days", + i18n_label: "notification.snooze.5_days", value: () => { const date = new Date(); return new Date(date.getTime() + 5 * 24 * 60 * 60 * 1000); @@ -83,7 +84,7 @@ export const NOTIFICATION_SNOOZE_OPTIONS = [ }, { key: "1_week", - label: "1 week", + i18n_label: "notification.snooze.1_week", value: () => { const date = new Date(); return new Date(date.getTime() + 7 * 24 * 60 * 60 * 1000); @@ -91,7 +92,7 @@ export const NOTIFICATION_SNOOZE_OPTIONS = [ }, { key: "2_weeks", - label: "2 weeks", + i18n_label: "notification.snooze.2_weeks", value: () => { const date = new Date(); return new Date(date.getTime() + 14 * 24 * 60 * 60 * 1000); @@ -99,7 +100,7 @@ export const NOTIFICATION_SNOOZE_OPTIONS = [ }, { key: "custom", - label: "Custom", + i18n_label: "notification.snooze.custom", value: undefined, }, ]; diff --git a/packages/constants/src/page.ts b/packages/constants/src/page.ts new file mode 100644 index 000000000..182cff40e --- /dev/null +++ b/packages/constants/src/page.ts @@ -0,0 +1,14 @@ +export enum EPageAccess { + PUBLIC = 0, + PRIVATE = 1, +} + +export type TCreatePageModal = { + isOpen: boolean; + pageAccess?: EPageAccess; +}; + +export const DEFAULT_CREATE_PAGE_MODAL_DATA: TCreatePageModal = { + isOpen: false, + pageAccess: EPageAccess.PUBLIC, +}; diff --git a/web/core/constants/profile.ts b/packages/constants/src/profile.ts similarity index 67% rename from web/core/constants/profile.ts rename to packages/constants/src/profile.ts index 479cb825b..f7765a0cf 100644 --- a/web/core/constants/profile.ts +++ b/packages/constants/src/profile.ts @@ -1,78 +1,73 @@ -import React from "react"; -// icons -import { Activity, Bell, CircleUser, KeyRound, LucideProps, Settings2 } from "lucide-react"; - export const PROFILE_ACTION_LINKS: { key: string; - label: string; + i18n_label: string; href: string; highlight: (pathname: string) => boolean; - Icon: React.FC; }[] = [ { key: "profile", - label: "Profile", + i18n_label: "profile.actions.profile", href: `/profile`, highlight: (pathname: string) => pathname === "/profile/", - Icon: CircleUser, }, { key: "security", - label: "Security", + i18n_label: "profile.actions.security", href: `/profile/security`, highlight: (pathname: string) => pathname === "/profile/security/", - Icon: KeyRound, }, { key: "activity", - label: "Activity", + i18n_label: "profile.actions.activity", href: `/profile/activity`, highlight: (pathname: string) => pathname === "/profile/activity/", - Icon: Activity, }, { key: "appearance", - label: "Appearance", + i18n_label: "profile.actions.appearance", href: `/profile/appearance`, highlight: (pathname: string) => pathname.includes("/profile/appearance"), - Icon: Settings2, }, { key: "notifications", - label: "Notifications", + i18n_label: "profile.actions.notifications", href: `/profile/notifications`, highlight: (pathname: string) => pathname === "/profile/notifications/", - Icon: Bell, }, ]; export const PROFILE_VIEWER_TAB = [ { + key: "summary", route: "", - label: "Summary", + i18n_label: "profile.tabs.summary", selected: "/", }, ]; export const PROFILE_ADMINS_TAB = [ { + key: "assigned", route: "assigned", - label: "Assigned", + i18n_label: "profile.tabs.assigned", selected: "/assigned/", }, { + key: "created", route: "created", - label: "Created", + i18n_label: "profile.tabs.created", selected: "/created/", }, { + key: "subscribed", route: "subscribed", - label: "Subscribed", + i18n_label: "profile.tabs.subscribed", selected: "/subscribed/", }, { + key: "activity", route: "activity", - label: "Activity", + i18n_label: "profile.tabs.activity", selected: "/activity/", }, ]; diff --git a/web/core/constants/project.ts b/packages/constants/src/project.ts similarity index 61% rename from web/core/constants/project.ts rename to packages/constants/src/project.ts index e984cb968..a4d68295f 100644 --- a/web/core/constants/project.ts +++ b/packages/constants/src/project.ts @@ -1,41 +1,65 @@ // icons -import { Globe2, Lock, LucideIcon } from "lucide-react"; -import { TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types"; +import { + TProjectAppliedDisplayFilterKeys, + TProjectOrderByOptions, +} from "@plane/types"; -export const NETWORK_CHOICES: { +export type TNetworkChoiceIconKey = "Lock" | "Globe2"; + +export type TNetworkChoice = { key: 0 | 2; - label: string; + labelKey: string; + i18n_label: string; description: string; - icon: LucideIcon; -}[] = [ + iconKey: TNetworkChoiceIconKey; +}; + +export const NETWORK_CHOICES: TNetworkChoice[] = [ { key: 0, - label: "Private", - description: "Accessible only by invite", - icon: Lock, + labelKey: "Private", + i18n_label: "workspace_projects.network.private.title", + description: "workspace_projects.network.private.description", //"Accessible only by invite", + iconKey: "Lock", }, { key: 2, - label: "Public", - description: "Anyone in the workspace except Guests can join", - icon: Globe2, + labelKey: "Public", + i18n_label: "workspace_projects.network.public.title", + description: "workspace_projects.network.public.description", //"Anyone in the workspace except Guests can join", + iconKey: "Globe2", }, ]; export const GROUP_CHOICES = { - backlog: "Backlog", - unstarted: "Unstarted", - started: "Started", - completed: "Completed", - cancelled: "Cancelled", + backlog: { + key: "backlog", + i18n_label: "workspace_projects.state.backlog", + }, + unstarted: { + key: "unstarted", + i18n_label: "workspace_projects.state.unstarted", + }, + started: { + key: "started", + i18n_label: "workspace_projects.state.started", + }, + completed: { + key: "completed", + i18n_label: "workspace_projects.state.completed", + }, + cancelled: { + key: "cancelled", + i18n_label: "workspace_projects.state.cancelled", + }, }; export const PROJECT_AUTOMATION_MONTHS = [ - { label: "1 month", value: 1 }, - { label: "3 months", value: 3 }, - { label: "6 months", value: 6 }, - { label: "9 months", value: 9 }, - { label: "12 months", value: 12 }, + { i18n_label: "common.months_count", value: 1 }, + { i18n_label: "common.months_count", value: 3 }, + { i18n_label: "common.months_count", value: 6 }, + { i18n_label: "common.months_count", value: 9 }, + { i18n_label: "common.months_count", value: 12 }, ]; export const PROJECT_UNSPLASH_COVERS = [ @@ -59,55 +83,55 @@ export const PROJECT_UNSPLASH_COVERS = [ export const PROJECT_ORDER_BY_OPTIONS: { key: TProjectOrderByOptions; - label: string; + i18n_label: string; }[] = [ { key: "sort_order", - label: "Manual", + i18n_label: "workspace_projects.sort.manual", }, { key: "name", - label: "Name", + i18n_label: "workspace_projects.sort.name", }, { key: "created_at", - label: "Created date", + i18n_label: "workspace_projects.sort.created_at", }, { key: "members_length", - label: "Number of members", + i18n_label: "workspace_projects.sort.members_length", }, ]; export const PROJECT_DISPLAY_FILTER_OPTIONS: { key: TProjectAppliedDisplayFilterKeys; - label: string; + i18n_label: string; }[] = [ { key: "my_projects", - label: "My projects", + i18n_label: "workspace_projects.scope.my_projects", }, { key: "archived_projects", - label: "Archived", + i18n_label: "workspace_projects.scope.archived_projects", }, ]; export const PROJECT_ERROR_MESSAGES = { permissionError: { - title: "You don't have permission to perform this action.", - message: undefined, + i18n_title: "workspace_projects.error.permission", + i18n_message: undefined, }, cycleDeleteError: { - title: "Error", - message: "Failed to delete cycle", + i18n_title: "error", + i18n_message: "workspace_projects.error.cycle_delete", }, moduleDeleteError: { - title: "Error", - message: "Failed to delete module", + i18n_title: "error", + i18n_message: "workspace_projects.error.module_delete", }, issueDeleteError: { - title: "Error", - message: "Failed to delete issue", + i18n_title: "error", + i18n_message: "workspace_projects.error.issue_delete", }, }; diff --git a/packages/constants/src/spreadsheet.ts b/packages/constants/src/spreadsheet.ts new file mode 100644 index 000000000..57723e449 --- /dev/null +++ b/packages/constants/src/spreadsheet.ts @@ -0,0 +1 @@ +export const SPREADSHEET_SELECT_GROUP = "spreadsheet-issues"; diff --git a/web/core/constants/state.ts b/packages/constants/src/state.ts similarity index 60% rename from web/core/constants/state.ts rename to packages/constants/src/state.ts index 24d10cf56..9f5db17c7 100644 --- a/web/core/constants/state.ts +++ b/packages/constants/src/state.ts @@ -1,4 +1,9 @@ -import { TStateGroups } from "@plane/types"; +export type TStateGroups = + | "backlog" + | "unstarted" + | "started" + | "completed" + | "cancelled"; export type TDraggableData = { groupKey: TStateGroups; @@ -39,7 +44,10 @@ export const STATE_GROUPS: { }, }; -export const ARCHIVABLE_STATE_GROUPS = [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key]; +export const ARCHIVABLE_STATE_GROUPS = [ + STATE_GROUPS.completed.key, + STATE_GROUPS.cancelled.key, +]; export const COMPLETED_STATE_GROUPS = [STATE_GROUPS.completed.key]; export const PENDING_STATE_GROUPS = [ STATE_GROUPS.backlog.key, @@ -48,4 +56,27 @@ export const PENDING_STATE_GROUPS = [ STATE_GROUPS.cancelled.key, ]; +export const PROGRESS_STATE_GROUPS_DETAILS = [ + { + key: "completed_issues", + title: "Completed", + color: "#16A34A", + }, + { + key: "started_issues", + title: "Started", + color: "#F59E0B", + }, + { + key: "unstarted_issues", + title: "Unstarted", + color: "#3A3A3A", + }, + { + key: "backlog_issues", + title: "Backlog", + color: "#A3A3A3", + }, +]; + export const DISPLAY_WORKFLOW_PRO_CTA = false; diff --git a/packages/constants/src/stickies.ts b/packages/constants/src/stickies.ts new file mode 100644 index 000000000..6bf6fd20b --- /dev/null +++ b/packages/constants/src/stickies.ts @@ -0,0 +1 @@ +export const STICKIES_PER_PAGE = 30; diff --git a/packages/constants/src/swr.ts b/packages/constants/src/swr.ts new file mode 100644 index 000000000..e981dd1d0 --- /dev/null +++ b/packages/constants/src/swr.ts @@ -0,0 +1,16 @@ +export const DEFAULT_SWR_CONFIG = { + refreshWhenHidden: false, + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnMount: true, + refreshInterval: 600000, + errorRetryCount: 3, +}; + +export const WEB_SWR_CONFIG = { + refreshWhenHidden: false, + revalidateIfStale: true, + revalidateOnFocus: true, + revalidateOnMount: true, + errorRetryCount: 3, +}; diff --git a/web/core/constants/tab-indices.ts b/packages/constants/src/tab-indices.ts similarity index 84% rename from web/core/constants/tab-indices.ts rename to packages/constants/src/tab-indices.ts index 58a12f417..0382b67f3 100644 --- a/web/core/constants/tab-indices.ts +++ b/packages/constants/src/tab-indices.ts @@ -2,7 +2,6 @@ export const ISSUE_FORM_TAB_INDICES = [ "name", "description_html", "feeling_lucky", - "ai_assistant", "state_id", "priority", "assignee_ids", @@ -54,7 +53,14 @@ export const PROJECT_CREATE_TAB_INDICES = [ "logo_props", ]; -export const PROJECT_CYCLE_TAB_INDICES = ["name", "description", "date_range", "cancel", "submit", "project_id"]; +export const PROJECT_CYCLE_TAB_INDICES = [ + "name", + "description", + "date_range", + "cancel", + "submit", + "project_id", +]; export const PROJECT_MODULE_TAB_INDICES = [ "name", @@ -67,9 +73,21 @@ export const PROJECT_MODULE_TAB_INDICES = [ "submit", ]; -export const PROJECT_VIEW_TAB_INDICES = ["name", "description", "filters", "cancel", "submit"]; +export const PROJECT_VIEW_TAB_INDICES = [ + "name", + "description", + "filters", + "cancel", + "submit", +]; -export const PROJECT_PAGE_TAB_INDICES = ["name", "public", "private", "cancel", "submit"]; +export const PROJECT_PAGE_TAB_INDICES = [ + "name", + "public", + "private", + "cancel", + "submit", +]; export enum ETabIndices { ISSUE_FORM = "issue-form", diff --git a/web/core/constants/themes.ts b/packages/constants/src/themes.ts similarity index 69% rename from web/core/constants/themes.ts rename to packages/constants/src/themes.ts index c221a631e..84e8c0d0b 100644 --- a/web/core/constants/themes.ts +++ b/packages/constants/src/themes.ts @@ -1,8 +1,15 @@ -export const THEMES = ["light", "dark", "light-contrast", "dark-contrast", "custom"]; +export const THEMES = [ + "light", + "dark", + "light-contrast", + "dark-contrast", + "custom", +]; export interface I_THEME_OPTION { + key: string; value: string; - label: string; + i18n_label: string; type: string; icon: { border: string; @@ -13,8 +20,9 @@ export interface I_THEME_OPTION { export const THEME_OPTIONS: I_THEME_OPTION[] = [ { + key: "system_preference", value: "system", - label: "System preference", + i18n_label: "System preference", type: "light", icon: { border: "#DEE2E6", @@ -23,8 +31,9 @@ export const THEME_OPTIONS: I_THEME_OPTION[] = [ }, }, { + key: "light", value: "light", - label: "Light", + i18n_label: "Light", type: "light", icon: { border: "#DEE2E6", @@ -33,8 +42,9 @@ export const THEME_OPTIONS: I_THEME_OPTION[] = [ }, }, { + key: "dark", value: "dark", - label: "Dark", + i18n_label: "Dark", type: "dark", icon: { border: "#2E3234", @@ -43,8 +53,9 @@ export const THEME_OPTIONS: I_THEME_OPTION[] = [ }, }, { + key: "light_contrast", value: "light-contrast", - label: "Light high contrast", + i18n_label: "Light high contrast", type: "light", icon: { border: "#000000", @@ -53,8 +64,9 @@ export const THEME_OPTIONS: I_THEME_OPTION[] = [ }, }, { + key: "dark_contrast", value: "dark-contrast", - label: "Dark high contrast", + i18n_label: "Dark high contrast", type: "dark", icon: { border: "#FFFFFF", @@ -63,8 +75,9 @@ export const THEME_OPTIONS: I_THEME_OPTION[] = [ }, }, { + key: "custom", value: "custom", - label: "Custom theme", + i18n_label: "Custom theme", type: "light", icon: { border: "#FFC9C9", diff --git a/web/ce/constants/user-permissions/index.ts b/packages/constants/src/user.ts similarity index 51% rename from web/ce/constants/user-permissions/index.ts rename to packages/constants/src/user.ts index e37a2aae9..996756949 100644 --- a/web/ce/constants/user-permissions/index.ts +++ b/packages/constants/src/user.ts @@ -1,7 +1,42 @@ +export enum EAuthenticationPageType { + STATIC = "STATIC", + NOT_AUTHENTICATED = "NOT_AUTHENTICATED", + AUTHENTICATED = "AUTHENTICATED", +} + +export enum EInstancePageType { + PRE_SETUP = "PRE_SETUP", + POST_SETUP = "POST_SETUP", +} + +export enum EUserStatus { + ERROR = "ERROR", + AUTHENTICATION_NOT_DONE = "AUTHENTICATION_NOT_DONE", + NOT_YET_READY = "NOT_YET_READY", +} + +export type TUserStatus = { + status: EUserStatus | undefined; + message?: string; +}; + export enum EUserPermissionsLevel { WORKSPACE = "WORKSPACE", PROJECT = "PROJECT", } + +export enum EUserWorkspaceRoles { + ADMIN = 20, + MEMBER = 15, + GUEST = 5, +} + +export enum EUserProjectRoles { + ADMIN = 20, + MEMBER = 15, + GUEST = 5, +} + export type TUserPermissionsLevel = EUserPermissionsLevel; export enum EUserPermissions { @@ -29,7 +64,11 @@ export type TUserAllowedPermissions = { export const USER_ALLOWED_PERMISSIONS: TUserAllowedPermissions = { workspace: { dashboard: { - read: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + read: [ + EUserPermissions.ADMIN, + EUserPermissions.MEMBER, + EUserPermissions.GUEST, + ], }, }, project: {}, diff --git a/packages/constants/src/views.ts b/packages/constants/src/views.ts new file mode 100644 index 000000000..8ac837de8 --- /dev/null +++ b/packages/constants/src/views.ts @@ -0,0 +1,23 @@ +export enum EViewAccess { + PRIVATE, + PUBLIC, +} + +export const VIEW_ACCESS_SPECIFIERS: { + key: EViewAccess; + i18n_label: string; +}[] = [ + { key: EViewAccess.PUBLIC, i18n_label: "common.access.public" }, + { key: EViewAccess.PRIVATE, i18n_label: "common.access.private" }, +]; + +export const VIEW_SORTING_KEY_OPTIONS = [ + { key: "name", i18n_label: "project_view.sort_by.name" }, + { key: "created_at", i18n_label: "project_view.sort_by.created_at" }, + { key: "updated_at", i18n_label: "project_view.sort_by.updated_at" }, +]; + +export const VIEW_SORT_BY_OPTIONS = [ + { key: "asc", i18n_label: "common.order_by.asc" }, + { key: "desc", i18n_label: "common.order_by.desc" }, +]; diff --git a/web/core/constants/workspace-drafts.ts b/packages/constants/src/workspace-drafts.ts similarity index 100% rename from web/core/constants/workspace-drafts.ts rename to packages/constants/src/workspace-drafts.ts diff --git a/packages/constants/src/workspace.ts b/packages/constants/src/workspace.ts index c17b5432e..d1389759c 100644 --- a/packages/constants/src/workspace.ts +++ b/packages/constants/src/workspace.ts @@ -1,5 +1,8 @@ +import { TStaticViewTypes } from "@plane/types"; +import { EUserWorkspaceRoles } from "./user"; + export const ORGANIZATION_SIZE = [ - "Just myself", + "Just myself", // TODO: translate "2-10", "11-50", "51-200", @@ -74,3 +77,250 @@ export const RESTRICTED_URLS = [ "instances", "instance", ]; + +export const WORKSPACE_SETTINGS = { + general: { + key: "general", + i18n_label: "workspace_settings.settings.general.title", + href: `/settings`, + access: [EUserWorkspaceRoles.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`, + }, + members: { + key: "members", + i18n_label: "workspace_settings.settings.members.title", + href: `/settings/members`, + access: [EUserWorkspaceRoles.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`, + }, + "billing-and-plans": { + key: "billing-and-plans", + i18n_label: "workspace_settings.settings.billing_and_plans.title", + href: `/settings/billing`, + access: [EUserWorkspaceRoles.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/billing/`, + }, + export: { + key: "export", + i18n_label: "workspace_settings.settings.exports.title", + href: `/settings/exports`, + access: [EUserWorkspaceRoles.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/exports/`, + }, + webhooks: { + key: "webhooks", + i18n_label: "workspace_settings.settings.webhooks.title", + href: `/settings/webhooks`, + 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_LINKS: { + key: string; + i18n_label: string; + href: string; + access: EUserWorkspaceRoles[]; + highlight: (pathname: string, baseUrl: string) => boolean; +}[] = [ + WORKSPACE_SETTINGS["general"], + WORKSPACE_SETTINGS["members"], + WORKSPACE_SETTINGS["billing-and-plans"], + WORKSPACE_SETTINGS["export"], + WORKSPACE_SETTINGS["webhooks"], + WORKSPACE_SETTINGS["api-tokens"], +]; + +export const ROLE = { + [EUserWorkspaceRoles.GUEST]: "Guest", + [EUserWorkspaceRoles.MEMBER]: "Member", + [EUserWorkspaceRoles.ADMIN]: "Admin", +}; + +export const ROLE_DETAILS = { + [EUserWorkspaceRoles.GUEST]: { + i18n_title: "role_details.guest.title", + i18n_description: "role_details.guest.description", + }, + [EUserWorkspaceRoles.MEMBER]: { + i18n_title: "role_details.member.title", + i18n_description: "role_details.member.description", + }, + [EUserWorkspaceRoles.ADMIN]: { + i18n_title: "role_details.admin.title", + i18n_description: "role_details.admin.description", + }, +}; + +export const USER_ROLES = [ + { + value: "Product / Project Manager", + i18n_label: "user_roles.product_or_project_manager", + }, + { + value: "Development / Engineering", + i18n_label: "user_roles.development_or_engineering", + }, + { + value: "Founder / Executive", + i18n_label: "user_roles.founder_or_executive", + }, + { + value: "Freelancer / Consultant", + i18n_label: "user_roles.freelancer_or_consultant", + }, + { value: "Marketing / Growth", i18n_label: "user_roles.marketing_or_growth" }, + { + value: "Sales / Business Development", + i18n_label: "user_roles.sales_or_business_development", + }, + { + value: "Support / Operations", + i18n_label: "user_roles.support_or_operations", + }, + { + value: "Student / Professor", + i18n_label: "user_roles.student_or_professor", + }, + { value: "Human Resources", i18n_label: "user_roles.human_resources" }, + { value: "Other", i18n_label: "user_roles.other" }, +]; + +export const IMPORTERS_LIST = [ + { + provider: "github", + type: "import", + i18n_title: "importer.github.title", + i18n_description: "importer.github.description", + }, + { + provider: "jira", + type: "import", + i18n_title: "importer.jira.title", + i18n_description: "importer.jira.description", + }, +]; + +export const EXPORTERS_LIST = [ + { + provider: "csv", + type: "export", + i18n_title: "exporter.csv.title", + i18n_description: "exporter.csv.description", + }, + { + provider: "xlsx", + type: "export", + i18n_title: "exporter.excel.title", + i18n_description: "exporter.csv.description", + }, + { + provider: "json", + type: "export", + i18n_title: "exporter.json.title", + i18n_description: "exporter.csv.description", + }, +]; + +export const DEFAULT_GLOBAL_VIEWS_LIST: { + key: TStaticViewTypes; + i18n_label: string; +}[] = [ + { + key: "all-issues", + i18n_label: "default_global_view.all_issues", + }, + { + key: "assigned", + i18n_label: "default_global_view.assigned", + }, + { + key: "created", + i18n_label: "default_global_view.created", + }, + { + key: "subscribed", + i18n_label: "default_global_view.subscribed", + }, +]; + +export interface IWorkspaceSidebarNavigationItem { + key: string; + labelTranslationKey: string; + href: string; + access: EUserWorkspaceRoles[]; +} + +export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record = { + "your-work": { + key: "your_work", + labelTranslationKey: "your_work", + href: `/profile/`, + access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER], + }, + views: { + key: "views", + labelTranslationKey: "views", + href: `/workspace-views/all-issues/`, + access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST], + }, + analytics: { + key: "analytics", + labelTranslationKey: "analytics", + href: `/analytics/`, + access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER], + }, + drafts: { + key: "drafts", + labelTranslationKey: "drafts", + href: `/drafts/`, + access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER], + }, + archives: { + key: "archives", + labelTranslationKey: "archives", + href: `/projects/archives/`, + access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER], + }, +}; +export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [ + WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["views"], + WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["analytics"], + WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["your-work"], + WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["drafts"], + WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["archives"], +]; + +export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record = { + home: { + key: "home", + labelTranslationKey: "home.title", + href: `/`, + access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST], + }, + inbox: { + key: "inbox", + labelTranslationKey: "notification.label", + href: `/notifications/`, + access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST], + }, + projects: { + key: "projects", + labelTranslationKey: "projects", + href: `/projects/`, + access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST], + }, +}; + +export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [ + WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["home"], + WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["inbox"], + WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["projects"], +]; diff --git a/packages/editor/package.json b/packages/editor/package.json index 8f7295a0e..a82e2727c 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -12,14 +12,12 @@ "exports": { ".": { "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "module": "./dist/index.mjs" + "import": "./dist/index.mjs" }, "./lib": { "require": "./dist/lib.js", "types": "./dist/lib.d.mts", - "import": "./dist/lib.mjs", - "module": "./dist/lib.mjs" + "import": "./dist/lib.mjs" } }, "scripts": { @@ -36,53 +34,52 @@ }, "dependencies": { "@floating-ui/react": "^0.26.4", - "@hocuspocus/provider": "^2.13.5", + "@hocuspocus/provider": "^2.15.0", + "@plane/types": "*", "@plane/ui": "*", "@plane/utils": "*", - "@tiptap/core": "^2.1.13", - "@tiptap/extension-blockquote": "^2.1.13", - "@tiptap/extension-character-count": "^2.6.5", - "@tiptap/extension-collaboration": "^2.3.2", - "@tiptap/extension-image": "^2.1.13", - "@tiptap/extension-list-item": "^2.1.13", - "@tiptap/extension-mention": "^2.1.13", - "@tiptap/extension-placeholder": "^2.3.0", - "@tiptap/extension-task-item": "^2.1.13", - "@tiptap/extension-task-list": "^2.1.13", - "@tiptap/extension-text-align": "^2.8.0", - "@tiptap/extension-text-style": "^2.7.1", - "@tiptap/extension-underline": "^2.1.13", - "@tiptap/pm": "^2.1.13", - "@tiptap/react": "^2.1.13", - "@tiptap/starter-kit": "^2.1.13", - "@tiptap/suggestion": "^2.0.13", + "@tiptap/core": "2.10.4", + "@tiptap/extension-blockquote": "2.10.4", + "@tiptap/extension-character-count": "2.11.0", + "@tiptap/extension-collaboration": "2.11.0", + "@tiptap/extension-image": "2.11.0", + "@tiptap/extension-list-item": "2.11.0", + "@tiptap/extension-mention": "2.11.0", + "@tiptap/extension-placeholder": "2.11.0", + "@tiptap/extension-task-item": "2.11.0", + "@tiptap/extension-task-list": "2.11.0", + "@tiptap/extension-text-align": "2.11.0", + "@tiptap/extension-text-style": "2.11.0", + "@tiptap/extension-underline": "2.11.0", + "@tiptap/html": "2.11.0", + "@tiptap/pm": "2.11.0", + "@tiptap/react": "2.11.0", + "@tiptap/starter-kit": "2.11.0", + "@tiptap/suggestion": "2.11.0", "class-variance-authority": "^0.7.0", - "clsx": "^1.2.1", "highlight.js": "^11.8.0", "jsx-dom-cjs": "^8.0.3", "linkifyjs": "^4.1.3", "lowlight": "^3.0.0", - "lucide-react": "^0.378.0", + "lucide-react": "^0.469.0", "prosemirror-codemark": "^0.4.2", "prosemirror-utils": "^1.2.2", - "react-moveable": "^0.54.2", - "tailwind-merge": "^1.14.0", "tippy.js": "^6.3.7", - "tiptap-markdown": "^0.8.9", + "tiptap-markdown": "^0.8.10", "uuid": "^10.0.0", "y-indexeddb": "^9.0.12", - "y-prosemirror": "^1.2.5", + "y-prosemirror": "^1.2.15", "y-protocols": "^1.0.6", - "yjs": "^13.6.15" + "yjs": "^13.6.20" }, "devDependencies": { "@plane/eslint-config": "*", + "@plane/tailwind-config": "*", "@plane/typescript-config": "*", "@types/node": "18.15.3", "@types/react": "^18.3.11", "@types/react-dom": "^18.2.18", "postcss": "^8.4.38", - "tailwind-config-custom": "*", "tsup": "^7.2.0", "typescript": "5.3.3" }, diff --git a/packages/editor/src/ce/extensions/document-extensions.tsx b/packages/editor/src/ce/extensions/document-extensions.tsx index 35d7c0f3d..445f5e0f8 100644 --- a/packages/editor/src/ce/extensions/document-extensions.tsx +++ b/packages/editor/src/ce/extensions/document-extensions.tsx @@ -1,5 +1,6 @@ import { HocuspocusProvider } from "@hocuspocus/provider"; import { Extensions } from "@tiptap/core"; +import { AnyExtension } from "@tiptap/core"; import { SlashCommands } from "@/extensions"; // plane editor types import { TIssueEmbedConfig } from "@/plane-editor/types"; @@ -13,15 +14,24 @@ type Props = { userDetails: TUserDetails; }; -export const DocumentEditorAdditionalExtensions = (_props: Props) => { - const { disabledExtensions } = _props; - const extensions: Extensions = disabledExtensions?.includes("slash-commands") - ? [] - : [ - SlashCommands({ - disabledExtensions, - }), - ]; - - return extensions; +type ExtensionConfig = { + isEnabled: (disabledExtensions: TExtensions[]) => boolean; + getExtension: (props: Props) => AnyExtension; +}; + +const extensionRegistry: ExtensionConfig[] = [ + { + isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"), + getExtension: () => SlashCommands({}), + }, +]; + +export const DocumentEditorAdditionalExtensions = (_props: Props) => { + const { disabledExtensions = [] } = _props; + + const documentExtensions = extensionRegistry + .filter((config) => config.isEnabled(disabledExtensions)) + .map((config) => config.getExtension(_props)); + + return documentExtensions; }; diff --git a/packages/editor/src/ce/extensions/slash-commands.tsx b/packages/editor/src/ce/extensions/slash-commands.tsx index 6eabee082..faefa7452 100644 --- a/packages/editor/src/ce/extensions/slash-commands.tsx +++ b/packages/editor/src/ce/extensions/slash-commands.tsx @@ -4,7 +4,7 @@ import { TSlashCommandAdditionalOption } from "@/extensions"; import { TExtensions } from "@/types"; type Props = { - disabledExtensions: TExtensions[]; + disabledExtensions?: TExtensions[]; }; export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => { 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 cd7d6f354..751c79101 100644 --- a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx +++ b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx @@ -1,3 +1,4 @@ +import { Extensions } from "@tiptap/core"; import React from "react"; // components import { DocumentContentLoader, PageRenderer } from "@/components/editors"; @@ -16,9 +17,11 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { const { onTransaction, aiHandler, + bubbleMenuEnabled = true, containerClassName, disabledExtensions, displayConfig = DEFAULT_DISPLAY_CONFIG, + editable, editorClassName = "", embedHandler, fileHandler, @@ -33,7 +36,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { user, } = props; - const extensions = []; + const extensions: Extensions = []; if (embedHandler?.issue) { extensions.push( IssueWidget({ @@ -44,8 +47,8 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { // use document editor const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({ - onTransaction, disabledExtensions, + editable, editorClassName, embedHandler, extensions, @@ -54,6 +57,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { handleEditorReady, id, mentionHandler, + onTransaction, placeholder, realtimeConfig, serverHandler, @@ -73,8 +77,9 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { return ( { - const { - containerClassName, - disabledExtensions, - displayConfig = DEFAULT_DISPLAY_CONFIG, - editorClassName = "", - embedHandler, - fileHandler, - forwardedRef, - handleEditorReady, - id, - mentionHandler, - realtimeConfig, - serverHandler, - user, - } = props; - const extensions = []; - if (embedHandler?.issue) { - extensions.push( - IssueWidget({ - widgetCallback: embedHandler.issue.widgetCallback, - }) - ); - } - - const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({ - disabledExtensions, - editorClassName, - extensions, - fileHandler, - forwardedRef, - handleEditorReady, - id, - mentionHandler, - realtimeConfig, - serverHandler, - user, - }); - - const editorContainerClassName = getEditorClassNames({ - containerClassName, - }); - - if (!editor) return null; - - if (!hasServerSynced && !hasServerConnectionFailed) return ; - - return ( - - ); -}; - -const CollaborativeDocumentReadOnlyEditorWithRef = forwardRef< - EditorReadOnlyRefApi, - ICollaborativeDocumentReadOnlyEditor ->((props, ref) => ( - } /> -)); - -CollaborativeDocumentReadOnlyEditorWithRef.displayName = "CollaborativeDocumentReadOnlyEditorWithRef"; - -export { CollaborativeDocumentReadOnlyEditorWithRef }; diff --git a/packages/editor/src/core/components/editors/document/index.ts b/packages/editor/src/core/components/editors/document/index.ts index 514b620e3..571cb7e9a 100644 --- a/packages/editor/src/core/components/editors/document/index.ts +++ b/packages/editor/src/core/components/editors/document/index.ts @@ -1,5 +1,4 @@ export * from "./collaborative-editor"; -export * from "./collaborative-read-only-editor"; export * from "./loader"; export * from "./page-renderer"; export * from "./read-only-editor"; 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 d1ff3b3d0..a29768656 100644 --- a/packages/editor/src/core/components/editors/document/page-renderer.tsx +++ b/packages/editor/src/core/components/editors/document/page-renderer.tsx @@ -15,12 +15,13 @@ import { Editor, ReactRenderer } from "@tiptap/react"; // components import { EditorContainer, EditorContentWrapper } from "@/components/editors"; import { LinkView, LinkViewProps } from "@/components/links"; -import { AIFeaturesMenu, BlockMenu } from "@/components/menus"; +import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus"; // types import { TAIHandler, TDisplayConfig } from "@/types"; type IPageRenderer = { aiHandler?: TAIHandler; + bubbleMenuEnabled: boolean; displayConfig: TDisplayConfig; editor: Editor; editorContainerClassName: string; @@ -29,7 +30,7 @@ type IPageRenderer = { }; export const PageRenderer = (props: IPageRenderer) => { - const { aiHandler, displayConfig, editor, editorContainerClassName, id, tabIndex } = props; + const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props; // states const [linkViewProps, setLinkViewProps] = useState(); const [isOpen, setIsOpen] = useState(false); @@ -140,10 +141,11 @@ export const PageRenderer = (props: IPageRenderer) => { > {editor.isEditable && ( - <> +
+ {bubbleMenuEnabled && } - +
)}
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 b36fb44a7..fa1770f0c 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,3 +1,4 @@ +import { Extensions } from "@tiptap/core"; import { forwardRef, MutableRefObject } from "react"; // components import { PageRenderer } from "@/components/editors"; @@ -10,7 +11,13 @@ import { getEditorClassNames } from "@/helpers/common"; // hooks import { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; // types -import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TExtensions, TFileHandler } from "@/types"; +import { + EditorReadOnlyRefApi, + TDisplayConfig, + TExtensions, + TReadOnlyFileHandler, + TReadOnlyMentionHandler, +} from "@/types"; interface IDocumentReadOnlyEditor { disabledExtensions: TExtensions[]; @@ -20,12 +27,10 @@ interface IDocumentReadOnlyEditor { displayConfig?: TDisplayConfig; editorClassName?: string; embedHandler: any; - fileHandler: Pick; + fileHandler: TReadOnlyFileHandler; tabIndex?: number; handleEditorReady?: (value: boolean) => void; - mentionHandler: { - highlights: () => Promise; - }; + mentionHandler: TReadOnlyMentionHandler; forwardedRef?: React.MutableRefObject; } @@ -43,7 +48,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { initialValue, mentionHandler, } = props; - const extensions = []; + const extensions: Extensions = []; if (embedHandler?.issue) { extensions.push( IssueWidget({ @@ -71,6 +76,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { return ( React.ReactNode; - extensions: Extension[]; + extensions: Extensions; }; export const EditorWrapper: React.FC = (props) => { @@ -38,6 +38,7 @@ export const EditorWrapper: React.FC = (props) => { } = props; const editor = useEditor({ + editable: true, disabledExtensions, editorClassName, enableHistory: true, diff --git a/packages/editor/src/core/components/menus/ai-menu.tsx b/packages/editor/src/core/components/menus/ai-menu.tsx index 43793fae3..968c7d469 100644 --- a/packages/editor/src/core/components/menus/ai-menu.tsx +++ b/packages/editor/src/core/components/menus/ai-menu.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import tippy, { Instance } from "tippy.js"; -// helpers -import { cn } from "@/helpers/common"; +// plane utils +import { cn } from "@plane/utils"; // types import { TAIHandler } from "@/types"; @@ -23,6 +23,7 @@ export const AIFeaturesMenu: React.FC = (props) => { menuRef.current.remove(); menuRef.current.style.visibility = "visible"; + // @ts-expect-error - Tippy types are incorrect popup.current = tippy(document.body, { getReferenceClientRect: null, content: menuRef.current, diff --git a/packages/editor/src/core/components/menus/block-menu.tsx b/packages/editor/src/core/components/menus/block-menu.tsx index 77d5ce81e..c143abd00 100644 --- a/packages/editor/src/core/components/menus/block-menu.tsx +++ b/packages/editor/src/core/components/menus/block-menu.tsx @@ -34,6 +34,7 @@ export const BlockMenu = (props: BlockMenuProps) => { menuRef.current.remove(); menuRef.current.style.visibility = "visible"; + // @ts-expect-error - Tippy types are incorrect popup.current = tippy(document.body, { getReferenceClientRect: null, content: menuRef.current, diff --git a/packages/editor/src/core/components/menus/bubble-menu/alignment-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/alignment-selector.tsx index e3ccc6cf6..b168525a7 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/alignment-selector.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/alignment-selector.tsx @@ -1,20 +1,20 @@ import { Editor } from "@tiptap/core"; import { AlignCenter, AlignLeft, AlignRight, LucideIcon } from "lucide-react"; +// plane utils +import { cn } from "@plane/utils"; // components import { TextAlignItem } from "@/components/menus"; -// helpers -import { cn } from "@/helpers/common"; // types import { TEditorCommands } from "@/types"; +import { EditorStateType } from "./root"; type Props = { editor: Editor; - onClose: () => void; + editorState: EditorStateType; }; export const TextAlignmentSelector: React.FC = (props) => { - const { editor, onClose } = props; - + const { editor, editorState } = props; const menuItem = TextAlignItem(editor); const textAlignmentOptions: { @@ -32,10 +32,7 @@ export const TextAlignmentSelector: React.FC = (props) => { menuItem.command({ alignment: "left", }), - isActive: () => - menuItem.isActive({ - alignment: "left", - }), + isActive: () => editorState.left, }, { itemKey: "text-align", @@ -45,10 +42,7 @@ export const TextAlignmentSelector: React.FC = (props) => { menuItem.command({ alignment: "center", }), - isActive: () => - menuItem.isActive({ - alignment: "center", - }), + isActive: () => editorState.center, }, { itemKey: "text-align", @@ -58,10 +52,7 @@ export const TextAlignmentSelector: React.FC = (props) => { menuItem.command({ alignment: "right", }), - isActive: () => - menuItem.isActive({ - alignment: "right", - }), + isActive: () => editorState.right, }, ]; @@ -74,7 +65,6 @@ export const TextAlignmentSelector: React.FC = (props) => { onClick={(e) => { e.stopPropagation(); item.command(); - onClose(); }} className={cn( "size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-colors", diff --git a/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx index bc7f5a56f..ced7ea179 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx @@ -1,23 +1,26 @@ -import { Dispatch, FC, SetStateAction } from "react"; import { Editor } from "@tiptap/react"; import { ALargeSmall, Ban } from "lucide-react"; +import { Dispatch, FC, SetStateAction } from "react"; +// plane utils +import { cn } from "@plane/utils"; // constants import { COLORS_LIST } from "@/constants/common"; // helpers -import { cn } from "@/helpers/common"; import { BackgroundColorItem, TextColorItem } from "../menu-items"; +import { EditorStateType } from "./root"; type Props = { editor: Editor; isOpen: boolean; setIsOpen: Dispatch>; + editorState: EditorStateType; }; export const BubbleMenuColorSelector: FC = (props) => { - const { editor, isOpen, setIsOpen } = props; + const { editor, isOpen, setIsOpen, editorState } = props; - const activeTextColor = COLORS_LIST.find((c) => TextColorItem(editor).isActive({ color: c.key })); - const activeBackgroundColor = COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive({ color: c.key })); + const activeTextColor = editorState.color; + const activeBackgroundColor = editorState.backgroundColor; return (
@@ -70,7 +73,7 @@ export const BubbleMenuColorSelector: FC = (props) => { @@ -93,7 +96,7 @@ export const BubbleMenuColorSelector: FC = (props) => { 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 eaa20ed26..333edf78a 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 { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react"; import { Editor } from "@tiptap/core"; -import { Check, Link, Trash } from "lucide-react"; +import { Check, Link, Trash2 } from "lucide-react"; +import { Dispatch, FC, SetStateAction, useCallback, useRef, useState } from "react"; +// plane utils +import { cn } from "@plane/utils"; // helpers -import { cn, isValidHttpUrl } from "@/helpers/common"; +import { isValidHttpUrl } from "@/helpers/common"; import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands"; type Props = { @@ -13,22 +15,26 @@ type Props = { export const BubbleMenuLinkSelector: FC = (props) => { const { editor, isOpen, setIsOpen } = props; + // states + const [error, setError] = useState(false); // refs const inputRef = useRef(null); - const onLinkSubmit = useCallback(() => { + const handleLinkSubmit = useCallback(() => { const input = inputRef.current; - const url = input?.value; - if (url && isValidHttpUrl(url)) { + if (!input) return; + let url = input.value; + if (!url) return; + if (!url.startsWith("http")) url = `http://${url}`; + if (isValidHttpUrl(url)) { setLinkEditor(editor, url); setIsOpen(false); + setError(false); + } else { + setError(true); } }, [editor, inputRef, setIsOpen]); - useEffect(() => { - inputRef.current && inputRef.current?.focus(); - }); - return (
{isOpen && ( -
{ - if (e.key === "Enter") { - e.preventDefault(); - onLinkSubmit(); - } - }} - > - { - e.stopPropagation(); - }} - className="flex-1 border-r border-custom-border-300 bg-custom-background-100 p-1 text-sm outline-none placeholder:text-custom-text-400" - defaultValue={editor.getAttributes("link").href || ""} - /> - {editor.getAttributes("link").href ? ( - - ) : ( - + onFocus={() => setError(false)} + autoFocus + /> + {editor.getAttributes("link").href ? ( + + ) : ( + + )} +
+ {error && ( +

+ Please enter a valid URL +

)}
)} 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 a121c48ef..7d1378800 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,8 @@ import { Dispatch, FC, SetStateAction } from "react"; import { Editor } from "@tiptap/react"; import { Check, ChevronDown } from "lucide-react"; +// plane utils +import { cn } from "@plane/utils"; // components import { BulletListItem, @@ -17,8 +19,6 @@ import { HeadingSixItem, EditorMenuItem, } from "@/components/menus"; -// helpers -import { cn } from "@/helpers/common"; // types import { TEditorCommands } from "@/types"; 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 18079f089..149c6f6c2 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/root.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/root.tsx @@ -1,7 +1,10 @@ -import { FC, useEffect, useState } from "react"; -import { BubbleMenu, BubbleMenuProps, Editor, isNodeSelection } from "@tiptap/react"; +import { BubbleMenu, BubbleMenuProps, Editor, isNodeSelection, useEditorState } from "@tiptap/react"; +import { FC, useEffect, useState, useRef } from "react"; +// plane utils +import { cn } from "@plane/utils"; // components import { + BackgroundColorItem, BoldItem, BubbleMenuColorSelector, BubbleMenuLinkSelector, @@ -9,27 +12,74 @@ import { CodeItem, ItalicItem, StrikeThroughItem, + TextAlignItem, + TextColorItem, UnderLineItem, } from "@/components/menus"; +// constants +import { COLORS_LIST } from "@/constants/common"; // extensions import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection"; -// helpers -import { cn } from "@/helpers/common"; // local components import { TextAlignmentSelector } from "./alignment-selector"; type EditorBubbleMenuProps = Omit; -export const EditorBubbleMenu: FC = (props: any) => { - // states +export interface EditorStateType { + code: boolean; + bold: boolean; + italic: boolean; + underline: boolean; + strike: 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; +} + +export const EditorBubbleMenu: FC = (props: { editor: Editor }) => { + const menuRef = useRef(null); const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); const [isSelecting, setIsSelecting] = useState(false); - const basicFormattingOptions = props.editor.isActive("code") - ? [CodeItem(props.editor)] - : [BoldItem(props.editor), ItalicItem(props.editor), UnderLineItem(props.editor), StrikeThroughItem(props.editor)]; + const formattingItems = { + code: CodeItem(props.editor), + bold: BoldItem(props.editor), + italic: ItalicItem(props.editor), + underline: UnderLineItem(props.editor), + strike: StrikeThroughItem(props.editor), + textAlign: TextAlignItem(props.editor), + }; + + const editorState: EditorStateType = useEditorState({ + editor: props.editor, + selector: ({ editor }: { editor: Editor }) => ({ + code: formattingItems.code.isActive(), + 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" }), + color: COLORS_LIST.find((c) => TextColorItem(editor).isActive({ color: c.key })), + backgroundColor: COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive({ color: c.key })), + }), + }); + + const basicFormattingOptions = editorState.code + ? [formattingItems.code] + : [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strike]; const bubbleMenuProps: EditorBubbleMenuProps = { ...props, @@ -51,6 +101,7 @@ export const EditorBubbleMenu: FC = (props: any) => { }, tippyOptions: { moveTransition: "transform 0.15s ease-out", + duration: [300, 0], onHidden: () => { setIsNodeSelectorOpen(false); setIsLinkSelectorOpen(false); @@ -60,7 +111,9 @@ export const EditorBubbleMenu: FC = (props: any) => { }; useEffect(() => { - function handleMouseDown() { + function handleMouseDown(e: MouseEvent) { + if (menuRef.current?.contains(e.target as Node)) return; + function handleMouseMove() { if (!props.editor.state.selection.empty) { setIsSelecting(true); @@ -70,7 +123,6 @@ export const EditorBubbleMenu: FC = (props: any) => { function handleMouseUp() { setIsSelecting(false); - document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); } @@ -84,30 +136,28 @@ export const EditorBubbleMenu: FC = (props: any) => { return () => { document.removeEventListener("mousedown", handleMouseDown); }; - }, []); + }, [props.editor]); return ( - + {!isSelecting && ( - <> +
- {!props.editor.isActive("table") && ( - { - setIsNodeSelectorOpen((prev) => !prev); - setIsLinkSelectorOpen(false); - setIsColorSelectorOpen(false); - }} - /> - )} + { + setIsNodeSelectorOpen((prev) => !prev); + setIsLinkSelectorOpen(false); + setIsColorSelectorOpen(false); + }} + />
-
- {!props.editor.isActive("code") && ( + {!editorState.code && ( +
= (props: any) => { setIsColorSelectorOpen(false); }} /> - )} -
-
- {!props.editor.isActive("code") && ( +
+ )} + {!editorState.code && ( +
{ setIsColorSelectorOpen((prev) => !prev); setIsNodeSelectorOpen(false); setIsLinkSelectorOpen(false); }} /> - )} -
+
+ )}
{basicFormattingOptions.map((item) => ( ))}
- { - const editor = props.editor as Editor; - if (!editor) return; - const pos = editor.state.selection.to; - editor.commands.setTextSelection(pos ?? 0); - }} - /> - + +
)}
); diff --git a/packages/editor/src/core/components/menus/menu-items.ts b/packages/editor/src/core/components/menus/menu-items.ts index 5e987fca6..8cb4accc5 100644 --- a/packages/editor/src/core/components/menus/menu-items.ts +++ b/packages/editor/src/core/components/menus/menu-items.ts @@ -142,8 +142,8 @@ export const UnderLineItem = (editor: Editor): EditorMenuItem<"underline"> => ({ icon: UnderlineIcon, }); -export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough"> => ({ - key: "strikethrough", +export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strike"> => ({ + key: "strike", name: "Strikethrough", isActive: () => editor?.isActive("strike"), command: () => toggleStrike(editor), @@ -202,8 +202,7 @@ export const ImageItem = (editor: Editor): EditorMenuItem<"image"> => ({ key: "image", name: "Image", isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"), - command: ({ savedSelection }) => - insertImage({ editor, event: "insert", pos: savedSelection?.from ?? editor.state.selection.from }), + command: () => insertImage({ editor, event: "insert", pos: editor.state.selection.from }), icon: ImageIcon, }); @@ -219,24 +218,33 @@ export const HorizontalRuleItem = (editor: Editor) => export const TextColorItem = (editor: Editor): EditorMenuItem<"text-color"> => ({ key: "text-color", name: "Color", - isActive: ({ color }) => editor.isActive("customColor", { color }), - command: ({ color }) => toggleTextColor(color, editor), + isActive: (props) => editor.isActive("customColor", { color: props?.color }), + command: (props) => { + if (!props) return; + toggleTextColor(props.color, editor); + }, icon: Palette, }); export const BackgroundColorItem = (editor: Editor): EditorMenuItem<"background-color"> => ({ key: "background-color", name: "Background color", - isActive: ({ color }) => editor.isActive("customColor", { backgroundColor: color }), - command: ({ color }) => toggleBackgroundColor(color, editor), + isActive: (props) => editor.isActive("customColor", { backgroundColor: props?.color }), + command: (props) => { + if (!props) return; + toggleBackgroundColor(props.color, editor); + }, icon: Palette, }); export const TextAlignItem = (editor: Editor): EditorMenuItem<"text-align"> => ({ key: "text-align", name: "Text align", - isActive: ({ alignment }) => editor.isActive({ textAlign: alignment }), - command: ({ alignment }) => setTextAlign(alignment, editor), + isActive: (props) => editor.isActive({ textAlign: props?.alignment }), + command: (props) => { + if (!props) return; + setTextAlign(props.alignment, editor); + }, icon: AlignCenter, }); diff --git a/packages/editor/src/core/constants/common.ts b/packages/editor/src/core/constants/common.ts index 7f4f7f66f..bae06d303 100644 --- a/packages/editor/src/core/constants/common.ts +++ b/packages/editor/src/core/constants/common.ts @@ -1,3 +1,185 @@ +import { + AlignCenter, + AlignLeft, + AlignRight, + Bold, + CaseSensitive, + Code2, + Heading1, + Heading2, + Heading3, + Heading4, + Heading5, + Heading6, + Image, + Italic, + List, + ListOrdered, + ListTodo, + LucideIcon, + Strikethrough, + Table, + TextQuote, + Underline, +} from "lucide-react"; +import { TCommandExtraProps, TEditorCommands } from "@/types/editor"; + +export type TEditorTypes = "lite" | "document"; + +// Utility type to enforce the necessary extra props or make extraProps optional +export type ExtraPropsForCommand = T extends keyof TCommandExtraProps + ? TCommandExtraProps[T] + : object; // Default to empty object for commands without extra props + +export type ToolbarMenuItem = { + itemKey: T; + renderKey: string; + name: string; + icon: LucideIcon; + shortcut?: string[]; + editors: TEditorTypes[]; + extraProps?: ExtraPropsForCommand; +}; + +export const TYPOGRAPHY_ITEMS: ToolbarMenuItem<"text" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6">[] = [ + { itemKey: "text", renderKey: "text", name: "Text", icon: CaseSensitive, editors: ["document"] }, + { itemKey: "h1", renderKey: "h1", name: "Heading 1", icon: Heading1, editors: ["document"] }, + { itemKey: "h2", renderKey: "h2", name: "Heading 2", icon: Heading2, editors: ["document"] }, + { itemKey: "h3", renderKey: "h3", name: "Heading 3", icon: Heading3, editors: ["document"] }, + { itemKey: "h4", renderKey: "h4", name: "Heading 4", icon: Heading4, editors: ["document"] }, + { itemKey: "h5", renderKey: "h5", name: "Heading 5", icon: Heading5, editors: ["document"] }, + { itemKey: "h6", renderKey: "h6", name: "Heading 6", icon: Heading6, editors: ["document"] }, +]; + +export const TEXT_ALIGNMENT_ITEMS: ToolbarMenuItem<"text-align">[] = [ + { + itemKey: "text-align", + renderKey: "text-align-left", + name: "Left align", + icon: AlignLeft, + shortcut: ["Cmd", "Shift", "L"], + editors: ["lite", "document"], + extraProps: { + alignment: "left", + }, + }, + { + itemKey: "text-align", + renderKey: "text-align-center", + name: "Center align", + icon: AlignCenter, + shortcut: ["Cmd", "Shift", "E"], + editors: ["lite", "document"], + extraProps: { + alignment: "center", + }, + }, + { + itemKey: "text-align", + renderKey: "text-align-right", + name: "Right align", + icon: AlignRight, + shortcut: ["Cmd", "Shift", "R"], + editors: ["lite", "document"], + extraProps: { + alignment: "right", + }, + }, +]; + +const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strike">[] = [ + { + itemKey: "bold", + renderKey: "bold", + name: "Bold", + icon: Bold, + shortcut: ["Cmd", "B"], + editors: ["lite", "document"], + }, + { + itemKey: "italic", + renderKey: "italic", + name: "Italic", + icon: Italic, + shortcut: ["Cmd", "I"], + editors: ["lite", "document"], + }, + { + itemKey: "underline", + renderKey: "underline", + name: "Underline", + icon: Underline, + shortcut: ["Cmd", "U"], + editors: ["lite", "document"], + }, + { + itemKey: "strike", + renderKey: "strikethrough", + name: "Strikethrough", + icon: Strikethrough, + shortcut: ["Cmd", "Shift", "S"], + editors: ["lite", "document"], + }, +]; + +const LIST_ITEMS: ToolbarMenuItem<"bulleted-list" | "numbered-list" | "to-do-list">[] = [ + { + itemKey: "bulleted-list", + renderKey: "bulleted-list", + name: "Bulleted list", + icon: List, + shortcut: ["Cmd", "Shift", "7"], + editors: ["lite", "document"], + }, + { + itemKey: "numbered-list", + renderKey: "numbered-list", + name: "Numbered list", + icon: ListOrdered, + shortcut: ["Cmd", "Shift", "8"], + editors: ["lite", "document"], + }, + { + itemKey: "to-do-list", + renderKey: "to-do-list", + name: "To-do list", + icon: ListTodo, + shortcut: ["Cmd", "Shift", "9"], + editors: ["lite", "document"], + }, +]; + +export const USER_ACTION_ITEMS: ToolbarMenuItem<"quote" | "code">[] = [ + { itemKey: "quote", renderKey: "quote", name: "Quote", icon: TextQuote, editors: ["lite", "document"] }, + { itemKey: "code", renderKey: "code", name: "Code", icon: Code2, editors: ["lite", "document"] }, +]; + +export const COMPLEX_ITEMS: ToolbarMenuItem<"table" | "image">[] = [ + { itemKey: "table", renderKey: "table", name: "Table", icon: Table, editors: ["document"] }, + { itemKey: "image", renderKey: "image", name: "Image", icon: Image, editors: ["lite", "document"] }, +]; + +export const TOOLBAR_ITEMS: { + [editorType in TEditorTypes]: { + [key: string]: ToolbarMenuItem[]; + }; +} = { + lite: { + basic: BASIC_MARK_ITEMS.filter((item) => item.editors.includes("lite")), + alignment: TEXT_ALIGNMENT_ITEMS.filter((item) => item.editors.includes("lite")), + list: LIST_ITEMS.filter((item) => item.editors.includes("lite")), + userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("lite")), + complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("lite")), + }, + document: { + basic: BASIC_MARK_ITEMS.filter((item) => item.editors.includes("document")), + alignment: TEXT_ALIGNMENT_ITEMS.filter((item) => item.editors.includes("document")), + list: LIST_ITEMS.filter((item) => item.editors.includes("document")), + userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("document")), + complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("document")), + }, +}; + export const COLORS_LIST: { key: string; label: string; diff --git a/packages/editor/src/core/constants/config.ts b/packages/editor/src/core/constants/config.ts index 5a9577044..bd4712de9 100644 --- a/packages/editor/src/core/constants/config.ts +++ b/packages/editor/src/core/constants/config.ts @@ -5,3 +5,6 @@ export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = { fontSize: "large-font", fontStyle: "sans-serif", }; + +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]}`); diff --git a/packages/editor/src/core/constants/document-collaborative-events.ts b/packages/editor/src/core/constants/document-collaborative-events.ts index 5e79efc7a..72e8b1dbd 100644 --- a/packages/editor/src/core/constants/document-collaborative-events.ts +++ b/packages/editor/src/core/constants/document-collaborative-events.ts @@ -3,4 +3,6 @@ export const DocumentCollaborativeEvents = { unlock: { client: "unlocked", server: "unlock" }, archive: { client: "archived", server: "archive" }, unarchive: { client: "unarchived", server: "unarchive" }, + "make-public": { client: "made-public", server: "make-public" }, + "make-private": { client: "made-private", server: "make-private" }, } as const; diff --git a/packages/editor/src/core/extensions/callout/color-selector.tsx b/packages/editor/src/core/extensions/callout/color-selector.tsx index 489b05166..ddc3b879c 100644 --- a/packages/editor/src/core/extensions/callout/color-selector.tsx +++ b/packages/editor/src/core/extensions/callout/color-selector.tsx @@ -1,8 +1,8 @@ import { Ban, ChevronDown } from "lucide-react"; +// plane utils +import { cn } from "@plane/utils"; // constants import { COLORS_LIST } from "@/constants/common"; -// helpers -import { cn } from "@/helpers/common"; type Props = { disabled: boolean; diff --git a/packages/editor/src/core/extensions/callout/logo-selector.tsx b/packages/editor/src/core/extensions/callout/logo-selector.tsx index 4c78a2c04..8ea47d50d 100644 --- a/packages/editor/src/core/extensions/callout/logo-selector.tsx +++ b/packages/editor/src/core/extensions/callout/logo-selector.tsx @@ -2,8 +2,8 @@ import { convertHexEmojiToDecimal } from "@plane/utils"; // plane ui import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui"; -// helpers -import { cn } from "@/helpers/common"; +// plane utils +import { cn } from "@plane/utils"; // types import { TCalloutBlockAttributes } from "./types"; // utils 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 8dbdb044f..a06d83990 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 @@ -8,8 +8,8 @@ import { common, createLowlight } from "lowlight"; import { CopyIcon, CheckIcon } from "lucide-react"; // ui import { Tooltip } from "@plane/ui"; -// helpers -import { cn } from "@/helpers/common"; +// plane utils +import { cn } from "@plane/utils"; // we just have ts support for now const lowlight = createLowlight(common); diff --git a/packages/editor/src/core/extensions/core-without-props.ts b/packages/editor/src/core/extensions/core-without-props.ts index 212e1c241..8864f49f7 100644 --- a/packages/editor/src/core/extensions/core-without-props.ts +++ b/packages/editor/src/core/extensions/core-without-props.ts @@ -13,7 +13,7 @@ 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 { CustomMentionWithoutProps } from "./mentions/mentions-without-props"; +import { CustomMentionExtensionConfig } from "./mentions/extension-config"; import { CustomQuoteExtension } from "./quote"; import { TableHeader, TableCell, TableRow, Table } from "./table"; import { CustomTextAlignExtension } from "./text-align"; @@ -97,7 +97,7 @@ export const CoreEditorExtensionsWithoutProps = [ TableHeader, TableCell, TableRow, - CustomMentionWithoutProps(), + CustomMentionExtensionConfig, CustomTextAlignExtension, CustomCalloutExtensionConfig, CustomColorExtension, diff --git a/packages/editor/src/core/extensions/custom-color.ts b/packages/editor/src/core/extensions/custom-color.ts index dc966816c..b377099fb 100644 --- a/packages/editor/src/core/extensions/custom-color.ts +++ b/packages/editor/src/core/extensions/custom-color.ts @@ -93,6 +93,21 @@ export const CustomColorExtension = Mark.create({ }; }, + addStorage() { + return { + markdown: { + serialize: { + open: "", + close: "", + mixable: true, + expelEnclosingWhitespace: true, + }, + }, + }; + }, + + // @ts-expect-error types are incorrect + // TODO: check this and update types parseHTML() { return [ { diff --git a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx index b5b27e271..0cc38f5a4 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx @@ -1,9 +1,10 @@ -import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react"; import { NodeSelection } from "@tiptap/pm/state"; +import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react"; +// plane utils +import { cn } from "@plane/utils"; // extensions import { CustoBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image"; -// helpers -import { cn } from "@/helpers/common"; +import { ImageUploadStatus } from "./upload-status"; const MIN_SIZE = 100; @@ -38,11 +39,11 @@ const ensurePixelString = (value: Pixel | TDefault | number | undefin }; type CustomImageBlockProps = CustoBaseImageNodeViewProps & { - imageFromFileSystem: string; + imageFromFileSystem: string | undefined; setFailedToLoadImage: (isError: boolean) => void; editorContainer: HTMLDivElement | null; setEditorContainer: (editorContainer: HTMLDivElement | null) => void; - src: string; + src: string | undefined; }; export const CustomImageBlock: React.FC = (props) => { @@ -62,8 +63,8 @@ export const CustomImageBlock: React.FC = (props) => { const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs; // states const [size, setSize] = useState({ - width: ensurePixelString(nodeWidth, "35%"), - height: ensurePixelString(nodeHeight, "auto"), + width: ensurePixelString(nodeWidth, "35%") ?? "35%", + height: ensurePixelString(nodeHeight, "auto") ?? "auto", aspectRatio: nodeAspectRatio || null, }); const [isResizing, setIsResizing] = useState(false); @@ -144,8 +145,8 @@ export const CustomImageBlock: React.FC = (props) => { useLayoutEffect(() => { setSize((prevSize) => ({ ...prevSize, - width: ensurePixelString(nodeWidth), - height: ensurePixelString(nodeHeight), + width: ensurePixelString(nodeWidth) ?? "35%", + height: ensurePixelString(nodeHeight) ?? "auto", aspectRatio: nodeAspectRatio, })); }, [nodeWidth, nodeHeight, nodeAspectRatio]); @@ -210,6 +211,8 @@ export const CustomImageBlock: React.FC = (props) => { // show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or) // if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete const showImageLoader = !(resolvedImageSrc || imageFromFileSystem) || !initialResizeComplete || hasErroredOnFirstLoad; + // show the image upload status only when the resolvedImageSrc is not ready + const showUploadStatus = !resolvedImageSrc; // show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem) const showImageUtils = resolvedImageSrc && initialResizeComplete; // show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem) @@ -247,7 +250,16 @@ export const CustomImageBlock: React.FC = (props) => { try { setHasErroredOnFirstLoad(true); // this is a type error from tiptap, don't remove await until it's fixed + if (!imgNodeSrc) { + throw new Error("No source image to restore from"); + } await editor?.commands.restoreImage?.(imgNodeSrc); + if (!imageRef.current) { + throw new Error("Image reference not found"); + } + if (!resolvedImageSrc) { + throw new Error("No resolved image source available"); + } imageRef.current.src = resolvedImageSrc; } catch { // if the image failed to even restore, then show the error state @@ -270,6 +282,7 @@ export const CustomImageBlock: React.FC = (props) => { ...(size.aspectRatio && { aspectRatio: size.aspectRatio }), }} /> + {showUploadStatus && node.attrs.id && } {showImageUtils && ( = (props) => { } image={{ src: resolvedImageSrc, - aspectRatio: size.aspectRatio, + aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio, height: size.height, width: size.width, }} diff --git a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx index 58b60b306..2bd84fcb3 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState } from "react"; import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { useEffect, useRef, useState } from "react"; // extensions import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image"; diff --git a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx index 8ad99bc44..0fd0e6dd4 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx @@ -1,11 +1,13 @@ -import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react"; import { ImageIcon } from "lucide-react"; -// helpers -import { cn } from "@/helpers/common"; -// hooks -import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload"; +import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react"; +// plane utils +import { cn } from "@plane/utils"; +// constants +import { ACCEPTED_FILE_EXTENSIONS } from "@/constants/config"; // extensions import { CustoBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image"; +// hooks +import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload"; type CustomImageUploaderProps = CustoBaseImageNodeViewProps & { maxFileSize: number; @@ -36,6 +38,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { const onUpload = useCallback( (url: string) => { if (url) { + if (!imageEntityId) return; setIsUploaded(true); // Update the node view's src attribute post upload updateAttributes({ src: url }); @@ -66,6 +69,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { ); // hooks const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ + blockId: imageEntityId ?? "", editor, loadImageFromFileSystem, maxFileSize, @@ -80,7 +84,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { // the meta data of the image component const meta = useMemo( - () => imageComponentImageFileMap?.get(imageEntityId), + () => imageComponentImageFileMap?.get(imageEntityId ?? ""), [imageComponentImageFileMap, imageEntityId] ); @@ -94,7 +98,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { if (meta.hasOpenedFileInputOnce) return; fileInputRef.current.click(); hasTriggeredFilePickerRef.current = true; - imageComponentImageFileMap?.set(imageEntityId, { ...meta, hasOpenedFileInputOnce: true }); + imageComponentImageFileMap?.set(imageEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true }); } } }, [meta, uploadFile, imageComponentImageFileMap]); @@ -127,7 +131,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { return "Uploading..."; } - if (draggedInside) { + if (draggedInside && editor.isEditable) { return "Drop image here"; } @@ -137,14 +141,16 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { return (
{ ref={fileInputRef} hidden type="file" - accept=".jpg,.jpeg,.png,.webp" + accept={ACCEPTED_FILE_EXTENSIONS.join(",")} onChange={onFileChange} multiple /> 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 38ea23c99..61ae307bb 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,7 +1,7 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react"; -// helpers -import { cn } from "@/helpers/common"; +import { useCallback, useEffect, useMemo, useState, useRef } from "react"; +// plane utils +import { cn } from "@plane/utils"; type Props = { image: { @@ -14,46 +14,77 @@ type Props = { toggleFullScreenMode: (val: boolean) => void; }; -const MAGNIFICATION_VALUES = [0.5, 0.75, 1, 1.5, 1.75, 2]; +const MIN_ZOOM = 0.5; +const MAX_ZOOM = 2; +const ZOOM_SPEED = 0.05; +const ZOOM_STEPS = [0.5, 1, 1.5, 2]; export const ImageFullScreenAction: React.FC = (props) => { const { image, isOpen: isFullScreenEnabled, toggleFullScreenMode } = props; const { src, width, aspectRatio } = image; - // states - const [magnification, setMagnification] = useState(1); - // refs + + const [magnification, setMagnification] = useState(1); + const [initialMagnification, setInitialMagnification] = useState(1); + const [isDragging, setIsDragging] = useState(false); + const dragStart = useRef({ x: 0, y: 0 }); + const dragOffset = useRef({ x: 0, y: 0 }); const modalRef = useRef(null); - // derived values + const imgRef = useRef(null); + const widthInNumber = useMemo(() => Number(width?.replace("px", "")), [width]); - // close handler - const handleClose = useCallback(() => { - toggleFullScreenMode(false); - setTimeout(() => { + + const setImageRef = useCallback( + (node: HTMLImageElement | null) => { + if (!node || !isFullScreenEnabled) return; + + imgRef.current = node; + + const viewportWidth = window.innerWidth * 0.9; + const viewportHeight = window.innerHeight * 0.75; + const imageWidth = widthInNumber; + const imageHeight = imageWidth / aspectRatio; + + const widthRatio = viewportWidth / imageWidth; + const heightRatio = viewportHeight / imageHeight; + + setInitialMagnification(Math.min(widthRatio, heightRatio)); setMagnification(1); - }, 200); - }, [toggleFullScreenMode]); - // download handler - const handleOpenInNewTab = () => { - const link = document.createElement("a"); - link.href = src; - link.target = "_blank"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }; - // magnification decrease handler - const handleDecreaseMagnification = useCallback(() => { - const currentIndex = MAGNIFICATION_VALUES.indexOf(magnification); - if (currentIndex === 0) return; - setMagnification(MAGNIFICATION_VALUES[currentIndex - 1]); - }, [magnification]); - // magnification increase handler - const handleIncreaseMagnification = useCallback(() => { - const currentIndex = MAGNIFICATION_VALUES.indexOf(magnification); - if (currentIndex === MAGNIFICATION_VALUES.length - 1) return; - setMagnification(MAGNIFICATION_VALUES[currentIndex + 1]); - }, [magnification]); - // keydown handler + + // Reset image position + node.style.left = "0px"; + node.style.top = "0px"; + }, + [isFullScreenEnabled, widthInNumber, aspectRatio] + ); + + const handleClose = useCallback(() => { + if (isDragging) return; + toggleFullScreenMode(false); + setMagnification(1); + setInitialMagnification(1); + }, [isDragging, toggleFullScreenMode]); + + const handleMagnification = useCallback((direction: "increase" | "decrease") => { + setMagnification((prev) => { + // Find the appropriate target zoom level based on current magnification + let targetZoom: number; + if (direction === "increase") { + targetZoom = ZOOM_STEPS.find((step) => step > prev) ?? MAX_ZOOM; + } else { + // Reverse the array to find the next lower step + targetZoom = [...ZOOM_STEPS].reverse().find((step) => step < prev) ?? MIN_ZOOM; + } + + // Reset position when zoom matches initial magnification + if (targetZoom === 1 && imgRef.current) { + imgRef.current.style.left = "0px"; + imgRef.current.style.top = "0px"; + } + + return targetZoom; + }); + }, []); + const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === "Escape" || e.key === "+" || e.key === "=" || e.key === "-") { @@ -61,43 +92,113 @@ export const ImageFullScreenAction: React.FC = (props) => { e.stopPropagation(); if (e.key === "Escape") handleClose(); - if (e.key === "+" || e.key === "=") handleIncreaseMagnification(); - if (e.key === "-") handleDecreaseMagnification(); + if (e.key === "+" || e.key === "=") handleMagnification("increase"); + if (e.key === "-") handleMagnification("decrease"); } }, - [handleClose, handleDecreaseMagnification, handleIncreaseMagnification] + [handleClose, handleMagnification] ); - // click outside handler - const handleClickOutside = useCallback( - (e: React.MouseEvent) => { - if (modalRef.current && e.target === modalRef.current) { - handleClose(); - } - }, - [handleClose] - ); - // register keydown listener - useEffect(() => { - if (isFullScreenEnabled) { - document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("keydown", handleKeyDown); + const handleMouseDown = (e: React.MouseEvent) => { + if (!imgRef.current) return; + + const imgWidth = imgRef.current.offsetWidth * magnification; + const imgHeight = imgRef.current.offsetHeight * magnification; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + if (imgWidth > viewportWidth || imgHeight > viewportHeight) { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + dragStart.current = { x: e.clientX, y: e.clientY }; + dragOffset.current = { + x: parseInt(imgRef.current.style.left || "0"), + y: parseInt(imgRef.current.style.top || "0"), }; } - }, [handleKeyDown, isFullScreenEnabled]); + }; + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDragging || !imgRef.current) return; + + const dx = e.clientX - dragStart.current.x; + const dy = e.clientY - dragStart.current.y; + + // Apply the scale factor to the drag movement + const scaledDx = dx / magnification; + const scaledDy = dy / magnification; + + imgRef.current.style.left = `${dragOffset.current.x + scaledDx}px`; + imgRef.current.style.top = `${dragOffset.current.y + scaledDy}px`; + }, + [isDragging, magnification] + ); + + const handleMouseUp = useCallback(() => { + if (!isDragging || !imgRef.current) return; + setIsDragging(false); + }, [isDragging]); + + const handleWheel = useCallback( + (e: WheelEvent) => { + if (!imgRef.current || !isFullScreenEnabled) return; + + e.preventDefault(); + + // Handle pinch-to-zoom + if (e.ctrlKey) { + const delta = e.deltaY; + setMagnification((prev) => { + const newZoom = prev * (1 - delta * ZOOM_SPEED); + const clampedZoom = Math.min(Math.max(newZoom, MIN_ZOOM), MAX_ZOOM); + + // Reset position when zoom matches initial magnification + if (clampedZoom === 1 && imgRef.current) { + imgRef.current.style.left = "0px"; + imgRef.current.style.top = "0px"; + } + + return clampedZoom; + }); + return; + } + }, + [isFullScreenEnabled, magnification] + ); + + // Event listeners + useEffect(() => { + if (!isFullScreenEnabled) return; + + document.addEventListener("keydown", handleKeyDown); + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + window.addEventListener("wheel", handleWheel, { passive: false }); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + window.removeEventListener("wheel", handleWheel); + }; + }, [isFullScreenEnabled, handleKeyDown, handleMouseMove, handleMouseUp, handleWheel]); return ( <>
-
+
e.target === modalRef.current && handleClose()} + className="relative size-full grid place-items-center overflow-hidden" + > -
-
-
+
+
+ + {Math.round(100 * magnification)}% + +
- {(100 * magnification).toFixed(0)}% -
-
+ ); + })} +
+ )) + ) : ( +
No results
+ )} +
+ ); +}); + +MentionsListDropdown.displayName = "MentionsListDropdown"; diff --git a/packages/editor/src/core/extensions/mentions/mentions-list.tsx b/packages/editor/src/core/extensions/mentions/mentions-list.tsx deleted file mode 100644 index 279567a20..000000000 --- a/packages/editor/src/core/extensions/mentions/mentions-list.tsx +++ /dev/null @@ -1,171 +0,0 @@ -"use client"; - -import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; -import { Editor } from "@tiptap/react"; -import { v4 as uuidv4 } from "uuid"; -// ui -import { Avatar } from "@plane/ui"; -// helpers -import { cn } from "@/helpers/common"; -// types -import { IMentionSuggestion } from "@/types"; - -interface MentionListProps { - command: (item: { - id: string; - label: string; - entity_name: string; - entity_identifier: string; - target: string; - redirect_uri: string; - }) => void; - query: string; - editor: Editor; - mentionSuggestions: () => Promise; -} - -export const MentionList = forwardRef((props: MentionListProps, ref) => { - const { query, mentionSuggestions } = props; - const [items, setItems] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(0); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - const fetchSuggestions = async () => { - setIsLoading(true); - try { - const suggestions = await mentionSuggestions(); - const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => { - const transactionId = uuidv4(); - return { - ...suggestion, - id: transactionId, - }; - }); - - const filteredSuggestions = mappedSuggestions.filter((suggestion) => - suggestion.title.toLowerCase().startsWith(query.toLowerCase()) - ); - - setItems(filteredSuggestions); - } catch (error) { - console.error("Failed to fetch suggestions:", error); - } finally { - setIsLoading(false); - } - }; - - fetchSuggestions(); - }, [query, mentionSuggestions]); - - const selectItem = (index: number) => { - try { - const item = items[index]; - - if (item) { - props.command({ - id: item.id, - label: item.title, - entity_identifier: item.entity_identifier, - entity_name: item.entity_name, - target: "users", - redirect_uri: item.redirect_uri, - }); - } - } catch (error) { - console.error("Error selecting item:", error); - } - }; - - const commandListContainer = useRef(null); - - useLayoutEffect(() => { - const container = commandListContainer?.current; - - const item = container?.children[selectedIndex] as HTMLElement; - - if (item && container) updateScrollView(container, item); - }, [selectedIndex]); - - const updateScrollView = (container: HTMLElement, item: HTMLElement) => { - const containerHeight = container.offsetHeight; - const itemHeight = item ? item.offsetHeight : 0; - - const top = item.offsetTop; - const bottom = top + itemHeight; - - if (top < container.scrollTop) { - container.scrollTop -= container.scrollTop - top + 5; - } else if (bottom > containerHeight + container.scrollTop) { - container.scrollTop += bottom - containerHeight - container.scrollTop + 5; - } - }; - const upHandler = () => { - setSelectedIndex((selectedIndex + items.length - 1) % items.length); - }; - - const downHandler = () => { - setSelectedIndex((selectedIndex + 1) % items.length); - }; - - const enterHandler = () => { - selectItem(selectedIndex); - }; - - useEffect(() => { - setSelectedIndex(0); - }, [items]); - - useImperativeHandle(ref, () => ({ - onKeyDown: ({ event }: { event: KeyboardEvent }) => { - if (event.key === "ArrowUp") { - upHandler(); - return true; - } - - if (event.key === "ArrowDown") { - downHandler(); - return true; - } - - if (event.key === "Enter") { - enterHandler(); - return true; - } - - return false; - }, - })); - - return ( -
- {isLoading ? ( -
Loading...
- ) : items.length ? ( - items.map((item, index) => ( - - )) - ) : ( -
No results
- )} -
- ); -}); - -MentionList.displayName = "MentionList"; diff --git a/packages/editor/src/core/extensions/mentions/mentions-without-props.tsx b/packages/editor/src/core/extensions/mentions/mentions-without-props.tsx deleted file mode 100644 index 8fa8ef695..000000000 --- a/packages/editor/src/core/extensions/mentions/mentions-without-props.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { mergeAttributes } from "@tiptap/core"; -import Mention, { MentionOptions } from "@tiptap/extension-mention"; -// types -import { IMentionHighlight } from "@/types"; - -interface CustomMentionOptions extends MentionOptions { - mentionHighlights: () => Promise; - readonly?: boolean; -} - -export const CustomMentionWithoutProps = () => - Mention.extend({ - addAttributes() { - return { - id: { - default: null, - }, - label: { - default: null, - }, - target: { - default: null, - }, - self: { - default: false, - }, - redirect_uri: { - default: "/", - }, - entity_identifier: { - default: null, - }, - entity_name: { - default: null, - }, - }; - }, - parseHTML() { - return [ - { - tag: "mention-component", - }, - ]; - }, - renderHTML({ HTMLAttributes }) { - return ["mention-component", mergeAttributes(HTMLAttributes)]; - }, - HTMLAttributes: { - class: "mention", - }, - }); diff --git a/packages/editor/src/core/extensions/mentions/types.ts b/packages/editor/src/core/extensions/mentions/types.ts new file mode 100644 index 000000000..5252aa8b8 --- /dev/null +++ b/packages/editor/src/core/extensions/mentions/types.ts @@ -0,0 +1,14 @@ +// plane types +import { TSearchEntities } from "@plane/types"; + +export enum EMentionComponentAttributeNames { + ID = "id", + ENTITY_IDENTIFIER = "entity_identifier", + ENTITY_NAME = "entity_name", +} + +export type TMentionComponentAttributes = { + [EMentionComponentAttributeNames.ID]: string | null; + [EMentionComponentAttributeNames.ENTITY_IDENTIFIER]: string | null; + [EMentionComponentAttributeNames.ENTITY_NAME]: TSearchEntities | null; +}; diff --git a/packages/editor/src/core/extensions/mentions/utils.ts b/packages/editor/src/core/extensions/mentions/utils.ts new file mode 100644 index 000000000..e8e7ed4b7 --- /dev/null +++ b/packages/editor/src/core/extensions/mentions/utils.ts @@ -0,0 +1,72 @@ +import { Editor } from "@tiptap/core"; +import { SuggestionOptions } from "@tiptap/suggestion"; +import { ReactRenderer } from "@tiptap/react"; +import tippy from "tippy.js"; +// helpers +import { CommandListInstance } from "@/helpers/tippy"; +// types +import { TMentionHandler } from "@/types"; +// local components +import { MentionsListDropdown, MentionsListDropdownProps } from "./mentions-list-dropdown"; + +export const renderMentionsDropdown = + (props: Pick): SuggestionOptions["render"] => + // @ts-expect-error - Tiptap types are incorrect + () => { + const { searchCallback } = props; + let component: ReactRenderer | null = null; + let popup: any | null = null; + + return { + onStart: (props: { editor: Editor; clientRect: DOMRect }) => { + if (!searchCallback) return; + if (!props.clientRect) return; + component = new ReactRenderer(MentionsListDropdown, { + props: { + ...props, + searchCallback, + }, + editor: props.editor, + }); + props.editor.storage.mentionsOpen = true; + // @ts-expect-error - Tippy types are incorrect + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: () => + document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]'), + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + }, + onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { + component?.updateProps(props); + popup?.[0]?.setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + onKeyDown: (props: { event: KeyboardEvent }) => { + if (props.event.key === "Escape") { + popup?.[0]?.hide(); + return true; + } + + const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; + + if (navigationKeys.includes(props.event.key)) { + props.event?.stopPropagation(); + if (component?.ref?.onKeyDown(props)) { + return true; + } + } + return false; + }, + onExit: (props: { editor: Editor; event: KeyboardEvent }) => { + props.editor.storage.mentionsOpen = false; + popup?.[0]?.destroy(); + component?.destroy(); + }, + }; + }; diff --git a/packages/editor/src/core/extensions/read-only-extensions.tsx b/packages/editor/src/core/extensions/read-only-extensions.tsx index 4debda019..8711b2cb3 100644 --- a/packages/editor/src/core/extensions/read-only-extensions.tsx +++ b/packages/editor/src/core/extensions/read-only-extensions.tsx @@ -19,8 +19,7 @@ import { TableCell, TableRow, Table, - CustomMention, - HeadingListExtension, + CustomMentionExtension, CustomReadOnlyImageExtension, CustomTextAlignExtension, CustomCalloutReadOnlyExtension, @@ -28,23 +27,22 @@ import { } from "@/extensions"; // helpers import { isValidHttpUrl } from "@/helpers/common"; -// types -import { IMentionHighlight, TExtensions, TFileHandler } from "@/types"; // plane editor extensions import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions"; +// types +import { TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types"; type Props = { disabledExtensions: TExtensions[]; - fileHandler: Pick; - mentionConfig: { - mentionHighlights?: () => Promise; - }; + fileHandler: TReadOnlyFileHandler; + mentionHandler: TReadOnlyMentionHandler; }; export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { - const { disabledExtensions, fileHandler, mentionConfig } = props; + const { disabledExtensions, fileHandler, mentionHandler } = props; return [ + // @ts-expect-error tiptap types are incorrect StarterKit.configure({ bulletList: { HTMLAttributes: { @@ -96,16 +94,12 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { }, }), CustomTypographyExtension, - ReadOnlyImageExtension({ - getAssetSrc: fileHandler.getAssetSrc, - }).configure({ + ReadOnlyImageExtension(fileHandler).configure({ HTMLAttributes: { class: "rounded-md", }, }), - CustomReadOnlyImageExtension({ - getAssetSrc: fileHandler.getAssetSrc, - }), + CustomReadOnlyImageExtension(fileHandler), TiptapUnderline, TextStyle, TaskList.configure({ @@ -133,13 +127,9 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { TableHeader, TableCell, TableRow, - CustomMention({ - mentionHighlights: mentionConfig.mentionHighlights, - readonly: true, - }), + CustomMentionExtension(mentionHandler), CharacterCount, CustomColorExtension, - HeadingListExtension, CustomTextAlignExtension, CustomCalloutReadOnlyExtension, ...CoreReadOnlyEditorAdditionalExtensions({ diff --git a/packages/editor/src/core/extensions/side-menu.tsx b/packages/editor/src/core/extensions/side-menu.tsx index 5ab6fbdf5..5f11286b5 100644 --- a/packages/editor/src/core/extensions/side-menu.tsx +++ b/packages/editor/src/core/extensions/side-menu.tsx @@ -42,7 +42,7 @@ export const SideMenuExtension = (props: Props) => { ai: aiEnabled, dragDrop: dragDropEnabled, }, - scrollThreshold: { up: 200, down: 100 }, + scrollThreshold: { up: 200, down: 150 }, }), ]; }, @@ -134,10 +134,6 @@ const SideMenu = (options: SideMenuPluginProps) => { rect.left -= 8; } - if (node.parentElement?.matches("td") || node.parentElement?.matches("th")) { - rect.left += 8; - } - rect.width = options.dragHandleWidth; if (!editorSideMenu) return; 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 1efb72901..034d3d897 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 @@ -39,11 +39,11 @@ import { setText, } from "@/helpers/editor-commands"; // types -import { CommandProps, ISlashCommandItem, TExtensions, TSlashCommandSectionKeys } from "@/types"; +import { CommandProps, ISlashCommandItem, TSlashCommandSectionKeys } from "@/types"; // plane editor extensions import { coreEditorAdditionalSlashCommandOptions } from "@/plane-editor/extensions"; // local types -import { TSlashCommandAdditionalOption } from "./root"; +import { TExtensionProps } from "./root"; export type TSlashCommandSection = { key: TSlashCommandSectionKeys; @@ -51,13 +51,8 @@ export type TSlashCommandSection = { items: ISlashCommandItem[]; }; -type TArgs = { - additionalOptions?: TSlashCommandAdditionalOption[]; - disabledExtensions: TExtensions[]; -}; - export const getSlashCommandFilteredSections = - (args: TArgs) => + (args: TExtensionProps) => ({ query }: { query: string }): TSlashCommandSection[] => { const { additionalOptions, disabledExtensions } = args; const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [ diff --git a/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx b/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx index 3a03c3b6a..bd8ce2aec 100644 --- a/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx +++ b/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx @@ -1,5 +1,5 @@ -// helpers -import { cn } from "@/helpers/common"; +// plane utils +import { cn } from "@plane/utils"; // types import { ISlashCommandItem } from "@/types"; 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 93b0ce2ea..4ecd3f8fa 100644 --- a/packages/editor/src/core/extensions/slash-commands/command-menu.tsx +++ b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx @@ -1,14 +1,18 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; +import { Editor } from "@tiptap/core"; +// helpers +import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy"; // components import { TSlashCommandSection } from "./command-items-list"; import { CommandMenuItem } from "./command-menu-item"; export type SlashCommandsMenuProps = { + editor: Editor; items: TSlashCommandSection[]; command: any; }; -export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => { +export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref) => { const { items: sections, command } = props; // states const [selectedIndex, setSelectedIndex] = useState({ @@ -41,12 +45,12 @@ export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => { if (nextItem < 0) { nextSection = currentSection - 1; if (nextSection < 0) nextSection = sections.length - 1; - nextItem = sections[nextSection]?.items.length - 1; + nextItem = sections[nextSection]?.items?.length - 1; } } if (e.key === "ArrowDown") { nextItem = currentItem + 1; - if (nextItem >= sections[currentSection].items.length) { + if (nextItem >= sections[currentSection]?.items?.length) { nextSection = currentSection + 1; if (nextSection >= sections.length) nextSection = 0; nextItem = 0; @@ -84,7 +88,26 @@ export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => { item?.scrollIntoView({ block: "nearest" }); }, [sections, selectedIndex]); - const areSearchResultsEmpty = sections.map((s) => s.items.length).reduce((acc, curr) => acc + curr, 0) === 0; + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: { event: KeyboardEvent }) => { + if (!DROPDOWN_NAVIGATION_KEYS.includes(event.key)) return; + event.preventDefault(); + + if (event.key === "Enter") { + selectItem(selectedIndex.section, selectedIndex.item); + return; + } + + const newIndex = getNextValidIndex({ + event, + sections, + selectedIndex, + }); + setSelectedIndex(newIndex); + }, + })); + + const areSearchResultsEmpty = sections.map((s) => s.items?.length).reduce((acc, curr) => acc + curr, 0) === 0; if (areSearchResultsEmpty) return null; @@ -98,7 +121,7 @@ export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => {
{section.title &&
{section.title}
}
- {section.items.map((item, itemIndex) => ( + {section.items?.map((item, itemIndex) => ( { ))}
); -}; +}); + +SlashCommandsMenu.displayName = "SlashCommandsMenu"; diff --git a/packages/editor/src/core/extensions/slash-commands/root.tsx b/packages/editor/src/core/extensions/slash-commands/root.tsx index 62c353f92..c0c078a2d 100644 --- a/packages/editor/src/core/extensions/slash-commands/root.tsx +++ b/packages/editor/src/core/extensions/slash-commands/root.tsx @@ -2,6 +2,8 @@ import { Editor, Range, Extension } from "@tiptap/core"; import { ReactRenderer } from "@tiptap/react"; import Suggestion, { SuggestionOptions } from "@tiptap/suggestion"; import tippy from "tippy.js"; +// helpers +import { CommandListInstance } from "@/helpers/tippy"; // types import { ISlashCommandItem, TEditorCommands, TExtensions, TSlashCommandSectionKeys } from "@/types"; // components @@ -55,22 +57,19 @@ const Command = Extension.create({ }, }); -interface CommandListInstance { - onKeyDown: (props: { event: KeyboardEvent }) => boolean; -} - const renderItems = () => { let component: ReactRenderer | null = null; let popup: any | null = null; return { onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { - component = new ReactRenderer(SlashCommandsMenu, { + component = new ReactRenderer(SlashCommandsMenu, { props, editor: props.editor, }); const tippyContainer = document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]'); + // @ts-expect-error - Tippy types are incorrect popup = tippy("body", { getReferenceClientRect: props.clientRect, appendTo: tippyContainer, @@ -91,10 +90,8 @@ const renderItems = () => { onKeyDown: (props: { event: KeyboardEvent }) => { if (props.event.key === "Escape") { popup?.[0].hide(); - return true; } - if (component?.ref?.onKeyDown(props)) { return true; } @@ -107,9 +104,9 @@ const renderItems = () => { }; }; -type TExtensionProps = { +export type TExtensionProps = { additionalOptions?: TSlashCommandAdditionalOption[]; - disabledExtensions: TExtensions[]; + disabledExtensions?: TExtensions[]; }; export const SlashCommands = (props: TExtensionProps) => diff --git a/packages/editor/src/core/extensions/table/table/table.ts b/packages/editor/src/core/extensions/table/table/table.ts index 981788f54..fd775d211 100644 --- a/packages/editor/src/core/extensions/table/table/table.ts +++ b/packages/editor/src/core/extensions/table/table/table.ts @@ -39,7 +39,12 @@ export interface TableOptions { declare module "@tiptap/core" { interface Commands { table: { - insertTable: (options?: { rows?: number; cols?: number; withHeaderRow?: boolean }) => ReturnType; + insertTable: (options?: { + rows?: number; + cols?: number; + withHeaderRow?: boolean; + columnWidth?: number; + }) => ReturnType; addColumnBefore: () => ReturnType; addColumnAfter: () => ReturnType; deleteColumn: () => ReturnType; @@ -108,9 +113,9 @@ export const Table = Node.create({ addCommands() { return { insertTable: - ({ rows = 3, cols = 3, withHeaderRow = false } = {}) => + ({ rows = 3, cols = 3, withHeaderRow = false, columnWidth = 150 } = {}) => ({ tr, dispatch, editor }) => { - const node = createTable(editor.schema, rows, cols, withHeaderRow); + const node = createTable(editor.schema, rows, cols, withHeaderRow, undefined, columnWidth); if (dispatch) { const offset = tr.selection.anchor + 1; diff --git a/packages/editor/src/core/extensions/table/table/utilities/create-cell.ts b/packages/editor/src/core/extensions/table/table/utilities/create-cell.ts index 684a6d344..50fb376c0 100644 --- a/packages/editor/src/core/extensions/table/table/utilities/create-cell.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/create-cell.ts @@ -2,11 +2,12 @@ import { Fragment, Node as ProsemirrorNode, NodeType } from "@tiptap/pm/model"; export function createCell( cellType: NodeType, - cellContent?: Fragment | ProsemirrorNode | Array + cellContent?: Fragment | ProsemirrorNode | Array, + attrs?: Record ): ProsemirrorNode | null | undefined { if (cellContent) { - return cellType.createChecked(null, cellContent); + return cellType.createChecked(attrs, cellContent); } - return cellType.createAndFill(); + return cellType.createAndFill(attrs); } diff --git a/packages/editor/src/core/extensions/table/table/utilities/create-table.ts b/packages/editor/src/core/extensions/table/table/utilities/create-table.ts index 0c05cff77..5e9c4ba24 100644 --- a/packages/editor/src/core/extensions/table/table/utilities/create-table.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/create-table.ts @@ -8,21 +8,22 @@ export function createTable( rowsCount: number, colsCount: number, withHeaderRow: boolean, - cellContent?: Fragment | ProsemirrorNode | Array + cellContent?: Fragment | ProsemirrorNode | Array, + columnWidth: number = 100 ): ProsemirrorNode { const types = getTableNodeTypes(schema); const headerCells: ProsemirrorNode[] = []; const cells: ProsemirrorNode[] = []; for (let index = 0; index < colsCount; index += 1) { - const cell = createCell(types.cell, cellContent); + const cell = createCell(types.cell, cellContent, { colwidth: [columnWidth] }); if (cell) { cells.push(cell); } if (withHeaderRow) { - const headerCell = createCell(types.header_cell, cellContent); + const headerCell = createCell(types.header_cell, cellContent, { colwidth: [columnWidth] }); if (headerCell) { headerCells.push(headerCell); diff --git a/packages/editor/src/core/extensions/typography/index.ts b/packages/editor/src/core/extensions/typography/index.ts index e9d48b415..6b736953b 100644 --- a/packages/editor/src/core/extensions/typography/index.ts +++ b/packages/editor/src/core/extensions/typography/index.ts @@ -1,4 +1,4 @@ -import { Extension } from "@tiptap/core"; +import { Extension, InputRule } from "@tiptap/core"; import { TypographyOptions, emDash, @@ -26,7 +26,7 @@ export const CustomTypographyExtension = Extension.create({ name: "typography", addInputRules() { - const rules = []; + const rules: InputRule[] = []; if (this.options.emDash !== false) { rules.push(emDash(this.options.emDash)); diff --git a/packages/editor/src/core/helpers/common.ts b/packages/editor/src/core/helpers/common.ts index 0fb32310d..8638d2c15 100644 --- a/packages/editor/src/core/helpers/common.ts +++ b/packages/editor/src/core/helpers/common.ts @@ -1,6 +1,6 @@ import { EditorState, Selection } from "@tiptap/pm/state"; -import { clsx, type ClassValue } from "clsx"; -import { twMerge } from "tailwind-merge"; +// plane utils +import { cn } from "@plane/utils"; interface EditorClassNames { noBorder?: boolean; @@ -18,10 +18,6 @@ export const getEditorClassNames = ({ noBorder, borderOnFocus, containerClassNam containerClassName ); -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} - // Helper function to find the parent node of a specific type export function findParentNodeOfType(selection: Selection, typeName: string) { let depth = selection.$anchor.depth; diff --git a/packages/editor/src/core/helpers/editor-commands.ts b/packages/editor/src/core/helpers/editor-commands.ts index ec593d536..71072f097 100644 --- a/packages/editor/src/core/helpers/editor-commands.ts +++ b/packages/editor/src/core/helpers/editor-commands.ts @@ -13,41 +13,51 @@ export const setText = (editor: Editor, range?: Range) => { export const toggleHeadingOne = (editor: Editor, range?: Range) => { if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run(); + // @ts-expect-error tiptap types are incorrect else editor.chain().focus().toggleHeading({ level: 1 }).run(); }; export const toggleHeadingTwo = (editor: Editor, range?: Range) => { if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run(); + // @ts-expect-error tiptap types are incorrect else editor.chain().focus().toggleHeading({ level: 2 }).run(); }; export const toggleHeadingThree = (editor: Editor, range?: Range) => { if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run(); + // @ts-expect-error tiptap types are incorrect else editor.chain().focus().toggleHeading({ level: 3 }).run(); }; export const toggleHeadingFour = (editor: Editor, range?: Range) => { if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 4 }).run(); + // @ts-expect-error tiptap types are incorrect else editor.chain().focus().toggleHeading({ level: 4 }).run(); }; export const toggleHeadingFive = (editor: Editor, range?: Range) => { if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 5 }).run(); + // @ts-expect-error tiptap types are incorrect else editor.chain().focus().toggleHeading({ level: 5 }).run(); }; export const toggleHeadingSix = (editor: Editor, range?: Range) => { if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 6 }).run(); + // @ts-expect-error tiptap types are incorrect else editor.chain().focus().toggleHeading({ level: 6 }).run(); }; export const toggleBold = (editor: Editor, range?: Range) => { + // @ts-expect-error tiptap types are incorrect if (range) editor.chain().focus().deleteRange(range).toggleBold().run(); + // @ts-expect-error tiptap types are incorrect else editor.chain().focus().toggleBold().run(); }; export const toggleItalic = (editor: Editor, range?: Range) => { + // @ts-expect-error tiptap types are incorrect if (range) editor.chain().focus().deleteRange(range).toggleItalic().run(); + // @ts-expect-error tiptap types are incorrect else editor.chain().focus().toggleItalic().run(); }; @@ -86,12 +96,16 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => { }; export const toggleOrderedList = (editor: Editor, range?: Range) => { + // @ts-expect-error tiptap types are incorrect if (range) editor.chain().focus().deleteRange(range).toggleOrderedList().run(); + // @ts-expect-error tiptap types are incorrect else editor.chain().focus().toggleOrderedList().run(); }; export const toggleBulletList = (editor: Editor, range?: Range) => { + // @ts-expect-error tiptap types are incorrect if (range) editor.chain().focus().deleteRange(range).toggleBulletList().run(); + // @ts-expect-error tiptap types are incorrect else editor.chain().focus().toggleBulletList().run(); }; @@ -101,7 +115,9 @@ export const toggleTaskList = (editor: Editor, range?: Range) => { }; export const toggleStrike = (editor: Editor, range?: Range) => { + // @ts-expect-error tiptap types are incorrect if (range) editor.chain().focus().deleteRange(range).toggleStrike().run(); + // @ts-expect-error tiptap types are incorrect else editor.chain().focus().toggleStrike().run(); }; @@ -122,8 +138,9 @@ export const insertTableCommand = (editor: Editor, range?: Range) => { } } } - if (range) editor.chain().focus().deleteRange(range).clearNodes().insertTable({ rows: 3, cols: 3 }).run(); - else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3 }).run(); + if (range) + editor.chain().focus().deleteRange(range).clearNodes().insertTable({ rows: 3, cols: 3, columnWidth: 150 }).run(); + else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3, columnWidth: 150 }).run(); }; export const insertImage = ({ diff --git a/packages/editor/src/core/helpers/insert-content-at-cursor-position.ts b/packages/editor/src/core/helpers/insert-content-at-cursor-position.ts index eefe69e3e..fb226b23f 100644 --- a/packages/editor/src/core/helpers/insert-content-at-cursor-position.ts +++ b/packages/editor/src/core/helpers/insert-content-at-cursor-position.ts @@ -1,27 +1,21 @@ -import { MutableRefObject } from "react"; -import { Selection } from "@tiptap/pm/state"; import { Editor } from "@tiptap/react"; -export const insertContentAtSavedSelection = ( - editorRef: MutableRefObject, - content: string, - savedSelection: Selection -) => { - if (!editorRef.current || editorRef.current.isDestroyed) { +export const insertContentAtSavedSelection = (editor: Editor, content: string) => { + if (!editor || editor.isDestroyed) { console.error("Editor reference is not available or has been destroyed."); return; } - if (!savedSelection) { + if (!editor.state.selection) { console.error("Saved selection is invalid."); return; } - const docSize = editorRef.current.state.doc.content.size; - const safePosition = Math.max(0, Math.min(savedSelection.anchor, docSize)); + const docSize = editor.state.doc.content.size; + const safePosition = Math.max(0, Math.min(editor.state.selection.anchor, docSize)); try { - editorRef.current.chain().focus().insertContentAt(safePosition, content).run(); + editor.chain().focus().insertContentAt(safePosition, content).run(); } catch (error) { console.error("An error occurred while inserting content at saved selection:", error); } diff --git a/packages/editor/src/core/helpers/tippy.ts b/packages/editor/src/core/helpers/tippy.ts new file mode 100644 index 000000000..c254bd450 --- /dev/null +++ b/packages/editor/src/core/helpers/tippy.ts @@ -0,0 +1,58 @@ +export type CommandListInstance = { + onKeyDown: (props: { event: KeyboardEvent }) => boolean; +}; + +type TArgs = { + event: KeyboardEvent; + sections: { + items: any[]; + }[]; + selectedIndex: { + section: number; + item: number; + }; +}; + +export const DROPDOWN_NAVIGATION_KEYS = ["ArrowUp", "ArrowDown", "Enter"]; + +export const getNextValidIndex = ( + args: TArgs +): + | { + section: number; + item: number; + } + | undefined => { + const { event, sections, selectedIndex } = args; + const direction = event.key === "ArrowUp" ? "up" : "down"; + if (!sections.length) return { section: 0, item: 0 }; + // next available selection + let nextSection = selectedIndex.section; + let nextItem = selectedIndex.item; + + if (direction === "up") { + nextItem--; + if (nextItem < 0) { + // Move to previous section + nextSection--; + if (nextSection < 0) { + // Wrap to last section + nextSection = sections?.length - 1; + } + nextItem = sections?.[nextSection]?.items?.length - 1; + } + } else { + nextItem++; + if (nextItem >= sections?.[nextSection]?.items?.length) { + // Move to next section + nextSection++; + if (nextSection >= sections?.length) { + // Wrap to first section + nextSection = 0; + } + nextItem = 0; + } + } + + return { section: nextSection, item: nextItem }; +}; diff --git a/packages/editor/src/core/helpers/yjs-utils.ts b/packages/editor/src/core/helpers/yjs-utils.ts new file mode 100644 index 000000000..dce75fd1f --- /dev/null +++ b/packages/editor/src/core/helpers/yjs-utils.ts @@ -0,0 +1,142 @@ +import { getSchema } from "@tiptap/core"; +import { generateHTML, generateJSON } from "@tiptap/html"; +import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror"; +import * as Y from "yjs"; +// extensions +import { + CoreEditorExtensionsWithoutProps, + DocumentEditorExtensionsWithoutProps, +} from "@/extensions/core-without-props"; + +// editor extension configs +const RICH_TEXT_EDITOR_EXTENSIONS = CoreEditorExtensionsWithoutProps; +const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps]; +// editor schemas +// @ts-expect-error tiptap types are incorrect +const richTextEditorSchema = getSchema(RICH_TEXT_EDITOR_EXTENSIONS); +// @ts-expect-error tiptap types are incorrect +const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS); + +/** + * @description apply updates to a doc and return the updated doc in binary format + * @param {Uint8Array} document + * @param {Uint8Array} updates + * @returns {Uint8Array} + */ +export const applyUpdates = (document: Uint8Array, updates?: Uint8Array): Uint8Array => { + const yDoc = new Y.Doc(); + Y.applyUpdate(yDoc, document); + if (updates) { + Y.applyUpdate(yDoc, updates); + } + + const encodedDoc = Y.encodeStateAsUpdate(yDoc); + return encodedDoc; +}; + +/** + * @description this function encodes binary data to base64 string + * @param {Uint8Array} document + * @returns {string} + */ +export const convertBinaryDataToBase64String = (document: Uint8Array): string => + Buffer.from(document).toString("base64"); + +/** + * @description this function decodes base64 string to binary data + * @param {string} document + * @returns {ArrayBuffer} + */ +export const convertBase64StringToBinaryData = (document: string): ArrayBuffer => Buffer.from(document, "base64"); + +/** + * @description this function generates the binary equivalent of html content for the rich text editor + * @param {string} descriptionHTML + * @returns {Uint8Array} + */ +export const getBinaryDataFromRichTextEditorHTMLString = (descriptionHTML: string): Uint8Array => { + // convert HTML to JSON + // @ts-expect-error tiptap types are incorrect + const contentJSON = generateJSON(descriptionHTML ?? "

", RICH_TEXT_EDITOR_EXTENSIONS); + // convert JSON to Y.Doc format + const transformedData = prosemirrorJSONToYDoc(richTextEditorSchema, contentJSON, "default"); + // convert Y.Doc to Uint8Array format + const encodedData = Y.encodeStateAsUpdate(transformedData); + return encodedData; +}; + +/** + * @description this function generates the binary equivalent of html content for the document editor + * @param {string} descriptionHTML + * @returns {Uint8Array} + */ +export const getBinaryDataFromDocumentEditorHTMLString = (descriptionHTML: string): Uint8Array => { + // convert HTML to JSON + // @ts-expect-error tiptap types are incorrect + const contentJSON = generateJSON(descriptionHTML ?? "

", DOCUMENT_EDITOR_EXTENSIONS); + // convert JSON to Y.Doc format + const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default"); + // convert Y.Doc to Uint8Array format + const encodedData = Y.encodeStateAsUpdate(transformedData); + return encodedData; +}; + +/** + * @description this function generates all document formats for the provided binary data for the rich text editor + * @param {Uint8Array} description + * @returns + */ +export const getAllDocumentFormatsFromRichTextEditorBinaryData = ( + description: Uint8Array +): { + contentBinaryEncoded: string; + contentJSON: object; + contentHTML: string; +} => { + // encode binary description data + const base64Data = convertBinaryDataToBase64String(description); + const yDoc = new Y.Doc(); + Y.applyUpdate(yDoc, description); + // convert to JSON + const type = yDoc.getXmlFragment("default"); + const contentJSON = yXmlFragmentToProseMirrorRootNode(type, richTextEditorSchema).toJSON(); + // convert to HTML + // @ts-expect-error tiptap types are incorrect + const contentHTML = generateHTML(contentJSON, RICH_TEXT_EDITOR_EXTENSIONS); + + return { + contentBinaryEncoded: base64Data, + contentJSON, + contentHTML, + }; +}; + +/** + * @description this function generates all document formats for the provided binary data for the document editor + * @param {Uint8Array} description + * @returns + */ +export const getAllDocumentFormatsFromDocumentEditorBinaryData = ( + description: Uint8Array +): { + contentBinaryEncoded: string; + contentJSON: object; + contentHTML: string; +} => { + // encode binary description data + const base64Data = convertBinaryDataToBase64String(description); + const yDoc = new Y.Doc(); + Y.applyUpdate(yDoc, description); + // convert to JSON + const type = yDoc.getXmlFragment("default"); + const contentJSON = yXmlFragmentToProseMirrorRootNode(type, documentEditorSchema).toJSON(); + // convert to HTML + // @ts-expect-error tiptap types are incorrect + const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS); + + return { + contentBinaryEncoded: base64Data, + contentJSON, + contentHTML, + }; +}; diff --git a/packages/editor/src/core/helpers/yjs.ts b/packages/editor/src/core/helpers/yjs.ts deleted file mode 100644 index ffd936710..000000000 --- a/packages/editor/src/core/helpers/yjs.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as Y from "yjs"; - -/** - * @description apply updates to a doc and return the updated doc in base64(binary) format - * @param {Uint8Array} document - * @param {Uint8Array} updates - * @returns {string} base64(binary) form of the updated doc - */ -export const applyUpdates = (document: Uint8Array, updates: Uint8Array): Uint8Array => { - const yDoc = new Y.Doc(); - Y.applyUpdate(yDoc, document); - Y.applyUpdate(yDoc, updates); - - const encodedDoc = Y.encodeStateAsUpdate(yDoc); - return encodedDoc; -}; diff --git a/packages/editor/src/core/hooks/use-collaborative-editor.ts b/packages/editor/src/core/hooks/use-collaborative-editor.ts index b3c7d6cfc..4abf7d6d1 100644 --- a/packages/editor/src/core/hooks/use-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-collaborative-editor.ts @@ -15,6 +15,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { const { onTransaction, disabledExtensions, + editable, editorClassName, editorProps = {}, embedHandler, @@ -75,7 +76,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { const editor = useEditor({ disabledExtensions, id, - onTransaction, + editable, editorProps, editorClassName, enableHistory: false, @@ -97,9 +98,10 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { }), ], fileHandler, - handleEditorReady, forwardedRef, + handleEditorReady, mentionHandler, + onTransaction, placeholder, provider, tabIndex, diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index 15fbd19d5..16df9151c 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -1,12 +1,11 @@ -import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react"; import { HocuspocusProvider } from "@hocuspocus/provider"; import { DOMSerializer } from "@tiptap/pm/model"; -import { Selection } from "@tiptap/pm/state"; import { EditorProps } from "@tiptap/pm/view"; -import { useEditor as useTiptapEditor, Editor } from "@tiptap/react"; +import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react"; +import { useImperativeHandle, MutableRefObject, useEffect } from "react"; import * as Y from "yjs"; // components -import { EditorMenuItem, getEditorMenuItems } from "@/components/menus"; +import { getEditorMenuItems } from "@/components/menus"; // extensions import { CoreEditorExtensions } from "@/extensions"; // helpers @@ -19,28 +18,25 @@ import { CoreEditorProps } from "@/props"; import type { TDocumentEventsServer, EditorRefApi, - IMentionHighlight, - IMentionSuggestion, TEditorCommands, TFileHandler, TExtensions, + TMentionHandler, } from "@/types"; export interface CustomEditorProps { + editable: boolean; editorClassName: string; editorProps?: EditorProps; enableHistory: boolean; disabledExtensions: TExtensions[]; - extensions?: any; + extensions?: Extensions; fileHandler: TFileHandler; forwardedRef?: MutableRefObject; handleEditorReady?: (value: boolean) => void; id?: string; initialValue?: string; - mentionHandler: { - highlights: () => Promise; - suggestions?: () => Promise; - }; + mentionHandler: TMentionHandler; onChange?: (json: object, html: string) => void; onTransaction?: () => void; autofocus?: boolean; @@ -55,6 +51,7 @@ export interface CustomEditorProps { export const useEditor = (props: CustomEditorProps) => { const { disabledExtensions, + editable = true, editorClassName, editorProps = {}, enableHistory, @@ -73,61 +70,53 @@ export const useEditor = (props: CustomEditorProps) => { provider, autofocus = false, } = props; - // states - const [savedSelection, setSavedSelection] = useState(null); - // refs - const editorRef: MutableRefObject = useRef(null); - const savedSelectionRef = useRef(savedSelection); - const editor = useTiptapEditor({ - autofocus, - editorProps: { - ...CoreEditorProps({ - editorClassName, - }), - ...editorProps, + const editor = useTiptapEditor( + { + editable, + immediatelyRender: false, + shouldRerenderOnTransaction: false, + autofocus, + editorProps: { + ...CoreEditorProps({ + editorClassName, + }), + ...editorProps, + }, + extensions: [ + ...CoreEditorExtensions({ + editable, + disabledExtensions, + enableHistory, + fileHandler, + mentionHandler, + placeholder, + tabIndex, + }), + ...extensions, + ], + content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "

", + onCreate: () => handleEditorReady?.(true), + onTransaction: () => { + onTransaction?.(); + }, + onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()), + onDestroy: () => handleEditorReady?.(false), }, - extensions: [ - ...CoreEditorExtensions({ - disabledExtensions, - enableHistory, - fileHandler, - mentionConfig: { - mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve([])), - mentionHighlights: mentionHandler.highlights, - }, - placeholder, - tabIndex, - }), - ...extensions, - ], - content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "

", - onCreate: () => handleEditorReady?.(true), - onTransaction: ({ editor }) => { - setSavedSelection(editor.state.selection); - onTransaction?.(); - }, - onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()), - onDestroy: () => handleEditorReady?.(false), - }); - - // Update the ref whenever savedSelection changes - useEffect(() => { - savedSelectionRef.current = savedSelection; - }, [savedSelection]); + [editable] + ); // Effect for syncing SWR data useEffect(() => { // value is null when intentionally passed where syncing is not yet // supported and value is undefined when the data from swr is not populated - if (value === null || value === undefined) return; + if (value == null) return; if (editor && !editor.isDestroyed && !editor.storage.imageComponent.uploadInProgress) { try { editor.commands.setContent(value, false, { preserveWhitespace: "full" }); - const currentSavedSelection = savedSelectionRef.current; - if (currentSavedSelection) { + if (editor.state.selection) { const docLength = editor.state.doc.content.size; - const relativePosition = Math.min(currentSavedSelection.from, docLength - 1); + const relativePosition = Math.min(editor.state.selection.from, docLength - 1); editor.commands.setTextSelection(relativePosition); } } catch (error) { @@ -136,49 +125,50 @@ export const useEditor = (props: CustomEditorProps) => { } }, [editor, value, id]); + // update assets upload status + useEffect(() => { + if (!editor) return; + const assetsUploadStatus = fileHandler.assetsUploadStatus; + editor.commands.updateAssetsUploadStatus(assetsUploadStatus); + }, [editor, fileHandler.assetsUploadStatus]); + useImperativeHandle( forwardedRef, () => ({ - blur: () => editorRef.current?.commands.blur(), + blur: () => editor?.commands.blur(), scrollToNodeViaDOMCoordinates(behavior?: ScrollBehavior, pos?: number) { - const resolvedPos = pos ?? savedSelection?.from; - if (!editorRef.current || !resolvedPos) return; - scrollToNodeViaDOMCoordinates(editorRef.current, resolvedPos, behavior); + const resolvedPos = pos ?? editor?.state.selection.from; + if (!editor || !resolvedPos) return; + scrollToNodeViaDOMCoordinates(editor, resolvedPos, behavior); }, - getCurrentCursorPosition: () => savedSelection?.from, + getCurrentCursorPosition: () => editor?.state.selection.from, clearEditor: (emitUpdate = false) => { - editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run(); + editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run(); }, setEditorValue: (content: string) => { - editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" }); + editor?.commands.setContent(content, false, { preserveWhitespace: "full" }); }, setEditorValueAtCursorPosition: (content: string) => { - if (savedSelection) { - insertContentAtSavedSelection(editorRef, content, savedSelection); + if (editor?.state.selection) { + insertContentAtSavedSelection(editor, content); } }, executeMenuItemCommand: (props) => { const { itemKey } = props; - const editorItems = getEditorMenuItems(editorRef.current); + const editorItems = getEditorMenuItems(editor); const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey); const item = getEditorMenuItem(itemKey); if (item) { - if (item.key === "image") { - (item as EditorMenuItem<"image">).command({ - savedSelection: savedSelectionRef.current, - }); - } else { - item.command(props); - } + item.command(props); } else { console.warn(`No command found for item: ${itemKey}`); } }, isMenuItemActive: (props) => { const { itemKey } = props; - const editorItems = getEditorMenuItems(editorRef.current); + const editorItems = getEditorMenuItems(editor); const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey); const item = getEditorMenuItem(itemKey); @@ -188,20 +178,20 @@ export const useEditor = (props: CustomEditorProps) => { }, onHeadingChange: (callback: (headings: IMarking[]) => void) => { // Subscribe to update event emitted from headers extension - editorRef.current?.on("update", () => { - callback(editorRef.current?.storage.headingList.headings); + editor?.on("update", () => { + callback(editor?.storage.headingList.headings); }); // Return a function to unsubscribe to the continuous transactions of // the editor on unmounting the component that has subscribed to this // method return () => { - editorRef.current?.off("update"); + editor?.off("update"); }; }, - getHeadings: () => editorRef?.current?.storage.headingList.headings, + getHeadings: () => editor?.storage.headingList.headings, onStateChange: (callback: () => void) => { // Subscribe to editor state changes - editorRef.current?.on("transaction", () => { + editor?.on("transaction", () => { callback(); }); @@ -209,17 +199,17 @@ export const useEditor = (props: CustomEditorProps) => { // the editor on unmounting the component that has subscribed to this // method return () => { - editorRef.current?.off("transaction"); + editor?.off("transaction"); }; }, getMarkDown: (): string => { - const markdownOutput = editorRef.current?.storage.markdown.getMarkdown(); + const markdownOutput = editor?.storage.markdown.getMarkdown(); return markdownOutput; }, getDocument: () => { const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null; - const documentHTML = editorRef.current?.getHTML() ?? "

"; - const documentJSON = editorRef.current?.getJSON() ?? null; + const documentHTML = editor?.getHTML() ?? "

"; + const documentJSON = editor?.getJSON() ?? null; return { binary: documentBinary, @@ -228,19 +218,19 @@ export const useEditor = (props: CustomEditorProps) => { }; }, scrollSummary: (marking: IMarking): void => { - if (!editorRef.current) return; - scrollSummary(editorRef.current, marking); + if (!editor) return; + scrollSummary(editor, marking); }, - isEditorReadyToDiscard: () => editorRef.current?.storage.imageComponent.uploadInProgress === false, + isEditorReadyToDiscard: () => editor?.storage.imageComponent.uploadInProgress === false, setFocusAtPosition: (position: number) => { - if (!editorRef.current || editorRef.current.isDestroyed) { + if (!editor || editor.isDestroyed) { console.error("Editor reference is not available or has been destroyed."); return; } try { - const docSize = editorRef.current.state.doc.content.size; + const docSize = editor.state.doc.content.size; const safePosition = Math.max(0, Math.min(position, docSize)); - editorRef.current + editor .chain() .insertContentAt(safePosition, [{ type: "paragraph" }]) .focus() @@ -250,17 +240,17 @@ export const useEditor = (props: CustomEditorProps) => { } }, getSelectedText: () => { - if (!editorRef.current) return null; + if (!editor) return null; - const { state } = editorRef.current; + const { state } = editor; const { from, to, empty } = state.selection; if (empty) return null; const nodesArray: string[] = []; state.doc.nodesBetween(from, to, (node, _pos, parent) => { - if (parent === state.doc && editorRef.current) { - const serializer = DOMSerializer.fromSchema(editorRef.current?.schema); + if (parent === state.doc && editor) { + const serializer = DOMSerializer.fromSchema(editor.schema); const dom = serializer.serializeNode(node); const tempDiv = document.createElement("div"); tempDiv.appendChild(dom); @@ -271,28 +261,21 @@ export const useEditor = (props: CustomEditorProps) => { return selection; }, insertText: (contentHTML, insertOnNextLine) => { - if (!editorRef.current) return; - // get selection - const { from, to, empty } = editorRef.current.state.selection; + if (!editor) return; + const { from, to, empty } = editor.state.selection; if (empty) return; if (insertOnNextLine) { // move cursor to the end of the selection and insert a new line - editorRef.current - .chain() - .focus() - .setTextSelection(to) - .insertContent("
") - .insertContent(contentHTML) - .run(); + editor.chain().focus().setTextSelection(to).insertContent("
").insertContent(contentHTML).run(); } else { // replace selected text with the content provided - editorRef.current.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run(); + editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run(); } }, getDocumentInfo: () => ({ - characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0, - paragraphs: getParagraphCount(editorRef?.current?.state), - words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0, + characters: editor?.storage?.characterCount?.characters?.() ?? 0, + paragraphs: getParagraphCount(editor?.state), + words: editor?.storage?.characterCount?.words?.() ?? 0, }), setProviderDocument: (value) => { const document = provider?.document; @@ -302,16 +285,12 @@ export const useEditor = (props: CustomEditorProps) => { emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message), listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) }, }), - [editorRef, savedSelection] + [editor] ); if (!editor) { return null; } - // the editorRef is used to access the editor instance from outside the hook - // and should only be used after editor is initialized - editorRef.current = editor; - return editor; }; diff --git a/packages/editor/src/core/hooks/use-file-upload.ts b/packages/editor/src/core/hooks/use-file-upload.ts index f5f930f29..7d3dc7eae 100644 --- a/packages/editor/src/core/hooks/use-file-upload.ts +++ b/packages/editor/src/core/hooks/use-file-upload.ts @@ -6,6 +6,7 @@ import { insertImagesSafely } from "@/extensions/drop"; import { isFileValid } from "@/plugins/image"; type TUploaderArgs = { + blockId: string; editor: Editor; loadImageFromFileSystem: (file: string) => void; maxFileSize: number; @@ -13,7 +14,7 @@ type TUploaderArgs = { }; export const useUploader = (args: TUploaderArgs) => { - const { editor, loadImageFromFileSystem, maxFileSize, onUpload } = args; + const { blockId, editor, loadImageFromFileSystem, maxFileSize, onUpload } = args; // states const [uploading, setUploading] = useState(false); @@ -49,7 +50,7 @@ export const useUploader = (args: TUploaderArgs) => { 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(fileWithTrimmedName); + const url: string = await editor?.commands.uploadImage(blockId, fileWithTrimmedName); if (!url) { throw new Error("Something went wrong while uploading the image"); @@ -105,7 +106,7 @@ export const useDropZone = (args: TDropzoneArgs) => { async (e: DragEvent) => { e.preventDefault(); setDraggedInside(false); - if (e.dataTransfer.files.length === 0) { + if (e.dataTransfer.files.length === 0 || !editor.isEditable) { return; } const filesList = e.dataTransfer.files; diff --git a/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts b/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts deleted file mode 100644 index 01ca19b81..000000000 --- a/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { HocuspocusProvider } from "@hocuspocus/provider"; -import Collaboration from "@tiptap/extension-collaboration"; -import { IndexeddbPersistence } from "y-indexeddb"; -// extensions -import { HeadingListExtension } from "@/extensions"; -// hooks -import { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; -// types -import { TReadOnlyCollaborativeEditorProps } from "@/types"; - -export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEditorProps) => { - const { - disabledExtensions, - editorClassName, - editorProps = {}, - extensions, - fileHandler, - forwardedRef, - handleEditorReady, - id, - mentionHandler, - realtimeConfig, - serverHandler, - user, - } = props; - // states - const [hasServerConnectionFailed, setHasServerConnectionFailed] = useState(false); - const [hasServerSynced, setHasServerSynced] = useState(false); - // initialize Hocuspocus provider - const provider = useMemo( - () => - new HocuspocusProvider({ - name: id, - url: realtimeConfig.url, - token: JSON.stringify(user), - parameters: realtimeConfig.queryParams, - onAuthenticationFailed: () => { - serverHandler?.onServerError?.(); - setHasServerConnectionFailed(true); - }, - onConnect: () => serverHandler?.onConnect?.(), - onClose: (data) => { - if (data.event.code === 1006) { - serverHandler?.onServerError?.(); - setHasServerConnectionFailed(true); - } - }, - onSynced: () => setHasServerSynced(true), - }), - [id, realtimeConfig, serverHandler, user] - ); - - // indexed db integration for offline support - const localProvider = useMemo( - () => (id ? new IndexeddbPersistence(id, provider.document) : undefined), - [id, provider] - ); - - // destroy and disconnect connection on unmount - useEffect( - () => () => { - provider.destroy(); - localProvider?.destroy(); - }, - [provider, localProvider] - ); - - const editor = useReadOnlyEditor({ - disabledExtensions, - editorProps, - editorClassName, - extensions: [ - ...(extensions ?? []), - HeadingListExtension, - Collaboration.configure({ - document: provider.document, - }), - ], - fileHandler, - forwardedRef, - handleEditorReady, - mentionHandler, - provider, - }); - - return { - editor, - hasServerConnectionFailed, - hasServerSynced, - }; -}; 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 5fb49be5f..6d33c0f8a 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -1,7 +1,7 @@ -import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react"; import { HocuspocusProvider } from "@hocuspocus/provider"; import { EditorProps } from "@tiptap/pm/view"; -import { useEditor as useCustomEditor, Editor } from "@tiptap/react"; +import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react"; +import { useImperativeHandle, MutableRefObject, useEffect } from "react"; import * as Y from "yjs"; // extensions import { CoreReadOnlyEditorExtensions } from "@/extensions"; @@ -11,26 +11,18 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node"; // props import { CoreReadOnlyEditorProps } from "@/props"; // types -import type { - EditorReadOnlyRefApi, - IMentionHighlight, - TExtensions, - TDocumentEventsServer, - TFileHandler, -} from "@/types"; +import type { EditorReadOnlyRefApi, TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types"; interface CustomReadOnlyEditorProps { disabledExtensions: TExtensions[]; editorClassName: string; editorProps?: EditorProps; - extensions?: any; + extensions?: Extensions; forwardedRef?: MutableRefObject; initialValue?: string; - fileHandler: Pick; + fileHandler: TReadOnlyFileHandler; handleEditorReady?: (value: boolean) => void; - mentionHandler: { - highlights: () => Promise; - }; + mentionHandler: TReadOnlyMentionHandler; provider?: HocuspocusProvider; } @@ -48,8 +40,10 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { provider, } = props; - const editor = useCustomEditor({ + const editor = useTiptapEditor({ editable: false, + immediatelyRender: true, + shouldRerenderOnTransaction: false, content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "

", editorProps: { ...CoreReadOnlyEditorProps({ @@ -63,9 +57,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { extensions: [ ...CoreReadOnlyEditorExtensions({ disabledExtensions, - mentionConfig: { - mentionHighlights: mentionHandler.highlights, - }, + mentionHandler, fileHandler, }), ...extensions, @@ -81,23 +73,21 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" }); }, [editor, initialValue]); - const editorRef: MutableRefObject = useRef(null); - useImperativeHandle(forwardedRef, () => ({ clearEditor: (emitUpdate = false) => { - editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run(); + editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run(); }, setEditorValue: (content: string) => { - editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" }); + editor?.commands.setContent(content, false, { preserveWhitespace: "full" }); }, getMarkDown: (): string => { - const markdownOutput = editorRef.current?.storage.markdown.getMarkdown(); + const markdownOutput = editor?.storage.markdown.getMarkdown(); return markdownOutput; }, getDocument: () => { const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null; - const documentHTML = editorRef.current?.getHTML() ?? "

"; - const documentJSON = editorRef.current?.getJSON() ?? null; + const documentHTML = editor?.getHTML() ?? "

"; + const documentJSON = editor?.getJSON() ?? null; return { binary: documentBinary, @@ -106,35 +96,19 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { }; }, scrollSummary: (marking: IMarking): void => { - if (!editorRef.current) return; - scrollSummary(editorRef.current, marking); + if (!editor) return; + scrollSummary(editor, marking); }, getDocumentInfo: () => ({ - characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0, - paragraphs: getParagraphCount(editorRef?.current?.state), - words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0, + characters: editor.storage?.characterCount?.characters?.() ?? 0, + paragraphs: getParagraphCount(editor.state), + words: editor.storage?.characterCount?.words?.() ?? 0, }), - onHeadingChange: (callback: (headings: IMarking[]) => void) => { - // Subscribe to update event emitted from headers extension - editorRef.current?.on("update", () => { - callback(editorRef.current?.storage.headingList.headings); - }); - // Return a function to unsubscribe to the continuous transactions of - // the editor on unmounting the component that has subscribed to this - // method - return () => { - editorRef.current?.off("update"); - }; - }, - emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message), - listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) }, - getHeadings: () => editorRef?.current?.storage.headingList.headings, })); if (!editor) { return null; } - editorRef.current = editor; return editor; }; diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index 1c015dcb0..e71c38b30 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -1,5 +1,5 @@ -import { Fragment, Slice, Node } from "@tiptap/pm/model"; -import { NodeSelection, TextSelection } from "@tiptap/pm/state"; +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"; // extensions @@ -8,6 +8,29 @@ import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions"; const verticalEllipsisIcon = ''; +const generalSelectors = [ + "li", + "p:not(:first-child)", + ".code-block", + "blockquote", + "h1, h2, h3, h4, h5, h6", + "[data-type=horizontalRule]", + ".table-wrapper", + ".issue-embed", + ".image-component", + ".image-upload-component", + ".editor-callout-component", +].join(", "); + +const maxScrollSpeed = 20; +const acceleration = 0.5; + +const scrollParentCache = new WeakMap(); + +function easeOutQuadAnimation(t: number) { + return t * (2 - t); +} + const createDragHandleElement = (): HTMLElement => { const dragHandleElement = document.createElement("button"); dragHandleElement.type = "button"; @@ -30,33 +53,53 @@ const createDragHandleElement = (): HTMLElement => { return dragHandleElement; }; +const isScrollable = (node: HTMLElement | SVGElement) => { + if (!(node instanceof HTMLElement || node instanceof SVGElement)) { + return false; + } + const style = getComputedStyle(node); + return ["overflow", "overflow-y"].some((propertyName) => { + const value = style.getPropertyValue(propertyName); + return value === "auto" || value === "scroll"; + }); +}; + +const getScrollParent = (node: HTMLElement | SVGElement) => { + if (scrollParentCache.has(node)) { + return scrollParentCache.get(node); + } + + let currentParent = node.parentElement; + + while (currentParent) { + if (isScrollable(currentParent)) { + scrollParentCache.set(node, currentParent); + return currentParent; + } + currentParent = currentParent.parentElement; + } + + const result = document.scrollingElement || document.documentElement; + scrollParentCache.set(node, result); + return result; +}; + export const nodeDOMAtCoords = (coords: { x: number; y: number }) => { const elements = document.elementsFromPoint(coords.x, coords.y); - const generalSelectors = [ - "li", - "p:not(:first-child)", - ".code-block", - "blockquote", - "h1, h2, h3, h4, h5, h6", - "[data-type=horizontalRule]", - ".table-wrapper", - ".issue-embed", - ".image-component", - ".image-upload-component", - ".editor-callout-component", - ].join(", "); for (const elem of elements) { + // Check for table wrapper first + if (elem.matches(".table-wrapper")) { + return elem; + } + if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) { return elem; } - // if the element is a

tag that is the first child of a td or th - if ( - (elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) && - elem?.textContent?.trim() !== "" - ) { - return elem; // Return only if p tag is not empty in td or th + // Skip table cells + if (elem.closest(".table-wrapper")) { + continue; } // apply general selector @@ -85,140 +128,73 @@ const nodePosAtDOMForBlockQuotes = (node: Element, view: EditorView) => { })?.inside; }; -const calcNodePos = (pos: number, view: EditorView, node: Element) => { - const maxPos = view.state.doc.content.size; - const safePos = Math.max(0, Math.min(pos, maxPos)); - const $pos = view.state.doc.resolve(safePos); - - if ($pos.depth > 1) { - if (node.matches("ul li, ol li")) { - // only for nested lists - const newPos = $pos.before($pos.depth); - return Math.max(0, Math.min(newPos, maxPos)); - } - } - - return safePos; -}; - export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOptions => { let listType = ""; - const handleDragStart = (event: DragEvent, view: EditorView) => { - view.focus(); - - if (!event.dataTransfer) return; - - const node = nodeDOMAtCoords({ - x: event.clientX + 50 + options.dragHandleWidth, - y: event.clientY, - }); - - if (!(node instanceof Element)) return; - - let draggedNodePos = nodePosAtDOM(node, view, options); - if (draggedNodePos == null || draggedNodePos < 0) return; - draggedNodePos = calcNodePos(draggedNodePos, view, node); - - const { from, to } = view.state.selection; - const diff = from - to; - - const fromSelectionPos = calcNodePos(from, view, node); - let differentNodeSelected = false; - - const nodePos = view.state.doc.resolve(fromSelectionPos); - - // Check if nodePos points to the top level node - if (nodePos.node().type.name === "doc") differentNodeSelected = true; - else { - // TODO FIX ERROR - const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before()); - // Check if the node where the drag event started is part of the current selection - differentNodeSelected = !( - draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos - ); - } - - if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) { - const endSelection = NodeSelection.create(view.state.doc, to - 1); - const multiNodeSelection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos); - view.dispatch(view.state.tr.setSelection(multiNodeSelection)); - } else { - // TODO FIX ERROR - const nodeSelection = NodeSelection.create(view.state.doc, draggedNodePos); - view.dispatch(view.state.tr.setSelection(nodeSelection)); - } - - // If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL - if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === "listItem") { - listType = node.parentElement!.tagName; - } - - if (node.matches("blockquote")) { - let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view); - if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return; - - const docSize = view.state.doc.content.size; - nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize)); - - if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) { - // TODO FIX ERROR - const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes); - view.dispatch(view.state.tr.setSelection(nodeSelection)); - } - } - - const slice = view.state.selection.content(); - const { dom, text } = __serializeForClipboard(view, slice); - - event.dataTransfer.clearData(); - event.dataTransfer.setData("text/html", dom.innerHTML); - event.dataTransfer.setData("text/plain", text); - event.dataTransfer.effectAllowed = "copyMove"; - - event.dataTransfer.setDragImage(node, 0, 0); - - view.dragging = { slice, move: event.ctrlKey }; - }; + let isDragging = false; + let lastClientY = 0; + let scrollAnimationFrame = null; + let isDraggedOutsideWindow: "top" | "bottom" | boolean = false; + let isMouseInsideWhileDragging = false; + let currentScrollSpeed = 0; const handleClick = (event: MouseEvent, view: EditorView) => { - view.focus(); + handleNodeSelection(event, view, false, options); + }; - const node = nodeDOMAtCoords({ - x: event.clientX + 50 + options.dragHandleWidth, - y: event.clientY, - }); + const handleDragStart = (event: DragEvent, view: EditorView) => { + const { listType: listTypeFromDragStart } = handleNodeSelection(event, view, true, options); + listType = listTypeFromDragStart; + isDragging = true; + lastClientY = event.clientY; + scroll(); + }; - if (!(node instanceof Element)) return; + const handleDragEnd = (event: TEvent, view?: EditorView) => { + event.preventDefault(); + isDragging = false; + isMouseInsideWhileDragging = false; + if (scrollAnimationFrame) { + cancelAnimationFrame(scrollAnimationFrame); + scrollAnimationFrame = null; + } - if (node.matches("blockquote")) { - let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view); - if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return; + view?.dom.classList.remove("dragging"); + }; - const docSize = view.state.doc.content.size; - nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize)); - - if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) { - // TODO FIX ERROR - const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes); - view.dispatch(view.state.tr.setSelection(nodeSelection)); - } + function scroll() { + if (!isDragging) { + currentScrollSpeed = 0; return; } - let nodePos = nodePosAtDOM(node, view, options); + const scrollableParent = getScrollParent(dragHandleElement); + if (!scrollableParent) return; - if (nodePos === null || nodePos === undefined) return; + const scrollRegionUp = options.scrollThreshold.up; + const scrollRegionDown = window.innerHeight - options.scrollThreshold.down; - // Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied - nodePos = calcNodePos(nodePos, view, node); + let targetScrollAmount = 0; - // TODO FIX ERROR - // Use NodeSelection to select the node at the calculated position - const nodeSelection = NodeSelection.create(view.state.doc, nodePos); + if (isDraggedOutsideWindow === "top") { + targetScrollAmount = -maxScrollSpeed * 5; + } else if (isDraggedOutsideWindow === "bottom") { + targetScrollAmount = maxScrollSpeed * 5; + } else if (lastClientY < scrollRegionUp) { + const ratio = easeOutQuadAnimation((scrollRegionUp - lastClientY) / options.scrollThreshold.up); + targetScrollAmount = -maxScrollSpeed * ratio; + } else if (lastClientY > scrollRegionDown) { + const ratio = easeOutQuadAnimation((lastClientY - scrollRegionDown) / options.scrollThreshold.down); + targetScrollAmount = maxScrollSpeed * ratio; + } - // Dispatch the transaction to update the selection - view.dispatch(view.state.tr.setSelection(nodeSelection)); - }; + currentScrollSpeed += (targetScrollAmount - currentScrollSpeed) * acceleration; + + if (Math.abs(currentScrollSpeed) > 0.1) { + scrollableParent.scrollBy({ top: currentScrollSpeed }); + } + + scrollAnimationFrame = requestAnimationFrame(scroll); + } let dragHandleElement: HTMLElement | null = null; // drag handle view actions @@ -231,51 +207,46 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp const view = (view: EditorView, sideMenu: HTMLDivElement | null) => { dragHandleElement = createDragHandleElement(); dragHandleElement.addEventListener("dragstart", (e) => handleDragStart(e, view)); + dragHandleElement.addEventListener("dragend", (e) => handleDragEnd(e, view)); dragHandleElement.addEventListener("click", (e) => handleClick(e, view)); dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view)); - const isScrollable = (node: HTMLElement | SVGElement) => { - if (!(node instanceof HTMLElement || node instanceof SVGElement)) { - return false; + const dragOverHandler = (e: DragEvent) => { + e.preventDefault(); + if (isDragging) { + lastClientY = e.clientY; } - const style = getComputedStyle(node); - return ["overflow", "overflow-y"].some((propertyName) => { - const value = style.getPropertyValue(propertyName); - return value === "auto" || value === "scroll"; - }); }; - const getScrollParent = (node: HTMLElement | SVGElement) => { - let currentParent = node.parentElement; - while (currentParent) { - if (isScrollable(currentParent)) { - return currentParent; + const mouseMoveHandler = (e: MouseEvent) => { + if (isMouseInsideWhileDragging) { + handleDragEnd(e, view); + } + }; + + const dragLeaveHandler = (e: DragEvent) => { + if (e.clientY <= 0 || e.clientX <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) { + isMouseInsideWhileDragging = true; + + const windowMiddleY = window.innerHeight / 2; + + if (lastClientY < windowMiddleY) { + isDraggedOutsideWindow = "top"; + } else { + isDraggedOutsideWindow = "bottom"; } - currentParent = currentParent.parentElement; } - return document.scrollingElement || document.documentElement; }; - const maxScrollSpeed = 100; + const dragEnterHandler = () => { + isDraggedOutsideWindow = false; + }; - dragHandleElement.addEventListener("drag", (e) => { - hideDragHandle(); - const scrollableParent = getScrollParent(dragHandleElement); - if (!scrollableParent) return; - const scrollThreshold = options.scrollThreshold; + window.addEventListener("dragleave", dragLeaveHandler); + window.addEventListener("dragenter", dragEnterHandler); - if (e.clientY < scrollThreshold.up) { - const overflow = scrollThreshold.up - e.clientY; - const ratio = Math.min(overflow / scrollThreshold.up, 1); - const scrollAmount = -maxScrollSpeed * ratio; - scrollableParent.scrollBy({ top: scrollAmount }); - } else if (window.innerHeight - e.clientY < scrollThreshold.down) { - const overflow = e.clientY - (window.innerHeight - scrollThreshold.down); - const ratio = Math.min(overflow / scrollThreshold.down, 1); - const scrollAmount = maxScrollSpeed * ratio; - scrollableParent.scrollBy({ top: scrollAmount }); - } - }); + document.addEventListener("dragover", dragOverHandler); + document.addEventListener("mousemove", mouseMoveHandler); hideDragHandle(); @@ -285,6 +256,15 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp destroy: () => { dragHandleElement?.remove?.(); dragHandleElement = null; + isDragging = false; + if (scrollAnimationFrame) { + cancelAnimationFrame(scrollAnimationFrame); + scrollAnimationFrame = null; + } + window.removeEventListener("dragleave", dragLeaveHandler); + window.removeEventListener("dragenter", dragEnterHandler); + document.removeEventListener("dragover", dragOverHandler); + document.removeEventListener("mousemove", mouseMoveHandler); }, }; }; @@ -313,29 +293,36 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp const resolvedPos = view.state.doc.resolve(dropPos.pos); let isDroppedInsideList = false; + let dropDepth = 0; // 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") { isDroppedInsideList = true; + dropDepth = i; break; } } - // If the selected node is a list item and is not dropped inside a list, we need to wrap it inside

    tag otherwise ol list items will be transformed into ul list item when dropped - if ( - view.state.selection instanceof NodeSelection && - view.state.selection.node.type.name === "listItem" && - !isDroppedInsideList && - listType == "OL" - ) { - const text = droppedNode.textContent; - if (!text) return; - const paragraph = view.state.schema.nodes.paragraph?.createAndFill({}, view.state.schema.text(text)); - const listItem = view.state.schema.nodes.listItem?.createAndFill({}, paragraph); + // Handle nested list items and task items + if (droppedNode.type.name === "listItem") { + let slice = view.state.selection.content(); + let newFragment = slice.content; - const newList = view.state.schema.nodes.orderedList?.createAndFill(null, listItem); - const slice = new Slice(Fragment.from(newList), 0, 0); + // If dropping outside a list or at a different depth, adjust the structure + if (!isDroppedInsideList || dropDepth !== resolvedPos.depth) { + // Flatten the structure if needed + newFragment = flattenListStructure(newFragment, view.state.schema); + } + + // Wrap in appropriate list type if dropped outside a list + if (!isDroppedInsideList) { + const listNodeType = + listType === "OL" ? view.state.schema.nodes.orderedList : view.state.schema.nodes.bulletList; + newFragment = Fragment.from(listNodeType.create(null, newFragment)); + } + + slice = new Slice(newFragment, slice.openStart, slice.openEnd); view.dragging = { slice, move: event.ctrlKey }; } }, @@ -349,3 +336,90 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp domEvents, }; }; + +// Helper function to flatten nested list structure +function flattenListStructure(fragment: Fragment, schema: Schema): Fragment { + const result: Node[] = []; + fragment.forEach((node) => { + if (node.type === schema.nodes.listItem || node.type === schema.nodes.taskItem) { + result.push(node); + if ( + node.content.firstChild && + (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); + flattened.forEach((subNode) => result.push(subNode)); + } + } + }); + return Fragment.from(result); +} + +const handleNodeSelection = ( + event: MouseEvent | DragEvent, + view: EditorView, + isDragStart: boolean, + options: SideMenuPluginProps +) => { + let listType = ""; + view.focus(); + + const node = nodeDOMAtCoords({ + x: event.clientX + 50 + options.dragHandleWidth, + y: event.clientY, + }); + + if (!(node instanceof Element)) return; + + let draggedNodePos = nodePosAtDOM(node, view, options); + if (draggedNodePos == null || draggedNodePos < 0) return; + + // Handle blockquotes separately + if (node.matches("blockquote")) { + draggedNodePos = nodePosAtDOMForBlockQuotes(node, view); + if (draggedNodePos === null || draggedNodePos === undefined) return; + } else { + // Resolve the position to get the parent node + 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) { + draggedNodePos = $pos.before($pos.depth); + } + } + + const docSize = view.state.doc.content.size; + draggedNodePos = Math.max(0, Math.min(draggedNodePos, docSize)); + + // Use NodeSelection to select the node at the calculated position + const nodeSelection = NodeSelection.create(view.state.doc, draggedNodePos); + + // Dispatch the transaction to update the selection + view.dispatch(view.state.tr.setSelection(nodeSelection)); + + if (isDragStart) { + // Additional logic for drag start + if (event instanceof DragEvent && !event.dataTransfer) return; + + if (nodeSelection.node.type.name === "listItem" || nodeSelection.node.type.name === "taskItem") { + listType = node.closest("ol, ul")?.tagName || ""; + } + + const slice = view.state.selection.content(); + const { dom, text } = __serializeForClipboard(view, slice); + + if (event instanceof DragEvent) { + event.dataTransfer.clearData(); + event.dataTransfer.setData("text/html", dom.innerHTML); + event.dataTransfer.setData("text/plain", text); + event.dataTransfer.effectAllowed = "copyMove"; + event.dataTransfer.setDragImage(node, 0, 0); + } + + view.dragging = { slice, move: event.ctrlKey }; + } + + return { listType }; +}; diff --git a/packages/editor/src/core/plugins/image/utils/validate-file.ts b/packages/editor/src/core/plugins/image/utils/validate-file.ts index db88f3f73..703bb2bf0 100644 --- a/packages/editor/src/core/plugins/image/utils/validate-file.ts +++ b/packages/editor/src/core/plugins/image/utils/validate-file.ts @@ -1,3 +1,6 @@ +// constants +import { ACCEPTED_FILE_MIME_TYPES } from "@/constants/config"; + type TArgs = { file: File; maxFileSize: number; @@ -11,9 +14,8 @@ export const isFileValid = (args: TArgs): boolean => { return false; } - const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"]; - if (!allowedTypes.includes(file.type)) { - alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file."); + if (!ACCEPTED_FILE_MIME_TYPES.includes(file.type)) { + alert("Invalid file type. Please select a JPEG, JPG, PNG, WEBP or GIF file."); return false; } diff --git a/packages/editor/src/core/props/props.tsx b/packages/editor/src/core/props/props.tsx index 4bda3e51a..ee0b9e500 100644 --- a/packages/editor/src/core/props/props.tsx +++ b/packages/editor/src/core/props/props.tsx @@ -1,6 +1,6 @@ import { EditorProps } from "@tiptap/pm/view"; -// helpers -import { cn } from "@/helpers/common"; +// plane utils +import { cn } from "@plane/utils"; export type TCoreEditorProps = { editorClassName: string; diff --git a/packages/editor/src/core/props/read-only.tsx b/packages/editor/src/core/props/read-only.tsx index aaa635a50..ea5bf09f3 100644 --- a/packages/editor/src/core/props/read-only.tsx +++ b/packages/editor/src/core/props/read-only.tsx @@ -1,6 +1,6 @@ import { EditorProps } from "@tiptap/pm/view"; -// helpers -import { cn } from "@/helpers/common"; +// plane utils +import { cn } from "@plane/utils"; // props import { TCoreEditorProps } from "@/props"; diff --git a/packages/editor/src/core/types/collaboration.ts b/packages/editor/src/core/types/collaboration.ts index 35fbdb996..82e2f81f9 100644 --- a/packages/editor/src/core/types/collaboration.ts +++ b/packages/editor/src/core/types/collaboration.ts @@ -6,10 +6,11 @@ import { TEmbedConfig } from "@/plane-editor/types"; import { EditorReadOnlyRefApi, EditorRefApi, - IMentionHighlight, - IMentionSuggestion, TExtensions, TFileHandler, + TMentionHandler, + TReadOnlyFileHandler, + TReadOnlyMentionHandler, TRealtimeConfig, TUserDetails, } from "@/types"; @@ -21,15 +22,12 @@ export type TServerHandler = { type TCollaborativeEditorHookProps = { disabledExtensions: TExtensions[]; + editable?: boolean; editorClassName: string; editorProps?: EditorProps; extensions?: Extensions; handleEditorReady?: (value: boolean) => void; id: string; - mentionHandler: { - highlights: () => Promise; - suggestions?: () => Promise; - }; realtimeConfig: TRealtimeConfig; serverHandler?: TServerHandler; user: TUserDetails; @@ -40,11 +38,13 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & { embedHandler?: TEmbedConfig; fileHandler: TFileHandler; forwardedRef?: React.MutableRefObject; + mentionHandler: TMentionHandler; placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; }; export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & { - fileHandler: Pick; + 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 3bb4d1af2..d4d8ca901 100644 --- a/packages/editor/src/core/types/config.ts +++ b/packages/editor/src/core/types/config.ts @@ -1,11 +1,15 @@ import { DeleteImage, RestoreImage, UploadImage } from "@/types"; -export type TFileHandler = { +export type TReadOnlyFileHandler = { getAssetSrc: (path: string) => Promise; + restore: RestoreImage; +}; + +export type TFileHandler = TReadOnlyFileHandler & { + assetsUploadStatus: Record; // blockId => progress percentage cancel: () => void; delete: DeleteImage; upload: UploadImage; - restore: RestoreImage; validation: { /** * @description max file size in bytes diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index e91af8e49..edf696ab8 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -1,11 +1,13 @@ -import { JSONContent } from "@tiptap/core"; +import { Extensions, JSONContent } from "@tiptap/core"; import { Selection } from "@tiptap/pm/state"; +// plane types +import { TWebhookConnectionQueryParams } from "@plane/types"; +// extension types +import { TTextAlign } from "@/extensions"; // helpers import { IMarking } from "@/helpers/scroll-to-node"; // types import { - IMentionHighlight, - IMentionSuggestion, TAIHandler, TDisplayConfig, TDocumentEventEmitter, @@ -13,9 +15,11 @@ import { TEmbedConfig, TExtensions, TFileHandler, + TMentionHandler, + TReadOnlyFileHandler, + TReadOnlyMentionHandler, TServerHandler, } from "@/types"; -import { TTextAlign } from "@/extensions"; export type TEditorCommands = | "text" @@ -28,7 +32,7 @@ export type TEditorCommands = | "bold" | "italic" | "underline" - | "strikethrough" + | "strike" | "bulleted-list" | "numbered-list" | "to-do-list" @@ -41,12 +45,16 @@ export type TEditorCommands = | "text-color" | "background-color" | "text-align" - | "callout"; + | "callout" + | "attachment"; export type TCommandExtraProps = { image: { savedSelection: Selection | null; }; + attachment: { + savedSelection: Selection | null; + }; "text-color": { color: string | undefined; }; @@ -83,10 +91,6 @@ export type EditorReadOnlyRefApi = { paragraphs: number; words: number; }; - onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void; - getHeadings: () => IMarking[]; - emitRealTimeUpdate: (action: TDocumentEventsServer) => void; - listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined; }; export interface EditorRefApi extends EditorReadOnlyRefApi { @@ -102,6 +106,10 @@ export interface EditorRefApi extends EditorReadOnlyRefApi { getSelectedText: () => string | null; insertText: (contentHTML: string, insertOnNextLine?: boolean) => void; setProviderDocument: (value: Uint8Array) => void; + onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void; + getHeadings: () => IMarking[]; + emitRealTimeUpdate: (action: TDocumentEventsServer) => void; + listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined; } // editor props @@ -114,10 +122,7 @@ export interface IEditorProps { forwardedRef?: React.MutableRefObject; id: string; initialValue: string; - mentionHandler: { - highlights: () => Promise; - suggestions?: () => Promise; - }; + mentionHandler: TMentionHandler; onChange?: (json: object, html: string) => void; onTransaction?: () => void; handleEditorReady?: (value: boolean) => void; @@ -128,10 +133,10 @@ export interface IEditorProps { value?: string | null; } export interface ILiteTextEditor extends IEditorProps { - extensions?: any[]; + extensions?: Extensions; } export interface IRichTextEditor extends IEditorProps { - extensions?: any[]; + extensions?: Extensions; bubbleMenuEnabled?: boolean; dragDropEnabled?: boolean; } @@ -139,6 +144,8 @@ export interface IRichTextEditor extends IEditorProps { export interface ICollaborativeDocumentEditor extends Omit { aiHandler?: TAIHandler; + bubbleMenuEnabled?: boolean; + editable: boolean; embedHandler: TEmbedConfig; handleEditorReady?: (value: boolean) => void; id: string; @@ -153,13 +160,11 @@ export interface IReadOnlyEditorProps { disabledExtensions: TExtensions[]; displayConfig?: TDisplayConfig; editorClassName?: string; - fileHandler: Pick; + fileHandler: TReadOnlyFileHandler; forwardedRef?: React.MutableRefObject; id: string; initialValue: string; - mentionHandler: { - highlights: () => Promise; - }; + mentionHandler: TReadOnlyMentionHandler; } export type ILiteTextReadOnlyEditor = IReadOnlyEditorProps; @@ -189,7 +194,5 @@ export type TUserDetails = { export type TRealtimeConfig = { url: string; - queryParams: { - [key: string]: string; - }; + queryParams: TWebhookConnectionQueryParams; }; diff --git a/packages/editor/src/core/types/image.ts b/packages/editor/src/core/types/image.ts index 5c707bf33..ca6f76fb1 100644 --- a/packages/editor/src/core/types/image.ts +++ b/packages/editor/src/core/types/image.ts @@ -2,4 +2,4 @@ export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise; -export type UploadImage = (file: File) => 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 527264d39..e99a74b28 100644 --- a/packages/editor/src/core/types/index.ts +++ b/packages/editor/src/core/types/index.ts @@ -5,7 +5,7 @@ export * from "./editor"; export * from "./embed"; export * from "./extensions"; export * from "./image"; -export * from "./mention-suggestion"; +export * from "./mention"; export * from "./slash-commands-suggestion"; export * from "@/plane-editor/types"; export * from "./document-collaborative-events"; diff --git a/packages/editor/src/core/types/mention-suggestion.ts b/packages/editor/src/core/types/mention-suggestion.ts deleted file mode 100644 index a51bed704..000000000 --- a/packages/editor/src/core/types/mention-suggestion.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type IMentionSuggestion = { - id: string; - type: string; - entity_name: string; - entity_identifier: string; - avatar: string; - title: string; - subtitle: string; - redirect_uri: string; -}; - -export type IMentionHighlight = string; diff --git a/packages/editor/src/core/types/mention.ts b/packages/editor/src/core/types/mention.ts new file mode 100644 index 000000000..20f1ec0dc --- /dev/null +++ b/packages/editor/src/core/types/mention.ts @@ -0,0 +1,27 @@ +// plane types +import { TSearchEntities } from "@plane/types"; + +export type TMentionSuggestion = { + entity_identifier: string; + entity_name: TSearchEntities; + icon: React.ReactNode; + id: string; + subTitle?: string; + title: string; +}; + +export type TMentionSection = { + key: string; + title?: string; + items: TMentionSuggestion[]; +}; + +export type TMentionComponentProps = Pick; + +export type TReadOnlyMentionHandler = { + renderComponent: (props: TMentionComponentProps) => React.ReactNode; +}; + +export type TMentionHandler = TReadOnlyMentionHandler & { + searchCallback?: (query: string) => Promise; +}; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index ed7d91346..a2a9afaf9 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -9,7 +9,6 @@ import "./styles/drag-drop.css"; // editors export { CollaborativeDocumentEditorWithRef, - CollaborativeDocumentReadOnlyEditorWithRef, DocumentReadOnlyEditorWithRef, LiteTextEditorWithRef, LiteTextReadOnlyEditorWithRef, @@ -25,7 +24,7 @@ export * from "@/constants/common"; // helpers export * from "@/helpers/common"; export * from "@/helpers/editor-commands"; -export * from "@/helpers/yjs"; +export * from "@/helpers/yjs-utils"; export * from "@/extensions/table/table"; // components diff --git a/packages/editor/src/lib.ts b/packages/editor/src/lib.ts index e32fa0785..44388a00e 100644 --- a/packages/editor/src/lib.ts +++ b/packages/editor/src/lib.ts @@ -1,4 +1,5 @@ export * from "@/extensions/core-without-props"; export * from "@/constants/document-collaborative-events"; export * from "@/helpers/get-document-server-event"; +export * from "@/helpers/yjs-utils"; export * from "@/types/document-collaborative-events"; diff --git a/packages/editor/src/styles/editor.css b/packages/editor/src/styles/editor.css index db60c7cf5..69bb61961 100644 --- a/packages/editor/src/styles/editor.css +++ b/packages/editor/src/styles/editor.css @@ -25,7 +25,7 @@ .ProseMirror p.is-editor-empty:first-child::before { content: attr(data-placeholder); float: left; - color: rgb(var(--color-text-400)); + color: var(--color-placeholder); pointer-events: none; height: 0; } @@ -34,7 +34,7 @@ .ProseMirror p.is-empty::before { content: attr(data-placeholder); float: left; - color: rgb(var(--color-text-400)); + color: var(--color-placeholder); pointer-events: none; height: 0; } @@ -105,23 +105,27 @@ ul[data-type="taskList"] li > div { } ul[data-type="taskList"] li > label input[type="checkbox"] { - border: 1px solid rgba(var(--color-border-300)) !important; + border: 1px solid rgba(var(--color-text-100), 0.2) !important; outline: none; border-radius: 2px; transform: scale(1.05); } -ul[data-type="taskList"] li > label input[type="checkbox"]:hover { - background-color: rgba(var(--color-background-80)) !important; +.ProseMirror[contenteditable="true"] input[type="checkbox"]:hover { + background-color: rgba(var(--color-text-100), 0.1); } -ul[data-type="taskList"] li > label input[type="checkbox"][checked] { +.ProseMirror[contenteditable="false"] input[type="checkbox"] { + pointer-events: none; +} + +ul[data-type="taskList"] li > label input[type="checkbox"]:checked { background-color: rgba(var(--color-primary-100)) !important; border-color: rgba(var(--color-primary-100)) !important; color: white !important; } -ul[data-type="taskList"] li > label input[type="checkbox"][checked]:hover { +ul[data-type="taskList"] li > label input[type="checkbox"]:checked:hover { background-color: rgba(var(--color-primary-300)) !important; border-color: rgba(var(--color-primary-300)) !important; } @@ -141,7 +145,7 @@ ul[data-type="taskList"] li > label input[type="checkbox"] { position: relative; -webkit-appearance: none; appearance: none; - background-color: rgb(var(--color-background-100)); + background-color: transparent; margin: 0; cursor: pointer; width: 0.8rem; @@ -151,10 +155,6 @@ ul[data-type="taskList"] li > label input[type="checkbox"] { margin-right: 0.2rem; margin-top: 0.15rem; - &:hover { - background-color: rgb(var(--color-background-80)); - } - &:active { background-color: rgb(var(--color-background-90)); } @@ -174,7 +174,7 @@ ul[data-type="taskList"] li > label input[type="checkbox"] { clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); } - &[checked]::before { + &:checked::before { transform: scale(1) translate(-50%, -50%); } } @@ -192,7 +192,7 @@ ul[data-type="taskList"] li > div { ul[data-type="taskList"] li[data-checked="true"] { & > div > p.editor-paragraph-block { - color: rgb(var(--color-text-400)); + color: var(--color-placeholder); } [data-text-color] { @@ -408,12 +408,14 @@ p.editor-paragraph-block { padding-top: 4px; } - &:last-child { - padding-bottom: 4px; - } + &:not(td p.editor-paragraph-block, th p.editor-paragraph-block) { + &:last-child { + padding-bottom: 4px; + } - &:not(:last-child) { - padding-bottom: 8px; + &:not(:last-child) { + padding-bottom: 8px; + } } font-size: var(--font-size-regular); diff --git a/packages/editor/src/styles/table.css b/packages/editor/src/styles/table.css index a0fbbe38f..b4a1ab820 100644 --- a/packages/editor/src/styles/table.css +++ b/packages/editor/src/styles/table.css @@ -16,7 +16,7 @@ .table-wrapper table th { min-width: 1em; border: 1px solid rgba(var(--color-border-200)); - padding: 10px 20px; + padding: 7px 10px; vertical-align: top; box-sizing: border-box; position: relative; @@ -48,7 +48,7 @@ /* table dropdown */ .table-wrapper table .column-resize-handle { position: absolute; - right: -2px; + right: 0; top: 0; width: 2px; height: 100%; diff --git a/packages/editor/src/styles/variables.css b/packages/editor/src/styles/variables.css index d25500692..66880f156 100644 --- a/packages/editor/src/styles/variables.css +++ b/packages/editor/src/styles/variables.css @@ -1,42 +1,6 @@ -:root { - /* text colors */ - --editor-colors-gray-text: #5c5e63; - --editor-colors-peach-text: #ff5b59; - --editor-colors-pink-text: #f65385; - --editor-colors-orange-text: #fd9038; - --editor-colors-green-text: #0fc27b; - --editor-colors-light-blue-text: #17bee9; - --editor-colors-dark-blue-text: #266df0; - --editor-colors-purple-text: #9162f9; - /* end text colors */ -} - -/* text background colors */ -[data-theme="light"], -[data-theme="light-contrast"] { - --editor-colors-gray-background: #d6d6d8; - --editor-colors-peach-background: #ffd5d7; - --editor-colors-pink-background: #fdd4e3; - --editor-colors-orange-background: #ffe3cd; - --editor-colors-green-background: #c3f0de; - --editor-colors-light-blue-background: #c5eff9; - --editor-colors-dark-blue-background: #c9dafb; - --editor-colors-purple-background: #e3d8fd; -} -[data-theme="dark"], -[data-theme="dark-contrast"] { - --editor-colors-gray-background: #404144; - --editor-colors-peach-background: #593032; - --editor-colors-pink-background: #562e3d; - --editor-colors-orange-background: #583e2a; - --editor-colors-green-background: #1d4a3b; - --editor-colors-light-blue-background: #1f495c; - --editor-colors-dark-blue-background: #223558; - --editor-colors-purple-background: #3d325a; -} -/* end text background colors */ - .editor-container { + --color-placeholder: rgba(var(--color-text-100), 0.5); + /* font sizes and line heights */ &.large-font { --font-size-h1: 1.75rem; diff --git a/packages/editor/tailwind.config.js b/packages/editor/tailwind.config.js index f32063158..de93a571f 100644 --- a/packages/editor/tailwind.config.js +++ b/packages/editor/tailwind.config.js @@ -1,4 +1,4 @@ -const sharedConfig = require("tailwind-config-custom/tailwind.config.js"); +const sharedConfig = require("@plane/tailwind-config/tailwind.config.js"); module.exports = { // prefix ui lib classes to avoid conflicting with the app diff --git a/packages/editor/tsconfig.json b/packages/editor/tsconfig.json index 8edd9106f..58b8640e6 100644 --- a/packages/editor/tsconfig.json +++ b/packages/editor/tsconfig.json @@ -12,6 +12,7 @@ "@/styles/*": ["src/styles/*"], "@/plane-editor/*": ["src/ce/*"] }, + "strictNullChecks": true, "allowSyntheticDefaultImports": true }, "include": ["src/**/*", "index.d.ts"], diff --git a/packages/eslint-config/library.js b/packages/eslint-config/library.js index 283590693..790364230 100644 --- a/packages/eslint-config/library.js +++ b/packages/eslint-config/library.js @@ -44,6 +44,30 @@ module.exports = { "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-useless-empty-export": "error", "@typescript-eslint/prefer-ts-expect-error": "warn", + "import/order": [ + "warn", + { + groups: ["builtin", "external", "internal", "parent", "sibling"], + pathGroups: [ + { + pattern: "@plane/**", + group: "external", + position: "after", + }, + { + pattern: "@/**", + group: "internal", + position: "before", + }, + ], + pathGroupsExcludedImportTypes: ["builtin", "internal", "react"], + alphabetize: { + order: "asc", + caseInsensitive: true, + }, + }, + ], }, + ignorePatterns: [".*.js", "node_modules/", "dist/"], }; diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index c07642907..11c33cfa4 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -1,2 +1,3 @@ export * from "./use-local-storage"; export * from "./use-outside-click-detector"; +export * from "./use-platform-os"; diff --git a/packages/hooks/src/use-outside-click-detector.tsx b/packages/hooks/src/use-outside-click-detector.tsx index 9436b51bf..54979f96a 100644 --- a/packages/hooks/src/use-outside-click-detector.tsx +++ b/packages/hooks/src/use-outside-click-detector.tsx @@ -8,9 +8,9 @@ export const useOutsideClickDetector = ( const handleClick = (event: MouseEvent) => { if (ref.current && !ref.current.contains(event.target as any)) { // check for the closest element with attribute name data-prevent-outside-click - const preventOutsideClickElement = ( - event.target as unknown as HTMLElement | undefined - )?.closest("[data-prevent-outside-click]"); + const preventOutsideClickElement = (event.target as unknown as HTMLElement | undefined)?.closest( + "[data-prevent-outside-click]" + ); // if the closest element with attribute name data-prevent-outside-click is found, return if (preventOutsideClickElement) { return; diff --git a/packages/hooks/src/use-platform-os.tsx b/packages/hooks/src/use-platform-os.tsx new file mode 100644 index 000000000..3f62e1499 --- /dev/null +++ b/packages/hooks/src/use-platform-os.tsx @@ -0,0 +1,34 @@ +import { useState, useEffect } from "react"; + +export const usePlatformOS = () => { + const [platformData, setPlatformData] = useState({ + isMobile: false, + platform: "", + }); + + useEffect(() => { + const detectPlatform = () => { + const userAgent = window.navigator.userAgent; + const isMobile = /iPhone|iPad|iPod|Android/i.test(userAgent); + let platform = ""; + + if (!isMobile) { + if (userAgent.indexOf("Win") !== -1) { + platform = "Windows"; + } else if (userAgent.indexOf("Mac") !== -1) { + platform = "MacOS"; + } else if (userAgent.indexOf("Linux") !== -1) { + platform = "Linux"; + } else { + platform = "Unknown"; + } + } + + setPlatformData({ isMobile, platform }); + }; + + detectPlatform(); + }, []); + + return platformData; +}; diff --git a/packages/i18n/.eslintignore b/packages/i18n/.eslintignore new file mode 100644 index 000000000..6019047c3 --- /dev/null +++ b/packages/i18n/.eslintignore @@ -0,0 +1,3 @@ +build/* +dist/* +out/* \ No newline at end of file diff --git a/packages/i18n/.eslintrc.js b/packages/i18n/.eslintrc.js new file mode 100644 index 000000000..558b8f76e --- /dev/null +++ b/packages/i18n/.eslintrc.js @@ -0,0 +1,9 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ["@plane/eslint-config/library.js"], + parser: "@typescript-eslint/parser", + parserOptions: { + project: true, + }, +}; diff --git a/packages/i18n/.prettierignore b/packages/i18n/.prettierignore new file mode 100644 index 000000000..d5be669c5 --- /dev/null +++ b/packages/i18n/.prettierignore @@ -0,0 +1,4 @@ +.turbo +out/ +dist/ +build/ \ No newline at end of file diff --git a/packages/i18n/.prettierrc b/packages/i18n/.prettierrc new file mode 100644 index 000000000..87d988f1b --- /dev/null +++ b/packages/i18n/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/packages/i18n/package.json b/packages/i18n/package.json new file mode 100644 index 000000000..96b488024 --- /dev/null +++ b/packages/i18n/package.json @@ -0,0 +1,21 @@ +{ + "name": "@plane/i18n", + "version": "0.24.1", + "description": "I18n shared across multiple apps internally", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "lint": "eslint src --ext .ts,.tsx", + "lint:errors": "eslint src --ext .ts,.tsx --quiet" + }, + "dependencies": { + "@plane/utils": "*", + "intl-messageformat": "^10.7.11" + }, + "devDependencies": { + "@plane/eslint-config": "*", + "@types/node": "^22.5.4", + "typescript": "^5.3.3" + } +} diff --git a/packages/i18n/src/constants/index.ts b/packages/i18n/src/constants/index.ts new file mode 100644 index 000000000..1f0daf2fc --- /dev/null +++ b/packages/i18n/src/constants/index.ts @@ -0,0 +1 @@ +export * from "./language"; diff --git a/packages/i18n/src/constants/language.ts b/packages/i18n/src/constants/language.ts new file mode 100644 index 000000000..67b8e9d3f --- /dev/null +++ b/packages/i18n/src/constants/language.ts @@ -0,0 +1,13 @@ +import { TLanguage, ILanguageOption } from "../types"; + +export const FALLBACK_LANGUAGE: TLanguage = "en"; + +export const SUPPORTED_LANGUAGES: ILanguageOption[] = [ + { label: "English", value: "en" }, + { label: "Français", value: "fr" }, + { label: "Español", value: "es" }, + { label: "日本語", value: "ja" }, + { label: "中文", value: "zh-CN" }, +]; + +export const STORAGE_KEY = "userLanguage"; diff --git a/packages/i18n/src/context/index.tsx b/packages/i18n/src/context/index.tsx new file mode 100644 index 000000000..cf8a960fe --- /dev/null +++ b/packages/i18n/src/context/index.tsx @@ -0,0 +1,19 @@ +import { observer } from "mobx-react"; +import React, { createContext } from "react"; +// store +import { TranslationStore } from "../store"; + +export const TranslationContext = createContext(null); + +interface TranslationProviderProps { + children: React.ReactNode; +} + +/** + * Provides the translation store to the application + */ +export const TranslationProvider: React.FC = observer(({ children }) => { + const [store] = React.useState(() => new TranslationStore()); + + return {children}; +}); diff --git a/packages/i18n/src/hooks/index.ts b/packages/i18n/src/hooks/index.ts new file mode 100644 index 000000000..fb4e297e2 --- /dev/null +++ b/packages/i18n/src/hooks/index.ts @@ -0,0 +1 @@ +export * from "./use-translation"; diff --git a/packages/i18n/src/hooks/use-translation.ts b/packages/i18n/src/hooks/use-translation.ts new file mode 100644 index 000000000..485c437c1 --- /dev/null +++ b/packages/i18n/src/hooks/use-translation.ts @@ -0,0 +1,35 @@ +import { useContext } from 'react'; +// context +import { TranslationContext } from '../context'; +// types +import { ILanguageOption, TLanguage } from '../types'; + +export type TTranslationStore = { + t: (key: string, params?: Record) => string; + currentLocale: TLanguage; + changeLanguage: (lng: TLanguage) => void; + languages: ILanguageOption[]; +}; + +/** + * Provides the translation store to the application + * @returns {TTranslationStore} + * @returns {(key: string, params?: Record) => string} t: method to translate the key with params + * @returns {TLanguage} currentLocale - current locale language + * @returns {(lng: TLanguage) => void} changeLanguage - method to change the language + * @returns {ILanguageOption[]} languages - available languages + * @throws {Error} if the TranslationProvider is not used + */ +export function useTranslation(): TTranslationStore { + const store = useContext(TranslationContext); + if (!store) { + throw new Error('useTranslation must be used within a TranslationProvider'); + } + + return { + t: store.t.bind(store), + currentLocale: store.currentLocale, + changeLanguage: (lng: TLanguage) => store.setLanguage(lng), + languages: store.availableLanguages, + }; +} diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts new file mode 100644 index 000000000..a93747a89 --- /dev/null +++ b/packages/i18n/src/index.ts @@ -0,0 +1,4 @@ +export * from "./constants"; +export * from "./context"; +export * from "./hooks"; +export * from "./types"; diff --git a/packages/i18n/src/locales/en/core.json b/packages/i18n/src/locales/en/core.json new file mode 100644 index 000000000..14207c5a8 --- /dev/null +++ b/packages/i18n/src/locales/en/core.json @@ -0,0 +1,171 @@ +{ + "sidebar": { + "projects": "Projects", + "pages": "Pages", + "new_work_item": "New work item", + "home": "Home", + "your_work": "Your work", + "inbox": "Inbox", + "workspace": "Workspace", + "views": "Views", + "analytics": "Analytics", + "work_items": "Work Items", + "cycles": "Cycles", + "modules": "Modules", + "intake": "Intake", + "drafts": "Drafts", + "favorites": "Favorites", + "pro": "Pro", + "upgrade": "Upgrade" + }, + + "auth": { + "common": { + "email": { + "label": "Email", + "placeholder": "name@company.com", + "errors": { + "required": "Email is required", + "invalid": "Email is invalid" + } + }, + "password": { + "label": "Password", + "set_password": "Set a password", + "placeholder": "Enter password", + "confirm_password": { + "label": "Confirm password", + "placeholder": "Confirm password" + }, + "current_password": { + "label": "Current password" + }, + "new_password": { + "label": "New password", + "placeholder": "Enter new password" + }, + "change_password": { + "label": { + "default": "Change password", + "submitting": "Changing password" + } + }, + "errors": { + "match": "Passwords don't match", + "empty": "Please enter your password", + "length": "Password length should me more than 8 characters", + "strength": { + "weak": "Password is weak", + "strong": "Password is strong" + } + }, + "submit": "Set password", + "toast": { + "change_password": { + "success": { + "title": "Success!", + "message": "Password changed successfully." + }, + "error": { + "title": "Error!", + "message": "Something went wrong. Please try again." + } + } + } + }, + "unique_code": { + "label": "Unique code", + "placeholder": "gets-sets-flys", + "paste_code": "Paste the code sent to your email", + "requesting_new_code": "Requesting new code", + "sending_code": "Sending code" + }, + "already_have_an_account": "Already have an account?", + "login": "Log in", + "create_account": "Create an account", + "new_to_plane": "New to Plane?", + "back_to_sign_in": "Back to sign in", + "resend_in": "Resend in {seconds} seconds", + "sign_in_with_unique_code": "Sign in with unique code", + "forgot_password": "Forgot your password?" + }, + "sign_up": { + "header": { + "label": "Create an account to start managing work with your team.", + "step": { + "email": { + "header": "Sign up", + "sub_header": "" + }, + "password": { + "header": "Sign up", + "sub_header": "Sign up using an email-password combination." + }, + "unique_code": { + "header": "Sign up", + "sub_header": "Sign up using a unique code sent to the email address above." + } + } + }, + "errors": { + "password": { + "strength": "Try setting-up a strong password to proceed" + } + } + }, + "sign_in": { + "header": { + "label": "Log in to start managing work with your team.", + "step": { + "email": { + "header": "Log in or sign up", + "sub_header": "" + }, + "password": { + "header": "Log in or sign up", + "sub_header": "Use your email-password combination to log in." + }, + "unique_code": { + "header": "Log in or sign up", + "sub_header": "Log in using a unique code sent to the email address above." + } + } + } + }, + "forgot_password": { + "title": "Reset your password", + "description": "Enter your user account's verified email address and we will send you a password reset link.", + "email_sent": "We sent the reset link to your email address", + "send_reset_link": "Send reset link", + "errors": { + "smtp_not_enabled": "We see that your god hasn't enabled SMTP, we will not be able to send a password reset link" + }, + "toast": { + "success": { + "title": "Email sent", + "message": "Check your inbox for a link to reset your password. If it doesn't appear within a few minutes, check your spam folder." + }, + "error": { + "title": "Error!", + "message": "Something went wrong. Please try again." + } + } + }, + "reset_password": { + "title": "Set new password", + "description": "Secure your account with a strong password" + }, + "set_password": { + "title": "Secure your account", + "description": "Setting password helps you login securely" + }, + "sign_out": { + "toast": { + "error": { + "title": "Error!", + "message": "Failed to sign out. Please try again." + } + } + } + } +} diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json new file mode 100644 index 000000000..7c74ff2ee --- /dev/null +++ b/packages/i18n/src/locales/en/translations.json @@ -0,0 +1,2194 @@ +{ + "submit": "Submit", + "cancel": "Cancel", + "loading": "Loading", + "error": "Error", + "success": "Success", + "warning": "Warning", + "info": "Info", + "close": "Close", + "yes": "Yes", + "no": "No", + "ok": "OK", + "name": "Name", + "description": "Description", + "search": "Search", + "add_member": "Add member", + "adding_members": "Adding members", + "remove_member": "Remove member", + "add_members": "Add members", + "adding_member": "Adding members", + "remove_members": "Remove members", + "add": "Add", + "adding": "Adding", + "remove": "Remove", + "add_new": "Add new", + "remove_selected": "Remove selected", + "first_name": "First name", + "last_name": "Last name", + "email": "Email", + "display_name": "Display name", + "role": "Role", + "timezone": "Timezone", + "avatar": "Avatar", + "cover_image": "Cover image", + "password": "Password", + "change_cover": "Change cover", + "language": "Language", + "saving": "Saving", + "save_changes": "Save changes", + "deactivate_account": "Deactivate account", + "deactivate_account_description": "When deactivating an account, all of the data and resources within that account will be permanently removed and cannot be recovered.", + "profile_settings": "Profile settings", + "your_account": "Your account", + "security": "Security", + "activity": "Activity", + "appearance": "Appearance", + "notifications": "Notifications", + "workspaces": "Workspaces", + "create_workspace": "Create workspace", + "invitations": "Invitations", + "summary": "Summary", + "assigned": "Assigned", + "created": "Created", + "subscribed": "Subscribed", + "you_do_not_have_the_permission_to_access_this_page": "You do not have the permission to access this page.", + "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.", + "theme": "Theme", + "system_preference": "System preference", + "light": "Light", + "dark": "Dark", + "light_contrast": "Light high contrast", + "dark_contrast": "Dark high contrast", + "custom": "Custom theme", + "select_your_theme": "Select your theme", + "customize_your_theme": "Customize your theme", + "background_color": "Background color", + "text_color": "Text color", + "primary_color": "Primary(Theme) color", + "sidebar_background_color": "Sidebar background color", + "sidebar_text_color": "Sidebar text color", + "set_theme": "Set theme", + "enter_a_valid_hex_code_of_6_characters": "Enter a valid hex code of 6 characters", + "background_color_is_required": "Background color is required", + "text_color_is_required": "Text color is required", + "primary_color_is_required": "Primary color is required", + "sidebar_background_color_is_required": "Sidebar background color is required", + "sidebar_text_color_is_required": "Sidebar text color is required", + "updating_theme": "Updating theme", + "theme_updated_successfully": "Theme updated successfully", + "failed_to_update_the_theme": "Failed to update the theme", + "email_notifications": "Email notifications", + "stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "Stay in the loop on Work items you are subscribed to. Enable this to get notified.", + "email_notification_setting_updated_successfully": "Email notification setting updated successfully", + "failed_to_update_email_notification_setting": "Failed to update email notification setting", + "notify_me_when": "Notify me when", + "property_changes": "Property changes", + "property_changes_description": "Notify me when work items' properties like assignees, priority, estimates or anything else changes.", + "state_change": "State change", + "state_change_description": "Notify me when the work items moves to a different state", + "issue_completed": "Work item completed", + "issue_completed_description": "Notify me only when a work item is completed", + "comments": "Comments", + "comments_description": "Notify me when someone leaves a comment on the work item", + "mentions": "Mentions", + "mentions_description": "Notify me only when someone mentions me in the comments or description", + "old_password": "Old password", + "general_settings": "General settings", + "sign_out": "Sign out", + "signing_out": "Signing out", + "active_cycles": "Active cycles", + "active_cycles_description": "Monitor cycles across projects, track high-priority work items, and zoom in cycles that need attention.", + "on_demand_snapshots_of_all_your_cycles": "On-demand snapshots of all your cycles", + "upgrade": "Upgrade", + "10000_feet_view": "10,000-feet view of all active cycles.", + "10000_feet_view_description": "Zoom out to see running cycles across all your projects at once instead of going from Cycle to Cycle in each project.", + "get_snapshot_of_each_active_cycle": "Get a snapshot of each active cycle.", + "get_snapshot_of_each_active_cycle_description": "Track high-level metrics for all active cycles, see their state of progress, and get a sense of scope against deadlines.", + "compare_burndowns": "Compare burndowns.", + "compare_burndowns_description": "Monitor how each of your teams are performing with a peek into each cycle's burndown report.", + "quickly_see_make_or_break_issues": "Quickly see make-or-break work items.", + "quickly_see_make_or_break_issues_description": "Preview high-priority work items for each cycle against due dates. See all of them per cycle in one click.", + "zoom_into_cycles_that_need_attention": "Zoom into cycles that need attention.", + "zoom_into_cycles_that_need_attention_description": "Investigate the state of any cycle that doesn't conform to expectations in one click.", + "stay_ahead_of_blockers": "Stay ahead of blockers.", + "stay_ahead_of_blockers_description": "Spot challenges from one project to another and see inter-cycle dependencies that aren't obvious from any other view.", + "analytics": "Analytics", + "workspace_invites": "Workspace invites", + "enter_god_mode": "Enter god mode", + "workspace_logo": "Workspace logo", + "new_issue": "New work item", + "your_work": "Your work", + "drafts": "Drafts", + "projects": "Projects", + "views": "Views", + "workspace": "Workspace", + "archives": "Archives", + "settings": "Settings", + "failed_to_move_favorite": "Failed to move favorite", + "favorites": "Favorites", + "no_favorites_yet": "No favorites yet", + "create_folder": "Create folder", + "new_folder": "New folder", + "favorite_updated_successfully": "Favorite updated successfully", + "favorite_created_successfully": "Favorite created successfully", + "folder_already_exists": "Folder already exists", + "folder_name_cannot_be_empty": "Folder name cannot be empty", + "something_went_wrong": "Something went wrong", + "failed_to_reorder_favorite": "Failed to reorder favorite", + "favorite_removed_successfully": "Favorite removed successfully", + "failed_to_create_favorite": "Failed to create favorite", + "failed_to_rename_favorite": "Failed to rename favorite", + "project_link_copied_to_clipboard": "Project link copied to clipboard", + "link_copied": "Link copied", + "add_project": "Add project", + "create_project": "Create project", + "failed_to_remove_project_from_favorites": "Couldn't remove the project from favorites. Please try again.", + "project_created_successfully": "Project created successfully", + "project_created_successfully_description": "Project created successfully. You can now start adding work items to it.", + "project_cover_image_alt": "Project cover image", + "name_is_required": "Name is required", + "title_should_be_less_than_255_characters": "Title should be less than 255 characters", + "project_name": "Project name", + "project_id_must_be_at_least_1_character": "Project ID must at least be of 1 character", + "project_id_must_be_at_most_5_characters": "Project ID must at most be of 5 characters", + "project_id": "Project ID", + "project_id_tooltip_content": "Helps you identify work items in the project uniquely. Max 5 characters.", + "description_placeholder": "Description", + "only_alphanumeric_non_latin_characters_allowed": "Only Alphanumeric & Non-latin characters are allowed.", + "project_id_is_required": "Project ID is required", + "project_id_allowed_char": "Only Alphanumeric & Non-latin characters are allowed.", + "project_id_min_char": "Project ID must at least be of 1 character", + "project_id_max_char": "Project ID must at most be of 5 characters", + "project_description_placeholder": "Enter project description", + "select_network": "Select network", + "lead": "Lead", + "date_range": "Date range", + "private": "Private", + "public": "Public", + "accessible_only_by_invite": "Accessible only by invite", + "anyone_in_the_workspace_except_guests_can_join": "Anyone in the workspace except Guests can join", + "creating": "Creating", + "creating_project": "Creating project", + "adding_project_to_favorites": "Adding project to favorites", + "project_added_to_favorites": "Project added to favorites", + "couldnt_add_the_project_to_favorites": "Couldn't add the project to favorites. Please try again.", + "removing_project_from_favorites": "Removing project from favorites", + "project_removed_from_favorites": "Project removed from favorites", + "couldnt_remove_the_project_from_favorites": "Couldn't remove the project from favorites. Please try again.", + "add_to_favorites": "Add to favorites", + "remove_from_favorites": "Remove from favorites", + "publish_settings": "Publish settings", + "publish": "Publish", + "copy_link": "Copy link", + "leave_project": "Leave project", + "join_the_project_to_rearrange": "Join the project to rearrange", + "drag_to_rearrange": "Drag to rearrange", + "congrats": "Congrats!", + "open_project": "Open project", + "issues": "Work items", + "cycles": "Cycles", + "modules": "Modules", + "pages": "Pages", + "intake": "Intake", + "time_tracking": "Time Tracking", + "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.", + "work_management_description": "Manage your work and projects with ease.", + "documentation": "Documentation", + "message_support": "Message support", + "contact_sales": "Contact sales", + "hyper_mode": "Hyper Mode", + "keyboard_shortcuts": "Keyboard shortcuts", + "whats_new": "What's new?", + "version": "Version", + "we_are_having_trouble_fetching_the_updates": "We are having trouble fetching the updates.", + "our_changelogs": "our changelogs", + "for_the_latest_updates": "for the latest updates.", + "please_visit": "Please visit", + "docs": "Docs", + "full_changelog": "Full changelog", + "support": "Support", + "discord": "Discord", + "powered_by_plane_pages": "Powered by Plane Pages", + "please_select_at_least_one_invitation": "Please select at least one invitation.", + "please_select_at_least_one_invitation_description": "Please select at least one invitation to join the workspace.", + "we_see_that_someone_has_invited_you_to_join_a_workspace": "We see that someone has invited you to join a workspace", + "join_a_workspace": "Join a workspace", + "we_see_that_someone_has_invited_you_to_join_a_workspace_description": "We see that someone has invited you to join a workspace", + "join_a_workspace_description": "Join a workspace", + "accept_and_join": "Accept & Join", + "go_home": "Go Home", + "no_pending_invites": "No pending invites", + "you_can_see_here_if_someone_invites_you_to_a_workspace": "You can see here if someone invites you to a workspace", + "back_to_home": "Back to home", + "workspace_name": "workspace-name", + "deactivate_your_account": "Deactivate your account", + "deactivate_your_account_description": "Once deactivated, you can't be assigned work items and be billed for your workspace. To reactivate your account, you will need an invite to a workspace at this email address.", + "deactivating": "Deactivating", + "confirm": "Confirm", + "confirming": "Confirming", + "draft_created": "Draft created", + "issue_created_successfully": "Work item created successfully", + "draft_creation_failed": "Draft creation failed", + "issue_creation_failed": "Work item creation failed", + "draft_issue": "Draft work item", + "issue_updated_successfully": "Work item updated successfully", + "issue_could_not_be_updated": "Work item could not be updated", + "create_a_draft": "Create a draft", + "save_to_drafts": "Save to Drafts", + "save": "Save", + "update": "Update", + "updating": "Updating", + "create_new_issue": "Create new work item", + "editor_is_not_ready_to_discard_changes": "Editor is not ready to discard changes", + "failed_to_move_issue_to_project": "Failed to move work item to project", + "create_more": "Create more", + "add_to_project": "Add to project", + "discard": "Discard", + "duplicate_issue_found": "Duplicate work item found", + "duplicate_issues_found": "Duplicate work items found", + "no_matching_results": "No matching results", + "title_is_required": "Title is required", + "title": "Title", + "state": "State", + "priority": "Priority", + "none": "None", + "urgent": "Urgent", + "high": "High", + "medium": "Medium", + "low": "Low", + "members": "Members", + "assignee": "Assignee", + "assignees": "Assignees", + "you": "You", + "labels": "Labels", + "create_new_label": "Create new label", + "start_date": "Start date", + "end_date": "End date", + "due_date": "Due date", + "estimate": "Estimate", + "change_parent_issue": "Change parent work item", + "remove_parent_issue": "Remove parent work item", + "add_parent": "Add parent", + "loading_members": "Loading members", + "view_link_copied_to_clipboard": "View link copied to clipboard.", + "required": "Required", + "optional": "Optional", + "Cancel": "Cancel", + "edit": "Edit", + "archive": "Archive", + "restore": "Restore", + "open_in_new_tab": "Open in new tab", + "delete": "Delete", + "deleting": "Deleting", + "make_a_copy": "Make a copy", + "move_to_project": "Move to project", + "good": "Good", + "morning": "morning", + "afternoon": "afternoon", + "evening": "evening", + "show_all": "Show all", + "show_less": "Show less", + "no_data_yet": "No Data yet", + "syncing": "Syncing", + "add_work_item": "Add work item", + "advanced_description_placeholder": "Press '/' for commands", + "create_work_item": "Create work item", + "attachments": "Attachments", + "declining": "Declining", + "declined": "Declined", + "decline": "Decline", + "unassigned": "Unassigned", + "work_items": "Work items", + "add_link": "Add link", + "points": "Points", + "no_assignee": "No assignee", + "no_assignees_yet": "No assignees yet", + "no_labels_yet": "No labels yet", + "ideal": "Ideal", + "current": "Current", + "no_matching_members": "No matching members", + "leaving": "Leaving", + "removing": "Removing", + "leave": "Leave", + "refresh": "Refresh", + "refreshing": "Refreshing", + "refresh_status": "Refresh status", + "prev": "Prev", + "next": "Next", + "re_generating": "Re-generating", + "re_generate": "Re-generate", + "re_generate_key": "Re-generate key", + "export": "Export", + "member": "{count, plural, one{# member} other{# members}}", + + "project_view": { + "sort_by": { + "created_at": "Created at", + "updated_at": "Updated at", + "name": "Name" + } + }, + + "toast": { + "success": "Success!", + "error": "Error!" + }, + + "links": { + "toasts": { + "created": { + "title": "Link created", + "message": "The link has been successfully created" + }, + "not_created": { + "title": "Link not created", + "message": "The link could not be created" + }, + "updated": { + "title": "Link updated", + "message": "The link has been successfully updated" + }, + "not_updated": { + "title": "Link not updated", + "message": "The link could not be updated" + }, + "removed": { + "title": "Link removed", + "message": "The link has been successfully removed" + }, + "not_removed": { + "title": "Link not removed", + "message": "The link could not be removed" + } + } + }, + + "home": { + "empty": { + "quickstart_guide": "Your quickstart guide", + "not_right_now": "Not right now", + "create_project": { + "title": "Create a project", + "description": "Most things start with a project in Plane.", + "cta": "Get started" + }, + "invite_team": { + "title": "Invite your team", + "description": "Build, ship, and manage with coworkers.", + "cta": "Get them in" + }, + "configure_workspace": { + "title": "Set up your workspace.", + "description": "Turn features on or off or go beyond that.", + "cta": "Configure this workspace" + }, + "personalize_account": { + "title": "Make Plane yours.", + "description": "Choose your picture, colors, and more.", + "cta": "Personalize now" + }, + "widgets": { + "title": "It's Quiet Without Widgets, Turn Them On", + "description": "It looks like all your widgets are turned off. Enable them\nnow to enhance your experience!", + "primary_button": { + "text": "Manage widgets" + } + } + }, + "quick_links": { + "empty": "Save links to work things that you'd like handy.", + "add": "Add quick Link", + "title": "Quicklink", + "title_plural": "Quicklinks" + }, + "recents": { + "title": "Recents", + "empty": { + "project": "Your recent projects will appear here once you visit one.", + "page": "Your recent pages will appear here once you visit one.", + "issue": "Your recent work items will appear here once you visit one.", + "default": "You don't have any recents yet." + }, + "filters": { + "all": "All items", + "projects": "Projects", + "pages": "Pages", + "issues": "Work items" + } + }, + "new_at_plane": { + "title": "New at Plane" + }, + "quick_tutorial": { + "title": "Quick tutorial" + }, + "widget": { + "reordered_successfully": "Widget reordered successfully.", + "reordering_failed": "Error occurred while reordering widget." + }, + "manage_widgets": "Manage widgets", + "title": "Home", + "star_us_on_github": "Star us on GitHub" + }, + + "link": { + "modal": { + "url": { + "text": "URL", + "required": "URL is invalid", + "placeholder": "Type or paste a URL" + }, + "title": { + "text": "Display title", + "placeholder": "What you'd like to see this link as" + } + } + }, + + "common": { + "all": "All", + "states": "States", + "state": "State", + "state_groups": "State groups", + "priority": "Priority", + "team_project": "Team project", + "project": "Project", + "cycle": "Cycle", + "cycles": "Cycles", + "module": "Module", + "modules": "Modules", + "labels": "Labels", + "assignees": "Assignees", + "assignee": "Assignee", + "created_by": "Created by", + "none": "None", + "link": "Link", + "estimate": "Estimate", + "layout": "Layout", + "filters": "Filters", + "display": "Display", + "load_more": "Load more", + "activity": "Activity", + "analytics": "Analytics", + "dates": "Dates", + "success": "Success!", + "something_went_wrong": "Something went wrong", + "error": { + "label": "Error!", + "message": "Some error occurred. Please try again." + }, + "group_by": "Group by", + "epic": "Epic", + "epics": "Epics", + "work_item": "Work item", + "work_items": "Work items", + "sub_work_item": "Sub-work item", + "add": "Add", + "warning": "Warning", + "updating": "Updating", + "adding": "Adding", + "update": "Update", + "creating": "Creating", + "create": "Create", + "cancel": "Cancel", + "description": "Description", + "title": "Title", + "attachment": "Attachment", + "general": "General", + "features": "Features", + "automation": "Automation", + "project_name": "Project name", + "project_id": "Project ID", + "project_timezone": "Project Timezone", + "created_on": "Created on", + "update_project": "Update project", + "identifier_already_exists": "Identifier already exists", + "add_more": "Add more", + "defaults": "Defaults", + "add_label": "Add label", + "estimates": "Estimates", + "customize_time_range": "Customize time range", + "loading": "Loading", + "attachments": "Attachments", + "property": "Property", + "properties": "Properties", + "parent": "Parent", + "remove": "Remove", + "archiving": "Archiving", + "archive": "Archive", + "access": { + "public": "Public", + "private": "Private" + }, + "done": "Done", + "sub_work_items": "Sub-work items", + "comment": "Comment", + "workspace_level": "Workspace level", + "order_by": { + "label": "Order by", + "manual": "Manual", + "last_created": "Last created", + "last_updated": "Last updated", + "start_date": "Start date", + "due_date": "Due date", + "asc": "Ascending", + "desc": "Descending", + "updated_on": "Updated on" + }, + "sort": { + "asc": "Ascending", + "desc": "Descending", + "created_on": "Created on", + "updated_on": "Updated on" + }, + "comments": "Comments", + "updates": "Updates", + "clear_all": "Clear all", + "copied": "Copied!", + "link_copied": "Link copied!", + "link_copied_to_clipboard": "Link copied to clipboard", + "copied_to_clipboard": "Work item link copied to clipboard", + "is_copied_to_clipboard": "Work item is copied to clipboard", + "no_links_added_yet": "No links added yet", + "add_link": "Add link", + "links": "Links", + "go_to_workspace": "Go to workspace", + "progress": "Progress", + "optional": "Optional", + "join": "Join", + "go_back": "Go back", + "continue": "Continue", + "resend": "Resend", + "relations": "Relations", + "errors": { + "default": { + "title": "Error!", + "message": "Something went wrong. Please try again." + }, + "required": "This field is required", + "entity_required": "{entity} is required" + }, + "update_link": "Update link", + "attach": "Attach", + "create_new": "Create new", + "add_existing": "Add existing", + "type_or_paste_a_url": "Type or paste a URL", + "url_is_invalid": "URL is invalid", + "display_title": "Display title", + "link_title_placeholder": "What you'd like to see this link as", + "url": "URL", + "side_peek": "Side Peek", + "modal": "Modal", + "full_screen": "Full Screen", + "close_peek_view": "Close the peek view", + "toggle_peek_view_layout": "Toggle peek view layout", + "options": "Options", + "duration": "Duration", + "today": "Today", + "week": "Week", + "month": "Month", + "quarter": "Quarter", + "press_for_commands": "Press '/' for commands", + "click_to_add_description": "Click to add description", + "search": { + "label": "Search", + "placeholder": "Type to search", + "no_matches_found": "No matches found", + "no_matching_results": "No matching results" + }, + "actions": { + "edit": "Edit", + "make_a_copy": "Make a copy", + "open_in_new_tab": "Open in new tab", + "copy_link": "Copy link", + "archive": "Archive", + "restore": "Restore", + "delete": "Delete", + "remove_relation": "Remove relation", + "subscribe": "Subscribe", + "unsubscribe": "Unsubscribe", + "clear_sorting": "Clear sorting", + "show_weekends": "Show weekends", + "enable": "Enable", + "disable": "Disable" + }, + "name": "Name", + "discard": "Discard", + "confirm": "Confirm", + "confirming": "Confirming", + "read_the_docs": "Read the docs", + "default": "Default", + "active": "Active", + "enabled": "Enabled", + "disabled": "Disabled", + "mandate": "Mandate", + "mandatory": "Mandatory", + "yes": "Yes", + "no": "No", + "please_wait": "Please wait", + "enabling": "Enabling", + "disabling": "Disabling", + "beta": "Beta", + "or": "or", + "next": "Next", + "back": "Back", + "cancelling": "Cancelling", + "configuring": "Configuring", + "clear": "Clear", + "import": "Import", + "connect": "Connect", + "authorizing": "Authorizing", + "processing": "Processing", + "no_data_available": "No data available", + "from": "from {name}", + "authenticated": "Authenticated", + "select": "Select", + "upgrade": "Upgrade", + "add_seats": "Add Seats", + "label": "Label", + "priorities": "Priorities", + "projects": "Projects", + "workspace": "Workspace", + "workspaces": "Workspaces", + "team": "Team", + "teams": "Teams", + "entity": "Entity", + "entities": "Entities", + "task": "Task", + "tasks": "Tasks", + "section": "Section", + "sections": "Sections", + "edit": "Edit", + "connecting": "Connecting", + "connected": "Connected", + "disconnect": "Disconnect", + "disconnecting": "Disconnecting", + "installing": "Installing", + "install": "Install", + "reset": "Reset", + "live": "Live", + "change_history": "Change History", + "coming_soon": "Coming soon", + "members": "Members", + "you": "You", + "upgrade_cta": { + "higher_subscription": "Upgrade to higher subscription", + "talk_to_sales": "Talk to sales" + }, + "category": "Category", + "categories": "Categories", + "saving": "Saving", + "save_changes": "Save changes", + "delete": "Delete", + "deleting": "Deleting", + "pending": "Pending", + "invite": "Invite" + }, + + "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", + "all": "All {entity}", + "drop_here_to_move": "Drop here to move the {entity}", + "delete": { + "label": "Delete {entity}", + "success": "{entity} deleted successfully", + "failed": "{entity} delete failed" + }, + "update": { + "failed": "{entity} update failed", + "success": "{entity} updated successfully" + }, + "link_copied_to_clipboard": "{entity} link copied to clipboard", + "fetch": { + "failed": "Error fetching {entity}" + }, + "add": { + "success": "{entity} added successfully", + "failed": "Error adding {entity}" + } + }, + + "epic": { + "all": "All Epics", + "label": "{count, plural, one {Epic} other {Epics}}", + "new": "New Epic", + "adding": "Adding epic", + "create": { + "success": "Epic created successfully" + }, + "add": { + "press_enter": "Press 'Enter' to add another epic", + "label": "Add Epic" + }, + "title": { + "label": "Epic Title", + "required": "Epic title is required." + } + }, + + "issue": { + "label": "{count, plural, one {Work item} other {Work items}}", + "all": "All Work items", + "edit": "Edit work item", + "title": { + "label": "Work item title", + "required": "Work item title is required." + }, + "add": { + "press_enter": "Press 'Enter' to add another work item", + "label": "Add work item", + "cycle": { + "failed": "Work item could not be added to the cycle. Please try again.", + "success": "{count, plural, one {Work item} other {Work items}} added to the cycle successfully.", + "loading": "Adding {count, plural, one {work item} other {work items}} to the cycle" + }, + "assignee": "Add assignees", + "start_date": "Add start date", + "due_date": "Add due date", + "parent": "Add parent work item", + "sub_issue": "Add sub-work item", + "relation": "Add relation", + "link": "Add link", + "existing": "Add existing work item" + }, + "remove": { + "label": "Remove work item", + "cycle": { + "loading": "Removing work item from the cycle", + "success": "Work item removed from the cycle successfully.", + "failed": "Work item could not be removed from the cycle. Please try again." + }, + "module": { + "loading": "Removing work item from the module", + "success": "Work item removed from the module successfully.", + "failed": "Work item could not be removed from the module. Please try again." + }, + "parent": { + "label": "Remove parent work item" + } + }, + "new": "New Work item", + "adding": "Adding work item", + "create": { + "success": "Work item created successfully" + }, + "priority": { + "urgent": "Urgent", + "high": "High", + "medium": "Medium", + "low": "Low" + }, + "display": { + "properties": { + "label": "Display Properties", + "id": "ID", + "issue_type": "Work item Type", + "sub_issue_count": "Sub-work item count", + "attachment_count": "Attachment count", + "created_on": "Created on", + "sub_issue": "Sub-work item" + }, + "extra": { + "show_sub_issues": "Show sub-work items", + "show_empty_groups": "Show empty groups" + } + }, + "layouts": { + "ordered_by_label": "This layout is ordered by", + "list": "List", + "kanban": "Board", + "calendar": "Calendar", + "spreadsheet": "Table", + "gantt": "Timeline", + "title": { + "list": "List Layout", + "kanban": "Board Layout", + "calendar": "Calendar Layout", + "spreadsheet": "Table Layout", + "gantt": "Timeline Layout" + } + }, + "states": { + "active": "Active", + "backlog": "Backlog" + }, + "comments": { + "placeholder": "Add comment", + "switch": { + "private": "Switch to private comment", + "public": "Switch to public comment" + }, + "create": { + "success": "Comment created successfully", + "error": "Comment creation failed. Please try again later." + }, + "update": { + "success": "Comment updated successfully", + "error": "Comment update failed. Please try again later." + }, + "remove": { + "success": "Comment removed successfully", + "error": "Comment remove failed. Please try again later." + }, + "upload": { + "error": "Asset upload failed. Please try again later." + } + }, + "empty_state": { + "issue_detail": { + "title": "Work item does not exist", + "description": "The work item you are looking for does not exist, has been archived, or has been deleted.", + "primary_button": { + "text": "View other work items" + } + } + }, + "sibling": { + "label": "Sibling work items" + }, + "archive": { + "description": "Only completed or canceled\nwork items can be archived", + "label": "Archive Work item", + "confirm_message": "Are you sure you want to archive the work item? All your archived work items can be restored later.", + "success": { + "label": "Archive success", + "message": "Your archives can be found in project archives." + }, + "failed": { + "message": "Work item could not be archived. Please try again." + } + }, + "restore": { + "success": { + "title": "Restore success", + "message": "Your work item can be found in project work items." + }, + "failed": { + "message": "Work item could not be restored. Please try again." + } + }, + "relation": { + "relates_to": "Relates to", + "duplicate": "Duplicate of", + "blocked_by": "Blocked by", + "blocking": "Blocking" + }, + "copy_link": "Copy work item link", + "delete": { + "label": "Delete work item", + "error": "Error deleting work item" + }, + "subscription": { + "actions": { + "subscribed": "Work item subscribed successfully", + "unsubscribed": "Work item unsubscribed successfully" + } + }, + "select": { + "error": "Please select at least one work item", + "empty": "No work items selected", + "add_selected": "Add selected work items" + }, + "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.", + "file_size_limit": "File must be of {size}MB or less in size.", + "drag_and_drop": "Drag and drop anywhere to upload", + "delete": "Delete attachment" + }, + + "label": { + "select": "Select label", + "create": { + "success": "Label created successfully", + "failed": "Label creation failed", + "already_exists": "Label already exists", + "type": "Type to add a new label" + } + }, + + "sub_work_item": { + "update": { + "success": "Sub-work item updated successfully", + "error": "Error updating sub-work item" + }, + "remove": { + "success": "Sub-work item removed successfully", + "error": "Error removing sub-work item" + } + }, + + "view": { + "label": "{count, plural, one {View} other {Views}}", + "create": { + "label": "Create View" + }, + "update": { + "label": "Update View" + } + }, + + "inbox_issue": { + "status": { + "pending": { + "title": "Pending", + "description": "Pending" + }, + "declined": { + "title": "Declined", + "description": "Declined" + }, + "snoozed": { + "title": "Snoozed", + "description": "{days, plural, one{# day} other{# days}} to go" + }, + "accepted": { + "title": "Accepted", + "description": "Accepted" + }, + "duplicate": { + "title": "Duplicate", + "description": "Duplicate" + } + }, + "modals": { + "decline": { + "title": "Decline work item", + "content": "Are you sure you want to decline work item {value}?" + }, + "delete": { + "title": "Delete work item", + "content": "Are you sure you want to delete work item {value}?", + "success": "Work item deleted successfully" + } + }, + "errors": { + "snooze_permission": "Only project admins can snooze/Un-snooze work items", + "accept_permission": "Only project admins can accept work items", + "decline_permission": "Only project admins can deny work items" + }, + "actions": { + "accept": "Accept", + "decline": "Decline", + "snooze": "Snooze", + "unsnooze": "Un snooze", + "copy": "Copy work item link", + "delete": "Delete", + "open": "Open work item", + "mark_as_duplicate": "Mark as duplicate", + "move": "Move {value} to project work items" + }, + "source": { + "in-app": "in-app" + }, + "order_by": { + "created_at": "Created at", + "updated_at": "Updated at", + "id": "ID" + }, + "label": "Intake", + "page_label": "{workspace} - Intake", + "modal": { + "title": "Create intake work item" + }, + "tabs": { + "open": "Open", + "closed": "Closed" + }, + "empty_state": { + "sidebar_open_tab": { + "title": "No open work items", + "description": "Find open work items here. Create new work item." + }, + "sidebar_closed_tab": { + "title": "No closed work items", + "description": "All the work items whether accepted or declined can be found here." + }, + "sidebar_filter": { + "title": "No matching work items", + "description": "No work item matches filter applied in intake. Create a new work item." + }, + "detail": { + "title": "Select a work item to view its details." + } + } + }, + + "workspace_creation": { + "heading": "Create your workspace", + "subheading": "To start using Plane, you need to create or join a workspace.", + "form": { + "name": { + "label": "Name your workspace", + "placeholder": "Something familiar and recognizable is always best." + }, + "url": { + "label": "Set your workspace's URL", + "placeholder": "Type or paste a URL", + "edit_slug": "You can only edit the slug of the URL" + }, + "organization_size": { + "label": "How many people will use this workspace?", + "placeholder": "Select a range" + } + }, + "errors": { + "creation_disabled": { + "title": "Only your instance admin can create workspaces", + "description": "If you know your instance admin's email address, click the button below to get in touch with them.", + "request_button": "Request instance admin" + }, + "validation": { + "name_alphanumeric": "Workspaces names can contain only (' '), ('-'), ('_') and alphanumeric characters.", + "name_length": "Limit your name to 80 characters.", + "url_alphanumeric": "URLs can contain only ('-') and alphanumeric characters.", + "url_length": "Limit your URL to 48 characters.", + "url_already_taken": "Workspace URL is already taken!" + } + }, + "request_email": { + "subject": "Requesting a new workspace", + "body": "Hi instance admin(s),\n\nPlease create a new workspace with the URL [/workspace-name] for [purpose of creating the workspace].\n\nThanks,\n{firstName} {lastName}\n{email}" + }, + "button": { + "default": "Create workspace", + "loading": "Creating workspace" + }, + "toast": { + "success": { + "title": "Success", + "message": "Workspace created successfully" + }, + "error": { + "title": "Error", + "message": "Workspace could not be created. Please try again." + } + } + }, + + "workspace_dashboard": { + "empty_state": { + "general": { + "title": "Overview of your projects, activity, and metrics", + "description": "Welcome to Plane, we are excited to have you here. Create your first project and track your work items, and this page will transform into a space that helps you progress. Admins will also see items which help their team progress.", + "primary_button": { + "text": "Build your first project", + "comic": { + "title": "Everything starts with a project in Plane", + "description": "A project could be a product's roadmap, a marketing campaign, or launching a new car." + } + } + } + } + }, + + "workspace_analytics": { + "label": "Analytics", + "page_label": "{workspace} - Analytics", + "open_tasks": "Total open tasks", + "error": "There was some error in fetching the data.", + "work_items_closed_in": "Work items closed in", + "selected_projects": "Selected projects", + "total_members": "Total members", + "total_cycles": "Total cycles", + "total_modules": "Total modules", + "pending_work_items": { + "title": "Pending work items", + "empty_state": "Analysis of pending work items by co-workers appears here." + }, + "work_items_closed_in_a_year": { + "title": "Work items closed in a year", + "empty_state": "Close work items to view analysis of the same in the form of a graph." + }, + "most_work_items_created": { + "title": "Most work items created", + "empty_state": "Co-workers and the number of work items created by them appears here." + }, + "most_work_items_closed": { + "title": "Most work items closed", + "empty_state": "Co-workers and the number of work items closed by them appears here." + }, + "tabs": { + "scope_and_demand": "Scope and Demand", + "custom": "Custom Analytics" + }, + "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." + } + } + } + } + }, + + "workspace_projects": { + "label": "{count, plural, one {Project} other {Projects}}", + "create": { + "label": "Add Project" + }, + "network": { + "label": "Network", + "private": { + "title": "Private", + "description": "Accessible only by invite" + }, + "public": { + "title": "Public", + "description": "Anyone in the workspace except Guests can join" + } + }, + "error": { + "permission": "You don't have permission to perform this action.", + "cycle_delete": "Failed to delete cycle", + "module_delete": "Failed to delete module", + "issue_delete": "Failed to delete work item" + }, + "state": { + "backlog": "Backlog", + "unstarted": "Unstarted", + "started": "Started", + "completed": "Completed", + "cancelled": "Cancelled" + }, + "sort": { + "manual": "Manual", + "name": "Name", + "created_at": "Created date", + "members_length": "Number of members" + }, + "scope": { + "my_projects": "My projects", + "archived_projects": "Archived" + }, + "common": { + "months_count": "{months, plural, one{# month} other{# months}}" + }, + "empty_state": { + "general": { + "title": "No active projects", + "description": "Think of each project as the parent for goal-oriented work. Projects are where Jobs, Cycles, and Modules live and, along with your colleagues, help you achieve that goal. Create a new project or filter for archived projects.", + "primary_button": { + "text": "Start your first project", + "comic": { + "title": "Everything starts with a project in Plane", + "description": "A project could be a product's roadmap, a marketing campaign, or launching a new car." + } + } + }, + "no_projects": { + "title": "No project", + "description": "To create work items or manage your work, you need to create a project or be a part of one.", + "primary_button": { + "text": "Start your first project", + "comic": { + "title": "Everything starts with a project in Plane", + "description": "A project could be a product's roadmap, a marketing campaign, or launching a new car." + } + } + }, + "filter": { + "title": "No matching projects", + "description": "No projects detected with the matching criteria. \n Create a new project instead." + }, + "search": { + "description": "No projects detected with the matching criteria.\nCreate a new project instead" + } + } + }, + + "workspace_views": { + "add_view": "Add view", + "empty_state": { + "all-issues": { + "title": "No work items in the project", + "description": "First project done! Now, slice your work into trackable pieces with work items. Let's go!", + "primary_button": { + "text": "Create new work item" + } + }, + "assigned": { + "title": "No work items yet", + "description": "Work items assigned to you can be tracked from here.", + "primary_button": { + "text": "Create new work item" + } + }, + "created": { + "title": "No work items yet", + "description": "All work items created by you come here, track them here directly.", + "primary_button": { + "text": "Create new work item" + } + }, + "subscribed": { + "title": "No work items yet", + "description": "Subscribe to work items you are interested in, track all of them here." + }, + "custom-view": { + "title": "No work items yet", + "description": "Work items that applies to the filters, track all of them here." + } + } + }, + + "workspace_settings": { + "label": "Workspace settings", + "page_label": "{workspace} - General settings", + "key_created": "Key created", + "copy_key": "Copy and save this secret key in Plane Pages. You can't see this key after you hit Close. A CSV file containing the key has been downloaded.", + "token_copied": "Token copied to clipboard.", + "settings": { + "general": { + "title": "General", + "upload_logo": "Upload logo", + "edit_logo": "Edit logo", + "name": "Workspace name", + "company_size": "Company size", + "url": "Workspace URL", + "update_workspace": "Update workspace", + "delete_workspace": "Delete this workspace", + "delete_workspace_description": "When deleting a workspace, all of the data and resources within that workspace will be permanently removed and cannot be recovered.", + "delete_btn": "Delete this workspace", + "delete_modal": { + "title": "Are you sure you want to delete this workspace?", + "description": "You have an active trial to one of our paid plans. Please cancel it first to proceed.", + "dismiss": "Dismiss", + "cancel": "Cancel trial", + "success_title": "Workspace deleted.", + "success_message": "You will soon go to your profile page.", + "error_title": "That didn't work.", + "error_message": "Try again, please." + }, + "errors": { + "name": { + "required": "Name is required", + "max_length": "Workspace name should not exceed 80 characters" + }, + "company_size": { + "required": "Company size is required" + } + } + }, + "members": { + "title": "Members", + "add_member": "Add member", + "pending_invites": "Pending invites", + "invitations_sent_successfully": "Invitations sent successfully", + "leave_confirmation": "Are you sure you want to leave the workspace? You will no longer have access to this workspace. This action cannot be undone.", + "details": { + "full_name": "Full name", + "display_name": "Display name", + "email_address": "Email address", + "account_type": "Account type", + "authentication": "Authentication", + "joining_date": "Joining date" + }, + "modal": { + "title": "Invite people to collaborate", + "description": "Invite people to collaborate on your workspace.", + "button": "Send invitations", + "button_loading": "Sending invitations", + "placeholder": "name@company.com", + "errors": { + "required": "We need an email address to invite them.", + "invalid": "Email is invalid" + } + } + }, + "billing_and_plans": { + "title": "Billing & Plans", + "current_plan": "Current plan", + "free_plan": "You are currently using the free plan", + "view_plans": "View plans" + }, + "exports": { + "title": "Exports", + "exporting": "Exporting", + "previous_exports": "Previous exports", + "export_separate_files": "Export the data into separate files", + "modal": { + "title": "Export to", + "toasts": { + "success": { + "title": "Export successful", + "message": "You will be able to download the exported {entity} from the previous export." + }, + "error": { + "title": "Export failed", + "message": "Export was unsuccessful. Please try again." + } + } + } + }, + "webhooks": { + "title": "Webhooks", + "add_webhook": "Add webhook", + "modal": { + "title": "Create webhook", + "details": "Webhook details", + "payload": "Payload URL", + "question": "Which events would you like to trigger this webhook?", + "error": "URL is required" + }, + "secret_key": { + "title": "Secret key", + "message": "Generate a token to sign-in to the webhook payload" + }, + "options": { + "all": "Send me everything", + "individual": "Select individual events" + }, + "toasts": { + "created": { + "title": "Webhook created", + "message": "The webhook has been successfully created" + }, + "not_created": { + "title": "Webhook not created", + "message": "The webhook could not be created" + }, + "updated": { + "title": "Webhook updated", + "message": "The webhook has been successfully updated" + }, + "not_updated": { + "title": "Webhook not updated", + "message": "The webhook could not be updated" + }, + "removed": { + "title": "Webhook removed", + "message": "The webhook has been successfully removed" + }, + "not_removed": { + "title": "Webhook not removed", + "message": "The webhook could not be removed" + }, + "secret_key_copied": { + "message": "Secret key copied to clipboard." + }, + "secret_key_not_copied": { + "message": "Error occurred while copying secret key." + } + } + }, + "api_tokens": { + "title": "API Tokens", + "add_token": "Add API token", + "create_token": "Create token", + "never_expires": "Never expires", + "generate_token": "Generate token", + "generating": "Generating", + "delete": { + "title": "Delete API 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" + }, + "error": { + "title": "Error!", + "message": "The API token could not be deleted" + } + } + } + }, + "empty_state": { + "api_tokens": { + "title": "No API 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": { + "title": "No webhooks added", + "description": "Create webhooks to receive real-time updates and automate actions." + }, + "exports": { + "title": "No exports yet", + "description": "Anytime you export, you will also have a copy here for reference." + }, + "imports": { + "title": "No imports yet", + "description": "Find all your previous imports here and download them." + } + } + }, + + "profile": { + "label": "Profile", + "page_label": "Your work", + "work": "Work", + "details": { + "joined_on": "Joined on", + "time_zone": "Timezone" + }, + "stats": { + "workload": "Workload", + "overview": "Overview", + "created": "Work items created", + "assigned": "Work items assigned", + "subscribed": "Work items subscribed", + "state_distribution": { + "title": "Work items by state", + "empty": "Create work items to view the them by states in the graph for better analysis." + }, + "priority_distribution": { + "title": "Work items by Priority", + "empty": "Create work items to view the them by priority in the graph for better analysis." + }, + "recent_activity": { + "title": "Recent activity", + "empty": "We couldn't find data. Kindly view your inputs", + "button": "Download today's activity", + "button_loading": "Downloading" + } + }, + "actions": { + "profile": "Profile", + "security": "Security", + "activity": "Activity", + "appearance": "Appearance", + "notifications": "Notifications" + }, + "tabs": { + "summary": "Summary", + "assigned": "Assigned", + "created": "Created", + "subscribed": "Subscribed", + "activity": "Activity" + }, + "empty_state": { + "activity": { + "title": "No activities yet", + "description": "Get started by creating a new work item! Add details and properties to it. Explore more in Plane to see your activity." + }, + "assigned": { + "title": "No work items are assigned to you", + "description": "Work items assigned to you can be tracked from here." + }, + "created": { + "title": "No work items yet", + "description": "All work items created by you come here, track them here directly." + }, + "subscribed": { + "title": "No work items yet", + "description": "Subscribe to work items you are interested in, track all of them here." + } + } + }, + + "project_settings": { + "general": { + "enter_project_id": "Enter project ID", + "please_select_a_timezone": "Please select a timezone", + "archive_project": { + "title": "Archive project", + "description": "Archiving a project will unlist your project from your side navigation although you will still be able to access it from your projects page. You can restore the project or delete it whenever you want.", + "button": "Archive project" + }, + "delete_project": { + "title": "Delete project", + "description": "When deleting a project, all of the data and resources within that project will be permanently removed and cannot be recovered.", + "button": "Delete my project" + }, + "toast": { + "success": "Project updated successfully", + "error": "Project could not be updated. Please try again." + } + }, + "members": { + "label": "Members", + "project_lead": "Project lead", + "default_assignee": "Default assignee", + "guest_super_permissions": { + "title": "Grant view access to all work items for guest users:", + "sub_heading": "This will allow guests to have view access to all the project work items." + }, + "invite_members": { + "title": "Invite members", + "sub_heading": "Invite members to work on your project.", + "select_co_worker": "Select co-worker" + } + }, + "states": { + "describe_this_state_for_your_members": "Describe this state for your members.", + "empty_state": { + "title": "No states available for the {groupKey} group", + "description": "Please create a new state" + } + }, + "labels": { + "label_title": "Label title", + "label_title_is_required": "Label title is required", + "label_max_char": "Label name should not exceed 255 characters", + "toast": { + "error": "Error while updating the label" + } + }, + "estimates": { + "title": "Enable estimates for my project", + "description": "They help you in communicating complexity and workload of the team." + }, + "automations": { + "label": "Automations", + "auto-archive": { + "title": "Auto-archive closed work items", + "description": "Plane will auto archive work items that have been completed or canceled.", + "duration": "Auto-archive work items that are closed for" + }, + "auto-close": { + "title": "Auto-close work items", + "description": "Plane will automatically close work items that haven't been completed or canceled.", + "duration": "Auto-close work items that are inactive for", + "auto_close_status": "Auto-close status" + } + }, + + "empty_state": { + "labels": { + "title": "No labels yet", + "description": "Create labels to help organize and filter work items in you project." + }, + "estimates": { + "title": "No estimate systems yet", + "description": "Create a set of estimates to communicate the amount of work per work item.", + "primary_button": "Add estimate system" + } + } + }, + + "project_cycles": { + "add_cycle": "Add cycle", + "more_details": "More details", + "cycle": "Cycle", + "update_cycle": "Update cycle", + "create_cycle": "Create cycle", + "no_matching_cycles": "No matching cycles", + "remove_filters_to_see_all_cycles": "Remove the filters to see all cycles", + "remove_search_criteria_to_see_all_cycles": "Remove the search criteria to see all cycles", + "only_completed_cycles_can_be_archived": "Only completed cycles can be archived", + "active_cycle": { + "label": "Active cycle", + "progress": "Progress", + "chart": "Burndown chart", + "priority_issue": "Priority work items", + "assignees": "Assignees", + "issue_burndown": "Work item burndown", + "ideal": "Ideal", + "current": "Current", + "labels": "Labels" + }, + "upcoming_cycle": { + "label": "Upcoming cycle" + }, + "completed_cycle": { + "label": "Completed cycle" + }, + "status": { + "days_left": "Days left", + "completed": "Completed", + "yet_to_start": "Yet to start", + "in_progress": "In progress", + "draft": "Draft" + }, + "action": { + "restore": { + "title": "Restore cycle", + "success": { + "title": "Cycle restored", + "description": "The cycle has been restored." + }, + "failed": { + "title": "Cycle restore failed", + "description": "The cycle could not be restored. Please try again." + } + }, + "favorite": { + "loading": "Adding cycle to favorites", + "success": { + "description": "Cycle added to favorites.", + "title": "Success!" + }, + "failed": { + "description": "Couldn't add the cycle to favorites. Please try again.", + "title": "Error!" + } + }, + "unfavorite": { + "loading": "Removing cycle from favorites", + "success": { + "description": "Cycle removed from favorites.", + "title": "Success!" + }, + "failed": { + "description": "Couldn't remove the cycle from favorites. Please try again.", + "title": "Error!" + } + }, + "update": { + "loading": "Updating cycle", + "success": { + "description": "Cycle updated successfully.", + "title": "Success!" + }, + "failed": { + "description": "Error updating the cycle. Please try again.", + "title": "Error!" + }, + "error": { + "already_exists": "You already have a cycle on the given dates, if you want to create a draft cycle, you can do that by removing both the dates." + } + } + }, + "empty_state": { + "general": { + "title": "Group and timebox your work in Cycles.", + "description": "Break work down by timeboxed chunks, work backwards from your project deadline to set dates, and make tangible progress as a team.", + "primary_button": { + "text": "Set your first cycle", + "comic": { + "title": "Cycles are repetitive time-boxes.", + "description": "A sprint, an iteration, and or any other term you use for weekly or fortnightly tracking of work is a cycle." + } + } + }, + "no_issues": { + "title": "No work items added to the cycle", + "description": "Add or create work items you wish to timebox and deliver within this cycle", + "primary_button": { + "text": "Create new work item" + }, + "secondary_button": { + "text": "Add existing work item" + } + }, + "completed_no_issues": { + "title": "No work items in the cycle", + "description": "No work items in the cycle. Work items are either transferred or hidden. To see hidden work items if any, update your display properties accordingly." + }, + "active": { + "title": "No active cycle", + "description": "An active cycle includes any period that encompasses today's date within its range. Find the progress and details of the active cycle here." + }, + "archived": { + "title": "No archived cycles yet", + "description": "To tidy up your project, archive completed cycles. Find them here once archived." + } + } + }, + + "project_issues": { + "empty_state": { + "no_issues": { + "title": "Create a work item and assign it to someone, even yourself", + "description": "Think of work items as jobs, tasks, work, or JTBD. Which we like. A work item and its sub-work items are usually time-based actionables assigned to members of your team. Your team creates, assigns, and completes work items to move your project towards its goal.", + "primary_button": { + "text": "Create your first work item", + "comic": { + "title": "Work items are building blocks in Plane.", + "description": "Redesign the Plane UI, Rebrand the company, or Launch the new fuel injection system are examples of work items that likely have sub-work items." + } + } + }, + "no_archived_issues": { + "title": "No archived work items yet", + "description": "Manually or through automation, you can archive work items that are completed or cancelled. Find them here once archived.", + "primary_button": { + "text": "Set automation" + } + }, + "issues_empty_filter": { + "title": "No work items found matching the filters applied", + "secondary_button": { + "text": "Clear all filters" + } + } + } + }, + + "project_module": { + "add_module": "Add Module", + "update_module": "Update Module", + "create_module": "Create Module", + "archive_module": "Archive Module", + "restore_module": "Restore Module", + "delete_module": "Delete module", + "empty_state": { + "general": { + "title": "Map your project milestones to Modules and track aggregated work easily.", + "description": "A group of work items that belong to a logical, hierarchical parent form a module. Think of them as a way to track work by project milestones. They have their own periods and deadlines as well as analytics to help you see how close or far you are from a milestone.", + "primary_button": { + "text": "Build your first module", + "comic": { + "title": "Modules help group work by hierarchy.", + "description": "A cart module, a chassis module, and a warehouse module are all good example of this grouping." + } + } + }, + "no_issues": { + "title": "No work items in the module", + "description": "Create or add work items which you want to accomplish as part of this module", + "primary_button": { + "text": "Create new work items" + }, + "secondary_button": { + "text": "Add an existing work item" + } + }, + "archived": { + "title": "No archived Modules yet", + "description": "To tidy up your project, archive completed or cancelled modules. Find them here once archived." + }, + "sidebar": { + "in_active": "This module isn't active yet.", + "invalid_date": "Invalid date. Please enter valid date." + } + }, + "quick_actions": { + "archive_module": "Archive module", + "archive_module_description": "Only completed or canceled\nmodule can be archived.", + "delete_module": "Delete module" + }, + "toast": { + "copy": { + "success": "Module link copied to clipboard" + }, + "delete": { + "success": "Module deleted successfully", + "error": "Failed to delete module" + } + } + }, + + "project_views": { + "empty_state": { + "general": { + "title": "Save filtered views for your project. Create as many as you need", + "description": "Views are a set of saved filters that you use frequently or want easy access to. All your colleagues in a project can see everyone’s views and choose whichever suits their needs best.", + "primary_button": { + "text": "Create your first view", + "comic": { + "title": "Views work atop Work item properties.", + "description": "You can create a view from here with as many properties as filters as you see fit." + } + } + }, + "filter": { + "title": "No matching views", + "description": "No views match the search criteria. \n Create a new view instead." + } + } + }, + + "project_page": { + "empty_state": { + "general": { + "title": "Write a note, a doc, or a full knowledge base. Get Galileo, Plane's AI assistant, to help you get started", + "description": "Pages are thoughts potting space in Plane. Take down meeting notes, format them easily, embed work items, lay them out using a library of components, and keep them all in your project's context. To make short work of any doc, invoke Galileo, Plane's AI, with a shortcut or the click of a button.", + "primary_button": { + "text": "Create your first page" + } + }, + "private": { + "title": "No private pages yet", + "description": "Keep your private thoughts here. When you're ready to share, the team's just a click away.", + "primary_button": { + "text": "Create your first page" + } + }, + "public": { + "title": "No public pages yet", + "description": "See pages shared with everyone in your project right here.", + "primary_button": { + "text": "Create your first page" + } + }, + "archived": { + "title": "No archived pages yet", + "description": "Archive pages not on your radar. Access them here when needed." + } + } + }, + + "command_k": { + "empty_state": { + "search": { + "title": "No results found" + } + } + }, + + "issue_relation": { + "empty_state": { + "search": { + "title": "No matching work items found" + }, + "no_issues": { + "title": "No work items found" + } + } + }, + + "issue_comment": { + "empty_state": { + "general": { + "title": "No comments yet", + "description": "Comments can be used as a discussion and follow-up space for the work items" + } + } + }, + + "notification": { + "label": "Inbox", + "page_label": "{workspace} - Inbox", + "options": { + "mark_all_as_read": "Mark all as read", + "mark_read": "Mark as read", + "mark_unread": "Mark as unread", + "refresh": "Refresh", + "filters": "Inbox Filters", + "show_unread": "Show unread", + "show_snoozed": "Show snoozed", + "show_archived": "Show archived", + "mark_archive": "Archive", + "mark_unarchive": "Un archive", + "mark_snooze": "Snooze", + "mark_unsnooze": "Un snooze" + }, + "toasts": { + "read": "Notification marked as read", + "unread": "Notification marked as unread", + "archived": "Notification marked as archived", + "unarchived": "Notification marked as un archived", + "snoozed": "Notification snoozed", + "unsnoozed": "Notification un snoozed" + }, + "empty_state": { + "detail": { + "title": "Select to view details." + }, + "all": { + "title": "No work items assigned", + "description": "Updates for work items assigned to you can be \n seen here" + }, + "mentions": { + "title": "No work items assigned", + "description": "Updates for work items assigned to you can be \n seen here" + } + }, + "tabs": { + "all": "All", + "mentions": "Mentions" + }, + "filter": { + "assigned": "Assigned to me", + "created": "Created by me", + "subscribed": "Subscribed by me" + }, + "snooze": { + "1_day": "1 day", + "3_days": "3 days", + "5_days": "5 days", + "1_week": "1 week", + "2_weeks": "2 weeks", + "custom": "Custom" + } + }, + + "active_cycle": { + "empty_state": { + "progress": { + "title": "Add work items to the cycle to view it's progress" + }, + "chart": { + "title": "Add work items to the cycle to view the burndown chart." + }, + "priority_issue": { + "title": "Observe high priority work items tackled in the cycle at a glance." + }, + "assignee": { + "title": "Add assignees to work items to see a breakdown of work by assignees." + }, + "label": { + "title": "Add labels to work items to see the breakdown of work by labels." + } + } + }, + + "disabled_project": { + "empty_state": { + "inbox": { + "title": "Intake is not enabled for the project.", + "description": "Intake helps you manage incoming requests to your project and add them as work items in your workflow. Enable intake from project settings to manage requests.", + "primary_button": { + "text": "Manage features" + } + }, + "cycle": { + "title": "Cycles is not enabled for this project.", + "description": "Break work down by timeboxed chunks, work backwards from your project deadline to set dates, and make tangible progress as a team. Enable the cycles feature for your project to start using them.", + "primary_button": { + "text": "Manage features" + } + }, + "module": { + "title": "Modules are not enabled for the project.", + "description": "Modules are the building blocks of your project. Enable modules from project settings to start using them.", + "primary_button": { + "text": "Manage features" + } + }, + "page": { + "title": "Pages are not enabled for the project.", + "description": "Pages are the building blocks of your project. Enable pages from project settings to start using them.", + "primary_button": { + "text": "Manage features" + } + }, + "view": { + "title": "Views are not enabled for the project.", + "description": "Views are the building blocks of your project. Enable views from project settings to start using them.", + "primary_button": { + "text": "Manage features" + } + } + } + }, + "workspace_draft_issues": { + "draft_an_issue": "Draft a work item", + "empty_state": { + "title": "Half-written work items, and soon, comments will show up here.", + "description": "To try this out, start adding a work item and leave it mid-way or create your first draft below. 😉", + "primary_button": { + "text": "Create your first draft" + } + }, + "delete_modal": { + "title": "Delete draft", + "description": "Are you sure you want to delete this draft? This can't be undone." + }, + "toasts": { + "created": { + "success": "Draft created", + "error": "Work item could not be created. Please try again." + }, + "deleted": { + "success": "Draft deleted" + } + } + }, + + "stickies": { + "title": "Your stickies", + "placeholder": "click to type here", + "all": "All stickies", + "no-data": "Jot down an idea, capture an aha, or record a brainwave. Add a sticky to get started.", + "add": "Add sticky", + "search_placeholder": "Search by title", + "delete": "Delete sticky", + "delete_confirmation": "Are you sure you want to delete this sticky?", + "empty_state": { + "simple": "Jot down an idea, capture an aha, or record a brainwave. Add a sticky to get started.", + "general": { + "title": "Stickies are quick notes and to-dos you take down on the fly.", + "description": "Capture your thoughts and ideas effortlessly by creating stickies that you can access anytime and from anywhere.", + "primary_button": { + "text": "Add sticky" + } + }, + "search": { + "title": "That doesn't match any of your stickies.", + "description": "Try a different term or let us know\nif you are sure your search is right. ", + "primary_button": { + "text": "Add sticky" + } + } + }, + "toasts": { + "errors": { + "wrong_name": "The sticky name cannot be longer than 100 characters.", + "already_exists": "There already exists a sticky with no description" + }, + "created": { + "title": "Sticky created", + "message": "The sticky has been successfully created" + }, + "not_created": { + "title": "Sticky not created", + "message": "The sticky could not be created" + }, + "updated": { + "title": "Sticky updated", + "message": "The sticky has been successfully updated" + }, + "not_updated": { + "title": "Sticky not updated", + "message": "The sticky could not be updated" + }, + "removed": { + "title": "Sticky removed", + "message": "The sticky has been successfully removed" + }, + "not_removed": { + "title": "Sticky not removed", + "message": "The sticky could not be removed" + } + } + }, + + "role_details": { + "guest": { + "title": "Guest", + "description": "External members of organizations can be invited as guests." + }, + "member": { + "title": "Member", + "description": "Ability to read, write, edit, and delete entities inside projects, cycles, and modules" + }, + "admin": { + "title": "Admin", + "description": "All permissions set to true within the workspace." + } + }, + + "user_roles": { + "product_or_project_manager": "Product / Project Manager", + "development_or_engineering": "Development / Engineering", + "founder_or_executive": "Founder / Executive", + "freelancer_or_consultant": "Freelancer / Consultant", + "marketing_or_growth": "Marketing / Growth", + "sales_or_business_development": "Sales / Business Development", + "support_or_operations": "Support / Operations", + "student_or_professor": "Student / Professor", + "human_resources": "Human / Resources", + "other": "Other" + }, + + "importer": { + "github": { + "title": "Github", + "description": "Import work items from GitHub repositories and sync them." + }, + "jira": { + "title": "Jira", + "description": "Import work items and epics from Jira projects and epics." + } + }, + + "exporter": { + "csv": { + "title": "CSV", + "description": "Export work items to a CSV file.", + "short_description": "Export as csv" + }, + "excel": { + "title": "Excel", + "description": "Export work items to a Excel file.", + "short_description": "Export as excel" + }, + "xlsx": { + "title": "Excel", + "description": "Export work items to a Excel file.", + "short_description": "Export as excel" + }, + "json": { + "title": "JSON", + "description": "Export work items to a JSON file.", + "short_description": "Export as json" + } + }, + "default_global_view": { + "all_issues": "All work items", + "assigned": "Assigned", + "created": "Created", + "subscribed": "Subscribed" + }, + + "themes": { + "theme_options": { + "system_preference": { + "label": "System preference" + }, + "light": { + "label": "Light" + }, + "dark": { + "label": "Dark" + }, + "light_contrast": { + "label": "Light high contrast" + }, + "dark_contrast": { + "label": "Dark high contrast" + }, + "custom": { + "label": "Custom theme" + } + } + }, + "project_modules": { + "status": { + "backlog": "Backlog", + "planned": "Planned", + "in_progress": "In Progress", + "paused": "Paused", + "completed": "Completed", + "cancelled": "Cancelled" + }, + "layout": { + "list": "List layout", + "board": "Gallery layout", + "timeline": "Timeline layout" + }, + "order_by": { + "name": "Name", + "progress": "Progress", + "issues": "Number of work items", + "due_date": "Due date", + "created_at": "Created date", + "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" + } +} diff --git a/packages/i18n/src/locales/es/translations.json b/packages/i18n/src/locales/es/translations.json new file mode 100644 index 000000000..bc57dba1d --- /dev/null +++ b/packages/i18n/src/locales/es/translations.json @@ -0,0 +1,2364 @@ +{ + "sidebar": { + "projects": "Proyectos", + "pages": "Páginas", + "new_work_item": "Nuevo elemento de trabajo", + "home": "Inicio", + "your_work": "Tu trabajo", + "inbox": "Bandeja de entrada", + "workspace": "Espacio de trabajo", + "views": "Vistas", + "analytics": "Análisis", + "work_items": "Elementos de trabajo", + "cycles": "Ciclos", + "modules": "Módulos", + "intake": "Entrada", + "drafts": "Borradores", + "favorites": "Favoritos", + "pro": "Pro", + "upgrade": "Mejorar" + }, + + "auth": { + "common": { + "email": { + "label": "Correo electrónico", + "placeholder": "nombre@empresa.com", + "errors": { + "required": "El correo electrónico es obligatorio", + "invalid": "El correo electrónico no es válido" + } + }, + "password": { + "label": "Contraseña", + "set_password": "Establecer una contraseña", + "placeholder": "Ingresa la contraseña", + "confirm_password": { + "label": "Confirmar contraseña", + "placeholder": "Confirmar contraseña" + }, + "current_password": { + "label": "Contraseña actual" + }, + "new_password": { + "label": "Nueva contraseña", + "placeholder": "Ingresa nueva contraseña" + }, + "change_password": { + "label": { + "default": "Cambiar contraseña", + "submitting": "Cambiando contraseña" + } + }, + "errors": { + "match": "Las contraseñas no coinciden", + "empty": "Por favor ingresa tu contraseña", + "length": "La contraseña debe tener más de 8 caracteres", + "strength": { + "weak": "La contraseña es débil", + "strong": "La contraseña es fuerte" + } + }, + "submit": "Establecer contraseña", + "toast": { + "change_password": { + "success": { + "title": "¡Éxito!", + "message": "Contraseña cambiada exitosamente." + }, + "error": { + "title": "¡Error!", + "message": "Algo salió mal. Por favor intenta de nuevo." + } + } + } + }, + "unique_code": { + "label": "Código único", + "placeholder": "obtiene-establece-vuela", + "paste_code": "Pega el código enviado a tu correo electrónico", + "requesting_new_code": "Solicitando nuevo código", + "sending_code": "Enviando código" + }, + "already_have_an_account": "¿Ya tienes una cuenta?", + "login": "Iniciar sesión", + "create_account": "Crear una cuenta", + "new_to_plane": "¿Nuevo en Plane?", + "back_to_sign_in": "Volver a iniciar sesión", + "resend_in": "Reenviar en {seconds} segundos", + "sign_in_with_unique_code": "Iniciar sesión con código único", + "forgot_password": "¿Olvidaste tu contraseña?" + }, + "sign_up": { + "header": { + "label": "Crea una cuenta para comenzar a gestionar el trabajo con tu equipo.", + "step": { + "email": { + "header": "Registrarse", + "sub_header": "" + }, + "password": { + "header": "Registrarse", + "sub_header": "Regístrate usando una combinación de correo electrónico y contraseña." + }, + "unique_code": { + "header": "Registrarse", + "sub_header": "Regístrate usando un código único enviado a la dirección de correo electrónico anterior." + } + } + }, + "errors": { + "password": { + "strength": "Intenta establecer una contraseña fuerte para continuar" + } + } + }, + "sign_in": { + "header": { + "label": "Inicia sesión para comenzar a gestionar el trabajo con tu equipo.", + "step": { + "email": { + "header": "Iniciar sesión o registrarse", + "sub_header": "" + }, + "password": { + "header": "Iniciar sesión o registrarse", + "sub_header": "Usa tu combinación de correo electrónico y contraseña para iniciar sesión." + }, + "unique_code": { + "header": "Iniciar sesión o registrarse", + "sub_header": "Inicia sesión usando un código único enviado a la dirección de correo electrónico anterior." + } + } + } + }, + "forgot_password": { + "title": "Restablecer tu contraseña", + "description": "Ingresa la dirección de correo electrónico verificada de tu cuenta de usuario y te enviaremos un enlace para restablecer la contraseña.", + "email_sent": "Enviamos el enlace de restablecimiento a tu dirección de correo electrónico", + "send_reset_link": "Enviar enlace de restablecimiento", + "errors": { + "smtp_not_enabled": "Vemos que tu administrador no ha habilitado SMTP, no podremos enviar un enlace para restablecer la contraseña" + }, + "toast": { + "success": { + "title": "Correo enviado", + "message": "Revisa tu bandeja de entrada para encontrar un enlace para restablecer tu contraseña. Si no aparece en unos minutos, revisa tu carpeta de spam." + }, + "error": { + "title": "¡Error!", + "message": "Algo salió mal. Por favor intenta de nuevo." + } + } + }, + "reset_password": { + "title": "Establecer nueva contraseña", + "description": "Asegura tu cuenta con una contraseña fuerte" + }, + "set_password": { + "title": "Asegura tu cuenta", + "description": "Establecer una contraseña te ayuda a iniciar sesión de forma segura" + }, + "sign_out": { + "toast": { + "error": { + "title": "¡Error!", + "message": "Error al cerrar sesión. Por favor intenta de nuevo." + } + } + } + }, + + "submit": "Enviar", + "cancel": "Cancelar", + "loading": "Cargando", + "error": "Error", + "success": "Éxito", + "warning": "Advertencia", + "info": "Información", + "close": "Cerrar", + "yes": "Sí", + "no": "No", + "ok": "Aceptar", + "name": "Nombre", + "description": "Descripción", + "search": "Buscar", + "add_member": "Agregar miembro", + "adding_members": "Agregando miembros", + "remove_member": "Eliminar miembro", + "add_members": "Agregar miembros", + "adding_member": "Agregando miembros", + "remove_members": "Eliminar miembros", + "add": "Agregar", + "adding": "Agregando", + "remove": "Eliminar", + "add_new": "Agregar nuevo", + "remove_selected": "Eliminar seleccionados", + "first_name": "Nombre", + "last_name": "Apellido", + "email": "Correo electrónico", + "display_name": "Nombre para mostrar", + "role": "Rol", + "timezone": "Zona horaria", + "avatar": "Avatar", + "cover_image": "Imagen de portada", + "password": "Contraseña", + "change_cover": "Cambiar portada", + "language": "Idioma", + "saving": "Guardando", + "save_changes": "Guardar cambios", + "deactivate_account": "Desactivar cuenta", + "deactivate_account_description": "Al desactivar una cuenta, todos los datos y recursos dentro de esa cuenta se eliminarán permanentemente y no se podrán recuperar.", + "profile_settings": "Configuración del perfil", + "your_account": "Tu cuenta", + "security": "Seguridad", + "activity": "Actividad", + "appearance": "Apariencia", + "notifications": "Notificaciones", + "connections": "Conexiones", + "workspaces": "Espacios de trabajo", + "create_workspace": "Crear espacio de trabajo", + "invitations": "Invitaciones", + "summary": "Resumen", + "assigned": "Asignado", + "created": "Creado", + "subscribed": "Suscrito", + "you_do_not_have_the_permission_to_access_this_page": "No tienes permiso para acceder a esta página.", + "something_went_wrong_please_try_again": "Algo salió mal. Por favor, inténtalo de nuevo.", + "load_more": "Cargar más", + "select_or_customize_your_interface_color_scheme": "Selecciona o personaliza el esquema de colores de tu interfaz.", + "theme": "Tema", + "system_preference": "Preferencia del sistema", + "light": "Claro", + "dark": "Oscuro", + "light_contrast": "Alto contraste claro", + "dark_contrast": "Alto contraste oscuro", + "custom": "Tema personalizado", + "select_your_theme": "Selecciona tu tema", + "customize_your_theme": "Personaliza tu tema", + "background_color": "Color de fondo", + "text_color": "Color del texto", + "primary_color": "Color primario (Tema)", + "sidebar_background_color": "Color de fondo de la barra lateral", + "sidebar_text_color": "Color del texto de la barra lateral", + "set_theme": "Establecer tema", + "enter_a_valid_hex_code_of_6_characters": "Ingresa un código hexadecimal válido de 6 caracteres", + "background_color_is_required": "El color de fondo es requerido", + "text_color_is_required": "El color del texto es requerido", + "primary_color_is_required": "El color primario es requerido", + "sidebar_background_color_is_required": "El color de fondo de la barra lateral es requerido", + "sidebar_text_color_is_required": "El color del texto de la barra lateral es requerido", + "updating_theme": "Actualizando tema", + "theme_updated_successfully": "Tema actualizado exitosamente", + "failed_to_update_the_theme": "Error al actualizar el tema", + "email_notifications": "Notificaciones por correo electrónico", + "stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "Mantente al tanto de los elementos de trabajo a los que estás suscrito. Activa esto para recibir notificaciones.", + "email_notification_setting_updated_successfully": "Configuración de notificaciones por correo electrónico actualizada exitosamente", + "failed_to_update_email_notification_setting": "Error al actualizar la configuración de notificaciones por correo electrónico", + "notify_me_when": "Notificarme cuando", + "property_changes": "Cambios de propiedades", + "property_changes_description": "Notificarme cuando cambien las propiedades de los elementos de trabajo como asignados, prioridad, estimaciones o cualquier otra cosa.", + "state_change": "Cambio de estado", + "state_change_description": "Notificarme cuando los elementos de trabajo se muevan a un estado diferente", + "issue_completed": "Elemento de trabajo completado", + "issue_completed_description": "Notificarme solo cuando se complete un elemento de trabajo", + "comments": "Comentarios", + "comments_description": "Notificarme cuando alguien deje un comentario en el elemento de trabajo", + "mentions": "Menciones", + "mentions_description": "Notificarme solo cuando alguien me mencione en los comentarios o descripción", + "old_password": "Contraseña anterior", + "general_settings": "Configuración general", + "sign_out": "Cerrar sesión", + "signing_out": "Cerrando sesión", + "active_cycles": "Ciclos activos", + "active_cycles_description": "Monitorea ciclos en todos los proyectos, rastrea elementos de trabajo de alta prioridad y enfócate en los ciclos que necesitan atención.", + "on_demand_snapshots_of_all_your_cycles": "Instantáneas bajo demanda de todos tus ciclos", + "upgrade": "Actualizar", + "10000_feet_view": "Vista panorámica de todos los ciclos activos.", + "10000_feet_view_description": "Aléjate para ver los ciclos en ejecución en todos tus proyectos a la vez en lugar de ir de Ciclo en Ciclo en cada proyecto.", + "get_snapshot_of_each_active_cycle": "Obtén una instantánea de cada ciclo activo.", + "get_snapshot_of_each_active_cycle_description": "Rastrea métricas de alto nivel para todos los ciclos activos, ve su estado de progreso y obtén una idea del alcance contra los plazos.", + "compare_burndowns": "Compara los burndowns.", + "compare_burndowns_description": "Monitorea cómo se está desempeñando cada uno de tus equipos con un vistazo al informe de burndown de cada ciclo.", + "quickly_see_make_or_break_issues": "Ve rápidamente los elementos de trabajo críticos.", + "quickly_see_make_or_break_issues_description": "Previsualiza elementos de trabajo de alta prioridad para cada ciclo contra fechas de vencimiento. Vélos todos por ciclo con un clic.", + "zoom_into_cycles_that_need_attention": "Enfócate en los ciclos que necesitan atención.", + "zoom_into_cycles_that_need_attention_description": "Investiga el estado de cualquier ciclo que no se ajuste a las expectativas con un clic.", + "stay_ahead_of_blockers": "Mantente adelante de los bloqueadores.", + "stay_ahead_of_blockers_description": "Detecta desafíos de un proyecto a otro y ve dependencias entre ciclos que no son obvias desde ninguna otra vista.", + "analytics": "Análisis", + "workspace_invites": "Invitaciones al espacio de trabajo", + "enter_god_mode": "Entrar en modo dios", + "workspace_logo": "Logo del espacio de trabajo", + "new_issue": "Nuevo elemento de trabajo", + "your_work": "Tu trabajo", + "workspace_dashboards": "Paneles de control", + "drafts": "Borradores", + "projects": "Proyectos", + "views": "Vistas", + "workspace": "Espacio de trabajo", + "archives": "Archivos", + "settings": "Configuración", + "failed_to_move_favorite": "Error al mover favorito", + "favorites": "Favoritos", + "no_favorites_yet": "Aún no hay favoritos", + "create_folder": "Crear carpeta", + "new_folder": "Nueva carpeta", + "favorite_updated_successfully": "Favorito actualizado exitosamente", + "favorite_created_successfully": "Favorito creado exitosamente", + "folder_already_exists": "La carpeta ya existe", + "folder_name_cannot_be_empty": "El nombre de la carpeta no puede estar vacío", + "something_went_wrong": "Algo salió mal", + "failed_to_reorder_favorite": "Error al reordenar favorito", + "favorite_removed_successfully": "Favorito eliminado exitosamente", + "failed_to_create_favorite": "Error al crear favorito", + "failed_to_rename_favorite": "Error al renombrar favorito", + "project_link_copied_to_clipboard": "Enlace del proyecto copiado al portapapeles", + "link_copied": "Enlace copiado", + "add_project": "Agregar proyecto", + "create_project": "Crear proyecto", + "failed_to_remove_project_from_favorites": "No se pudo eliminar el proyecto de favoritos. Por favor, inténtalo de nuevo.", + "project_created_successfully": "Proyecto creado exitosamente", + "project_created_successfully_description": "Proyecto creado exitosamente. Ahora puedes comenzar a agregar elementos de trabajo.", + "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", + "project_name": "Nombre del proyecto", + "project_id_must_be_at_least_1_character": "El ID del proyecto debe tener al menos 1 carácter", + "project_id_must_be_at_most_5_characters": "El ID del proyecto debe tener como máximo 5 caracteres", + "project_id": "ID del proyecto", + "project_id_tooltip_content": "Te ayuda a identificar elementos de trabajo en el proyecto de manera única. Máximo 5 caracteres.", + "description_placeholder": "Descripción", + "only_alphanumeric_non_latin_characters_allowed": "Solo se permiten caracteres alfanuméricos y no latinos.", + "project_id_is_required": "El ID del proyecto es requerido", + "project_id_allowed_char": "Solo se permiten caracteres alfanuméricos y no latinos.", + "project_id_min_char": "El ID del proyecto debe tener al menos 1 carácter", + "project_id_max_char": "El ID del proyecto debe tener como máximo 5 caracteres", + "project_description_placeholder": "Ingresa la descripción del proyecto", + "select_network": "Seleccionar red", + "lead": "Líder", + "date_range": "Rango de fechas", + "private": "Privado", + "public": "Público", + "accessible_only_by_invite": "Accesible solo por invitación", + "anyone_in_the_workspace_except_guests_can_join": "Cualquiera en el espacio de trabajo excepto invitados puede unirse", + "creating": "Creando", + "creating_project": "Creando proyecto", + "adding_project_to_favorites": "Agregando proyecto a favoritos", + "project_added_to_favorites": "Proyecto agregado a favoritos", + "couldnt_add_the_project_to_favorites": "No se pudo agregar el proyecto a favoritos. Por favor, inténtalo de nuevo.", + "removing_project_from_favorites": "Eliminando proyecto de favoritos", + "project_removed_from_favorites": "Proyecto eliminado de favoritos", + "couldnt_remove_the_project_from_favorites": "No se pudo eliminar el proyecto de favoritos. Por favor, inténtalo de nuevo.", + "add_to_favorites": "Agregar a favoritos", + "remove_from_favorites": "Eliminar de favoritos", + "publish_settings": "Configuración de publicación", + "publish": "Publicar", + "copy_link": "Copiar enlace", + "leave_project": "Abandonar proyecto", + "join_the_project_to_rearrange": "Únete al proyecto para reorganizar", + "drag_to_rearrange": "Arrastra para reorganizar", + "congrats": "¡Felicitaciones!", + "open_project": "Abrir proyecto", + "issues": "Elementos de trabajo", + "cycles": "Ciclos", + "modules": "Módulos", + "pages": "Páginas", + "intake": "Entrada", + "time_tracking": "Seguimiento de tiempo", + "work_management": "Gestión del trabajo", + "projects_and_issues": "Proyectos y 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.", + "work_management_description": "Gestiona tu trabajo y proyectos con facilidad.", + "documentation": "Documentación", + "message_support": "Mensaje al soporte", + "contact_sales": "Contactar ventas", + "hyper_mode": "Modo Hyper", + "keyboard_shortcuts": "Atajos de teclado", + "whats_new": "¿Qué hay de nuevo?", + "version": "Versión", + "we_are_having_trouble_fetching_the_updates": "Estamos teniendo problemas para obtener las actualizaciones.", + "our_changelogs": "nuestros registros de cambios", + "for_the_latest_updates": "para las últimas actualizaciones.", + "please_visit": "Por favor visita", + "docs": "Documentación", + "full_changelog": "Registro de cambios completo", + "support": "Soporte", + "discord": "Discord", + "powered_by_plane_pages": "Desarrollado por Plane Pages", + "please_select_at_least_one_invitation": "Por favor selecciona al menos una invitación.", + "please_select_at_least_one_invitation_description": "Por favor selecciona al menos una invitación para unirte al espacio de trabajo.", + "we_see_that_someone_has_invited_you_to_join_a_workspace": "Vemos que alguien te ha invitado a unirte a un espacio de trabajo", + "join_a_workspace": "Únete a un espacio de trabajo", + "we_see_that_someone_has_invited_you_to_join_a_workspace_description": "Vemos que alguien te ha invitado a unirte a un espacio de trabajo", + "join_a_workspace_description": "Únete a un espacio de trabajo", + "accept_and_join": "Aceptar y unirse", + "go_home": "Ir a inicio", + "no_pending_invites": "No hay invitaciones pendientes", + "you_can_see_here_if_someone_invites_you_to_a_workspace": "Puedes ver aquí si alguien te invita a un espacio de trabajo", + "back_to_home": "Volver a inicio", + "workspace_name": "nombre-del-espacio-de-trabajo", + "deactivate_your_account": "Desactivar tu cuenta", + "deactivate_your_account_description": "Una vez desactivada, no se te podrán asignar elementos de trabajo ni se te facturará por tu espacio de trabajo. Para reactivar tu cuenta, necesitarás una invitación a un espacio de trabajo con esta dirección de correo electrónico.", + "deactivating": "Desactivando", + "confirm": "Confirmar", + "confirming": "Confirmando", + "draft_created": "Borrador creado", + "issue_created_successfully": "Elemento de trabajo creado exitosamente", + "draft_creation_failed": "Error al crear borrador", + "issue_creation_failed": "Error al crear elemento de trabajo", + "draft_issue": "Borrador de elemento de trabajo", + "issue_updated_successfully": "Elemento de trabajo actualizado exitosamente", + "issue_could_not_be_updated": "El elemento de trabajo no pudo ser actualizado", + "create_a_draft": "Crear un borrador", + "save_to_drafts": "Guardar en borradores", + "save": "Guardar", + "update": "Actualizar", + "updating": "Actualizando", + "create_new_issue": "Crear nuevo elemento de trabajo", + "editor_is_not_ready_to_discard_changes": "El editor no está listo para descartar cambios", + "failed_to_move_issue_to_project": "Error al mover elemento de trabajo al proyecto", + "create_more": "Crear más", + "add_to_project": "Agregar al proyecto", + "discard": "Descartar", + "duplicate_issue_found": "Se encontró un elemento de trabajo duplicado", + "duplicate_issues_found": "Se encontraron elementos de trabajo duplicados", + "no_matching_results": "No hay resultados coincidentes", + "title_is_required": "El título es requerido", + "title": "Título", + "state": "Estado", + "priority": "Prioridad", + "none": "Ninguno", + "urgent": "Urgente", + "high": "Alta", + "medium": "Media", + "low": "Baja", + "members": "Miembros", + "assignee": "Asignado", + "assignees": "Asignados", + "you": "Tú", + "labels": "Etiquetas", + "create_new_label": "Crear nueva etiqueta", + "start_date": "Fecha de inicio", + "end_date": "Fecha de fin", + "due_date": "Fecha de vencimiento", + "estimate": "Estimación", + "change_parent_issue": "Cambiar elemento de trabajo padre", + "remove_parent_issue": "Eliminar elemento de trabajo padre", + "add_parent": "Agregar padre", + "loading_members": "Cargando miembros", + "view_link_copied_to_clipboard": "Enlace de vista copiado al portapapeles.", + "required": "Requerido", + "optional": "Opcional", + "Cancel": "Cancelar", + "edit": "Editar", + "archive": "Archivar", + "restore": "Restaurar", + "open_in_new_tab": "Abrir en nueva pestaña", + "delete": "Eliminar", + "deleting": "Eliminando", + "make_a_copy": "Hacer una copia", + "move_to_project": "Mover al proyecto", + "good": "Buenos", + "morning": "días", + "afternoon": "tardes", + "evening": "noches", + "show_all": "Mostrar todo", + "show_less": "Mostrar menos", + "no_data_yet": "Aún no hay datos", + "syncing": "Sincronizando", + "add_work_item": "Agregar elemento de trabajo", + "advanced_description_placeholder": "Presiona '/' para comandos", + "create_work_item": "Crear elemento de trabajo", + "attachments": "Archivos adjuntos", + "declining": "Rechazando", + "declined": "Rechazado", + "decline": "Rechazar", + "unassigned": "Sin asignar", + "work_items": "Elementos de trabajo", + "add_link": "Agregar enlace", + "points": "Puntos", + "no_assignee": "Sin asignado", + "no_assignees_yet": "Aún no hay asignados", + "no_labels_yet": "Aún no hay etiquetas", + "ideal": "Ideal", + "current": "Actual", + "no_matching_members": "No hay miembros coincidentes", + "leaving": "Abandonando", + "removing": "Eliminando", + "leave": "Abandonar", + "refresh": "Actualizar", + "refreshing": "Actualizando", + "refresh_status": "Actualizar estado", + "prev": "Anterior", + "next": "Siguiente", + "re_generating": "Regenerando", + "re_generate": "Regenerar", + "re_generate_key": "Regenerar clave", + "export": "Exportar", + "member": "{count, plural, one{# miembro} other{# miembros}}", + + "project_view": { + "sort_by": { + "created_at": "Creado el", + "updated_at": "Actualizado el", + "name": "Nombre" + } + }, + + "toast": { + "success": "¡Éxito!", + "error": "¡Error!" + }, + + "links": { + "toasts": { + "created": { + "title": "Enlace creado", + "message": "El enlace se ha creado correctamente" + }, + "not_created": { + "title": "Enlace no creado", + "message": "No se pudo crear el enlace" + }, + "updated": { + "title": "Enlace actualizado", + "message": "El enlace se ha actualizado correctamente" + }, + "not_updated": { + "title": "Enlace no actualizado", + "message": "No se pudo actualizar el enlace" + }, + "removed": { + "title": "Enlace eliminado", + "message": "El enlace se ha eliminado correctamente" + }, + "not_removed": { + "title": "Enlace no eliminado", + "message": "No se pudo eliminar el enlace" + } + } + }, + + "home": { + "empty": { + "quickstart_guide": "Guía de inicio rápido", + "not_right_now": "Ahora no", + "create_project": { + "title": "Crear un proyecto", + "description": "La mayoría de las cosas comienzan con un proyecto en Plane.", + "cta": "Comenzar" + }, + "invite_team": { + "title": "Invita a tu equipo", + "description": "Construye, implementa y gestiona con compañeros de trabajo.", + "cta": "Hazlos entrar" + }, + "configure_workspace": { + "title": "Configura tu espacio de trabajo.", + "description": "Activa o desactiva funciones o ve más allá.", + "cta": "Configurar este espacio de trabajo" + }, + "personalize_account": { + "title": "Haz Plane tuyo.", + "description": "Elige tu foto, colores y más.", + "cta": "Personalizar ahora" + }, + "widgets": { + "title": "Está Silencioso Sin Widgets, Actívalos", + "description": "Parece que todos tus widgets están desactivados. ¡Actívalos\nahora para mejorar tu experiencia!", + "primary_button": { + "text": "Gestionar widgets" + } + } + }, + "quick_links": { + "empty": "Guarda enlaces a cosas de trabajo que te gustaría tener a mano.", + "add": "Agregar enlace rápido", + "title": "Enlace rápido", + "title_plural": "Enlaces rápidos" + }, + "recents": { + "title": "Recientes", + "empty": { + "project": "Tus proyectos recientes aparecerán aquí una vez que visites uno.", + "page": "Tus páginas recientes aparecerán aquí una vez que visites una.", + "issue": "Tus elementos de trabajo recientes aparecerán aquí una vez que visites uno.", + "default": "Aún no tienes elementos recientes." + }, + "filters": { + "all": "Todos los elementos", + "projects": "Proyectos", + "pages": "Páginas", + "issues": "Elementos de trabajo" + } + }, + "new_at_plane": { + "title": "Nuevo en Plane" + }, + "quick_tutorial": { + "title": "Tutorial rápido" + }, + "widget": { + "reordered_successfully": "Widget reordenado correctamente.", + "reordering_failed": "Ocurrió un error al reordenar el widget." + }, + "manage_widgets": "Gestionar widgets", + "title": "Inicio", + "star_us_on_github": "Danos una estrella en GitHub" + }, + + "link": { + "modal": { + "url": { + "text": "URL", + "required": "La URL no es válida", + "placeholder": "Escribe o pega una URL" + }, + "title": { + "text": "Título a mostrar", + "placeholder": "Cómo te gustaría ver este enlace" + } + } + }, + + "common": { + "all": "Todo", + "states": "Estados", + "state": "Estado", + "state_groups": "Grupos de estados", + "priority": "Prioridad", + "team_project": "Proyecto de equipo", + "project": "Proyecto", + "cycle": "Ciclo", + "cycles": "Ciclos", + "module": "Módulo", + "modules": "Módulos", + "labels": "Etiquetas", + "assignees": "Asignados", + "assignee": "Asignado", + "created_by": "Creado por", + "none": "Ninguno", + "link": "Enlace", + "estimate": "Estimación", + "layout": "Diseño", + "filters": "Filtros", + "display": "Mostrar", + "load_more": "Cargar más", + "activity": "Actividad", + "analytics": "Análisis", + "dates": "Fechas", + "success": "¡Éxito!", + "something_went_wrong": "Algo salió mal", + "error": { + "label": "¡Error!", + "message": "Ocurrió un error. Por favor, inténtalo de nuevo." + }, + "group_by": "Agrupar por", + "epic": "Epic", + "epics": "Epics", + "work_item": "Elemento de trabajo", + "work_items": "Elementos de trabajo", + "sub_work_item": "Sub-elemento de trabajo", + "add": "Agregar", + "warning": "Advertencia", + "updating": "Actualizando", + "adding": "Agregando", + "update": "Actualizar", + "creating": "Creando", + "create": "Crear", + "cancel": "Cancelar", + "description": "Descripción", + "title": "Título", + "attachment": "Archivo adjunto", + "general": "General", + "features": "Características", + "automation": "Automatización", + "project_name": "Nombre del proyecto", + "project_id": "ID del proyecto", + "project_timezone": "Zona horaria del proyecto", + "created_on": "Creado el", + "update_project": "Actualizar proyecto", + "identifier_already_exists": "El identificador ya existe", + "add_more": "Agregar más", + "defaults": "Valores predeterminados", + "add_label": "Agregar etiqueta", + "estimates": "Estimaciones", + "customize_time_range": "Personalizar rango de tiempo", + "loading": "Cargando", + "attachments": "Archivos adjuntos", + "property": "Propiedad", + "properties": "Propiedades", + "parent": "Padre", + "remove": "Eliminar", + "archiving": "Archivando", + "archive": "Archivar", + "access": { + "public": "Público", + "private": "Privado" + }, + "done": "Hecho", + "sub_work_items": "Sub-elementos de trabajo", + "comment": "Comentario", + "workspace_level": "Nivel de espacio de trabajo", + "order_by": { + "label": "Ordenar por", + "manual": "Manual", + "last_created": "Último creado", + "last_updated": "Última actualización", + "start_date": "Fecha de inicio", + "due_date": "Fecha de vencimiento", + "asc": "Ascendente", + "desc": "Descendente", + "updated_on": "Actualizado el" + }, + "sort": { + "asc": "Ascendente", + "desc": "Descendente", + "created_on": "Creado el", + "updated_on": "Actualizado el" + }, + "comments": "Comentarios", + "updates": "Actualizaciones", + "clear_all": "Limpiar todo", + "copied": "¡Copiado!", + "link_copied": "¡Enlace copiado!", + "link_copied_to_clipboard": "Enlace copiado al portapapeles", + "copied_to_clipboard": "Enlace del elemento de trabajo copiado al portapapeles", + "is_copied_to_clipboard": "El elemento de trabajo está copiado al portapapeles", + "no_links_added_yet": "Aún no se han agregado enlaces", + "add_link": "Agregar enlace", + "links": "Enlaces", + "go_to_workspace": "Ir al espacio de trabajo", + "progress": "Progreso", + "optional": "Opcional", + "join": "Unirse", + "go_back": "Volver", + "continue": "Continuar", + "resend": "Reenviar", + "relations": "Relaciones", + "errors": { + "default": { + "title": "¡Error!", + "message": "Algo salió mal. Por favor, inténtalo de nuevo." + }, + "required": "Este campo es obligatorio", + "entity_required": "{entity} es obligatorio" + }, + "update_link": "Actualizar enlace", + "attach": "Adjuntar", + "create_new": "Crear nuevo", + "add_existing": "Agregar existente", + "type_or_paste_a_url": "Escribe o pega una URL", + "url_is_invalid": "La URL no es válida", + "display_title": "Título a mostrar", + "link_title_placeholder": "Cómo te gustaría ver este enlace", + "url": "URL", + "side_peek": "Vista lateral", + "modal": "Modal", + "full_screen": "Pantalla completa", + "close_peek_view": "Cerrar la vista previa", + "toggle_peek_view_layout": "Alternar diseño de vista previa", + "options": "Opciones", + "duration": "Duración", + "today": "Hoy", + "week": "Semana", + "month": "Mes", + "quarter": "Trimestre", + "press_for_commands": "Presiona '/' para comandos", + "click_to_add_description": "Haz clic para agregar descripción", + "search": { + "label": "Buscar", + "placeholder": "Escribe para buscar", + "no_matches_found": "No se encontraron coincidencias", + "no_matching_results": "No hay resultados coincidentes" + }, + "actions": { + "edit": "Editar", + "make_a_copy": "Hacer una copia", + "open_in_new_tab": "Abrir en nueva pestaña", + "copy_link": "Copiar enlace", + "archive": "Archivar", + "delete": "Eliminar", + "remove_relation": "Eliminar relación", + "subscribe": "Suscribirse", + "unsubscribe": "Cancelar suscripción", + "clear_sorting": "Limpiar ordenamiento", + "show_weekends": "Mostrar fines de semana", + "enable": "Habilitar", + "disable": "Deshabilitar" + }, + "name": "Nombre", + "discard": "Descartar", + "confirm": "Confirmar", + "confirming": "Confirmando", + "read_the_docs": "Leer la documentación", + "default": "Predeterminado", + "active": "Activo", + "enabled": "Habilitado", + "disabled": "Deshabilitado", + "mandate": "Mandato", + "mandatory": "Obligatorio", + "yes": "Sí", + "no": "No", + "please_wait": "Por favor espera", + "enabling": "Habilitando", + "disabling": "Deshabilitando", + "beta": "Beta", + "or": "o", + "next": "Siguiente", + "back": "Atrás", + "cancelling": "Cancelando", + "configuring": "Configurando", + "clear": "Limpiar", + "import": "Importar", + "connect": "Conectar", + "authorizing": "Autorizando", + "processing": "Procesando", + "no_data_available": "No hay datos disponibles", + "from": "de {name}", + "authenticated": "Autenticado", + "select": "Seleccionar", + "upgrade": "Mejorar", + "add_seats": "Agregar asientos", + "label": "Etiqueta", + "priorities": "Prioridades", + "projects": "Proyectos", + "workspace": "Espacio de trabajo", + "workspaces": "Espacios de trabajo", + "team": "Equipo", + "teams": "Equipos", + "entity": "Entidad", + "entities": "Entidades", + "task": "Tarea", + "tasks": "Tareas", + "section": "Sección", + "sections": "Secciones", + "edit": "Editar", + "connecting": "Conectando", + "connected": "Conectado", + "disconnect": "Desconectar", + "disconnecting": "Desconectando", + "installing": "Instalando", + "install": "Instalar", + "reset": "Reiniciar", + "live": "En vivo", + "change_history": "Historial de cambios", + "coming_soon": "Próximamente", + "members": "Miembros", + "you": "Tú", + "upgrade_cta": { + "higher_subscription": "Mejorar a una suscripción más alta", + "talk_to_sales": "Hablar con ventas" + }, + "category": "Categoría", + "categories": "Categorías", + "saving": "Guardando", + "save_changes": "Guardar cambios", + "delete": "Eliminar", + "deleting": "Eliminando", + "pending": "Pendiente", + "invite": "Invitar" + }, + + "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}", + "all": "Todos los {entity}", + "drop_here_to_move": "Suelta aquí para mover el {entity}", + "delete": { + "label": "Eliminar {entity}", + "success": "{entity} eliminado correctamente", + "failed": "Error al eliminar {entity}" + }, + "update": { + "failed": "Error al actualizar {entity}", + "success": "{entity} actualizado correctamente" + }, + "link_copied_to_clipboard": "Enlace de {entity} copiado al portapapeles", + "fetch": { + "failed": "Error al obtener {entity}" + }, + "add": { + "success": "{entity} agregado correctamente", + "failed": "Error al agregar {entity}" + } + }, + + "epic": { + "all": "Todos los Epics", + "label": "{count, plural, one {Epic} other {Epics}}", + "new": "Nuevo Epic", + "adding": "Agregando epic", + "create": { + "success": "Epic creado correctamente" + }, + "add": { + "press_enter": "Presiona 'Enter' para agregar otro epic", + "label": "Agregar Epic" + }, + "title": { + "label": "Título del Epic", + "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", + "edit": "Editar elemento de trabajo", + "title": { + "label": "Título del elemento de trabajo", + "required": "El título del elemento de trabajo es obligatorio." + }, + "add": { + "press_enter": "Presiona 'Enter' para agregar otro elemento de trabajo", + "label": "Agregar elemento de trabajo", + "cycle": { + "failed": "No se pudo agregar el elemento de trabajo al ciclo. Por favor, inténtalo de nuevo.", + "success": "{count, plural, one {Elemento de trabajo agregado} other {Elementos de trabajo agregados}} al ciclo correctamente.", + "loading": "Agregando {count, plural, one {elemento de trabajo} other {elementos de trabajo}} al ciclo" + }, + "assignee": "Agregar asignados", + "start_date": "Agregar fecha de inicio", + "due_date": "Agregar fecha de vencimiento", + "parent": "Agregar elemento de trabajo padre", + "sub_issue": "Agregar sub-elemento de trabajo", + "relation": "Agregar relación", + "link": "Agregar enlace", + "existing": "Agregar elemento de trabajo existente" + }, + "remove": { + "label": "Eliminar elemento de trabajo", + "cycle": { + "loading": "Eliminando elemento de trabajo del ciclo", + "success": "Elemento de trabajo eliminado del ciclo correctamente.", + "failed": "No se pudo eliminar el elemento de trabajo del ciclo. Por favor, inténtalo de nuevo." + }, + "module": { + "loading": "Eliminando elemento de trabajo del módulo", + "success": "Elemento de trabajo eliminado del módulo correctamente.", + "failed": "No se pudo eliminar el elemento de trabajo del módulo. Por favor, inténtalo de nuevo." + }, + "parent": { + "label": "Eliminar elemento de trabajo padre" + } + }, + "new": "Nuevo elemento de trabajo", + "adding": "Agregando elemento de trabajo", + "create": { + "success": "Elemento de trabajo creado correctamente" + }, + "priority": { + "urgent": "Urgente", + "high": "Alta", + "medium": "Media", + "low": "Baja" + }, + "display": { + "properties": { + "label": "Mostrar propiedades", + "id": "ID", + "issue_type": "Tipo de elemento de trabajo", + "sub_issue_count": "Cantidad de sub-elementos", + "attachment_count": "Cantidad de archivos adjuntos", + "created_on": "Creado el", + "sub_issue": "Sub-elemento de trabajo" + }, + "extra": { + "show_sub_issues": "Mostrar sub-elementos", + "show_empty_groups": "Mostrar grupos vacíos" + } + }, + "layouts": { + "ordered_by_label": "Este diseño está ordenado por", + "list": "Lista", + "kanban": "Tablero", + "calendar": "Calendario", + "spreadsheet": "Tabla", + "gantt": "Línea de tiempo", + "title": { + "list": "Diseño de lista", + "kanban": "Diseño de tablero", + "calendar": "Diseño de calendario", + "spreadsheet": "Diseño de tabla", + "gantt": "Diseño de línea de tiempo" + } + }, + "states": { + "active": "Activo", + "backlog": "Pendientes" + }, + "comments": { + "placeholder": "Agregar comentario", + "switch": { + "private": "Cambiar a comentario privado", + "public": "Cambiar a comentario público" + }, + "create": { + "success": "Comentario creado correctamente", + "error": "Error al crear el comentario. Por favor, inténtalo más tarde." + }, + "update": { + "success": "Comentario actualizado correctamente", + "error": "Error al actualizar el comentario. Por favor, inténtalo más tarde." + }, + "remove": { + "success": "Comentario eliminado correctamente", + "error": "Error al eliminar el comentario. Por favor, inténtalo más tarde." + }, + "upload": { + "error": "Error al subir el archivo. Por favor, inténtalo más tarde." + } + }, + "empty_state": { + "issue_detail": { + "title": "El elemento de trabajo no existe", + "description": "El elemento de trabajo que buscas no existe, ha sido archivado o ha sido eliminado.", + "primary_button": { + "text": "Ver otros elementos de trabajo" + } + } + }, + "sibling": { + "label": "Elementos de trabajo hermanos" + }, + "archive": { + "description": "Solo los elementos de trabajo completados\no cancelados pueden ser archivados", + "label": "Archivar elemento de trabajo", + "confirm_message": "¿Estás seguro de que quieres archivar el elemento de trabajo? Todos tus elementos archivados pueden ser restaurados más tarde.", + "success": { + "label": "Archivo exitoso", + "message": "Tus archivos se pueden encontrar en los archivos del proyecto." + }, + "failed": { + "message": "No se pudo archivar el elemento de trabajo. Por favor, inténtalo de nuevo." + } + }, + "restore": { + "success": { + "title": "Restauración exitosa", + "message": "Tu elemento de trabajo se puede encontrar en los elementos de trabajo del proyecto." + }, + "failed": { + "message": "No se pudo restaurar el elemento de trabajo. Por favor, inténtalo de nuevo." + } + }, + "relation": { + "relates_to": "Se relaciona con", + "duplicate": "Duplicado de", + "blocked_by": "Bloqueado por", + "blocking": "Bloqueando" + }, + "copy_link": "Copiar enlace del elemento de trabajo", + "delete": { + "label": "Eliminar elemento de trabajo", + "error": "Error al eliminar el elemento de trabajo" + }, + "subscription": { + "actions": { + "subscribed": "Suscrito al elemento de trabajo correctamente", + "unsubscribed": "Desuscrito del elemento de trabajo correctamente" + } + }, + "select": { + "error": "Por favor selecciona al menos un elemento de trabajo", + "empty": "No hay elementos de trabajo seleccionados", + "add_selected": "Agregar elementos seleccionados" + }, + "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.", + "file_size_limit": "El archivo debe tener {size}MB o menos de tamaño.", + "drag_and_drop": "Arrastra y suelta en cualquier lugar para subir", + "delete": "Eliminar archivo adjunto" + }, + + "label": { + "select": "Seleccionar etiqueta", + "create": { + "success": "Etiqueta creada correctamente", + "failed": "Error al crear la etiqueta", + "already_exists": "La etiqueta ya existe", + "type": "Escribe para agregar una nueva etiqueta" + } + }, + + "sub_work_item": { + "update": { + "success": "Sub-elemento actualizado correctamente", + "error": "Error al actualizar el sub-elemento" + }, + "remove": { + "success": "Sub-elemento eliminado correctamente", + "error": "Error al eliminar el sub-elemento" + } + }, + + "view": { + "label": "{count, plural, one {Vista} other {Vistas}}", + "create": { + "label": "Crear vista" + }, + "update": { + "label": "Actualizar vista" + } + }, + + "inbox_issue": { + "status": { + "pending": { + "title": "Pendiente", + "description": "Pendiente" + }, + "declined": { + "title": "Rechazado", + "description": "Rechazado" + }, + "snoozed": { + "title": "Pospuesto", + "description": "Faltan {days, plural, one{# día} other{# días}}" + }, + "accepted": { + "title": "Aceptado", + "description": "Aceptado" + }, + "duplicate": { + "title": "Duplicado", + "description": "Duplicado" + } + }, + "modals": { + "decline": { + "title": "Rechazar elemento de trabajo", + "content": "¿Estás seguro de que quieres rechazar el elemento de trabajo {value}?" + }, + "delete": { + "title": "Eliminar elemento de trabajo", + "content": "¿Estás seguro de que quieres eliminar el elemento de trabajo {value}?", + "success": "Elemento de trabajo eliminado correctamente" + } + }, + "errors": { + "snooze_permission": "Solo los administradores del proyecto pueden posponer/desposponer elementos de trabajo", + "accept_permission": "Solo los administradores del proyecto pueden aceptar elementos de trabajo", + "decline_permission": "Solo los administradores del proyecto pueden rechazar elementos de trabajo" + }, + "actions": { + "accept": "Aceptar", + "decline": "Rechazar", + "snooze": "Posponer", + "unsnooze": "Desposponer", + "copy": "Copiar enlace del elemento de trabajo", + "delete": "Eliminar", + "open": "Abrir elemento de trabajo", + "mark_as_duplicate": "Marcar como duplicado", + "move": "Mover {value} a elementos de trabajo del proyecto" + }, + "source": { + "in-app": "en-app" + }, + "order_by": { + "created_at": "Creado el", + "updated_at": "Actualizado el", + "id": "ID" + }, + "label": "Intake", + "page_label": "{workspace} - Intake", + "modal": { + "title": "Crear elemento de trabajo de intake" + }, + "tabs": { + "open": "Abiertos", + "closed": "Cerrados" + }, + "empty_state": { + "sidebar_open_tab": { + "title": "No hay elementos de trabajo abiertos", + "description": "Encuentra elementos de trabajo abiertos aquí. Crea un nuevo elemento de trabajo." + }, + "sidebar_closed_tab": { + "title": "No hay elementos de trabajo cerrados", + "description": "Todos los elementos de trabajo, ya sean aceptados o rechazados, se pueden encontrar aquí." + }, + "sidebar_filter": { + "title": "No hay elementos de trabajo coincidentes", + "description": "Ningún elemento de trabajo coincide con el filtro aplicado en intake. Crea un nuevo elemento de trabajo." + }, + "detail": { + "title": "Selecciona un elemento de trabajo para ver sus detalles." + } + } + }, + + "workspace_creation": { + "heading": "Crea tu espacio de trabajo", + "subheading": "Para comenzar a usar Plane, necesitas crear o unirte a un espacio de trabajo.", + "form": { + "name": { + "label": "Nombra tu espacio de trabajo", + "placeholder": "Algo familiar y reconocible es siempre lo mejor." + }, + "url": { + "label": "Establece la URL de tu espacio de trabajo", + "placeholder": "Escribe o pega una URL", + "edit_slug": "Solo puedes editar el slug de la URL" + }, + "organization_size": { + "label": "¿Cuántas personas usarán este espacio de trabajo?", + "placeholder": "Selecciona un rango" + } + }, + "errors": { + "creation_disabled": { + "title": "Solo el administrador de tu instancia puede crear espacios de trabajo", + "description": "Si conoces la dirección de correo electrónico del administrador de tu instancia, haz clic en el botón de abajo para ponerte en contacto con él.", + "request_button": "Solicitar administrador de instancia" + }, + "validation": { + "name_alphanumeric": "Los nombres de espacios de trabajo solo pueden contener (' '), ('-'), ('_') y caracteres alfanuméricos.", + "name_length": "Limita tu nombre a 80 caracteres.", + "url_alphanumeric": "Las URLs solo pueden contener ('-') y caracteres alfanuméricos.", + "url_length": "Limita tu URL a 48 caracteres.", + "url_already_taken": "¡La URL del espacio de trabajo ya está en uso!" + } + }, + "request_email": { + "subject": "Solicitando un nuevo espacio de trabajo", + "body": "Hola administrador(es) de instancia,\n\nPor favor, crea un nuevo espacio de trabajo con la URL [/nombre-espacio-trabajo] para [propósito de crear el espacio de trabajo].\n\nGracias,\n{firstName} {lastName}\n{email}" + }, + "button": { + "default": "Crear espacio de trabajo", + "loading": "Creando espacio de trabajo" + }, + "toast": { + "success": { + "title": "Éxito", + "message": "Espacio de trabajo creado correctamente" + }, + "error": { + "title": "Error", + "message": "No se pudo crear el espacio de trabajo. Por favor, inténtalo de nuevo." + } + } + }, + + "workspace_dashboard": { + "empty_state": { + "general": { + "title": "Resumen de tus proyectos, actividad y métricas", + "description": "Bienvenido a Plane, estamos emocionados de tenerte aquí. Crea tu primer proyecto y rastrea tus elementos de trabajo, y esta página se transformará en un espacio que te ayuda a progresar. Los administradores también verán elementos que ayudan a su equipo a progresar.", + "primary_button": { + "text": "Construye tu primer proyecto", + "comic": { + "title": "Todo comienza con un proyecto en Plane", + "description": "Un proyecto podría ser la hoja de ruta de un producto, una campaña de marketing o el lanzamiento de un nuevo automóvil." + } + } + } + } + }, + + "workspace_analytics": { + "label": "Análisis", + "page_label": "{workspace} - Análisis", + "open_tasks": "Total de tareas abiertas", + "error": "Hubo un error al obtener los datos.", + "work_items_closed_in": "Elementos de trabajo cerrados en", + "selected_projects": "Proyectos seleccionados", + "total_members": "Total de miembros", + "total_cycles": "Total de Ciclos", + "total_modules": "Total de Módulos", + "pending_work_items": { + "title": "Elementos de trabajo pendientes", + "empty_state": "El análisis de elementos de trabajo pendientes por compañeros aparece aquí." + }, + "work_items_closed_in_a_year": { + "title": "Elementos de trabajo cerrados en un año", + "empty_state": "Cierra elementos de trabajo para ver su análisis en forma de gráfico." + }, + "most_work_items_created": { + "title": "Más elementos de trabajo creados", + "empty_state": "Los compañeros y el número de elementos de trabajo creados por ellos aparecen aquí." + }, + "most_work_items_closed": { + "title": "Más elementos de trabajo cerrados", + "empty_state": "Los compañeros y el número de elementos de trabajo cerrados por ellos aparecen aquí." + }, + "tabs": { + "scope_and_demand": "Alcance y Demanda", + "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." + } + } + } + } + }, + + "workspace_projects": { + "label": "{count, plural, one {Proyecto} other {Proyectos}}", + "create": { + "label": "Agregar Proyecto" + }, + "network": { + "private": { + "title": "Privado", + "description": "Accesible solo por invitación" + }, + "public": { + "title": "Público", + "description": "Cualquiera en el espacio de trabajo excepto Invitados puede unirse" + } + }, + "error": { + "permission": "No tienes permiso para realizar esta acción.", + "cycle_delete": "Error al eliminar el ciclo", + "module_delete": "Error al eliminar el módulo", + "issue_delete": "Error al eliminar el elemento de trabajo" + }, + "state": { + "backlog": "Pendiente", + "unstarted": "Sin iniciar", + "started": "Iniciado", + "completed": "Completado", + "cancelled": "Cancelado" + }, + "sort": { + "manual": "Manual", + "name": "Nombre", + "created_at": "Fecha de creación", + "members_length": "Número de miembros" + }, + "scope": { + "my_projects": "Mis proyectos", + "archived_projects": "Archivados" + }, + "common": { + "months_count": "{months, plural, one{# mes} other{# meses}}" + }, + "empty_state": { + "general": { + "title": "No hay proyectos activos", + "description": "Piensa en cada proyecto como el padre para el trabajo orientado a objetivos. Los proyectos son donde viven las Tareas, Ciclos y Módulos y, junto con tus colegas, te ayudan a alcanzar ese objetivo. Crea un nuevo proyecto o filtra por proyectos archivados.", + "primary_button": { + "text": "Inicia tu primer proyecto", + "comic": { + "title": "Todo comienza con un proyecto en Plane", + "description": "Un proyecto podría ser la hoja de ruta de un producto, una campaña de marketing o el lanzamiento de un nuevo automóvil." + } + } + }, + "no_projects": { + "title": "Sin proyecto", + "description": "Para crear elementos de trabajo o gestionar tu trabajo, necesitas crear un proyecto o ser parte de uno.", + "primary_button": { + "text": "Inicia tu primer proyecto", + "comic": { + "title": "Todo comienza con un proyecto en Plane", + "description": "Un proyecto podría ser la hoja de ruta de un producto, una campaña de marketing o el lanzamiento de un nuevo automóvil." + } + } + }, + "filter": { + "title": "No hay proyectos coincidentes", + "description": "No se detectaron proyectos con los criterios coincidentes. \n Crea un nuevo proyecto en su lugar." + }, + "search": { + "description": "No se detectaron proyectos con los criterios coincidentes.\nCrea un nuevo proyecto en su lugar" + } + } + }, + + "workspace_views": { + "add_view": "Agregar vista", + "empty_state": { + "all-issues": { + "title": "No hay elementos de trabajo en el proyecto", + "description": "¡Primer proyecto completado! Ahora, divide tu trabajo en piezas rastreables con elementos de trabajo. ¡Vamos!", + "primary_button": { + "text": "Crear nuevo elemento de trabajo" + } + }, + "assigned": { + "title": "No hay elementos de trabajo aún", + "description": "Los elementos de trabajo asignados a ti se pueden rastrear desde aquí.", + "primary_button": { + "text": "Crear nuevo elemento de trabajo" + } + }, + "created": { + "title": "No hay elementos de trabajo aún", + "description": "Todos los elementos de trabajo creados por ti vienen aquí, rastréalos aquí directamente.", + "primary_button": { + "text": "Crear nuevo elemento de trabajo" + } + }, + "subscribed": { + "title": "No hay elementos de trabajo aún", + "description": "Suscríbete a los elementos de trabajo que te interesan, rastréalos todos aquí." + }, + "custom-view": { + "title": "No hay elementos de trabajo aún", + "description": "Elementos de trabajo que aplican a los filtros, rastréalos todos aquí." + } + } + }, + + "workspace_settings": { + "label": "Configuración del espacio de trabajo", + "page_label": "{workspace} - Configuración general", + "key_created": "Clave creada", + "copy_key": "Copia y guarda esta clave secreta en Plane Pages. No podrás ver esta clave después de hacer clic en Cerrar. Se ha descargado un archivo CSV que contiene la clave.", + "token_copied": "Token copiado al portapapeles.", + "settings": { + "general": { + "title": "General", + "upload_logo": "Subir logo", + "edit_logo": "Editar logo", + "name": "Nombre del espacio de trabajo", + "company_size": "Tamaño de la empresa", + "url": "URL del espacio de trabajo", + "update_workspace": "Actualizar espacio de trabajo", + "delete_workspace": "Eliminar este espacio de trabajo", + "delete_workspace_description": "Al eliminar un espacio de trabajo, todos los datos y recursos dentro de ese espacio se eliminarán permanentemente y no podrán recuperarse.", + "delete_btn": "Eliminar este espacio de trabajo", + "delete_modal": { + "title": "¿Está seguro de que desea eliminar este espacio de trabajo?", + "description": "Tiene una prueba activa de uno de nuestros planes de pago. Por favor, cancelela primero para continuar.", + "dismiss": "Descartar", + "cancel": "Cancelar prueba", + "success_title": "Espacio de trabajo eliminado.", + "success_message": "Pronto irá a su página de perfil.", + "error_title": "Eso no funcionó.", + "error_message": "Por favor, inténtelo de nuevo." + }, + "errors": { + "name": { + "required": "El nombre es obligatorio", + "max_length": "El nombre del espacio de trabajo no debe exceder los 80 caracteres" + }, + "company_size": { + "required": "El tamaño de la empresa es obligatorio" + } + } + }, + "members": { + "title": "Miembros", + "add_member": "Agregar miembro", + "pending_invites": "Invitaciones pendientes", + "invitations_sent_successfully": "Invitaciones enviadas exitosamente", + "leave_confirmation": "¿Estás seguro de que quieres abandonar el espacio de trabajo? Ya no tendrás acceso a este espacio de trabajo. Esta acción no se puede deshacer.", + "details": { + "full_name": "Nombre completo", + "display_name": "Nombre para mostrar", + "email_address": "Dirección de correo electrónico", + "account_type": "Tipo de cuenta", + "authentication": "Autenticación", + "joining_date": "Fecha de incorporación" + }, + "modal": { + "title": "Invitar personas a colaborar", + "description": "Invita personas a colaborar en tu espacio de trabajo.", + "button": "Enviar invitaciones", + "button_loading": "Enviando invitaciones", + "placeholder": "nombre@empresa.com", + "errors": { + "required": "Necesitamos una dirección de correo electrónico para invitarlos.", + "invalid": "El correo electrónico no es válido" + } + } + }, + "billing_and_plans": { + "title": "Facturación y Planes", + "current_plan": "Plan actual", + "free_plan": "Actualmente estás usando el plan gratuito", + "view_plans": "Ver planes" + }, + "exports": { + "title": "Exportaciones", + "exporting": "Exportando", + "previous_exports": "Exportaciones anteriores", + "export_separate_files": "Exportar los datos en archivos separados", + "modal": { + "title": "Exportar a", + "toasts": { + "success": { + "title": "Exportación exitosa", + "message": "Podrás descargar el {entity} exportado desde la exportación anterior." + }, + "error": { + "title": "Exportación fallida", + "message": "La exportación no tuvo éxito. Por favor, inténtalo de nuevo." + } + } + } + }, + "webhooks": { + "title": "Webhooks", + "add_webhook": "Agregar webhook", + "modal": { + "title": "Crear webhook", + "details": "Detalles del webhook", + "payload": "URL del payload", + "question": "¿Qué eventos te gustaría que activaran este webhook?", + "error": "La URL es obligatoria" + }, + "secret_key": { + "title": "Clave secreta", + "message": "Genera un token para iniciar sesión en el payload del webhook" + }, + "options": { + "all": "Envíame todo", + "individual": "Seleccionar eventos individuales" + }, + "toasts": { + "created": { + "title": "Webhook creado", + "message": "El webhook se ha creado exitosamente" + }, + "not_created": { + "title": "Webhook no creado", + "message": "No se pudo crear el webhook" + }, + "updated": { + "title": "Webhook actualizado", + "message": "El webhook se ha actualizado exitosamente" + }, + "not_updated": { + "title": "Webhook no actualizado", + "message": "No se pudo actualizar el webhook" + }, + "removed": { + "title": "Webhook eliminado", + "message": "El webhook se ha eliminado exitosamente" + }, + "not_removed": { + "title": "Webhook no eliminado", + "message": "No se pudo eliminar el webhook" + }, + "secret_key_copied": { + "message": "Clave secreta copiada al portapapeles." + }, + "secret_key_not_copied": { + "message": "Ocurrió un error al copiar la clave secreta." + } + } + }, + "api_tokens": { + "title": "Tokens de API", + "add_token": "Agregar token de API", + "create_token": "Crear token", + "never_expires": "Nunca expira", + "generate_token": "Generar token", + "generating": "Generando", + "delete": { + "title": "Eliminar token de API", + "description": "Cualquier aplicación que use este token ya no tendrá acceso a los datos de Plane. Esta acción no se puede deshacer.", + "success": { + "title": "¡Éxito!", + "message": "El token de API se ha eliminado exitosamente" + }, + "error": { + "title": "¡Error!", + "message": "No se pudo eliminar el token de API" + } + } + } + }, + "empty_state": { + "api_tokens": { + "title": "No se han creado tokens de API", + "description": "Las APIs de Plane se pueden usar para integrar tus datos en Plane con cualquier sistema externo. Crea un token para comenzar." + }, + "webhooks": { + "title": "No se han agregado webhooks", + "description": "Crea webhooks para recibir actualizaciones en tiempo real y automatizar acciones." + }, + "exports": { + "title": "No hay exportaciones aún", + "description": "Cada vez que exportes, también tendrás una copia aquí para referencia." + }, + "imports": { + "title": "No hay importaciones aún", + "description": "Encuentra todas tus importaciones anteriores aquí y descárgalas." + } + } + }, + + "profile": { + "label": "Perfil", + "page_label": "Tu trabajo", + "work": "Trabajo", + "details": { + "joined_on": "Se unió el", + "time_zone": "Zona horaria" + }, + "stats": { + "workload": "Carga de trabajo", + "overview": "Resumen", + "created": "Elementos de trabajo creados", + "assigned": "Elementos de trabajo asignados", + "subscribed": "Elementos de trabajo suscritos", + "state_distribution": { + "title": "Elementos de trabajo por estado", + "empty": "Crea elementos de trabajo para verlos por estados en el gráfico para un mejor análisis." + }, + "priority_distribution": { + "title": "Elementos de trabajo por Prioridad", + "empty": "Crea elementos de trabajo para verlos por prioridad en el gráfico para un mejor análisis." + }, + "recent_activity": { + "title": "Actividad reciente", + "empty": "No pudimos encontrar datos. Por favor revisa tus entradas", + "button": "Descargar actividad de hoy", + "button_loading": "Descargando" + } + }, + "actions": { + "profile": "Perfil", + "security": "Seguridad", + "activity": "Actividad", + "appearance": "Apariencia", + "notifications": "Notificaciones" + }, + "tabs": { + "summary": "Resumen", + "assigned": "Asignado", + "created": "Creado", + "subscribed": "Suscrito", + "activity": "Actividad" + }, + "empty_state": { + "activity": { + "title": "Aún no hay actividades", + "description": "¡Comienza creando un nuevo elemento de trabajo! Agrégale detalles y propiedades. Explora más en Plane para ver tu actividad." + }, + "assigned": { + "title": "No hay elementos de trabajo asignados a ti", + "description": "Los elementos de trabajo asignados a ti se pueden rastrear desde aquí." + }, + "created": { + "title": "Aún no hay elementos de trabajo", + "description": "Todos los elementos de trabajo creados por ti aparecen aquí, rastréalos directamente aquí." + }, + "subscribed": { + "title": "Aún no hay elementos de trabajo", + "description": "Suscríbete a los elementos de trabajo que te interesen, rastréalos todos aquí." + } + } + }, + + "project_settings": { + "general": { + "enter_project_id": "Ingresa el ID del proyecto", + "please_select_a_timezone": "Por favor selecciona una zona horaria", + "archive_project": { + "title": "Archivar proyecto", + "description": "Archivar un proyecto lo eliminará de tu navegación lateral aunque aún podrás acceder a él desde tu página de proyectos. Puedes restaurar el proyecto o eliminarlo cuando quieras.", + "button": "Archivar proyecto" + }, + "delete_project": { + "title": "Eliminar proyecto", + "description": "Al eliminar un proyecto, todos los datos y recursos dentro de ese proyecto se eliminarán permanentemente y no podrán recuperarse.", + "button": "Eliminar mi proyecto" + }, + "toast": { + "success": "Proyecto actualizado exitosamente", + "error": "No se pudo actualizar el proyecto. Por favor intenta de nuevo." + } + }, + "members": { + "label": "Miembros", + "project_lead": "Líder del proyecto", + "default_assignee": "Asignado por defecto", + "guest_super_permissions": { + "title": "Otorgar acceso de visualización a todos los elementos de trabajo para usuarios invitados:", + "sub_heading": "Esto permitirá a los invitados tener acceso de visualización a todos los elementos de trabajo del proyecto." + }, + "invite_members": { + "title": "Invitar miembros", + "sub_heading": "Invita miembros para trabajar en tu proyecto.", + "select_co_worker": "Seleccionar compañero de trabajo" + } + }, + "states": { + "describe_this_state_for_your_members": "Describe este estado para tus miembros.", + "empty_state": { + "title": "No estados disponibles para el grupo {groupKey}", + "description": "Por favor, crea un nuevo estado" + } + }, + "labels": { + "label_title": "Título de la etiqueta", + "label_title_is_required": "El título de la etiqueta es requerido", + "label_max_char": "El nombre de la etiqueta no debe exceder 255 caracteres", + "toast": { + "error": "Error al actualizar la etiqueta" + } + }, + "estimates": { + "title": "Habilitar estimaciones para mi proyecto", + "description": "Ayudan a comunicar la complejidad y la carga de trabajo del equipo." + }, + "automations": { + "label": "Automatizaciones", + "auto-archive": { + "title": "Auto-archivar elementos de trabajo cerrados", + "description": "Plane archivará automáticamente los elementos de trabajo que se hayan completado o cancelado.", + "duration": "Auto-archivar elementos de trabajo que están cerrados por" + }, + "auto-close": { + "title": "Auto-cerrar elementos de trabajo", + "description": "Plane cerrará automáticamente los elementos de trabajo que no se hayan completado o cancelado.", + "duration": "Auto-cerrar elementos de trabajo que están inactivos por", + "auto_close_status": "Estado de auto-cierre" + } + }, + + "empty_state": { + "labels": { + "title": "Aún no hay etiquetas", + "description": "Crea etiquetas para ayudar a organizar y filtrar elementos de trabajo en tu proyecto." + }, + "estimates": { + "title": "Aún no hay sistemas de estimación", + "description": "Crea un conjunto de estimaciones para comunicar la cantidad de trabajo por elemento de trabajo.", + "primary_button": "Agregar sistema de estimación" + } + } + }, + + "project_cycles": { + "add_cycle": "Agregar ciclo", + "more_details": "Más detalles", + "cycle": "Ciclo", + "update_cycle": "Actualizar ciclo", + "create_cycle": "Crear ciclo", + "no_matching_cycles": "No hay ciclos coincidentes", + "remove_filters_to_see_all_cycles": "Elimina los filtros para ver todos los ciclos", + "remove_search_criteria_to_see_all_cycles": "Elimina los criterios de búsqueda para ver todos los ciclos", + "only_completed_cycles_can_be_archived": "Solo los ciclos completados pueden ser archivados", + "active_cycle": { + "label": "Ciclo activo", + "progress": "Progreso", + "chart": "Gráfico de avance", + "priority_issue": "Elementos de trabajo prioritarios", + "assignees": "Asignados", + "issue_burndown": "Avance de elementos de trabajo", + "ideal": "Ideal", + "current": "Actual", + "labels": "Etiquetas" + }, + "upcoming_cycle": { + "label": "Ciclo próximo" + }, + "completed_cycle": { + "label": "Ciclo completado" + }, + "status": { + "days_left": "Días restantes", + "completed": "Completado", + "yet_to_start": "Por comenzar", + "in_progress": "En progreso", + "draft": "Borrador" + }, + "action": { + "restore": { + "title": "Restaurar ciclo", + "success": { + "title": "Ciclo restaurado", + "description": "El ciclo ha sido restaurado." + }, + "failed": { + "title": "Falló la restauración del ciclo", + "description": "No se pudo restaurar el ciclo. Por favor intenta de nuevo." + } + }, + "favorite": { + "loading": "Agregando ciclo a favoritos", + "success": { + "description": "Ciclo agregado a favoritos.", + "title": "¡Éxito!" + }, + "failed": { + "description": "No se pudo agregar el ciclo a favoritos. Por favor intenta de nuevo.", + "title": "¡Error!" + } + }, + "unfavorite": { + "loading": "Eliminando ciclo de favoritos", + "success": { + "description": "Ciclo eliminado de favoritos.", + "title": "¡Éxito!" + }, + "failed": { + "description": "No se pudo eliminar el ciclo de favoritos. Por favor intenta de nuevo.", + "title": "¡Error!" + } + }, + "update": { + "loading": "Actualizando ciclo", + "success": { + "description": "Ciclo actualizado exitosamente.", + "title": "¡Éxito!" + }, + "failed": { + "description": "Error al actualizar el ciclo. Por favor intenta de nuevo.", + "title": "¡Error!" + }, + "error": { + "already_exists": "Ya tienes un ciclo en las fechas dadas, si quieres crear un ciclo en borrador, puedes hacerlo eliminando ambas fechas." + } + } + }, + "empty_state": { + "general": { + "title": "Agrupa y delimita tu trabajo en Ciclos.", + "description": "Divide el trabajo en bloques de tiempo, trabaja hacia atrás desde la fecha límite de tu proyecto para establecer fechas, y haz un progreso tangible como equipo.", + "primary_button": { + "text": "Establece tu primer ciclo", + "comic": { + "title": "Los ciclos son bloques de tiempo repetitivos.", + "description": "Un sprint, una iteración, o cualquier otro término que uses para el seguimiento semanal o quincenal del trabajo es un ciclo." + } + } + }, + "no_issues": { + "title": "No hay elementos de trabajo agregados al ciclo", + "description": "Agrega o crea elementos de trabajo que desees delimitar y entregar dentro de este ciclo", + "primary_button": { + "text": "Crear nuevo elemento de trabajo" + }, + "secondary_button": { + "text": "Agregar elemento de trabajo existente" + } + }, + "completed_no_issues": { + "title": "No hay elementos de trabajo en el ciclo", + "description": "No hay elementos de trabajo en el ciclo. Los elementos de trabajo están transferidos u ocultos. Para ver elementos de trabajo ocultos si los hay, actualiza tus propiedades de visualización según corresponda." + }, + "active": { + "title": "No hay ciclo activo", + "description": "Un ciclo activo incluye cualquier período que abarque la fecha de hoy dentro de su rango. Encuentra el progreso y los detalles del ciclo activo aquí." + }, + "archived": { + "title": "Aún no hay ciclos archivados", + "description": "Para mantener ordenado tu proyecto, archiva los ciclos completados. Encuéntralos aquí una vez archivados." + } + } + }, + + "project_issues": { + "empty_state": { + "no_issues": { + "title": "Crea un elemento de trabajo y asígnalo a alguien, incluso a ti mismo", + "description": "Piensa en los elementos de trabajo como trabajos, tareas, trabajo o JTBD. Los cuales nos gustan. Un elemento de trabajo y sus sub-elementos de trabajo son generalmente acciones basadas en tiempo asignadas a miembros de tu equipo. Tu equipo crea, asigna y completa elementos de trabajo para mover tu proyecto hacia su objetivo.", + "primary_button": { + "text": "Crea tu primer elemento de trabajo", + "comic": { + "title": "Los elementos de trabajo son bloques de construcción en Plane.", + "description": "Rediseñar la interfaz de Plane, Cambiar la marca de la empresa o Lanzar el nuevo sistema de inyección de combustible son ejemplos de elementos de trabajo que probablemente tienen sub-elementos de trabajo." + } + } + }, + "no_archived_issues": { + "title": "Aún no hay elementos de trabajo archivados", + "description": "Manualmente o a través de automatización, puedes archivar elementos de trabajo que estén completados o cancelados. Encuéntralos aquí una vez archivados.", + "primary_button": { + "text": "Establecer automatización" + } + }, + "issues_empty_filter": { + "title": "No se encontraron elementos de trabajo que coincidan con los filtros aplicados", + "secondary_button": { + "text": "Limpiar todos los filtros" + } + } + } + }, + + "project_module": { + "add_module": "Agregar Módulo", + "update_module": "Actualizar Módulo", + "create_module": "Crear Módulo", + "archive_module": "Archivar Módulo", + "restore_module": "Restaurar Módulo", + "delete_module": "Eliminar módulo", + "empty_state": { + "general": { + "title": "Mapea los hitos de tu proyecto a Módulos y rastrea el trabajo agregado fácilmente.", + "description": "Un grupo de elementos de trabajo que pertenecen a un padre lógico y jerárquico forman un módulo. Piensa en ellos como una forma de rastrear el trabajo por hitos del proyecto. Tienen sus propios períodos y fechas límite, así como análisis para ayudarte a ver qué tan cerca o lejos estás de un hito.", + "primary_button": { + "text": "Construye tu primer módulo", + "comic": { + "title": "Los módulos ayudan a agrupar el trabajo por jerarquía.", + "description": "Un módulo de carrito, un módulo de chasis y un módulo de almacén son buenos ejemplos de esta agrupación." + } + } + }, + "no_issues": { + "title": "No hay elementos de trabajo en el módulo", + "description": "Crea o agrega elementos de trabajo que quieras lograr como parte de este módulo", + "primary_button": { + "text": "Crear nuevos elementos de trabajo" + }, + "secondary_button": { + "text": "Agregar un elemento de trabajo existente" + } + }, + "archived": { + "title": "Aún no hay Módulos archivados", + "description": "Para mantener ordenado tu proyecto, archiva los módulos completados o cancelados. Encuéntralos aquí una vez archivados." + }, + "sidebar": { + "in_active": "Este módulo aún no está activo.", + "invalid_date": "Fecha inválida. Por favor ingresa una fecha válida." + } + }, + "quick_actions": { + "archive_module": "Archivar módulo", + "archive_module_description": "Solo los módulos completados o\ncancelados pueden ser archivados.", + "delete_module": "Eliminar módulo" + }, + "toast": { + "copy": { + "success": "Enlace del módulo copiado al portapapeles" + }, + "delete": { + "success": "Módulo eliminado exitosamente", + "error": "Error al eliminar el módulo" + } + } + }, + + "project_views": { + "empty_state": { + "general": { + "title": "Guarda vistas filtradas para tu proyecto. Crea tantas como necesites", + "description": "Las vistas son un conjunto de filtros guardados que usas frecuentemente o a los que quieres tener fácil acceso. Todos tus colegas en un proyecto pueden ver las vistas de todos y elegir la que mejor se adapte a sus necesidades.", + "primary_button": { + "text": "Crea tu primera vista", + "comic": { + "title": "Las vistas funcionan sobre las propiedades de los Elementos de trabajo.", + "description": "Puedes crear una vista desde aquí con tantas propiedades como filtros como consideres apropiado." + } + } + }, + "filter": { + "title": "No hay vistas coincidentes", + "description": "Ninguna vista coincide con los criterios de búsqueda. \n Crea una nueva vista en su lugar." + } + } + }, + + "project_page": { + "empty_state": { + "general": { + "title": "Escribe una nota, un documento o una base de conocimiento completa. Obtén ayuda de Galileo, el asistente de IA de Plane, para comenzar", + "description": "Las páginas son espacios para pensamientos en Plane. Toma notas de reuniones, fórmalas fácilmente, integra elementos de trabajo, organízalas usando una biblioteca de componentes y mantenlas todas en el contexto de tu proyecto. Para hacer cualquier documento rápidamente, invoca a Galileo, la IA de Plane, con un atajo o haciendo clic en un botón.", + "primary_button": { + "text": "Crea tu primera página" + } + }, + "private": { + "title": "Aún no hay páginas privadas", + "description": "Mantén tus pensamientos privados aquí. Cuando estés listo para compartir, el equipo está a solo un clic de distancia.", + "primary_button": { + "text": "Crea tu primera página" + } + }, + "public": { + "title": "Aún no hay páginas públicas", + "description": "Ve las páginas compartidas con todos en tu proyecto aquí mismo.", + "primary_button": { + "text": "Crea tu primera página" + } + }, + "archived": { + "title": "Aún no hay páginas archivadas", + "description": "Archiva las páginas que no estén en tu radar. Accede a ellas aquí cuando las necesites." + } + } + }, + + "command_k": { + "empty_state": { + "search": { + "title": "No se encontraron resultados" + } + } + }, + + "issue_relation": { + "empty_state": { + "search": { + "title": "No se encontraron elementos de trabajo coincidentes" + }, + "no_issues": { + "title": "No se encontraron elementos de trabajo" + } + } + }, + + "issue_comment": { + "empty_state": { + "general": { + "title": "Aún no hay comentarios", + "description": "Los comentarios pueden usarse como un espacio de discusión y seguimiento para los elementos de trabajo" + } + } + }, + + "notification": { + "label": "Bandeja de entrada", + "page_label": "{workspace} - Bandeja de entrada", + "options": { + "mark_all_as_read": "Marcar todo como leído", + "mark_read": "Marcar como leído", + "mark_unread": "Marcar como no leído", + "refresh": "Actualizar", + "filters": "Filtros de bandeja de entrada", + "show_unread": "Mostrar no leídos", + "show_snoozed": "Mostrar pospuestos", + "show_archived": "Mostrar archivados", + "mark_archive": "Archivar", + "mark_unarchive": "Desarchivar", + "mark_snooze": "Posponer", + "mark_unsnooze": "Quitar posposición" + }, + "toasts": { + "read": "Notificación marcada como leída", + "unread": "Notificación marcada como no leída", + "archived": "Notificación marcada como archivada", + "unarchived": "Notificación marcada como no archivada", + "snoozed": "Notificación pospuesta", + "unsnoozed": "Notificación posposición cancelada" + }, + "empty_state": { + "detail": { + "title": "Selecciona para ver detalles." + }, + "all": { + "title": "No hay elementos de trabajo asignados", + "description": "Las actualizaciones de elementos de trabajo asignados a ti se pueden \n ver aquí" + }, + "mentions": { + "title": "No hay elementos de trabajo asignados", + "description": "Las actualizaciones de elementos de trabajo asignados a ti se pueden \n ver aquí" + } + }, + "tabs": { + "all": "Todo", + "mentions": "Menciones" + }, + "filter": { + "assigned": "Asignado a mí", + "created": "Creado por mí", + "subscribed": "Suscrito por mí" + }, + "snooze": { + "1_day": "1 día", + "3_days": "3 días", + "5_days": "5 días", + "1_week": "1 semana", + "2_weeks": "2 semanas", + "custom": "Personalizado" + } + }, + + "active_cycle": { + "empty_state": { + "progress": { + "title": "Agrega elementos de trabajo al ciclo para ver su progreso" + }, + "chart": { + "title": "Agrega elementos de trabajo al ciclo para ver el gráfico de avance." + }, + "priority_issue": { + "title": "Observa los elementos de trabajo de alta prioridad abordados en el ciclo de un vistazo." + }, + "assignee": { + "title": "Agrega asignados a los elementos de trabajo para ver un desglose del trabajo por asignados." + }, + "label": { + "title": "Agrega etiquetas a los elementos de trabajo para ver el desglose del trabajo por etiquetas." + } + } + }, + + "disabled_project": { + "empty_state": { + "inbox": { + "title": "Intake no está habilitado para el proyecto.", + "description": "Intake te ayuda a gestionar las solicitudes entrantes a tu proyecto y agregarlas como elementos de trabajo en tu flujo de trabajo. Habilita Intake desde la configuración del proyecto para gestionar las solicitudes.", + "primary_button": { + "text": "Gestionar funciones" + } + }, + "cycle": { + "title": "Los Ciclos no están habilitados para este proyecto.", + "description": "Divide el trabajo en fragmentos limitados por tiempo, trabaja hacia atrás desde la fecha límite de tu proyecto para establecer fechas y haz un progreso tangible como equipo. Habilita la función de ciclos para tu proyecto para comenzar a usarlos.", + "primary_button": { + "text": "Gestionar funciones" + } + }, + "module": { + "title": "Los Módulos no están habilitados para el proyecto.", + "description": "Los Módulos son los componentes básicos de tu proyecto. Habilita los módulos desde la configuración del proyecto para comenzar a usarlos.", + "primary_button": { + "text": "Gestionar funciones" + } + }, + "page": { + "title": "Las Páginas no están habilitadas para el proyecto.", + "description": "Las Páginas son los componentes básicos de tu proyecto. Habilita las páginas desde la configuración del proyecto para comenzar a usarlas.", + "primary_button": { + "text": "Gestionar funciones" + } + }, + "view": { + "title": "Las Vistas no están habilitadas para el proyecto.", + "description": "Las Vistas son los componentes básicos de tu proyecto. Habilita las vistas desde la configuración del proyecto para comenzar a usarlas.", + "primary_button": { + "text": "Gestionar funciones" + } + } + } + }, + "workspace_draft_issues": { + "draft_an_issue": "Borrador de elemento de trabajo", + "empty_state": { + "title": "Los elementos de trabajo a medio escribir y pronto los comentarios aparecerán aquí.", + "description": "Para probar esto, comienza a agregar un elemento de trabajo y déjalo a medias o crea tu primer borrador a continuación. 😉", + "primary_button": { + "text": "Crea tu primer borrador" + } + }, + "delete_modal": { + "title": "Eliminar borrador", + "description": "¿Estás seguro de que quieres eliminar este borrador? Esto no se puede deshacer." + }, + "toasts": { + "created": { + "success": "Borrador creado", + "error": "No se pudo crear el elemento de trabajo. Por favor, inténtalo de nuevo." + }, + "deleted": { + "success": "Borrador eliminado" + } + } + }, + + "stickies": { + "title": "Tus notas adhesivas", + "placeholder": "haz clic para escribir aquí", + "all": "Todas las notas adhesivas", + "no-data": "Anota una idea, captura un momento eureka o registra una inspiración. Agrega una nota adhesiva para comenzar.", + "add": "Agregar nota adhesiva", + "search_placeholder": "Buscar por título", + "delete": "Eliminar nota adhesiva", + "delete_confirmation": "¿Estás seguro de que quieres eliminar esta nota adhesiva?", + "empty_state": { + "simple": "Anota una idea, captura un momento eureka o registra una inspiración. Agrega una nota adhesiva para comenzar.", + "general": { + "title": "Las notas adhesivas son notas rápidas y tareas pendientes que anotas al vuelo.", + "description": "Captura tus pensamientos e ideas sin esfuerzo creando notas adhesivas a las que puedes acceder en cualquier momento y desde cualquier lugar.", + "primary_button": { + "text": "Agregar nota adhesiva" + } + }, + "search": { + "title": "Eso no coincide con ninguna de tus notas adhesivas.", + "description": "Prueba un término diferente o háznoslo saber\nsi estás seguro de que tu búsqueda es correcta.", + "primary_button": { + "text": "Agregar nota adhesiva" + } + } + }, + "toasts": { + "errors": { + "wrong_name": "El nombre de la nota adhesiva no puede tener más de 100 caracteres.", + "already_exists": "Ya existe una nota adhesiva sin descripción" + }, + "created": { + "title": "Nota adhesiva creada", + "message": "La nota adhesiva se ha creado exitosamente" + }, + "not_created": { + "title": "Nota adhesiva no creada", + "message": "No se pudo crear la nota adhesiva" + }, + "updated": { + "title": "Nota adhesiva actualizada", + "message": "La nota adhesiva se ha actualizado exitosamente" + }, + "not_updated": { + "title": "Nota adhesiva no actualizada", + "message": "No se pudo actualizar la nota adhesiva" + }, + "removed": { + "title": "Nota adhesiva eliminada", + "message": "La nota adhesiva se ha eliminado exitosamente" + }, + "not_removed": { + "title": "Nota adhesiva no eliminada", + "message": "No se pudo eliminar la nota adhesiva" + } + } + }, + + "role_details": { + "guest": { + "title": "Invitado", + "description": "Los miembros externos de las organizaciones pueden ser invitados como invitados." + }, + "member": { + "title": "Miembro", + "description": "Capacidad para leer, escribir, editar y eliminar entidades dentro de proyectos, ciclos y módulos" + }, + "admin": { + "title": "Administrador", + "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", + "founder_or_executive": "Fundador / Ejecutivo", + "freelancer_or_consultant": "Freelancer / Consultor", + "marketing_or_growth": "Marketing / Crecimiento", + "sales_or_business_development": "Ventas / Desarrollo de Negocios", + "support_or_operations": "Soporte / Operaciones", + "student_or_professor": "Estudiante / Profesor", + "human_resources": "Recursos Humanos", + "other": "Otro" + }, + + "importer": { + "github": { + "title": "GitHub", + "description": "Importa elementos de trabajo desde repositorios de GitHub y sincronízalos." + }, + "jira": { + "title": "Jira", + "description": "Importa elementos de trabajo y epics desde proyectos y epics de Jira." + } + }, + + "exporter": { + "csv": { + "title": "CSV", + "description": "Exporta elementos de trabajo a un archivo CSV.", + "short_description": "Exportar como csv" + }, + "excel": { + "title": "Excel", + "description": "Exporta elementos de trabajo a un archivo Excel.", + "short_description": "Exportar como excel" + }, + "xlsx": { + "title": "Excel", + "description": "Exporta elementos de trabajo a un archivo Excel.", + "short_description": "Exportar como excel" + }, + "json": { + "title": "JSON", + "description": "Exporta elementos de trabajo a un archivo JSON.", + "short_description": "Exportar como json" + } + }, + "default_global_view": { + "all_issues": "Todos los elementos de trabajo", + "assigned": "Asignados", + "created": "Creados", + "subscribed": "Suscritos" + }, + + "themes": { + "theme_options": { + "system_preference": { + "label": "Preferencia del sistema" + }, + "light": { + "label": "Claro" + }, + "dark": { + "label": "Oscuro" + }, + "light_contrast": { + "label": "Claro de alto contraste" + }, + "dark_contrast": { + "label": "Oscuro de alto contraste" + }, + "custom": { + "label": "Tema personalizado" + } + } + }, + "project_modules": { + "status": { + "backlog": "Pendientes", + "planned": "Planificado", + "in_progress": "En progreso", + "paused": "Pausado", + "completed": "Completado", + "cancelled": "Cancelado" + }, + "layout": { + "list": "Vista de lista", + "board": "Vista de galería", + "timeline": "Vista de línea de tiempo" + }, + "order_by": { + "name": "Nombre", + "progress": "Progreso", + "issues": "Número de elementos de trabajo", + "due_date": "Fecha de vencimiento", + "created_at": "Fecha de creación", + "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" + } +} diff --git a/packages/i18n/src/locales/fr/translations.json b/packages/i18n/src/locales/fr/translations.json new file mode 100644 index 000000000..f258d5381 --- /dev/null +++ b/packages/i18n/src/locales/fr/translations.json @@ -0,0 +1,2362 @@ +{ + "sidebar": { + "projects": "Projets", + "pages": "Pages", + "new_work_item": "Nouvel élément de travail", + "home": "Accueil", + "your_work": "Votre travail", + "inbox": "Boîte de réception", + "workspace": "Espace de travail", + "views": "Vues", + "analytics": "Analyses", + "work_items": "Éléments de travail", + "cycles": "Cycles", + "modules": "Modules", + "intake": "Intake", + "drafts": "Brouillons", + "favorites": "Favoris", + "pro": "Pro", + "upgrade": "Mettre à niveau" + }, + + "auth": { + "common": { + "email": { + "label": "E-mail", + "placeholder": "nom@entreprise.com", + "errors": { + "required": "L'e-mail est requis", + "invalid": "L'e-mail est invalide" + } + }, + "password": { + "label": "Mot de passe", + "set_password": "Définir un mot de passe", + "placeholder": "Entrer le mot de passe", + "confirm_password": { + "label": "Confirmer le mot de passe", + "placeholder": "Confirmer le mot de passe" + }, + "current_password": { + "label": "Mot de passe actuel" + }, + "new_password": { + "label": "Nouveau mot de passe", + "placeholder": "Entrer le nouveau mot de passe" + }, + "change_password": { + "label": { + "default": "Changer le mot de passe", + "submitting": "Changement du mot de passe" + } + }, + "errors": { + "match": "Les mots de passe ne correspondent pas", + "empty": "Veuillez entrer votre mot de passe", + "length": "Le mot de passe doit contenir plus de 8 caractères", + "strength": { + "weak": "Le mot de passe est faible", + "strong": "Le mot de passe est fort" + } + }, + "submit": "Définir le mot de passe", + "toast": { + "change_password": { + "success": { + "title": "Succès !", + "message": "Mot de passe changé avec succès." + }, + "error": { + "title": "Erreur !", + "message": "Une erreur s'est produite. Veuillez réessayer." + } + } + } + }, + "unique_code": { + "label": "Code unique", + "placeholder": "obtient-définit-vole", + "paste_code": "Collez le code envoyé à votre e-mail", + "requesting_new_code": "Demande d'un nouveau code", + "sending_code": "Envoi du code" + }, + "already_have_an_account": "Vous avez déjà un compte ?", + "login": "Se connecter", + "create_account": "Créer un compte", + "new_to_plane": "Nouveau sur Plane ?", + "back_to_sign_in": "Retour à la connexion", + "resend_in": "Renvoyer dans {seconds} secondes", + "sign_in_with_unique_code": "Se connecter avec un code unique", + "forgot_password": "Mot de passe oublié ?" + }, + "sign_up": { + "header": { + "label": "Créez un compte pour commencer à gérer le travail avec votre équipe.", + "step": { + "email": { + "header": "S'inscrire", + "sub_header": "" + }, + "password": { + "header": "S'inscrire", + "sub_header": "Inscrivez-vous en utilisant une combinaison e-mail-mot de passe." + }, + "unique_code": { + "header": "S'inscrire", + "sub_header": "Inscrivez-vous en utilisant un code unique envoyé à l'adresse e-mail ci-dessus." + } + } + }, + "errors": { + "password": { + "strength": "Essayez de définir un mot de passe fort pour continuer" + } + } + }, + "sign_in": { + "header": { + "label": "Connectez-vous pour commencer à gérer le travail avec votre équipe.", + "step": { + "email": { + "header": "Se connecter ou s'inscrire", + "sub_header": "" + }, + "password": { + "header": "Se connecter ou s'inscrire", + "sub_header": "Utilisez votre combinaison e-mail-mot de passe pour vous connecter." + }, + "unique_code": { + "header": "Se connecter ou s'inscrire", + "sub_header": "Connectez-vous en utilisant un code unique envoyé à l'adresse e-mail ci-dessus." + } + } + } + }, + "forgot_password": { + "title": "Réinitialiser votre mot de passe", + "description": "Entrez l'adresse e-mail vérifiée de votre compte utilisateur et nous vous enverrons un lien de réinitialisation du mot de passe.", + "email_sent": "Nous avons envoyé le lien de réinitialisation à votre adresse e-mail", + "send_reset_link": "Envoyer le lien de réinitialisation", + "errors": { + "smtp_not_enabled": "Nous constatons que votre administrateur n'a pas activé SMTP, nous ne pourrons pas envoyer de lien de réinitialisation du mot de passe" + }, + "toast": { + "success": { + "title": "E-mail envoyé", + "message": "Vérifiez votre boîte de réception pour un lien de réinitialisation de votre mot de passe. S'il n'apparaît pas dans quelques minutes, vérifiez votre dossier spam." + }, + "error": { + "title": "Erreur !", + "message": "Une erreur s'est produite. Veuillez réessayer." + } + } + }, + "reset_password": { + "title": "Définir un nouveau mot de passe", + "description": "Sécurisez votre compte avec un mot de passe fort" + }, + "set_password": { + "title": "Sécurisez votre compte", + "description": "La définition d'un mot de passe vous permet de vous connecter en toute sécurité" + }, + "sign_out": { + "toast": { + "error": { + "title": "Erreur !", + "message": "Échec de la déconnexion. Veuillez réessayer." + } + } + } + }, + + "submit": "Soumettre", + "cancel": "Annuler", + "loading": "Chargement", + "error": "Erreur", + "success": "Succès", + "warning": "Avertissement", + "info": "Info", + "close": "Fermer", + "yes": "Oui", + "no": "Non", + "ok": "OK", + "name": "Nom", + "description": "Description", + "search": "Rechercher", + "add_member": "Ajouter un membre", + "adding_members": "Ajout de membres", + "remove_member": "Supprimer le membre", + "add_members": "Ajouter des membres", + "adding_member": "Ajout de membres", + "remove_members": "Supprimer des membres", + "add": "Ajouter", + "adding": "Ajout", + "remove": "Supprimer", + "add_new": "Ajouter nouveau", + "remove_selected": "Supprimer la sélection", + "first_name": "Prénom", + "last_name": "Nom", + "email": "E-mail", + "display_name": "Nom d'affichage", + "role": "Rôle", + "timezone": "Fuseau horaire", + "avatar": "Avatar", + "cover_image": "Image de couverture", + "password": "Mot de passe", + "change_cover": "Changer la couverture", + "language": "Langue", + "saving": "Enregistrement", + "save_changes": "Enregistrer les modifications", + "deactivate_account": "Désactiver le compte", + "deactivate_account_description": "Lors de la désactivation d'un compte, toutes les données et ressources de ce compte seront définitivement supprimées et ne pourront pas être récupérées.", + "profile_settings": "Paramètres du profil", + "your_account": "Votre compte", + "security": "Sécurité", + "activity": "Activité", + "appearance": "Apparence", + "notifications": "Notifications", + "workspaces": "Espaces de travail", + "create_workspace": "Créer un espace de travail", + "invitations": "Invitations", + "summary": "Résumé", + "assigned": "Assigné", + "created": "Créé", + "subscribed": "Abonné", + "you_do_not_have_the_permission_to_access_this_page": "Vous n'avez pas la permission d'accéder à cette page.", + "something_went_wrong_please_try_again": "Une erreur s'est produite. Veuillez réessayer.", + "load_more": "Charger plus", + "select_or_customize_your_interface_color_scheme": "Sélectionnez ou personnalisez votre schéma de couleurs d'interface.", + "theme": "Thème", + "system_preference": "Préférence système", + "light": "Clair", + "dark": "Sombre", + "light_contrast": "Contraste élevé clair", + "dark_contrast": "Contraste élevé sombre", + "custom": "Thème personnalisé", + "select_your_theme": "Sélectionnez votre thème", + "customize_your_theme": "Personnalisez votre thème", + "background_color": "Couleur de fond", + "text_color": "Couleur du texte", + "primary_color": "Couleur principale (Thème)", + "sidebar_background_color": "Couleur de fond de la barre latérale", + "sidebar_text_color": "Couleur du texte de la barre latérale", + "set_theme": "Définir le thème", + "enter_a_valid_hex_code_of_6_characters": "Entrez un code hexadécimal valide de 6 caractères", + "background_color_is_required": "La couleur de fond est requise", + "text_color_is_required": "La couleur du texte est requise", + "primary_color_is_required": "La couleur principale est requise", + "sidebar_background_color_is_required": "La couleur de fond de la barre latérale est requise", + "sidebar_text_color_is_required": "La couleur du texte de la barre latérale est requise", + "updating_theme": "Mise à jour du thème", + "theme_updated_successfully": "Thème mis à jour avec succès", + "failed_to_update_the_theme": "Échec de la mise à jour du thème", + "email_notifications": "Notifications par e-mail", + "stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "Restez informé des éléments de travail auxquels vous êtes abonné. Activez ceci pour être notifié.", + "email_notification_setting_updated_successfully": "Paramètre de notification par e-mail mis à jour avec succès", + "failed_to_update_email_notification_setting": "Échec de la mise à jour du paramètre de notification par e-mail", + "notify_me_when": "Me notifier quand", + "property_changes": "Modifications des propriétés", + "property_changes_description": "Me notifier lorsque les propriétés des éléments de travail comme les assignés, la priorité, les estimations ou autre changent.", + "state_change": "Changement d'état", + "state_change_description": "Me notifier lorsque les éléments de travail passent à un état différent", + "issue_completed": "Élément de travail terminé", + "issue_completed_description": "Me notifier uniquement lorsqu'un élément de travail est terminé", + "comments": "Commentaires", + "comments_description": "Me notifier lorsque quelqu'un laisse un commentaire sur l'élément de travail", + "mentions": "Mentions", + "mentions_description": "Me notifier uniquement lorsque quelqu'un me mentionne dans les commentaires ou la description", + "old_password": "Ancien mot de passe", + "general_settings": "Paramètres généraux", + "sign_out": "Se déconnecter", + "signing_out": "Déconnexion", + "active_cycles": "Cycles actifs", + "active_cycles_description": "Surveillez les cycles à travers les projets, suivez les éléments de travail prioritaires et zoomez sur les cycles qui nécessitent de l'attention.", + "on_demand_snapshots_of_all_your_cycles": "Instantanés à la demande de tous vos cycles", + "upgrade": "Mettre à niveau", + "10000_feet_view": "Vue à 10 000 pieds de tous les cycles actifs.", + "10000_feet_view_description": "Dézoomez pour voir les cycles en cours dans tous vos projets en même temps au lieu de passer d'un cycle à l'autre dans chaque projet.", + "get_snapshot_of_each_active_cycle": "Obtenez un aperçu de chaque cycle actif.", + "get_snapshot_of_each_active_cycle_description": "Suivez les métriques de haut niveau pour tous les cycles actifs, voyez leur état d'avancement et obtenez une idée de la portée par rapport aux échéances.", + "compare_burndowns": "Comparez les burndowns.", + "compare_burndowns_description": "Surveillez les performances de chacune de vos équipes en jetant un coup d'œil au rapport burndown de chaque cycle.", + "quickly_see_make_or_break_issues": "Voyez rapidement les éléments de travail critiques.", + "quickly_see_make_or_break_issues_description": "Prévisualisez les éléments de travail hautement prioritaires pour chaque cycle par rapport aux dates d'échéance. Voyez-les tous par cycle en un clic.", + "zoom_into_cycles_that_need_attention": "Zoomez sur les cycles qui nécessitent de l'attention.", + "zoom_into_cycles_that_need_attention_description": "Examinez l'état de tout cycle qui ne correspond pas aux attentes en un clic.", + "stay_ahead_of_blockers": "Anticipez les blocages.", + "stay_ahead_of_blockers_description": "Repérez les défis d'un projet à l'autre et voyez les dépendances inter-cycles qui ne sont pas évidentes depuis une autre vue.", + "analytics": "Analyses", + "workspace_invites": "Invitations à l'espace de travail", + "enter_god_mode": "Entrer en mode dieu", + "workspace_logo": "Logo de l'espace de travail", + "new_issue": "Nouvel élément de travail", + "your_work": "Votre travail", + "drafts": "Brouillons", + "projects": "Projets", + "views": "Vues", + "workspace": "Espace de travail", + "archives": "Archives", + "settings": "Paramètres", + "failed_to_move_favorite": "Échec du déplacement du favori", + "favorites": "Favoris", + "no_favorites_yet": "Pas encore de favoris", + "create_folder": "Créer un dossier", + "new_folder": "Nouveau dossier", + "favorite_updated_successfully": "Favori mis à jour avec succès", + "favorite_created_successfully": "Favori créé avec succès", + "folder_already_exists": "Le dossier existe déjà", + "folder_name_cannot_be_empty": "Le nom du dossier ne peut pas être vide", + "something_went_wrong": "Une erreur s'est produite", + "failed_to_reorder_favorite": "Échec de la réorganisation du favori", + "favorite_removed_successfully": "Favori supprimé avec succès", + "failed_to_create_favorite": "Échec de la création du favori", + "failed_to_rename_favorite": "Échec du renommage du favori", + "project_link_copied_to_clipboard": "Lien du projet copié dans le presse-papiers", + "link_copied": "Lien copié", + "add_project": "Ajouter un projet", + "create_project": "Créer un projet", + "failed_to_remove_project_from_favorites": "Impossible de supprimer le projet des favoris. Veuillez réessayer.", + "project_created_successfully": "Projet créé avec succès", + "project_created_successfully_description": "Projet créé avec succès. Vous pouvez maintenant commencer à ajouter des éléments de travail.", + "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", + "project_name": "Nom du projet", + "project_id_must_be_at_least_1_character": "L'ID du projet doit comporter au moins 1 caractère", + "project_id_must_be_at_most_5_characters": "L'ID du projet doit comporter au plus 5 caractères", + "project_id": "ID du projet", + "project_id_tooltip_content": "Vous aide à identifier uniquement les éléments de travail dans le projet. Maximum 5 caractères.", + "description_placeholder": "Description", + "only_alphanumeric_non_latin_characters_allowed": "Seuls les caractères alphanumériques et non latins sont autorisés.", + "project_id_is_required": "L'ID du projet est requis", + "project_id_allowed_char": "Seuls les caractères alphanumériques et non latins sont autorisés.", + "project_id_min_char": "L'ID du projet doit comporter au moins 1 caractère", + "project_id_max_char": "L'ID du projet doit comporter au plus 5 caractères", + "project_description_placeholder": "Entrez la description du projet", + "select_network": "Sélectionner le réseau", + "lead": "Responsable", + "date_range": "Plage de dates", + "private": "Privé", + "public": "Public", + "accessible_only_by_invite": "Accessible uniquement sur invitation", + "anyone_in_the_workspace_except_guests_can_join": "Tout le monde dans l'espace de travail sauf les invités peut rejoindre", + "creating": "Création", + "creating_project": "Création du projet", + "adding_project_to_favorites": "Ajout du projet aux favoris", + "project_added_to_favorites": "Projet ajouté aux favoris", + "couldnt_add_the_project_to_favorites": "Impossible d'ajouter le projet aux favoris. Veuillez réessayer.", + "removing_project_from_favorites": "Suppression du projet des favoris", + "project_removed_from_favorites": "Projet supprimé des favoris", + "couldnt_remove_the_project_from_favorites": "Impossible de supprimer le projet des favoris. Veuillez réessayer.", + "add_to_favorites": "Ajouter aux favoris", + "remove_from_favorites": "Supprimer des favoris", + "publish_settings": "Paramètres de publication", + "publish": "Publier", + "copy_link": "Copier le lien", + "leave_project": "Quitter le projet", + "join_the_project_to_rearrange": "Rejoignez le projet pour réorganiser", + "drag_to_rearrange": "Glisser pour réorganiser", + "congrats": "Félicitations !", + "open_project": "Ouvrir le projet", + "issues": "Éléments de travail", + "cycles": "Cycles", + "modules": "Modules", + "pages": "Pages", + "intake": "Intake", + "time_tracking": "Suivi du temps", + "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.", + "work_management_description": "Gérez votre travail et vos projets facilement.", + "documentation": "Documentation", + "message_support": "Contacter le support", + "contact_sales": "Contacter les ventes", + "hyper_mode": "Mode Hyper", + "keyboard_shortcuts": "Raccourcis clavier", + "whats_new": "Quoi de neuf ?", + "version": "Version", + "we_are_having_trouble_fetching_the_updates": "Nous avons des difficultés à récupérer les mises à jour.", + "our_changelogs": "nos journaux des modifications", + "for_the_latest_updates": "pour les dernières mises à jour.", + "please_visit": "Veuillez visiter", + "docs": "Documentation", + "full_changelog": "Journal des modifications complet", + "support": "Support", + "discord": "Discord", + "powered_by_plane_pages": "Propulsé par Plane Pages", + "please_select_at_least_one_invitation": "Veuillez sélectionner au moins une invitation.", + "please_select_at_least_one_invitation_description": "Veuillez sélectionner au moins une invitation pour rejoindre l'espace de travail.", + "we_see_that_someone_has_invited_you_to_join_a_workspace": "Nous voyons que quelqu'un vous a invité à rejoindre un espace de travail", + "join_a_workspace": "Rejoindre un espace de travail", + "we_see_that_someone_has_invited_you_to_join_a_workspace_description": "Nous voyons que quelqu'un vous a invité à rejoindre un espace de travail", + "join_a_workspace_description": "Rejoindre un espace de travail", + "accept_and_join": "Accepter et rejoindre", + "go_home": "Aller à l'accueil", + "no_pending_invites": "Aucune invitation en attente", + "you_can_see_here_if_someone_invites_you_to_a_workspace": "Vous pouvez voir ici si quelqu'un vous invite à un espace de travail", + "back_to_home": "Retour à l'accueil", + "workspace_name": "nom-espace-de-travail", + "deactivate_your_account": "Désactiver votre compte", + "deactivate_your_account_description": "Une fois désactivé, vous ne pourrez plus être assigné à des éléments de travail ni être facturé pour votre espace de travail. Pour réactiver votre compte, vous aurez besoin d'une invitation à un espace de travail avec cette adresse e-mail.", + "deactivating": "Désactivation", + "confirm": "Confirmer", + "confirming": "Confirmation", + "draft_created": "Brouillon créé", + "issue_created_successfully": "Élément de travail créé avec succès", + "draft_creation_failed": "Échec de la création du brouillon", + "issue_creation_failed": "Échec de la création de l'élément de travail", + "draft_issue": "Élément de travail en brouillon", + "issue_updated_successfully": "Élément de travail mis à jour avec succès", + "issue_could_not_be_updated": "L'élément de travail n'a pas pu être mis à jour", + "create_a_draft": "Créer un brouillon", + "save_to_drafts": "Enregistrer dans les brouillons", + "save": "Enregistrer", + "update": "Mettre à jour", + "updating": "Mise à jour", + "create_new_issue": "Créer un nouvel élément de travail", + "editor_is_not_ready_to_discard_changes": "L'éditeur n'est pas prêt à annuler les modifications", + "failed_to_move_issue_to_project": "Échec du déplacement de l'élément de travail vers le projet", + "create_more": "Créer plus", + "add_to_project": "Ajouter au projet", + "discard": "Annuler", + "duplicate_issue_found": "Élément de travail en double trouvé", + "duplicate_issues_found": "Éléments de travail en double trouvés", + "no_matching_results": "Aucun résultat correspondant", + "title_is_required": "Le titre est requis", + "title": "Titre", + "state": "État", + "priority": "Priorité", + "none": "Aucun", + "urgent": "Urgent", + "high": "Élevé", + "medium": "Moyen", + "low": "Faible", + "members": "Membres", + "assignee": "Assigné", + "assignees": "Assignés", + "you": "Vous", + "labels": "Étiquettes", + "create_new_label": "Créer une nouvelle étiquette", + "start_date": "Date de début", + "end_date": "Date de fin", + "due_date": "Date d'échéance", + "estimate": "Estimation", + "change_parent_issue": "Changer l'élément de travail parent", + "remove_parent_issue": "Supprimer l'élément de travail parent", + "add_parent": "Ajouter un parent", + "loading_members": "Chargement des membres", + "view_link_copied_to_clipboard": "Lien de la vue copié dans le presse-papiers.", + "required": "Requis", + "optional": "Optionnel", + "Cancel": "Annuler", + "edit": "Modifier", + "archive": "Archiver", + "restore": "Restaurer", + "open_in_new_tab": "Ouvrir dans un nouvel onglet", + "delete": "Supprimer", + "deleting": "Suppression", + "make_a_copy": "Faire une copie", + "move_to_project": "Déplacer vers le projet", + "good": "Bonjour", + "morning": "matin", + "afternoon": "après-midi", + "evening": "soir", + "show_all": "Tout afficher", + "show_less": "Afficher moins", + "no_data_yet": "Pas encore de données", + "syncing": "Synchronisation", + "add_work_item": "Ajouter un élément de travail", + "advanced_description_placeholder": "Appuyez sur '/' pour les commandes", + "create_work_item": "Créer un élément de travail", + "attachments": "Pièces jointes", + "declining": "Refus", + "declined": "Refusé", + "decline": "Refuser", + "unassigned": "Non assigné", + "work_items": "Éléments de travail", + "add_link": "Ajouter un lien", + "points": "Points", + "no_assignee": "Pas d'assigné", + "no_assignees_yet": "Pas encore d'assignés", + "no_labels_yet": "Pas encore d'étiquettes", + "ideal": "Idéal", + "current": "Actuel", + "no_matching_members": "Aucun membre correspondant", + "leaving": "Départ", + "removing": "Suppression", + "leave": "Quitter", + "refresh": "Actualiser", + "refreshing": "Actualisation", + "refresh_status": "Actualiser l'état", + "prev": "Précédent", + "next": "Suivant", + "re_generating": "Régénération", + "re_generate": "Régénérer", + "re_generate_key": "Régénérer la clé", + "export": "Exporter", + "member": "{count, plural, one{# membre} other{# membres}}", + + "project_view": { + "sort_by": { + "created_at": "Créé le", + "updated_at": "Mis à jour le", + "name": "Nom" + } + }, + + "toast": { + "success": "Succès !", + "error": "Erreur !" + }, + + "links": { + "toasts": { + "created": { + "title": "Lien créé", + "message": "Le lien a été créé avec succès" + }, + "not_created": { + "title": "Lien non créé", + "message": "Le lien n'a pas pu être créé" + }, + "updated": { + "title": "Lien mis à jour", + "message": "Le lien a été mis à jour avec succès" + }, + "not_updated": { + "title": "Lien non mis à jour", + "message": "Le lien n'a pas pu être mis à jour" + }, + "removed": { + "title": "Lien supprimé", + "message": "Le lien a été supprimé avec succès" + }, + "not_removed": { + "title": "Lien non supprimé", + "message": "Le lien n'a pas pu être supprimé" + } + } + }, + + "home": { + "empty": { + "quickstart_guide": "Guide de démarrage rapide", + "not_right_now": "Pas maintenant", + "create_project": { + "title": "Créer un projet", + "description": "La plupart des choses commencent par un projet dans Plane.", + "cta": "Commencer" + }, + "invite_team": { + "title": "Inviter votre équipe", + "description": "Construisez, déployez et gérez avec vos collègues.", + "cta": "Les faire entrer" + }, + "configure_workspace": { + "title": "Configurez votre espace de travail.", + "description": "Activez ou désactivez des fonctionnalités ou allez plus loin.", + "cta": "Configurer cet espace de travail" + }, + "personalize_account": { + "title": "Faites de Plane le vôtre.", + "description": "Choisissez votre photo, vos couleurs et plus encore.", + "cta": "Personnaliser maintenant" + }, + "widgets": { + "title": "C'est calme sans widgets, activez-les", + "description": "Il semble que tous vos widgets soient désactivés. Activez-les\nmaintenant pour améliorer votre expérience !", + "primary_button": { + "text": "Gérer les widgets" + } + } + }, + "quick_links": { + "empty": "Enregistrez des liens vers des éléments de travail que vous souhaitez avoir à portée de main.", + "add": "Ajouter un lien rapide", + "title": "Lien rapide", + "title_plural": "Liens rapides" + }, + "recents": { + "title": "Récents", + "empty": { + "project": "Vos projets récents apparaîtront ici une fois que vous en aurez visité un.", + "page": "Vos pages récentes apparaîtront ici une fois que vous en aurez visité une.", + "issue": "Vos éléments de travail récents apparaîtront ici une fois que vous en aurez visité un.", + "default": "Vous n'avez pas encore d'éléments récents." + }, + "filters": { + "all": "Tous les éléments", + "projects": "Projets", + "pages": "Pages", + "issues": "Éléments de travail" + } + }, + "new_at_plane": { + "title": "Nouveau sur Plane" + }, + "quick_tutorial": { + "title": "Tutoriel rapide" + }, + "widget": { + "reordered_successfully": "Widget réorganisé avec succès.", + "reordering_failed": "Une erreur s'est produite lors de la réorganisation du widget." + }, + "manage_widgets": "Gérer les widgets", + "title": "Accueil", + "star_us_on_github": "Donnez-nous une étoile sur GitHub" + }, + + "link": { + "modal": { + "url": { + "text": "URL", + "required": "L'URL n'est pas valide", + "placeholder": "Tapez ou collez une URL" + }, + "title": { + "text": "Titre d'affichage", + "placeholder": "Comment souhaitez-vous voir ce lien" + } + } + }, + + "common": { + "all": "Tout", + "states": "États", + "state": "État", + "state_groups": "Groupes d'états", + "priority": "Priorité", + "team_project": "Projet d'équipe", + "project": "Projet", + "cycle": "Cycle", + "cycles": "Cycles", + "module": "Module", + "modules": "Modules", + "labels": "Étiquettes", + "assignees": "Assignés", + "assignee": "Assigné", + "created_by": "Créé par", + "none": "Aucun", + "link": "Lien", + "estimate": "Estimation", + "layout": "Disposition", + "filters": "Filtres", + "display": "Affichage", + "load_more": "Charger plus", + "activity": "Activité", + "analytics": "Analyses", + "dates": "Dates", + "success": "Succès !", + "something_went_wrong": "Quelque chose s'est mal passé", + "error": { + "label": "Erreur !", + "message": "Une erreur s'est produite. Veuillez réessayer." + }, + "group_by": "Grouper par", + "epic": "Epic", + "epics": "Epics", + "work_item": "Élément de travail", + "work_items": "Éléments de travail", + "sub_work_item": "Sous-élément de travail", + "add": "Ajouter", + "warning": "Avertissement", + "updating": "Mise à jour", + "adding": "Ajout", + "update": "Mettre à jour", + "creating": "Création", + "create": "Créer", + "cancel": "Annuler", + "description": "Description", + "title": "Titre", + "attachment": "Pièce jointe", + "general": "Général", + "features": "Fonctionnalités", + "automation": "Automatisation", + "project_name": "Nom du projet", + "project_id": "ID du projet", + "project_timezone": "Fuseau horaire du projet", + "created_on": "Créé le", + "update_project": "Mettre à jour le projet", + "identifier_already_exists": "L'identifiant existe déjà", + "add_more": "Ajouter plus", + "defaults": "Par défaut", + "add_label": "Ajouter une étiquette", + "estimates": "Estimations", + "customize_time_range": "Personnaliser la plage de temps", + "loading": "Chargement", + "attachments": "Pièces jointes", + "property": "Propriété", + "properties": "Propriétés", + "parent": "Parent", + "remove": "Supprimer", + "archiving": "Archivage", + "archive": "Archiver", + "access": { + "public": "Public", + "private": "Privé" + }, + "done": "Terminé", + "sub_work_items": "Sous-éléments de travail", + "comment": "Commentaire", + "workspace_level": "Niveau espace de travail", + "order_by": { + "label": "Trier par", + "manual": "Manuel", + "last_created": "Dernier créé", + "last_updated": "Dernière mise à jour", + "start_date": "Date de début", + "due_date": "Date d'échéance", + "asc": "Croissant", + "desc": "Décroissant", + "updated_on": "Mis à jour le" + }, + "sort": { + "asc": "Croissant", + "desc": "Décroissant", + "created_on": "Créé le", + "updated_on": "Mis à jour le" + }, + "comments": "Commentaires", + "updates": "Mises à jour", + "clear_all": "Tout effacer", + "copied": "Copié !", + "link_copied": "Lien copié !", + "link_copied_to_clipboard": "Lien copié dans le presse-papiers", + "copied_to_clipboard": "Lien de l'élément de travail copié dans le presse-papiers", + "is_copied_to_clipboard": "L'élément de travail est copié dans le presse-papiers", + "no_links_added_yet": "Aucun lien ajouté pour l'instant", + "add_link": "Ajouter un lien", + "links": "Liens", + "go_to_workspace": "Aller à l'espace de travail", + "progress": "Progression", + "optional": "Optionnel", + "join": "Rejoindre", + "go_back": "Retour", + "continue": "Continuer", + "resend": "Renvoyer", + "relations": "Relations", + "errors": { + "default": { + "title": "Erreur !", + "message": "Quelque chose s'est mal passé. Veuillez réessayer." + }, + "required": "Ce champ est obligatoire", + "entity_required": "{entity} est requis" + }, + "update_link": "Mettre à jour le lien", + "attach": "Joindre", + "create_new": "Créer nouveau", + "add_existing": "Ajouter existant", + "type_or_paste_a_url": "Tapez ou collez une URL", + "url_is_invalid": "L'URL n'est pas valide", + "display_title": "Titre d'affichage", + "link_title_placeholder": "Comment souhaitez-vous voir ce lien", + "url": "URL", + "side_peek": "Aperçu latéral", + "modal": "Modal", + "full_screen": "Plein écran", + "close_peek_view": "Fermer l'aperçu", + "toggle_peek_view_layout": "Basculer la disposition de l'aperçu", + "options": "Options", + "duration": "Durée", + "today": "Aujourd'hui", + "week": "Semaine", + "month": "Mois", + "quarter": "Trimestre", + "press_for_commands": "Appuyez sur '/' pour les commandes", + "click_to_add_description": "Cliquez pour ajouter une description", + "search": { + "label": "Rechercher", + "placeholder": "Tapez pour rechercher", + "no_matches_found": "Aucune correspondance trouvée", + "no_matching_results": "Aucun résultat correspondant" + }, + "actions": { + "edit": "Modifier", + "make_a_copy": "Faire une copie", + "open_in_new_tab": "Ouvrir dans un nouvel onglet", + "copy_link": "Copier le lien", + "archive": "Archiver", + "delete": "Supprimer", + "remove_relation": "Supprimer la relation", + "subscribe": "S'abonner", + "unsubscribe": "Se désabonner", + "clear_sorting": "Effacer le tri", + "show_weekends": "Afficher les week-ends", + "enable": "Activer", + "disable": "Désactiver" + }, + "name": "Nom", + "discard": "Abandonner", + "confirm": "Confirmer", + "confirming": "Confirmation", + "read_the_docs": "Lire la documentation", + "default": "Par défaut", + "active": "Actif", + "enabled": "Activé", + "disabled": "Désactivé", + "mandate": "Mandat", + "mandatory": "Obligatoire", + "yes": "Oui", + "no": "Non", + "please_wait": "Veuillez patienter", + "enabling": "Activation", + "disabling": "Désactivation", + "beta": "Bêta", + "or": "ou", + "next": "Suivant", + "back": "Retour", + "cancelling": "Annulation", + "configuring": "Configuration", + "clear": "Effacer", + "import": "Importer", + "connect": "Connecter", + "authorizing": "Autorisation", + "processing": "Traitement", + "no_data_available": "Aucune donnée disponible", + "from": "de {name}", + "authenticated": "Authentifié", + "select": "Sélectionner", + "upgrade": "Mettre à niveau", + "add_seats": "Ajouter des sièges", + "label": "Étiquette", + "priorities": "Priorités", + "projects": "Projets", + "workspace": "Espace de travail", + "workspaces": "Espaces de travail", + "team": "Équipe", + "teams": "Équipes", + "entity": "Entité", + "entities": "Entités", + "task": "Tâche", + "tasks": "Tâches", + "section": "Section", + "sections": "Sections", + "edit": "Modifier", + "connecting": "Connexion", + "connected": "Connecté", + "disconnect": "Déconnecter", + "disconnecting": "Déconnexion", + "installing": "Installation", + "install": "Installer", + "reset": "Réinitialiser", + "live": "En direct", + "change_history": "Historique des modifications", + "coming_soon": "À venir", + "members": "Membres", + "you": "Vous", + "upgrade_cta": { + "higher_subscription": "Passer à une abonnement plus élevé", + "talk_to_sales": "Parler à la vente" + }, + "category": "Catégorie", + "categories": "Catégories", + "saving": "Enregistrement", + "save_changes": "Enregistrer les modifications", + "delete": "Supprimer", + "deleting": "Suppression", + "pending": "En attente", + "invite": "Inviter" + }, + + "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}", + "all": "Tous les {entity}", + "drop_here_to_move": "Déposez ici pour déplacer le {entity}", + "delete": { + "label": "Supprimer {entity}", + "success": "{entity} supprimé avec succès", + "failed": "Échec de la suppression de {entity}" + }, + "update": { + "failed": "Échec de la mise à jour de {entity}", + "success": "{entity} mis à jour avec succès" + }, + "link_copied_to_clipboard": "Lien {entity} copié dans le presse-papiers", + "fetch": { + "failed": "Erreur lors de la récupération de {entity}" + }, + "add": { + "success": "{entity} ajouté avec succès", + "failed": "Erreur lors de l'ajout de {entity}" + } + }, + + "epic": { + "all": "Tous les Epics", + "label": "{count, plural, one {Epic} other {Epics}}", + "new": "Nouvel Epic", + "adding": "Ajout d'un epic", + "create": { + "success": "Epic créé avec succès" + }, + "add": { + "press_enter": "Appuyez sur 'Entrée' pour ajouter un autre epic", + "label": "Ajouter un Epic" + }, + "title": { + "label": "Titre de l'Epic", + "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", + "edit": "Modifier l'élément de travail", + "title": { + "label": "Titre de l'élément de travail", + "required": "Le titre de l'élément de travail est requis." + }, + "add": { + "press_enter": "Appuyez sur 'Entrée' pour ajouter un autre élément de travail", + "label": "Ajouter un élément de travail", + "cycle": { + "failed": "L'élément de travail n'a pas pu être ajouté au cycle. Veuillez réessayer.", + "success": "{count, plural, one {Élément de travail} other {Éléments de travail}} ajouté(s) au cycle avec succès.", + "loading": "Ajout de {count, plural, one {l'élément de travail} other {éléments de travail}} au cycle" + }, + "assignee": "Ajouter des assignés", + "start_date": "Ajouter une date de début", + "due_date": "Ajouter une date d'échéance", + "parent": "Ajouter un élément de travail parent", + "sub_issue": "Ajouter un sous-élément de travail", + "relation": "Ajouter une relation", + "link": "Ajouter un lien", + "existing": "Ajouter un élément de travail existant" + }, + "remove": { + "label": "Supprimer l'élément de travail", + "cycle": { + "loading": "Suppression de l'élément de travail du cycle", + "success": "Élément de travail supprimé du cycle avec succès.", + "failed": "L'élément de travail n'a pas pu être supprimé du cycle. Veuillez réessayer." + }, + "module": { + "loading": "Suppression de l'élément de travail du module", + "success": "Élément de travail supprimé du module avec succès.", + "failed": "L'élément de travail n'a pas pu être supprimé du module. Veuillez réessayer." + }, + "parent": { + "label": "Supprimer l'élément de travail parent" + } + }, + "new": "Nouvel élément de travail", + "adding": "Ajout d'un élément de travail", + "create": { + "success": "Élément de travail créé avec succès" + }, + "priority": { + "urgent": "Urgent", + "high": "Haute", + "medium": "Moyenne", + "low": "Basse" + }, + "display": { + "properties": { + "label": "Propriétés d'affichage", + "id": "ID", + "issue_type": "Type d'élément de travail", + "sub_issue_count": "Nombre de sous-éléments", + "attachment_count": "Nombre de pièces jointes", + "created_on": "Créé le", + "sub_issue": "Sous-élément de travail" + }, + "extra": { + "show_sub_issues": "Afficher les sous-éléments", + "show_empty_groups": "Afficher les groupes vides" + } + }, + "layouts": { + "ordered_by_label": "Cette disposition est triée par", + "list": "Liste", + "kanban": "Tableau", + "calendar": "Calendrier", + "spreadsheet": "Tableau", + "gantt": "Chronologie", + "title": { + "list": "Disposition en liste", + "kanban": "Disposition en tableau", + "calendar": "Disposition en calendrier", + "spreadsheet": "Disposition en tableau", + "gantt": "Disposition en chronologie" + } + }, + "states": { + "active": "Actif", + "backlog": "Backlog" + }, + "comments": { + "placeholder": "Ajouter un commentaire", + "switch": { + "private": "Passer en commentaire privé", + "public": "Passer en commentaire public" + }, + "create": { + "success": "Commentaire créé avec succès", + "error": "Échec de la création du commentaire. Veuillez réessayer plus tard." + }, + "update": { + "success": "Commentaire mis à jour avec succès", + "error": "Échec de la mise à jour du commentaire. Veuillez réessayer plus tard." + }, + "remove": { + "success": "Commentaire supprimé avec succès", + "error": "Échec de la suppression du commentaire. Veuillez réessayer plus tard." + }, + "upload": { + "error": "Échec du téléchargement du fichier. Veuillez réessayer plus tard." + } + }, + "empty_state": { + "issue_detail": { + "title": "L'élément de travail n'existe pas", + "description": "L'élément de travail que vous recherchez n'existe pas, a été archivé ou a été supprimé.", + "primary_button": { + "text": "Voir les autres éléments de travail" + } + } + }, + "sibling": { + "label": "Éléments de travail frères" + }, + "archive": { + "description": "Seuls les éléments de travail\nterminés ou annulés peuvent être archivés", + "label": "Archiver l'élément de travail", + "confirm_message": "Êtes-vous sûr de vouloir archiver l'élément de travail ? Tous vos éléments archivés peuvent être restaurés ultérieurement.", + "success": { + "label": "Archivage réussi", + "message": "Vos archives se trouvent dans les archives du projet." + }, + "failed": { + "message": "L'élément de travail n'a pas pu être archivé. Veuillez réessayer." + } + }, + "restore": { + "success": { + "title": "Restauration réussie", + "message": "Votre élément de travail se trouve dans les éléments de travail du projet." + }, + "failed": { + "message": "L'élément de travail n'a pas pu être restauré. Veuillez réessayer." + } + }, + "relation": { + "relates_to": "En relation avec", + "duplicate": "Doublon de", + "blocked_by": "Bloqué par", + "blocking": "Bloque" + }, + "copy_link": "Copier le lien de l'élément de travail", + "delete": { + "label": "Supprimer l'élément de travail", + "error": "Erreur lors de la suppression de l'élément de travail" + }, + "subscription": { + "actions": { + "subscribed": "Abonnement à l'élément de travail réussi", + "unsubscribed": "Désabonnement de l'élément de travail réussi" + } + }, + "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" + }, + "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.", + "file_size_limit": "Le fichier doit faire {size}MB ou moins.", + "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": { + "success": "Étiquette créée avec succès", + "failed": "Échec de la création de l'étiquette", + "already_exists": "L'étiquette existe déjà", + "type": "Tapez pour ajouter une nouvelle étiquette" + } + }, + + "sub_work_item": { + "update": { + "success": "Sous-élément de travail mis à jour avec succès", + "error": "Erreur lors de la mise à jour du sous-élément de travail" + }, + "remove": { + "success": "Sous-élément de travail supprimé avec succès", + "error": "Erreur lors de la suppression du sous-élément de travail" + } + }, + + "view": { + "label": "{count, plural, one {Vue} other {Vues}}", + "create": { + "label": "Créer une vue" + }, + "update": { + "label": "Mettre à jour la vue" + } + }, + + "inbox_issue": { + "status": { + "pending": { + "title": "En attente", + "description": "En attente" + }, + "declined": { + "title": "Refusé", + "description": "Refusé" + }, + "snoozed": { + "title": "Reporté", + "description": "{days, plural, one{# jour} other{# jours}} restant(s)" + }, + "accepted": { + "title": "Accepté", + "description": "Accepté" + }, + "duplicate": { + "title": "Doublon", + "description": "Doublon" + } + }, + "modals": { + "decline": { + "title": "Refuser l'élément de travail", + "content": "Êtes-vous sûr de vouloir refuser l'élément de travail {value} ?" + }, + "delete": { + "title": "Supprimer l'élément de travail", + "content": "Êtes-vous sûr de vouloir supprimer l'élément de travail {value} ?", + "success": "Élément de travail supprimé avec succès" + } + }, + "errors": { + "snooze_permission": "Seuls les administrateurs du projet peuvent reporter/annuler le report des éléments de travail", + "accept_permission": "Seuls les administrateurs du projet peuvent accepter les éléments de travail", + "decline_permission": "Seuls les administrateurs du projet peuvent refuser les éléments de travail" + }, + "actions": { + "accept": "Accepter", + "decline": "Refuser", + "snooze": "Reporter", + "unsnooze": "Annuler le report", + "copy": "Copier le lien de l'élément de travail", + "delete": "Supprimer", + "open": "Ouvrir l'élément de travail", + "mark_as_duplicate": "Marquer comme doublon", + "move": "Déplacer {value} vers les éléments de travail du projet" + }, + "source": { + "in-app": "in-app" + }, + "order_by": { + "created_at": "Créé le", + "updated_at": "Mis à jour le", + "id": "ID" + }, + "label": "Intake", + "page_label": "{workspace} - Intake", + "modal": { + "title": "Créer un élément de travail Intake" + }, + "tabs": { + "open": "Ouvert", + "closed": "Fermé" + }, + "empty_state": { + "sidebar_open_tab": { + "title": "Aucun élément de travail ouvert", + "description": "Trouvez les éléments de travail ouverts ici. Créez un nouvel élément de travail." + }, + "sidebar_closed_tab": { + "title": "Aucun élément de travail fermé", + "description": "Tous les éléments de travail, qu'ils soient acceptés ou refusés, peuvent être trouvés ici." + }, + "sidebar_filter": { + "title": "Aucun élément de travail correspondant", + "description": "Aucun élément de travail ne correspond au filtre appliqué dans Intake. Créez un nouvel élément de travail." + }, + "detail": { + "title": "Sélectionnez un élément de travail pour voir ses détails." + } + } + }, + + "workspace_creation": { + "heading": "Créez votre espace de travail", + "subheading": "Pour commencer à utiliser Plane, vous devez créer ou rejoindre un espace de travail.", + "form": { + "name": { + "label": "Nommez votre espace de travail", + "placeholder": "Quelque chose de familier et reconnaissable est toujours préférable." + }, + "url": { + "label": "Définissez l'URL de votre espace de travail", + "placeholder": "Tapez ou collez une URL", + "edit_slug": "Vous ne pouvez modifier que le slug de l'URL" + }, + "organization_size": { + "label": "Combien de personnes utiliseront cet espace de travail ?", + "placeholder": "Sélectionnez une plage" + } + }, + "errors": { + "creation_disabled": { + "title": "Seul l'administrateur de votre instance peut créer des espaces de travail", + "description": "Si vous connaissez l'adresse e-mail de votre administrateur d'instance, cliquez sur le bouton ci-dessous pour le contacter.", + "request_button": "Contacter l'administrateur d'instance" + }, + "validation": { + "name_alphanumeric": "Les noms d'espaces de travail ne peuvent contenir que (' '), ('-'), ('_') et des caractères alphanumériques.", + "name_length": "Limitez votre nom à 80 caractères.", + "url_alphanumeric": "Les URL ne peuvent contenir que ('-') et des caractères alphanumériques.", + "url_length": "Limitez votre URL à 48 caractères.", + "url_already_taken": "L'URL de l'espace de travail est déjà prise !" + } + }, + "request_email": { + "subject": "Demande d'un nouvel espace de travail", + "body": "Bonjour administrateur(s) d'instance,\n\nVeuillez créer un nouvel espace de travail avec l'URL [/workspace-name] pour [objectif de création de l'espace de travail].\n\nMerci,\n{firstName} {lastName}\n{email}" + }, + "button": { + "default": "Créer l'espace de travail", + "loading": "Création de l'espace de travail" + }, + "toast": { + "success": { + "title": "Succès", + "message": "Espace de travail créé avec succès" + }, + "error": { + "title": "Erreur", + "message": "L'espace de travail n'a pas pu être créé. Veuillez réessayer." + } + } + }, + + "workspace_dashboard": { + "empty_state": { + "general": { + "title": "Aperçu de vos projets, activités et métriques", + "description": "Bienvenue sur Plane, nous sommes ravis de vous avoir parmi nous. Créez votre premier projet et suivez vos éléments de travail, et cette page se transformera en un espace qui vous aide à progresser. Les administrateurs verront également les éléments qui aident leur équipe à progresser.", + "primary_button": { + "text": "Construisez votre premier projet", + "comic": { + "title": "Tout commence par un projet dans Plane", + "description": "Un projet peut être la feuille de route d'un produit, une campagne marketing ou le lancement d'une nouvelle voiture." + } + } + } + } + }, + + "workspace_analytics": { + "label": "Analytique", + "page_label": "{workspace} - Analytique", + "open_tasks": "Total des tâches ouvertes", + "error": "Une erreur s'est produite lors de la récupération des données.", + "work_items_closed_in": "Éléments de travail fermés dans", + "selected_projects": "Projets sélectionnés", + "total_members": "Total des membres", + "total_cycles": "Total des Cycles", + "total_modules": "Total des Modules", + "pending_work_items": { + "title": "Éléments de travail en attente", + "empty_state": "L'analyse des éléments de travail en attente par collègues apparaît ici." + }, + "work_items_closed_in_a_year": { + "title": "Éléments de travail fermés dans l'année", + "empty_state": "Fermez des éléments de travail pour voir leur analyse sous forme de graphique." + }, + "most_work_items_created": { + "title": "Plus d'éléments de travail créés", + "empty_state": "Les collègues et le nombre d'éléments de travail créés par eux apparaissent ici." + }, + "most_work_items_closed": { + "title": "Plus d'éléments de travail fermés", + "empty_state": "Les collègues et le nombre d'éléments de travail fermés par eux apparaissent ici." + }, + "tabs": { + "scope_and_demand": "Portée et Demande", + "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." + } + } + } + } + }, + + "workspace_projects": { + "label": "{count, plural, one {Projet} other {Projets}}", + "create": { + "label": "Ajouter un Projet" + }, + "network": { + "private": { + "title": "Privé", + "description": "Accessible uniquement sur invitation" + }, + "public": { + "title": "Public", + "description": "Accessible à tous dans l'espace de travail sauf les invités" + } + }, + "error": { + "permission": "Vous n'avez pas la permission d'effectuer cette action.", + "cycle_delete": "Échec de la suppression du cycle", + "module_delete": "Échec de la suppression du module", + "issue_delete": "Échec de la suppression de l'élément de travail" + }, + "state": { + "backlog": "Backlog", + "unstarted": "Non commencé", + "started": "Commencé", + "completed": "Terminé", + "cancelled": "Annulé" + }, + "sort": { + "manual": "Manuel", + "name": "Nom", + "created_at": "Date de création", + "members_length": "Nombre de membres" + }, + "scope": { + "my_projects": "Mes projets", + "archived_projects": "Archivés" + }, + "common": { + "months_count": "{months, plural, one{# mois} other{# mois}}" + }, + "empty_state": { + "general": { + "title": "Aucun projet actif", + "description": "Considérez chaque projet comme le parent d'un travail orienté objectif. Les projets sont l'endroit où vivent les Tâches, les Cycles et les Modules et, avec vos collègues, vous aident à atteindre cet objectif. Créez un nouveau projet ou filtrez les projets archivés.", + "primary_button": { + "text": "Commencez votre premier projet", + "comic": { + "title": "Tout commence par un projet dans Plane", + "description": "Un projet peut être la feuille de route d'un produit, une campagne marketing ou le lancement d'une nouvelle voiture." + } + } + }, + "no_projects": { + "title": "Aucun projet", + "description": "Pour créer des éléments de travail ou gérer votre travail, vous devez créer un projet ou faire partie d'un projet.", + "primary_button": { + "text": "Commencez votre premier projet", + "comic": { + "title": "Tout commence par un projet dans Plane", + "description": "Un projet peut être la feuille de route d'un produit, une campagne marketing ou le lancement d'une nouvelle voiture." + } + } + }, + "filter": { + "title": "Aucun projet correspondant", + "description": "Aucun projet détecté avec les critères correspondants. \n Créez plutôt un nouveau projet." + }, + "search": { + "description": "Aucun projet détecté avec les critères correspondants.\nCréez plutôt un nouveau projet" + } + } + }, + + "workspace_views": { + "add_view": "Ajouter une vue", + "empty_state": { + "all-issues": { + "title": "Aucun élément de travail dans le projet", + "description": "Premier projet terminé ! Maintenant, découpez votre travail en morceaux traçables avec des éléments de travail. Allons-y !", + "primary_button": { + "text": "Créer un nouvel élément de travail" + } + }, + "assigned": { + "title": "Aucun élément de travail pour le moment", + "description": "Les éléments de travail qui vous sont assignés peuvent être suivis ici.", + "primary_button": { + "text": "Créer un nouvel élément de travail" + } + }, + "created": { + "title": "Aucun élément de travail pour le moment", + "description": "Tous les éléments de travail que vous créez arrivent ici, suivez-les directement ici.", + "primary_button": { + "text": "Créer un nouvel élément de travail" + } + }, + "subscribed": { + "title": "Aucun élément de travail pour le moment", + "description": "Abonnez-vous aux éléments de travail qui vous intéressent, suivez-les tous ici." + }, + "custom-view": { + "title": "Aucun élément de travail pour le moment", + "description": "Les éléments de travail qui correspondent aux filtres, suivez-les tous ici." + } + } + }, + + "workspace_settings": { + "label": "Paramètres de l'espace de travail", + "page_label": "{workspace} - Paramètres généraux", + "key_created": "Clé créée", + "copy_key": "Copiez et sauvegardez cette clé secrète dans Plane Pages. Vous ne pourrez plus voir cette clé après avoir cliqué sur Fermer. Un fichier CSV contenant la clé a été téléchargé.", + "token_copied": "Jeton copié dans le presse-papiers.", + "settings": { + "general": { + "title": "Général", + "upload_logo": "Télécharger le logo", + "edit_logo": "Modifier le logo", + "name": "Nom de l'espace de travail", + "company_size": "Taille de l'entreprise", + "url": "URL de l'espace de travail", + "update_workspace": "Mettre à jour l'espace de travail", + "delete_workspace": "Supprimer cet espace de travail", + "delete_workspace_description": "Lors de la suppression d'un espace de travail, toutes les données et ressources au sein de cet espace seront définitivement supprimées et ne pourront pas être récupérées.", + "delete_btn": "Supprimer cet espace de travail", + "delete_modal": { + "title": "Êtes-vous sûr de vouloir supprimer cet espace de travail ?", + "description": "Vous avez un essai actif sur l'un de nos forfaits payants. Veuillez d'abord l'annuler pour continuer.", + "dismiss": "Fermer", + "cancel": "Annuler l'essai", + "success_title": "Espace de travail supprimé.", + "success_message": "Vous serez bientôt redirigé vers votre page de profil.", + "error_title": "Cela n'a pas fonctionné.", + "error_message": "Veuillez réessayer." + }, + "errors": { + "name": { + "required": "Le nom est requis", + "max_length": "Le nom de l'espace de travail ne doit pas dépasser 80 caractères" + }, + "company_size": { + "required": "La taille de l'entreprise est requise" + } + } + }, + "members": { + "title": "Membres", + "add_member": "Ajouter un membre", + "pending_invites": "Invitations en attente", + "invitations_sent_successfully": "Invitations envoyées avec succès", + "leave_confirmation": "Êtes-vous sûr de vouloir quitter l'espace de travail ? Vous n'aurez plus accès à cet espace de travail. Cette action ne peut pas être annulée.", + "details": { + "full_name": "Nom complet", + "display_name": "Nom d'affichage", + "email_address": "Adresse e-mail", + "account_type": "Type de compte", + "authentication": "Authentification", + "joining_date": "Date d'adhésion" + }, + "modal": { + "title": "Inviter des personnes à collaborer", + "description": "Invitez des personnes à collaborer sur votre espace de travail.", + "button": "Envoyer les invitations", + "button_loading": "Envoi des invitations", + "placeholder": "nom@entreprise.com", + "errors": { + "required": "Nous avons besoin d'une adresse e-mail pour les inviter.", + "invalid": "L'e-mail est invalide" + } + } + }, + "billing_and_plans": { + "title": "Facturation & Plans", + "current_plan": "Plan actuel", + "free_plan": "Vous utilisez actuellement le plan gratuit", + "view_plans": "Voir les plans" + }, + "exports": { + "title": "Exportations", + "exporting": "Exportation", + "previous_exports": "Exportations précédentes", + "export_separate_files": "Exporter les données dans des fichiers séparés", + "modal": { + "title": "Exporter vers", + "toasts": { + "success": { + "title": "Exportation réussie", + "message": "Vous pourrez télécharger les {entity} exportés depuis l'exportation précédente." + }, + "error": { + "title": "Échec de l'exportation", + "message": "L'exportation a échoué. Veuillez réessayer." + } + } + } + }, + "webhooks": { + "title": "Webhooks", + "add_webhook": "Ajouter un webhook", + "modal": { + "title": "Créer un webhook", + "details": "Détails du webhook", + "payload": "URL de la charge utile", + "question": "Quels événements souhaitez-vous déclencher avec ce webhook ?", + "error": "L'URL est requise" + }, + "secret_key": { + "title": "Clé secrète", + "message": "Générer un jeton pour signer la charge utile du webhook" + }, + "options": { + "all": "Envoyez-moi tout", + "individual": "Sélectionner des événements individuels" + }, + "toasts": { + "created": { + "title": "Webhook créé", + "message": "Le webhook a été créé avec succès" + }, + "not_created": { + "title": "Webhook non créé", + "message": "Le webhook n'a pas pu être créé" + }, + "updated": { + "title": "Webhook mis à jour", + "message": "Le webhook a été mis à jour avec succès" + }, + "not_updated": { + "title": "Webhook non mis à jour", + "message": "Le webhook n'a pas pu être mis à jour" + }, + "removed": { + "title": "Webhook supprimé", + "message": "Le webhook a été supprimé avec succès" + }, + "not_removed": { + "title": "Webhook non supprimé", + "message": "Le webhook n'a pas pu être supprimé" + }, + "secret_key_copied": { + "message": "Clé secrète copiée dans le presse-papiers." + }, + "secret_key_not_copied": { + "message": "Une erreur s'est produite lors de la copie de la clé secrète." + } + } + }, + "api_tokens": { + "title": "Jetons API", + "add_token": "Ajouter un jeton API", + "create_token": "Créer un jeton", + "never_expires": "N'expire jamais", + "generate_token": "Générer un jeton", + "generating": "Génération", + "delete": { + "title": "Supprimer le jeton API", + "description": "Toute application utilisant ce jeton n'aura plus accès aux données de Plane. Cette action ne peut pas être annulée.", + "success": { + "title": "Succès !", + "message": "Le jeton API a été supprimé avec succès" + }, + "error": { + "title": "Erreur !", + "message": "Le jeton API n'a pas pu être supprimé" + } + } + } + }, + "empty_state": { + "api_tokens": { + "title": "Aucun jeton API créé", + "description": "Les API Plane peuvent être utilisées pour intégrer vos données dans Plane avec n'importe quel système externe. Créez un jeton pour commencer." + }, + "webhooks": { + "title": "Aucun webhook ajouté", + "description": "Créez des webhooks pour recevoir des mises à jour en temps réel et automatiser des actions." + }, + "exports": { + "title": "Aucune exportation pour le moment", + "description": "Chaque fois que vous exportez, vous aurez également une copie ici pour référence." + }, + "imports": { + "title": "Aucune importation pour le moment", + "description": "Trouvez toutes vos importations précédentes ici et téléchargez-les." + } + } + }, + + "profile": { + "label": "Profil", + "page_label": "Votre travail", + "work": "Travail", + "details": { + "joined_on": "Inscrit le", + "time_zone": "Fuseau horaire" + }, + "stats": { + "workload": "Charge de travail", + "overview": "Vue d'ensemble", + "created": "Éléments de travail créés", + "assigned": "Éléments de travail assignés", + "subscribed": "Éléments de travail suivis", + "state_distribution": { + "title": "Éléments de travail par état", + "empty": "Créez des éléments de travail pour les visualiser par état dans le graphique pour une meilleure analyse." + }, + "priority_distribution": { + "title": "Éléments de travail par priorité", + "empty": "Créez des éléments de travail pour les visualiser par priorité dans le graphique pour une meilleure analyse." + }, + "recent_activity": { + "title": "Activité récente", + "empty": "Nous n'avons pas trouvé de données. Veuillez consulter vos contributions", + "button": "Télécharger l'activité du jour", + "button_loading": "Téléchargement" + } + }, + "actions": { + "profile": "Profil", + "security": "Sécurité", + "activity": "Activité", + "appearance": "Apparence", + "notifications": "Notifications" + }, + "tabs": { + "summary": "Résumé", + "assigned": "Assigné", + "created": "Créé", + "subscribed": "Suivi", + "activity": "Activité" + }, + "empty_state": { + "activity": { + "title": "Aucune activité pour le moment", + "description": "Commencez par créer un nouvel élément de travail ! Ajoutez-y des détails et des propriétés. Explorez davantage Plane pour voir votre activité." + }, + "assigned": { + "title": "Aucun élément de travail ne vous est assigné", + "description": "Les éléments de travail qui vous sont assignés peuvent être suivis ici." + }, + "created": { + "title": "Aucun élément de travail pour le moment", + "description": "Tous les éléments de travail que vous créez apparaissent ici, suivez-les directement ici." + }, + "subscribed": { + "title": "Aucun élément de travail pour le moment", + "description": "Abonnez-vous aux éléments de travail qui vous intéressent, suivez-les tous ici." + } + } + }, + + "project_settings": { + "general": { + "enter_project_id": "Saisissez l'ID du projet", + "please_select_a_timezone": "Veuillez sélectionner un fuseau horaire", + "archive_project": { + "title": "Archiver le projet", + "description": "L'archivage d'un projet le retirera de votre navigation latérale, bien que vous pourrez toujours y accéder depuis votre page de projets. Vous pouvez restaurer le projet ou le supprimer quand vous le souhaitez.", + "button": "Archiver le projet" + }, + "delete_project": { + "title": "Supprimer le projet", + "description": "Lors de la suppression d'un projet, toutes les données et ressources de ce projet seront définitivement supprimées et ne pourront pas être récupérées.", + "button": "Supprimer mon projet" + }, + "toast": { + "success": "Projet mis à jour avec succès", + "error": "Le projet n'a pas pu être mis à jour. Veuillez réessayer." + } + }, + "members": { + "label": "Membres", + "project_lead": "Chef de projet", + "default_assignee": "Assigné par défaut", + "guest_super_permissions": { + "title": "Accorder l'accès en lecture à tous les éléments de travail pour les utilisateurs invités :", + "sub_heading": "Cela permettra aux invités d'avoir un accès en lecture à tous les éléments de travail du projet." + }, + "invite_members": { + "title": "Inviter des membres", + "sub_heading": "Invitez des membres à travailler sur votre projet.", + "select_co_worker": "Sélectionner un collaborateur" + } + }, + "states": { + "describe_this_state_for_your_members": "Décrivez cet état pour vos membres.", + "empty_state": { + "title": "Aucun état disponible pour le groupe {groupKey}", + "description": "Veuillez créer un nouvel état" + } + }, + "labels": { + "label_title": "Titre de l'étiquette", + "label_title_is_required": "Le titre de l'étiquette est requis", + "label_max_char": "Le nom de l'étiquette ne doit pas dépasser 255 caractères", + "toast": { + "error": "Erreur lors de la mise à jour de l'étiquette" + } + }, + "estimates": { + "title": "Activer les estimations pour mon projet", + "description": "Elles vous aident à communiquer la complexité et la charge de travail de l'équipe." + }, + "automations": { + "label": "Automatisations", + "auto-archive": { + "title": "Archiver automatiquement les éléments de travail fermés", + "description": "Plane archivera automatiquement les éléments de travail qui ont été terminés ou annulés.", + "duration": "Archiver automatiquement les éléments de travail qui sont fermés depuis" + }, + "auto-close": { + "title": "Fermer automatiquement les éléments de travail", + "description": "Plane fermera automatiquement les éléments de travail qui n'ont pas été terminés ou annulés.", + "duration": "Fermer automatiquement les éléments de travail inactifs depuis", + "auto_close_status": "Statut de fermeture automatique" + } + }, + + "empty_state": { + "labels": { + "title": "Aucune étiquette pour le moment", + "description": "Créez des étiquettes pour aider à organiser et filtrer les éléments de travail dans votre projet." + }, + "estimates": { + "title": "Aucun système d'estimation pour le moment", + "description": "Créez un ensemble d'estimations pour communiquer la quantité de travail par élément de travail.", + "primary_button": "Ajouter un système d'estimation" + } + } + }, + + "project_cycles": { + "add_cycle": "Ajouter un cycle", + "more_details": "Plus de détails", + "cycle": "Cycle", + "update_cycle": "Mettre à jour le cycle", + "create_cycle": "Créer un cycle", + "no_matching_cycles": "Aucun cycle correspondant", + "remove_filters_to_see_all_cycles": "Supprimez les filtres pour voir tous les cycles", + "remove_search_criteria_to_see_all_cycles": "Supprimez les critères de recherche pour voir tous les cycles", + "only_completed_cycles_can_be_archived": "Seuls les cycles terminés peuvent être archivés", + "active_cycle": { + "label": "Cycle actif", + "progress": "Progression", + "chart": "Graphique d'avancement", + "priority_issue": "Éléments de travail prioritaires", + "assignees": "Assignés", + "issue_burndown": "Graphique d'avancement des éléments", + "ideal": "Idéal", + "current": "Actuel", + "labels": "Étiquettes" + }, + "upcoming_cycle": { + "label": "Cycle à venir" + }, + "completed_cycle": { + "label": "Cycle terminé" + }, + "status": { + "days_left": "Jours restants", + "completed": "Terminé", + "yet_to_start": "Pas encore commencé", + "in_progress": "En cours", + "draft": "Brouillon" + }, + "action": { + "restore": { + "title": "Restaurer le cycle", + "success": { + "title": "Cycle restauré", + "description": "Le cycle a été restauré." + }, + "failed": { + "title": "Échec de la restauration du cycle", + "description": "Le cycle n'a pas pu être restauré. Veuillez réessayer." + } + }, + "favorite": { + "loading": "Ajout du cycle aux favoris", + "success": { + "description": "Cycle ajouté aux favoris.", + "title": "Succès !" + }, + "failed": { + "description": "Impossible d'ajouter le cycle aux favoris. Veuillez réessayer.", + "title": "Erreur !" + } + }, + "unfavorite": { + "loading": "Suppression du cycle des favoris", + "success": { + "description": "Cycle retiré des favoris.", + "title": "Succès !" + }, + "failed": { + "description": "Impossible de retirer le cycle des favoris. Veuillez réessayer.", + "title": "Erreur !" + } + }, + "update": { + "loading": "Mise à jour du cycle", + "success": { + "description": "Cycle mis à jour avec succès.", + "title": "Succès !" + }, + "failed": { + "description": "Erreur lors de la mise à jour du cycle. Veuillez réessayer.", + "title": "Erreur !" + }, + "error": { + "already_exists": "Vous avez déjà un cycle aux dates indiquées. Si vous souhaitez créer un cycle en brouillon, vous pouvez le faire en supprimant les deux dates." + } + } + }, + "empty_state": { + "general": { + "title": "Regroupez et planifiez votre travail en Cycles.", + "description": "Découpez le travail en périodes définies, planifiez à rebours depuis la date limite de votre projet pour fixer les dates, et progressez concrètement en équipe.", + "primary_button": { + "text": "Définissez votre premier cycle", + "comic": { + "title": "Les cycles sont des périodes répétitives.", + "description": "Un sprint, une itération, ou tout autre terme que vous utilisez pour le suivi hebdomadaire ou bimensuel du travail est un cycle." + } + } + }, + "no_issues": { + "title": "Aucun élément de travail ajouté au cycle", + "description": "Ajoutez ou créez des éléments de travail que vous souhaitez planifier et livrer dans ce cycle", + "primary_button": { + "text": "Créer un nouvel élément de travail" + }, + "secondary_button": { + "text": "Ajouter un élément existant" + } + }, + "completed_no_issues": { + "title": "Aucun élément de travail dans le cycle", + "description": "Aucun élément de travail dans le cycle. Les éléments sont soit transférés soit masqués. Pour voir les éléments masqués s'il y en a, mettez à jour vos propriétés d'affichage en conséquence." + }, + "active": { + "title": "Aucun cycle actif", + "description": "Un cycle actif inclut toute période qui englobe la date d'aujourd'hui dans sa plage. Trouvez ici la progression et les détails du cycle actif." + }, + "archived": { + "title": "Aucun cycle archivé pour le moment", + "description": "Pour organiser votre projet, archivez les cycles terminés. Retrouvez-les ici une fois archivés." + } + } + }, + + "project_issues": { + "empty_state": { + "no_issues": { + "title": "Créez un élément de travail et assignez-le à quelqu'un, même à vous-même", + "description": "Pensez aux éléments de travail comme des tâches, du travail, ou des JTBD (Jobs To Be Done). Un élément de travail et ses sous-éléments sont généralement des actions temporelles assignées aux membres de votre équipe. Votre équipe crée, assigne et complète des éléments de travail pour faire progresser votre projet vers son objectif.", + "primary_button": { + "text": "Créez votre premier élément de travail", + "comic": { + "title": "Les éléments de travail sont les blocs de construction dans Plane.", + "description": "Refondre l'interface de Plane, Renouveler l'image de marque de l'entreprise, ou Lancer le nouveau système d'injection de carburant sont des exemples d'éléments de travail qui ont probablement des sous-éléments." + } + } + }, + "no_archived_issues": { + "title": "Aucun élément de travail archivé pour le moment", + "description": "Manuellement ou par automatisation, vous pouvez archiver les éléments de travail terminés ou annulés. Retrouvez-les ici une fois archivés.", + "primary_button": { + "text": "Configurer l'automatisation" + } + }, + "issues_empty_filter": { + "title": "Aucun élément de travail trouvé correspondant aux filtres appliqués", + "secondary_button": { + "text": "Effacer tous les filtres" + } + } + } + }, + + "project_module": { + "add_module": "Ajouter un module", + "update_module": "Mettre à jour le module", + "create_module": "Créer un module", + "archive_module": "Archiver le module", + "restore_module": "Restaurer le module", + "delete_module": "Supprimer le module", + "empty_state": { + "general": { + "title": "Associez vos jalons de projet aux Modules et suivez facilement le travail agrégé.", + "description": "Un groupe d'éléments de travail qui appartiennent à un parent logique et hiérarchique forme un module. Considérez-les comme un moyen de suivre le travail par jalons de projet. Ils ont leurs propres périodes et délais ainsi que des analyses pour vous aider à voir à quel point vous êtes proche ou loin d'un jalon.", + "primary_button": { + "text": "Construisez votre premier module", + "comic": { + "title": "Les modules aident à regrouper le travail par hiérarchie.", + "description": "Un module panier, un module châssis et un module entrepôt sont tous de bons exemples de ce regroupement." + } + } + }, + "no_issues": { + "title": "Aucun élément de travail dans le module", + "description": "Créez ou ajoutez des éléments de travail que vous souhaitez accomplir dans le cadre de ce module", + "primary_button": { + "text": "Créer de nouveaux éléments de travail" + }, + "secondary_button": { + "text": "Ajouter un élément existant" + } + }, + "archived": { + "title": "Aucun module archivé pour le moment", + "description": "Pour organiser votre projet, archivez les modules terminés ou annulés. Retrouvez-les ici une fois archivés." + }, + "sidebar": { + "in_active": "Ce module n'est pas encore actif.", + "invalid_date": "Date invalide. Veuillez entrer une date valide." + } + }, + "quick_actions": { + "archive_module": "Archiver le module", + "archive_module_description": "Seuls les modules terminés ou\nannulés peuvent être archivés.", + "delete_module": "Supprimer le module" + }, + "toast": { + "copy": { + "success": "Lien du module copié dans le presse-papiers" + }, + "delete": { + "success": "Module supprimé avec succès", + "error": "Échec de la suppression du module" + } + } + }, + + "project_views": { + "empty_state": { + "general": { + "title": "Enregistrez des vues filtrées pour votre projet. Créez-en autant que nécessaire", + "description": "Les vues sont un ensemble de filtres enregistrés que vous utilisez fréquemment ou auxquels vous souhaitez avoir un accès facile. Tous vos collègues dans un projet peuvent voir les vues de chacun et choisir celle qui convient le mieux à leurs besoins.", + "primary_button": { + "text": "Créez votre première vue", + "comic": { + "title": "Les vues fonctionnent sur les propriétés des éléments de travail.", + "description": "Vous pouvez créer une vue ici avec autant de propriétés comme filtres que vous le jugez approprié." + } + } + }, + "filter": { + "title": "Aucune vue correspondante", + "description": "Aucune vue ne correspond aux critères de recherche. \n Créez plutôt une nouvelle vue." + } + } + }, + + "project_page": { + "empty_state": { + "general": { + "title": "Rédigez une note, un document ou une base de connaissances complète. Obtenez l'aide de Galileo, l'assistant IA de Plane, pour commencer", + "description": "Les Pages sont un espace de réflexion dans Plane. Prenez des notes de réunion, formatez-les facilement, intégrez des éléments de travail, disposez-les à l'aide d'une bibliothèque de composants, et gardez-les tous dans le contexte de votre projet. Pour faciliter la rédaction de tout document, faites appel à Galileo, l'IA de Plane, avec un raccourci ou un clic sur un bouton.", + "primary_button": { + "text": "Créez votre première page" + } + }, + "private": { + "title": "Pas encore de pages privées", + "description": "Gardez vos pensées privées ici. Quand vous serez prêt à partager, l'équipe n'est qu'à un clic.", + "primary_button": { + "text": "Créez votre première page" + } + }, + "public": { + "title": "Pas encore de pages publiques", + "description": "Consultez ici les pages partagées avec tout le monde dans votre projet.", + "primary_button": { + "text": "Créez votre première page" + } + }, + "archived": { + "title": "Pas encore de pages archivées", + "description": "Archivez les pages qui ne sont pas dans votre radar. Accédez-y ici quand nécessaire." + } + } + }, + + "command_k": { + "empty_state": { + "search": { + "title": "Aucun résultat trouvé" + } + } + }, + + "issue_relation": { + "empty_state": { + "search": { + "title": "Aucun élément de travail correspondant trouvé" + }, + "no_issues": { + "title": "Aucun élément de travail trouvé" + } + } + }, + + "issue_comment": { + "empty_state": { + "general": { + "title": "Pas encore de commentaires", + "description": "Les commentaires peuvent être utilisés comme espace de discussion et de suivi pour les éléments de travail" + } + } + }, + + "notification": { + "label": "Boîte de réception", + "page_label": "{workspace} - Boîte de réception", + "options": { + "mark_all_as_read": "Tout marquer comme lu", + "mark_read": "Marquer comme lu", + "mark_unread": "Marquer comme non lu", + "refresh": "Actualiser", + "filters": "Filtres de la boîte de réception", + "show_unread": "Afficher les non lus", + "show_snoozed": "Afficher les reportés", + "show_archived": "Afficher les archivés", + "mark_archive": "Archiver", + "mark_unarchive": "Désarchiver", + "mark_snooze": "Reporter", + "mark_unsnooze": "Annuler le report" + }, + "toasts": { + "read": "Notification marquée comme lue", + "unread": "Notification marquée comme non lue", + "archived": "Notification marquée comme archivée", + "unarchived": "Notification marquée comme non archivée", + "snoozed": "Notification reportée", + "unsnoozed": "Report de la notification annulé" + }, + "empty_state": { + "detail": { + "title": "Sélectionnez pour voir les détails." + }, + "all": { + "title": "Aucun élément de travail assigné", + "description": "Les mises à jour des éléments de travail qui vous sont assignés peuvent être \n vues ici" + }, + "mentions": { + "title": "Aucun élément de travail assigné", + "description": "Les mises à jour des éléments de travail qui vous sont assignés peuvent être \n vues ici" + } + }, + "tabs": { + "all": "Tout", + "mentions": "Mentions" + }, + "filter": { + "assigned": "Assigné à moi", + "created": "Créé par moi", + "subscribed": "Suivi par moi" + }, + "snooze": { + "1_day": "1 jour", + "3_days": "3 jours", + "5_days": "5 jours", + "1_week": "1 semaine", + "2_weeks": "2 semaines", + "custom": "Personnalisé" + } + }, + + "active_cycle": { + "empty_state": { + "progress": { + "title": "Ajoutez des éléments de travail au cycle pour voir sa progression" + }, + "chart": { + "title": "Ajoutez des éléments de travail au cycle pour voir le graphique d'avancement." + }, + "priority_issue": { + "title": "Observez en un coup d'œil les éléments de travail prioritaires traités dans le cycle." + }, + "assignee": { + "title": "Ajoutez des assignés aux éléments de travail pour voir une répartition du travail par assigné." + }, + "label": { + "title": "Ajoutez des étiquettes aux éléments de travail pour voir la répartition du travail par étiquette." + } + } + }, + + "disabled_project": { + "empty_state": { + "inbox": { + "title": "L'Intake n'est pas activé pour le projet.", + "description": "L'Intake vous aide à gérer les demandes entrantes dans votre projet et à les ajouter comme éléments de travail dans votre flux. Activez l'Intake depuis les paramètres du projet pour gérer les demandes.", + "primary_button": { + "text": "Gérer les fonctionnalités" + } + }, + "cycle": { + "title": "Les Cycles ne sont pas activés pour ce projet.", + "description": "Découpez le travail en segments temporels, planifiez à rebours depuis la date limite de votre projet pour définir les dates, et progressez concrètement en équipe. Activez la fonctionnalité Cycles pour votre projet pour commencer à les utiliser.", + "primary_button": { + "text": "Gérer les fonctionnalités" + } + }, + "module": { + "title": "Les Modules ne sont pas activés pour le projet.", + "description": "Les Modules sont les éléments constitutifs de votre projet. Activez les modules depuis les paramètres du projet pour commencer à les utiliser.", + "primary_button": { + "text": "Gérer les fonctionnalités" + } + }, + "page": { + "title": "Les Pages ne sont pas activées pour le projet.", + "description": "Les Pages sont les éléments constitutifs de votre projet. Activez les pages depuis les paramètres du projet pour commencer à les utiliser.", + "primary_button": { + "text": "Gérer les fonctionnalités" + } + }, + "view": { + "title": "Les Vues ne sont pas activées pour le projet.", + "description": "Les Vues sont les éléments constitutifs de votre projet. Activez les vues depuis les paramètres du projet pour commencer à les utiliser.", + "primary_button": { + "text": "Gérer les fonctionnalités" + } + } + } + }, + "workspace_draft_issues": { + "draft_an_issue": "Créer un brouillon d'élément de travail", + "empty_state": { + "title": "Les éléments de travail partiellement rédigés, et bientôt les commentaires, apparaîtront ici.", + "description": "Pour essayer cela, commencez à ajouter un élément de travail et laissez-le à mi-chemin ou créez votre premier brouillon ci-dessous. 😉", + "primary_button": { + "text": "Créez votre premier brouillon" + } + }, + "delete_modal": { + "title": "Supprimer le brouillon", + "description": "Êtes-vous sûr de vouloir supprimer ce brouillon ? Cette action ne peut pas être annulée." + }, + "toasts": { + "created": { + "success": "Brouillon créé", + "error": "L'élément de travail n'a pas pu être créé. Veuillez réessayer." + }, + "deleted": { + "success": "Brouillon supprimé" + } + } + }, + + "stickies": { + "title": "Vos notes adhésives", + "placeholder": "cliquez pour écrire ici", + "all": "Toutes les notes adhésives", + "no-data": "Notez une idée, capturez une révélation ou enregistrez une inspiration. Ajoutez une note adhésive pour commencer.", + "add": "Ajouter une note adhésive", + "search_placeholder": "Rechercher par titre", + "delete": "Supprimer la note adhésive", + "delete_confirmation": "Êtes-vous sûr de vouloir supprimer cette note adhésive ?", + "empty_state": { + "simple": "Notez une idée, capturez une révélation ou enregistrez une inspiration. Ajoutez une note adhésive pour commencer.", + "general": { + "title": "Les notes adhésives sont des notes rapides et des tâches que vous prenez à la volée.", + "description": "Capturez vos pensées et idées facilement en créant des notes adhésives que vous pouvez consulter à tout moment et de n'importe où.", + "primary_button": { + "text": "Ajouter une note adhésive" + } + }, + "search": { + "title": "Cela ne correspond à aucune de vos notes adhésives.", + "description": "Essayez un terme différent ou faites-nous savoir\nsi vous êtes sûr que votre recherche est correcte.", + "primary_button": { + "text": "Ajouter une note adhésive" + } + } + }, + "toasts": { + "errors": { + "wrong_name": "Le nom de la note adhésive ne peut pas dépasser 100 caractères.", + "already_exists": "Il existe déjà une note adhésive sans description" + }, + "created": { + "title": "Note adhésive créée", + "message": "La note adhésive a été créée avec succès" + }, + "not_created": { + "title": "Note adhésive non créée", + "message": "La note adhésive n'a pas pu être créée" + }, + "updated": { + "title": "Note adhésive mise à jour", + "message": "La note adhésive a été mise à jour avec succès" + }, + "not_updated": { + "title": "Note adhésive non mise à jour", + "message": "La note adhésive n'a pas pu être mise à jour" + }, + "removed": { + "title": "Note adhésive supprimée", + "message": "La note adhésive a été supprimée avec succès" + }, + "not_removed": { + "title": "Note adhésive non supprimée", + "message": "La note adhésive n'a pas pu être supprimée" + } + } + }, + + "role_details": { + "guest": { + "title": "Invité", + "description": "Les membres externes des organisations peuvent être invités en tant qu'invités." + }, + "member": { + "title": "Membre", + "description": "Capacité à lire, écrire, modifier et supprimer des entités dans les projets, cycles et modules" + }, + "admin": { + "title": "Administrateur", + "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", + "founder_or_executive": "Fondateur / Dirigeant", + "freelancer_or_consultant": "Freelance / Consultant", + "marketing_or_growth": "Marketing / Croissance", + "sales_or_business_development": "Ventes / Développement commercial", + "support_or_operations": "Support / Opérations", + "student_or_professor": "Étudiant / Professeur", + "human_resources": "Ressources Humaines", + "other": "Autre" + }, + + "importer": { + "github": { + "title": "GitHub", + "description": "Importez des éléments de travail depuis les dépôts GitHub et synchronisez-les." + }, + "jira": { + "title": "Jira", + "description": "Importez des éléments de travail et des epics depuis les projets et epics Jira." + } + }, + + "exporter": { + "csv": { + "title": "CSV", + "description": "Exportez les éléments de travail vers un fichier CSV.", + "short_description": "Exporter en csv" + }, + "excel": { + "title": "Excel", + "description": "Exportez les éléments de travail vers un fichier Excel.", + "short_description": "Exporter en excel" + }, + "xlsx": { + "title": "Excel", + "description": "Exportez les éléments de travail vers un fichier Excel.", + "short_description": "Exporter en excel" + }, + "json": { + "title": "JSON", + "description": "Exportez les éléments de travail vers un fichier JSON.", + "short_description": "Exporter en json" + } + }, + "default_global_view": { + "all_issues": "Tous les éléments de travail", + "assigned": "Assignés", + "created": "Créés", + "subscribed": "Suivis" + }, + + "themes": { + "theme_options": { + "system_preference": { + "label": "Préférence système" + }, + "light": { + "label": "Clair" + }, + "dark": { + "label": "Sombre" + }, + "light_contrast": { + "label": "Contraste clair élevé" + }, + "dark_contrast": { + "label": "Contraste sombre élevé" + }, + "custom": { + "label": "Thème personnalisé" + } + } + }, + "project_modules": { + "status": { + "backlog": "Backlog", + "planned": "Planifié", + "in_progress": "En cours", + "paused": "En pause", + "completed": "Terminé", + "cancelled": "Annulé" + }, + "layout": { + "list": "Vue liste", + "board": "Vue galerie", + "timeline": "Vue chronologique" + }, + "order_by": { + "name": "Nom", + "progress": "Progression", + "issues": "Nombre d'éléments de travail", + "due_date": "Date d'échéance", + "created_at": "Date de création", + "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" + } +} diff --git a/packages/i18n/src/locales/ja/translations.json b/packages/i18n/src/locales/ja/translations.json new file mode 100644 index 000000000..eec78decc --- /dev/null +++ b/packages/i18n/src/locales/ja/translations.json @@ -0,0 +1,2362 @@ +{ + "sidebar": { + "projects": "プロジェクト", + "pages": "ページ", + "new_work_item": "新規作業項目", + "home": "ホーム", + "your_work": "あなたの作業", + "inbox": "受信トレイ", + "workspace": "ワークスペース", + "views": "ビュー", + "analytics": "アナリティクス", + "work_items": "作業項目", + "cycles": "サイクル", + "modules": "モジュール", + "intake": "インテーク", + "drafts": "下書き", + "favorites": "お気に入り", + "pro": "プロ", + "upgrade": "アップグレード" + }, + + "auth": { + "common": { + "email": { + "label": "メールアドレス", + "placeholder": "name@company.com", + "errors": { + "required": "メールアドレスは必須です", + "invalid": "メールアドレスが無効です" + } + }, + "password": { + "label": "パスワード", + "set_password": "パスワードを設定", + "placeholder": "パスワードを入力", + "confirm_password": { + "label": "パスワードの確認", + "placeholder": "パスワードを確認" + }, + "current_password": { + "label": "現在のパスワード" + }, + "new_password": { + "label": "新しいパスワード", + "placeholder": "新しいパスワードを入力" + }, + "change_password": { + "label": { + "default": "パスワードを変更", + "submitting": "パスワードを変更中" + } + }, + "errors": { + "match": "パスワードが一致しません", + "empty": "パスワードを入力してください", + "length": "パスワードは8文字以上である必要があります", + "strength": { + "weak": "パスワードが弱すぎます", + "strong": "パスワードは十分な強度です" + } + }, + "submit": "パスワードを設定", + "toast": { + "change_password": { + "success": { + "title": "成功!", + "message": "パスワードが正常に変更されました。" + }, + "error": { + "title": "エラー!", + "message": "問題が発生しました。もう一度お試しください。" + } + } + } + }, + "unique_code": { + "label": "ユニークコード", + "placeholder": "gets-sets-flys", + "paste_code": "メールで送信されたコードを貼り付けてください", + "requesting_new_code": "新しいコードをリクエスト中", + "sending_code": "コードを送信中" + }, + "already_have_an_account": "すでにアカウントをお持ちですか?", + "login": "ログイン", + "create_account": "アカウントを作成", + "new_to_plane": "Planeは初めてですか?", + "back_to_sign_in": "サインインに戻る", + "resend_in": "{seconds}秒後に再送信", + "sign_in_with_unique_code": "ユニークコードでサインイン", + "forgot_password": "パスワードをお忘れですか?" + }, + "sign_up": { + "header": { + "label": "チームと作業を管理するためのアカウントを作成してください。", + "step": { + "email": { + "header": "サインアップ", + "sub_header": "" + }, + "password": { + "header": "サインアップ", + "sub_header": "メールアドレスとパスワードの組み合わせでサインアップ。" + }, + "unique_code": { + "header": "サインアップ", + "sub_header": "上記のメールアドレスに送信されたユニークコードでサインアップ。" + } + } + }, + "errors": { + "password": { + "strength": "強力なパスワードを設定して続行してください" + } + } + }, + "sign_in": { + "header": { + "label": "チームと作業を管理するためにログインしてください。", + "step": { + "email": { + "header": "ログインまたはサインアップ", + "sub_header": "" + }, + "password": { + "header": "ログインまたはサインアップ", + "sub_header": "メールアドレスとパスワードの組み合わせでログイン。" + }, + "unique_code": { + "header": "ログインまたはサインアップ", + "sub_header": "上記のメールアドレスに送信されたユニークコードでログイン。" + } + } + } + }, + "forgot_password": { + "title": "パスワードをリセット", + "description": "確認済みのユーザーアカウントのメールアドレスを入力してください。パスワードリセットリンクを送信します。", + "email_sent": "リセットリンクをメールアドレスに送信しました", + "send_reset_link": "リセットリンクを送信", + "errors": { + "smtp_not_enabled": "管理者がSMTPを有効にしていないため、パスワードリセットリンクを送信できません" + }, + "toast": { + "success": { + "title": "メール送信完了", + "message": "パスワードをリセットするためのリンクを受信トレイで確認してください。数分以内に表示されない場合は、迷惑メールフォルダを確認してください。" + }, + "error": { + "title": "エラー!", + "message": "問題が発生しました。もう一度お試しください。" + } + } + }, + "reset_password": { + "title": "新しいパスワードを設定", + "description": "強力なパスワードでアカウントを保護" + }, + "set_password": { + "title": "アカウントを保護", + "description": "パスワードを設定して安全にログイン" + }, + "sign_out": { + "toast": { + "error": { + "title": "エラー!", + "message": "サインアウトに失敗しました。もう一度お試しください。" + } + } + } + }, + + "submit": "送信", + "cancel": "キャンセル", + "loading": "読み込み中", + "error": "エラー", + "success": "成功", + "warning": "警告", + "info": "情報", + "close": "閉じる", + "yes": "はい", + "no": "いいえ", + "ok": "OK", + "name": "名前", + "description": "説明", + "search": "検索", + "add_member": "メンバーを追加", + "adding_members": "メンバーを追加中", + "remove_member": "メンバーを削除", + "add_members": "メンバーを追加", + "adding_member": "メンバーを追加中", + "remove_members": "メンバーを削除", + "add": "追加", + "adding": "追加中", + "remove": "削除", + "add_new": "新規追加", + "remove_selected": "選択項目を削除", + "first_name": "名", + "last_name": "姓", + "email": "メールアドレス", + "display_name": "表示名", + "role": "役割", + "timezone": "タイムゾーン", + "avatar": "アバター", + "cover_image": "カバー画像", + "password": "パスワード", + "change_cover": "カバーを変更", + "language": "言語", + "saving": "保存中", + "save_changes": "変更を保存", + "deactivate_account": "アカウントを無効化", + "deactivate_account_description": "アカウントを無効化すると、そのアカウント内のすべてのデータとリソースが完全に削除され、復元できなくなります。", + "profile_settings": "プロフィール設定", + "your_account": "あなたのアカウント", + "security": "セキュリティ", + "activity": "アクティビティ", + "appearance": "外観", + "notifications": "通知", + "workspaces": "ワークスペース", + "create_workspace": "ワークスペースを作成", + "invitations": "招待", + "summary": "概要", + "assigned": "割り当て済み", + "created": "作成済み", + "subscribed": "購読中", + "you_do_not_have_the_permission_to_access_this_page": "このページにアクセスする権限がありません。", + "something_went_wrong_please_try_again": "問題が発生しました。もう一度お試しください。", + "load_more": "もっと読み込む", + "select_or_customize_your_interface_color_scheme": "インターフェースの配色を選択またはカスタマイズしてください。", + "theme": "テーマ", + "system_preference": "システム設定に従う", + "light": "ライト", + "dark": "ダーク", + "light_contrast": "ライトハイコントラスト", + "dark_contrast": "ダークハイコントラスト", + "custom": "カスタムテーマ", + "select_your_theme": "テーマを選択", + "customize_your_theme": "テーマをカスタマイズ", + "background_color": "背景色", + "text_color": "文字色", + "primary_color": "プライマリ(テーマ)カラー", + "sidebar_background_color": "サイドバーの背景色", + "sidebar_text_color": "サイドバーの文字色", + "set_theme": "テーマを設定", + "enter_a_valid_hex_code_of_6_characters": "6文字の有効な16進コードを入力してください", + "background_color_is_required": "背景色は必須です", + "text_color_is_required": "文字色は必須です", + "primary_color_is_required": "プライマリカラーは必須です", + "sidebar_background_color_is_required": "サイドバーの背景色は必須です", + "sidebar_text_color_is_required": "サイドバーの文字色は必須です", + "updating_theme": "テーマを更新中", + "theme_updated_successfully": "テーマが正常に更新されました", + "failed_to_update_the_theme": "テーマの更新に失敗しました", + "email_notifications": "メール通知", + "stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "購読している作業項目の最新情報を受け取ります。通知を受け取るには有効にしてください。", + "email_notification_setting_updated_successfully": "メール通知設定が正常に更新されました", + "failed_to_update_email_notification_setting": "メール通知設定の更新に失敗しました", + "notify_me_when": "通知を受け取るタイミング", + "property_changes": "プロパティの変更", + "property_changes_description": "作業項目の担当者、優先度、見積もりなどのプロパティが変更されたときに通知します。", + "state_change": "状態の変更", + "state_change_description": "作業項目が異なる状態に移動したときに通知します", + "issue_completed": "作業項目の完了", + "issue_completed_description": "作業項目が完了したときのみ通知します", + "comments": "コメント", + "comments_description": "誰かが作業項目にコメントを残したときに通知します", + "mentions": "メンション", + "mentions_description": "誰かがコメントや説明で自分をメンションしたときのみ通知します", + "old_password": "現在のパスワード", + "general_settings": "一般設定", + "sign_out": "サインアウト", + "signing_out": "サインアウト中", + "active_cycles": "アクティブサイクル", + "active_cycles_description": "プロジェクト全体のサイクルを監視し、優先度の高い作業項目を追跡し、注意が必要なサイクルにズームインします。", + "on_demand_snapshots_of_all_your_cycles": "すべてのサイクルのオンデマンドスナップショット", + "upgrade": "アップグレード", + "10000_feet_view": "すべてのアクティブサイクルの俯瞰図", + "10000_feet_view_description": "各プロジェクトのサイクル間を移動する代わりに、すべてのプロジェクトの実行中のサイクルを一度に確認できます。", + "get_snapshot_of_each_active_cycle": "各アクティブサイクルのスナップショットを取得", + "get_snapshot_of_each_active_cycle_description": "すべてのアクティブサイクルの主要な指標を追跡し、進捗状況を確認し、期限に対する範囲を把握します。", + "compare_burndowns": "バーンダウンを比較", + "compare_burndowns_description": "各サイクルのバーンダウンレポートを確認して、各チームのパフォーマンスを監視します。", + "quickly_see_make_or_break_issues": "重要な作業項目をすぐに確認", + "quickly_see_make_or_break_issues_description": "期限に対する各サイクルの優先度の高い作業項目をプレビューします。ワンクリックでサイクルごとにすべての項目を確認できます。", + "zoom_into_cycles_that_need_attention": "注意が必要なサイクルにズームイン", + "zoom_into_cycles_that_need_attention_description": "期待に沿わないサイクルの状態をワンクリックで調査します。", + "stay_ahead_of_blockers": "ブロッカーに先手を打つ", + "stay_ahead_of_blockers_description": "プロジェクト間の課題を特定し、他のビューでは明らかでないサイクル間の依存関係を確認します。", + "analytics": "アナリティクス", + "workspace_invites": "ワークスペースの招待", + "enter_god_mode": "ゴッドモードに入る", + "workspace_logo": "ワークスペースのロゴ", + "new_issue": "新規作業項目", + "your_work": "あなたの作業", + "drafts": "下書き", + "projects": "プロジェクト", + "views": "ビュー", + "workspace": "ワークスペース", + "archives": "アーカイブ", + "settings": "設定", + "failed_to_move_favorite": "お気に入りの移動に失敗しました", + "favorites": "お気に入り", + "no_favorites_yet": "まだお気に入りがありません", + "create_folder": "フォルダを作成", + "new_folder": "新規フォルダ", + "favorite_updated_successfully": "お気に入りが正常に更新されました", + "favorite_created_successfully": "お気に入りが正常に作成されました", + "folder_already_exists": "フォルダは既に存在します", + "folder_name_cannot_be_empty": "フォルダ名を空にすることはできません", + "something_went_wrong": "問題が発生しました", + "failed_to_reorder_favorite": "お気に入りの並び替えに失敗しました", + "favorite_removed_successfully": "お気に入りが正常に削除されました", + "failed_to_create_favorite": "お気に入りの作成に失敗しました", + "failed_to_rename_favorite": "お気に入りの名前変更に失敗しました", + "project_link_copied_to_clipboard": "プロジェクトリンクがクリップボードにコピーされました", + "link_copied": "リンクがコピーされました", + "add_project": "プロジェクトを追加", + "create_project": "プロジェクトを作成", + "failed_to_remove_project_from_favorites": "プロジェクトをお気に入りから削除できませんでした。もう一度お試しください。", + "project_created_successfully": "プロジェクトが正常に作成されました", + "project_created_successfully_description": "プロジェクトが正常に作成されました。作業項目を追加できるようになりました。", + "project_cover_image_alt": "プロジェクトのカバー画像", + "name_is_required": "名前は必須です", + "title_should_be_less_than_255_characters": "タイトルは255文字未満である必要があります", + "project_name": "プロジェクト名", + "project_id_must_be_at_least_1_character": "プロジェクトIDは最低1文字必要です", + "project_id_must_be_at_most_5_characters": "プロジェクトIDは最大5文字までです", + "project_id": "プロジェクトID", + "project_id_tooltip_content": "プロジェクト内の作業項目を一意に識別するのに役立ちます。最大5文字。", + "description_placeholder": "説明", + "only_alphanumeric_non_latin_characters_allowed": "英数字と非ラテン文字のみ使用できます。", + "project_id_is_required": "プロジェクトIDは必須です", + "project_id_allowed_char": "英数字と非ラテン文字のみ使用できます。", + "project_id_min_char": "プロジェクトIDは最低1文字必要です", + "project_id_max_char": "プロジェクトIDは最大5文字までです", + "project_description_placeholder": "プロジェクトの説明を入力", + "select_network": "ネットワークを選択", + "lead": "リード", + "date_range": "日付範囲", + "private": "プライベート", + "public": "パブリック", + "accessible_only_by_invite": "招待のみアクセス可能", + "anyone_in_the_workspace_except_guests_can_join": "ゲスト以外のワークスペースのメンバーが参加可能", + "creating": "作成中", + "creating_project": "プロジェクトを作成中", + "adding_project_to_favorites": "プロジェクトをお気に入りに追加中", + "project_added_to_favorites": "プロジェクトがお気に入りに追加されました", + "couldnt_add_the_project_to_favorites": "プロジェクトをお気に入りに追加できませんでした。もう一度お試しください。", + "removing_project_from_favorites": "プロジェクトをお気に入りから削除中", + "project_removed_from_favorites": "プロジェクトがお気に入りから削除されました", + "couldnt_remove_the_project_from_favorites": "プロジェクトをお気に入りから削除できませんでした。もう一度お試しください。", + "add_to_favorites": "お気に入りに追加", + "remove_from_favorites": "お気に入りから削除", + "publish_settings": "公開設定", + "publish": "公開", + "copy_link": "リンクをコピー", + "leave_project": "プロジェクトを退出", + "join_the_project_to_rearrange": "並び替えるにはプロジェクトに参加してください", + "drag_to_rearrange": "ドラッグして並び替え", + "congrats": "おめでとうございます!", + "open_project": "プロジェクトを開く", + "issues": "作業項目", + "cycles": "Cycles", + "modules": "Modules", + "pages": "Pages", + "intake": "Intake", + "time_tracking": "時間トラッキング", + "work_management": "作業管理", + "projects_and_issues": "プロジェクトと作業項目", + "projects_and_issues_description": "このプロジェクトでオン/オフを切り替えます。", + "cycles_description": "プロジェクトごとに作業を時間枠で区切り、期間を次の期間に変更します。", + "modules_description": "サブプロジェクトのような設定で、独自のリーダーと担当者を持つグループ作業を行います。", + "views_description": "並び替え、フィルター、表示オプションを後で使用するために保存するか、共有します。", + "pages_description": "何でも書けるように何でも書きます。", + "intake_description": "購読している作業項目の最新情報を受け取ります。通知を受け取るには有効にしてください。", + "time_tracking_description": "作業項目とプロジェクトの作業時間を追跡します。", + "work_management_description": "作業とプロジェクトを簡単に管理します。", + "documentation": "ドキュメント", + "message_support": "サポートにメッセージ", + "contact_sales": "営業に問い合わせ", + "hyper_mode": "Hyper Mode", + "keyboard_shortcuts": "キーボードショートカット", + "whats_new": "新機能", + "version": "バージョン", + "we_are_having_trouble_fetching_the_updates": "更新情報の取得に問題が発生しています。", + "our_changelogs": "変更履歴", + "for_the_latest_updates": "最新の更新情報については", + "please_visit": "をご覧ください", + "docs": "ドキュメント", + "full_changelog": "完全な変更履歴", + "support": "サポート", + "discord": "Discord", + "powered_by_plane_pages": "Powered by Plane Pages", + "please_select_at_least_one_invitation": "少なくとも1つの招待を選択してください。", + "please_select_at_least_one_invitation_description": "ワークスペースに参加するには少なくとも1つの招待を選択してください。", + "we_see_that_someone_has_invited_you_to_join_a_workspace": "誰かがあなたをワークスペースに招待しています", + "join_a_workspace": "ワークスペースに参加", + "we_see_that_someone_has_invited_you_to_join_a_workspace_description": "誰かがあなたをワークスペースに招待しています", + "join_a_workspace_description": "ワークスペースに参加", + "accept_and_join": "承諾して参加", + "go_home": "ホームへ", + "no_pending_invites": "保留中の招待はありません", + "you_can_see_here_if_someone_invites_you_to_a_workspace": "誰かがワークスペースに招待した場合、ここで確認できます", + "back_to_home": "ホームに戻る", + "workspace_name": "ワークスペース名", + "deactivate_your_account": "アカウントを無効化", + "deactivate_your_account_description": "無効化すると、作業項目を割り当てられなくなり、ワークスペースの請求対象外となります。アカウントを再有効化するには、このメールアドレスでワークスペースへの招待が必要です。", + "deactivating": "無効化中", + "confirm": "確認", + "confirming": "確認中", + "draft_created": "下書きが作成されました", + "issue_created_successfully": "作業項目が正常に作成されました", + "draft_creation_failed": "下書きの作成に失敗しました", + "issue_creation_failed": "作業項目の作成に失敗しました", + "draft_issue": "作業項目の下書き", + "issue_updated_successfully": "作業項目が正常に更新されました", + "issue_could_not_be_updated": "作業項目を更新できませんでした", + "create_a_draft": "下書きを作成", + "save_to_drafts": "下書きに保存", + "save": "保存", + "update": "更新", + "updating": "更新中", + "create_new_issue": "新規作業項目を作成", + "editor_is_not_ready_to_discard_changes": "エディターは変更を破棄する準備ができていません", + "failed_to_move_issue_to_project": "作業項目をプロジェクトに移動できませんでした", + "create_more": "さらに作成", + "add_to_project": "プロジェクトに追加", + "discard": "破棄", + "duplicate_issue_found": "重複する作業項目が見つかりました", + "duplicate_issues_found": "重複する作業項目が見つかりました", + "no_matching_results": "一致する結果がありません", + "title_is_required": "タイトルは必須です", + "title": "タイトル", + "state": "状態", + "priority": "優先度", + "none": "なし", + "urgent": "緊急", + "high": "高", + "medium": "中", + "low": "低", + "members": "メンバー", + "assignee": "担当者", + "assignees": "担当者", + "you": "あなた", + "labels": "ラベル", + "create_new_label": "新規ラベルを作成", + "start_date": "開始日", + "end_date": "終了日", + "due_date": "期限", + "estimate": "見積もり", + "change_parent_issue": "親作業項目を変更", + "remove_parent_issue": "親作業項目を削除", + "add_parent": "親を追加", + "loading_members": "メンバーを読み込み中", + "view_link_copied_to_clipboard": "ビューリンクがクリップボードにコピーされました。", + "required": "必須", + "optional": "任意", + "Cancel": "キャンセル", + "edit": "編集", + "archive": "アーカイブ", + "restore": "復元", + "open_in_new_tab": "新しいタブで開く", + "delete": "削除", + "deleting": "削除中", + "make_a_copy": "コピーを作成", + "move_to_project": "プロジェクトに移動", + "good": "おはよう", + "morning": "ございます", + "afternoon": "こんにちは", + "evening": "こんばんは", + "show_all": "すべて表示", + "show_less": "表示を減らす", + "no_data_yet": "まだデータがありません", + "syncing": "同期中", + "add_work_item": "作業項目を追加", + "advanced_description_placeholder": "コマンドには '/' を押してください", + "create_work_item": "作業項目を作成", + "attachments": "添付ファイル", + "declining": "辞退中", + "declined": "辞退済み", + "decline": "辞退", + "unassigned": "未割り当て", + "work_items": "作業項目", + "add_link": "リンクを追加", + "points": "ポイント", + "no_assignee": "担当者なし", + "no_assignees_yet": "まだ担当者がいません", + "no_labels_yet": "まだラベルがありません", + "ideal": "理想", + "current": "現在", + "no_matching_members": "一致するメンバーがいません", + "leaving": "退出中", + "removing": "削除中", + "leave": "退出", + "refresh": "更新", + "refreshing": "更新中", + "refresh_status": "状態を更新", + "prev": "前へ", + "next": "次へ", + "re_generating": "再生成中", + "re_generate": "再生成", + "re_generate_key": "キーを再生成", + "export": "エクスポート", + "member": "{count, plural, other{# メンバー}}", + + "project_view": { + "sort_by": { + "created_at": "作成日時", + "updated_at": "更新日時", + "name": "名前" + } + }, + + "toast": { + "success": "成功!", + "error": "エラー!" + }, + + "links": { + "toasts": { + "created": { + "title": "リンクが作成されました", + "message": "リンクが正常に作成されました" + }, + "not_created": { + "title": "リンクが作成されませんでした", + "message": "リンクを作成できませんでした" + }, + "updated": { + "title": "リンクが更新されました", + "message": "リンクが正常に更新されました" + }, + "not_updated": { + "title": "リンクが更新されませんでした", + "message": "リンクを更新できませんでした" + }, + "removed": { + "title": "リンクが削除されました", + "message": "リンクが正常に削除されました" + }, + "not_removed": { + "title": "リンクが削除されませんでした", + "message": "リンクを削除できませんでした" + } + } + }, + + "home": { + "empty": { + "quickstart_guide": "クイックスタートガイド", + "not_right_now": "今はしない", + "create_project": { + "title": "プロジェクトを作成", + "description": "Planeのほとんどはプロジェクトから始まります。", + "cta": "始める" + }, + "invite_team": { + "title": "チームを招待", + "description": "同僚と一緒に構築、デプロイ、管理しましょう。", + "cta": "招待する" + }, + "configure_workspace": { + "title": "ワークスペースを設定する。", + "description": "機能のオン/オフを切り替えたり、さらに詳細な設定を行ったりできます。", + "cta": "このワークスペースを設定" + }, + "personalize_account": { + "title": "Planeをあなた好みにカスタマイズ。", + "description": "プロフィール画像、カラー、その他の設定を選択してください。", + "cta": "今すぐパーソナライズ" + }, + "widgets": { + "title": "ウィジェットがないと静かですね、オンにしましょう", + "description": "すべてのウィジェットがオフになっているようです。体験を向上させるために\n今すぐ有効にしましょう!", + "primary_button": { + "text": "ウィジェットを管理" + } + } + }, + "quick_links": { + "empty": "手元に置いておきたい作業関連のリンクを保存してください。", + "add": "クイックリンクを追加", + "title": "クイックリンク", + "title_plural": "クイックリンク" + }, + "recents": { + "title": "最近", + "empty": { + "project": "プロジェクトを訪問すると、最近のプロジェクトがここに表示されます。", + "page": "ページを訪問すると、最近のページがここに表示されます。", + "issue": "作業項目を訪問すると、最近の作業項目がここに表示されます。", + "default": "まだ最近の項目がありません。" + }, + "filters": { + "all": "すべての項目", + "projects": "プロジェクト", + "pages": "ページ", + "issues": "作業項目" + } + }, + "new_at_plane": { + "title": "Planeの新機能" + }, + "quick_tutorial": { + "title": "クイックチュートリアル" + }, + "widget": { + "reordered_successfully": "ウィジェットの並び替えが完了しました。", + "reordering_failed": "ウィジェットの並び替え中にエラーが発生しました。" + }, + "manage_widgets": "ウィジェットを管理", + "title": "ホーム", + "star_us_on_github": "GitHubでスターをつける" + }, + + "link": { + "modal": { + "url": { + "text": "URL", + "required": "URLが無効です", + "placeholder": "URLを入力または貼り付け" + }, + "title": { + "text": "表示タイトル", + "placeholder": "このリンクをどのように表示したいか" + } + } + }, + + "common": { + "all": "すべて", + "states": "ステータス", + "state": "ステータス", + "state_groups": "ステータスグループ", + "priority": "優先度", + "team_project": "チームプロジェクト", + "project": "プロジェクト", + "cycle": "サイクル", + "cycles": "サイクル", + "module": "モジュール", + "modules": "モジュール", + "labels": "ラベル", + "assignees": "担当者", + "assignee": "担当者", + "created_by": "作成者", + "none": "なし", + "link": "リンク", + "estimate": "見積もり", + "layout": "レイアウト", + "filters": "フィルター", + "display": "表示", + "load_more": "もっと読み込む", + "activity": "アクティビティ", + "analytics": "アナリティクス", + "dates": "日付", + "success": "成功!", + "something_went_wrong": "問題が発生しました", + "error": { + "label": "エラー!", + "message": "エラーが発生しました。もう一度お試しください。" + }, + "group_by": "グループ化", + "epic": "エピック", + "epics": "エピック", + "work_item": "作業項目", + "work_items": "作業項目", + "sub_work_item": "サブ作業項目", + "add": "追加", + "warning": "警告", + "updating": "更新中", + "adding": "追加中", + "update": "更新", + "creating": "作成中", + "create": "作成", + "cancel": "キャンセル", + "description": "説明", + "title": "タイトル", + "attachment": "添付ファイル", + "general": "一般", + "features": "機能", + "automation": "自動化", + "project_name": "プロジェクト名", + "project_id": "プロジェクトID", + "project_timezone": "プロジェクトのタイムゾーン", + "created_on": "作成日", + "update_project": "プロジェクトを更新", + "identifier_already_exists": "識別子は既に存在します", + "add_more": "さらに追加", + "defaults": "デフォルト", + "add_label": "ラベルを追加", + "estimates": "見積もり", + "customize_time_range": "期間をカスタマイズ", + "loading": "読み込み中", + "attachments": "添付ファイル", + "property": "プロパティ", + "properties": "プロパティ", + "parent": "親", + "remove": "削除", + "archiving": "アーカイブ中", + "archive": "アーカイブ", + "access": { + "public": "公開", + "private": "非公開" + }, + "done": "完了", + "sub_work_items": "サブ作業項目", + "comment": "コメント", + "workspace_level": "ワークスペースレベル", + "order_by": { + "label": "並び順", + "manual": "手動", + "last_created": "最終作成日", + "last_updated": "最終更新日", + "start_date": "開始日", + "due_date": "期限日", + "asc": "昇順", + "desc": "降順", + "updated_on": "更新日" + }, + "sort": { + "asc": "昇順", + "desc": "降順", + "created_on": "作成日", + "updated_on": "更新日" + }, + "comments": "コメント", + "updates": "更新", + "clear_all": "すべてクリア", + "copied": "コピーしました!", + "link_copied": "リンクをコピーしました!", + "link_copied_to_clipboard": "リンクをクリップボードにコピーしました", + "copied_to_clipboard": "作業項目のリンクをクリップボードにコピーしました", + "is_copied_to_clipboard": "作業項目をクリップボードにコピーしました", + "no_links_added_yet": "リンクはまだ追加されていません", + "add_link": "リンクを追加", + "links": "リンク", + "go_to_workspace": "ワークスペースへ移動", + "progress": "進捗", + "optional": "任意", + "join": "参加", + "go_back": "戻る", + "continue": "続ける", + "resend": "再送信", + "relations": "関連", + "errors": { + "default": { + "title": "エラー!", + "message": "問題が発生しました。もう一度お試しください。" + }, + "required": "この項目は必須です", + "entity_required": "{entity}は必須です" + }, + "update_link": "リンクを更新", + "attach": "添付", + "create_new": "新規作成", + "add_existing": "既存を追加", + "type_or_paste_a_url": "URLを入力または貼り付け", + "url_is_invalid": "URLが無効です", + "display_title": "表示タイトル", + "link_title_placeholder": "このリンクをどのように表示したいか", + "url": "URL", + "side_peek": "サイドピーク", + "modal": "モーダル", + "full_screen": "全画面", + "close_peek_view": "ピークビューを閉じる", + "toggle_peek_view_layout": "ピークビューのレイアウトを切り替え", + "options": "オプション", + "duration": "期間", + "today": "今日", + "week": "週", + "month": "月", + "quarter": "四半期", + "press_for_commands": "コマンドは「/」を押してください", + "click_to_add_description": "クリックして説明を追加", + "search": { + "label": "検索", + "placeholder": "検索するキーワードを入力", + "no_matches_found": "一致する結果が見つかりません", + "no_matching_results": "一致する結果がありません" + }, + "actions": { + "edit": "編集", + "make_a_copy": "コピーを作成", + "open_in_new_tab": "新しいタブで開く", + "copy_link": "リンクをコピー", + "archive": "アーカイブ", + "delete": "削除", + "remove_relation": "関連を削除", + "subscribe": "購読", + "unsubscribe": "購読解除", + "clear_sorting": "並び替えをクリア", + "show_weekends": "週末を表示", + "enable": "有効化", + "disable": "無効化" + }, + "name": "名前", + "discard": "破棄", + "confirm": "確認", + "confirming": "確認中", + "read_the_docs": "ドキュメントを読む", + "default": "デフォルト", + "active": "アクティブ", + "enabled": "有効", + "disabled": "無効", + "mandate": "必須", + "mandatory": "必須", + "yes": "はい", + "no": "いいえ", + "please_wait": "お待ちください", + "enabling": "有効化中", + "disabling": "無効化中", + "beta": "ベータ", + "or": "または", + "next": "次へ", + "back": "戻る", + "cancelling": "キャンセル中", + "configuring": "設定中", + "clear": "クリア", + "import": "インポート", + "connect": "接続", + "authorizing": "認証中", + "processing": "処理中", + "no_data_available": "データがありません", + "from": "{name}から", + "authenticated": "認証済み", + "select": "選択", + "upgrade": "アップグレード", + "add_seats": "シートを追加", + "label": "ラベル", + "priorities": "優先度", + "projects": "プロジェクト", + "workspace": "ワークスペース", + "workspaces": "ワークスペース", + "team": "チーム", + "teams": "チーム", + "entity": "エンティティ", + "entities": "エンティティ", + "task": "タスク", + "tasks": "タスク", + "section": "セクション", + "sections": "セクション", + "edit": "編集", + "connecting": "接続中", + "connected": "接続済み", + "disconnect": "切断", + "disconnecting": "切断中", + "installing": "インストール中", + "install": "インストール", + "reset": "リセット", + "live": "ライブ", + "change_history": "変更履歴", + "coming_soon": "近日公開", + "members": "メンバー", + "you": "あなた", + "upgrade_cta": { + "higher_subscription": "高いサブスクリプションにアップグレード", + "talk_to_sales": "セールスに連絡" + }, + "category": "カテゴリー", + "categories": "カテゴリーズ", + "saving": "セービング", + "save_changes": "セーブ チェンジズ", + "delete": "デリート", + "deleting": "デリーティング", + "pending": "保留中", + "invite": "招待" + }, + + "chart": { + "x_axis": "エックス アクシス", + "y_axis": "ワイ アクシス", + "metric": "メトリック" + }, + + "form": { + "title": { + "required": "タイトルは必須です", + "max_length": "タイトルは{length}文字未満である必要があります" + } + }, + + "entity": { + "grouping_title": "{entity}のグループ化", + "priority": "{entity}の優先度", + "all": "すべての{entity}", + "drop_here_to_move": "ここにドロップして{entity}を移動", + "delete": { + "label": "{entity}を削除", + "success": "{entity}を削除しました", + "failed": "{entity}の削除に失敗しました" + }, + "update": { + "failed": "{entity}の更新に失敗しました", + "success": "{entity}を更新しました" + }, + "link_copied_to_clipboard": "{entity}のリンクをクリップボードにコピーしました", + "fetch": { + "failed": "{entity}の取得中にエラーが発生しました" + }, + "add": { + "success": "{entity}を追加しました", + "failed": "{entity}の追加中にエラーが発生しました" + } + }, + + "epic": { + "all": "すべてのエピック", + "label": "{count, plural, one {エピック} other {エピック}}", + "new": "新規エピック", + "adding": "エピックを追加中", + "create": { + "success": "エピックを作成しました" + }, + "add": { + "press_enter": "Enterを押して別のエピックを追加", + "label": "エピックを追加" + }, + "title": { + "label": "エピックのタイトル", + "required": "エピックのタイトルは必須です。" + } + }, + + "issue": { + "label": "{count, plural, one {作業項目} other {作業項目}}", + "all": "すべての作業項目", + "edit": "作業項目を編集", + "title": { + "label": "作業項目のタイトル", + "required": "作業項目のタイトルは必須です。" + }, + "add": { + "press_enter": "Enterを押して別の作業項目を追加", + "label": "作業項目を追加", + "cycle": { + "failed": "作業項目をサイクルに追加できませんでした。もう一度お試しください。", + "success": "{count, plural, one {作業項目} other {作業項目}}をサイクルに追加しました。", + "loading": "{count, plural, one {作業項目} other {作業項目}}をサイクルに追加中" + }, + "assignee": "担当者を追加", + "start_date": "開始日を追加", + "due_date": "期限日を追加", + "parent": "親作業項目を追加", + "sub_issue": "サブ作業項目を追加", + "relation": "関連を追加", + "link": "リンクを追加", + "existing": "既存の作業項目を追加" + }, + "remove": { + "label": "作業項目を削除", + "cycle": { + "loading": "サイクルから作業項目を削除中", + "success": "作業項目をサイクルから削除しました。", + "failed": "作業項目をサイクルから削除できませんでした。もう一度お試しください。" + }, + "module": { + "loading": "モジュールから作業項目を削除中", + "success": "作業項目をモジュールから削除しました。", + "failed": "作業項目をモジュールから削除できませんでした。もう一度お試しください。" + }, + "parent": { + "label": "親作業項目を削除" + } + }, + "new": "新規作業項目", + "adding": "作業項目を追加中", + "create": { + "success": "作業項目を作成しました" + }, + "priority": { + "urgent": "緊急", + "high": "高", + "medium": "中", + "low": "低" + }, + "display": { + "properties": { + "label": "表示プロパティ", + "id": "ID", + "issue_type": "作業項目タイプ", + "sub_issue_count": "サブ作業項目数", + "attachment_count": "添付ファイル数", + "created_on": "作成日", + "sub_issue": "サブ作業項目" + }, + "extra": { + "show_sub_issues": "サブ作業項目を表示", + "show_empty_groups": "空のグループを表示" + } + }, + "layouts": { + "ordered_by_label": "このレイアウトは次の順序で並べ替えられています:", + "list": "リスト", + "kanban": "ボード", + "calendar": "カレンダー", + "spreadsheet": "テーブル", + "gantt": "タイムライン", + "title": { + "list": "リストレイアウト", + "kanban": "ボードレイアウト", + "calendar": "カレンダーレイアウト", + "spreadsheet": "テーブルレイアウト", + "gantt": "タイムラインレイアウト" + } + }, + "states": { + "active": "アクティブ", + "backlog": "バックログ" + }, + "comments": { + "placeholder": "コメントを追加", + "switch": { + "private": "プライベートコメントに切り替え", + "public": "公開コメントに切り替え" + }, + "create": { + "success": "コメントを作成しました", + "error": "コメントの作成に失敗しました。後でもう一度お試しください。" + }, + "update": { + "success": "コメントを更新しました", + "error": "コメントの更新に失敗しました。後でもう一度お試しください。" + }, + "remove": { + "success": "コメントを削除しました", + "error": "コメントの削除に失敗しました。後でもう一度お試しください。" + }, + "upload": { + "error": "アセットのアップロードに失敗しました。後でもう一度お試しください。" + } + }, + "empty_state": { + "issue_detail": { + "title": "作業項目が存在しません", + "description": "お探しの作業項目は存在しないか、アーカイブされているか、削除されています。", + "primary_button": { + "text": "他の作業項目を表示" + } + } + }, + "sibling": { + "label": "兄弟作業項目" + }, + "archive": { + "description": "完了またはキャンセルされた\n作業項目のみアーカイブできます", + "label": "作業項目をアーカイブ", + "confirm_message": "作業項目をアーカイブしてもよろしいですか?アーカイブされた作業項目は後で復元できます。", + "success": { + "label": "アーカイブ成功", + "message": "アーカイブはプロジェクトのアーカイブで確認できます。" + }, + "failed": { + "message": "作業項目をアーカイブできませんでした。もう一度お試しください。" + } + }, + "restore": { + "success": { + "title": "復元成功", + "message": "作業項目はプロジェクトの作業項目で確認できます。" + }, + "failed": { + "message": "作業項目を復元できませんでした。もう一度お試しください。" + } + }, + "relation": { + "relates_to": "関連する", + "duplicate": "重複する", + "blocked_by": "ブロックされている", + "blocking": "ブロックしている" + }, + "copy_link": "作業項目のリンクをコピー", + "delete": { + "label": "作業項目を削除", + "error": "作業項目の削除中にエラーが発生しました" + }, + "subscription": { + "actions": { + "subscribed": "作業項目を購読しました", + "unsubscribed": "作業項目の購読を解除しました" + } + }, + "select": { + "error": "少なくとも1つの作業項目を選択してください", + "empty": "作業項目が選択されていません", + "add_selected": "選択した作業項目を追加" + }, + "open_in_full_screen": "作業項目をフルスクリーンで開く" + }, + + "attachment": { + "error": "ファイルを添付できませんでした。もう一度アップロードしてください。", + "only_one_file_allowed": "一度にアップロードできるファイルは1つだけです。", + "file_size_limit": "ファイルサイズは{size}MB以下である必要があります。", + "drag_and_drop": "どこにでもドラッグ&ドロップでアップロード", + "delete": "添付ファイルを削除" + }, + + "label": { + "select": "ラベルを選択", + "create": { + "success": "ラベルを作成しました", + "failed": "ラベルの作成に失敗しました", + "already_exists": "ラベルは既に存在します", + "type": "新しいラベルを追加するには入力してください" + } + }, + + "sub_work_item": { + "update": { + "success": "サブ作業項目を更新しました", + "error": "サブ作業項目の更新中にエラーが発生しました" + }, + "remove": { + "success": "サブ作業項目を削除しました", + "error": "サブ作業項目の削除中にエラーが発生しました" + } + }, + + "view": { + "label": "{count, plural, one {ビュー} other {ビュー}}", + "create": { + "label": "ビューを作成" + }, + "update": { + "label": "ビューを更新" + } + }, + + "inbox_issue": { + "status": { + "pending": { + "title": "保留中", + "description": "保留中" + }, + "declined": { + "title": "却下", + "description": "却下" + }, + "snoozed": { + "title": "スヌーズ", + "description": "残り{days, plural, one{# 日} other{# 日}}" + }, + "accepted": { + "title": "承認済み", + "description": "承認済み" + }, + "duplicate": { + "title": "重複", + "description": "重複" + } + }, + "modals": { + "decline": { + "title": "作業項目を却下", + "content": "作業項目{value}を却下してもよろしいですか?" + }, + "delete": { + "title": "作業項目を削除", + "content": "作業項目{value}を削除してもよろしいですか?", + "success": "作業項目を削除しました" + } + }, + "errors": { + "snooze_permission": "プロジェクト管理者のみが作業項目をスヌーズ/スヌーズ解除できます", + "accept_permission": "プロジェクト管理者のみが作業項目を承認できます", + "decline_permission": "プロジェクト管理者のみが作業項目を却下できます" + }, + "actions": { + "accept": "承認", + "decline": "却下", + "snooze": "スヌーズ", + "unsnooze": "スヌーズ解除", + "copy": "作業項目のリンクをコピー", + "delete": "削除", + "open": "作業項目を開く", + "mark_as_duplicate": "重複としてマーク", + "move": "{value}をプロジェクトの作業項目に移動" + }, + "source": { + "in-app": "アプリ内" + }, + "order_by": { + "created_at": "作成日", + "updated_at": "更新日", + "id": "ID" + }, + "label": "インテーク", + "page_label": "{workspace} - インテーク", + "modal": { + "title": "インテーク作業項目を作成" + }, + "tabs": { + "open": "オープン", + "closed": "クローズ" + }, + "empty_state": { + "sidebar_open_tab": { + "title": "オープンな作業項目がありません", + "description": "オープンな作業項目はここで見つかります。新しい作業項目を作成してください。" + }, + "sidebar_closed_tab": { + "title": "クローズされた作業項目がありません", + "description": "承認または却下されたすべての作業項目はここで見つかります。" + }, + "sidebar_filter": { + "title": "一致する作業項目がありません", + "description": "インテークに適用されたフィルターに一致する作業項目がありません。新しい作業項目を作成してください。" + }, + "detail": { + "title": "詳細を表示する作業項目を選択してください。" + } + } + }, + + "workspace_creation": { + "heading": "ワークスペースを作成", + "subheading": "Planeを使用するには、ワークスペースを作成するか参加する必要があります。", + "form": { + "name": { + "label": "ワークスペース名を設定", + "placeholder": "馴染みがあり認識しやすい名前が最適です。" + }, + "url": { + "label": "ワークスペースのURLを設定", + "placeholder": "URLを入力または貼り付け", + "edit_slug": "URLのスラッグのみ編集可能です" + }, + "organization_size": { + "label": "このワークスペースを何人で使用しますか?", + "placeholder": "範囲を選択" + } + }, + "errors": { + "creation_disabled": { + "title": "インスタンス管理者のみがワークスペースを作成できます", + "description": "インスタンス管理者のメールアドレスをご存知の場合は、下のボタンをクリックして連絡を取ってください。", + "request_button": "インスタンス管理者にリクエスト" + }, + "validation": { + "name_alphanumeric": "ワークスペース名には (' '), ('-'), ('_') と英数字のみ使用できます。", + "name_length": "名前は80文字以内にしてください。", + "url_alphanumeric": "URLには ('-') と英数字のみ使用できます。", + "url_length": "URLは48文字以内にしてください。", + "url_already_taken": "ワークスペースのURLは既に使用されています!" + } + }, + "request_email": { + "subject": "新規ワークスペースのリクエスト", + "body": "インスタンス管理者様\n\n[ワークスペース作成の目的]のために、URL [/workspace-name] の新規ワークスペースを作成していただけますでしょうか。\n\nよろしくお願いいたします。\n{firstName} {lastName}\n{email}" + }, + "button": { + "default": "ワークスペースを作成", + "loading": "ワークスペースを作成中" + }, + "toast": { + "success": { + "title": "成功", + "message": "ワークスペースが正常に作成されました" + }, + "error": { + "title": "エラー", + "message": "ワークスペースを作成できませんでした。もう一度お試しください。" + } + } + }, + + "workspace_dashboard": { + "empty_state": { + "general": { + "title": "プロジェクト、アクティビティ、メトリクスの概要", + "description": "Planeへようこそ。ご利用いただき嬉しく思います。最初のプロジェクトを作成して作業項目を追跡すると、このページは進捗を把握するのに役立つスペースに変わります。管理者はチームの進捗に役立つ項目も表示されます。", + "primary_button": { + "text": "最初のプロジェクトを作成", + "comic": { + "title": "Planeではすべてがプロジェクトから始まります", + "description": "プロジェクトは製品のロードマップ、マーケティングキャンペーン、新車の発売などになります。" + } + } + } + } + }, + + "workspace_analytics": { + "label": "アナリティクス", + "page_label": "{workspace} - アナリティクス", + "open_tasks": "オープンタスクの合計", + "error": "データの取得中にエラーが発生しました。", + "work_items_closed_in": "クローズされた作業項目", + "selected_projects": "選択されたプロジェクト", + "total_members": "メンバー総数", + "total_cycles": "サイクル総数", + "total_modules": "モジュール総数", + "pending_work_items": { + "title": "保留中の作業項目", + "empty_state": "同僚による保留中の作業項目の分析がここに表示されます。" + }, + "work_items_closed_in_a_year": { + "title": "1年間でクローズされた作業項目", + "empty_state": "作業項目をクローズすると、グラフ形式で分析が表示されます。" + }, + "most_work_items_created": { + "title": "作成された作業項目が最も多い", + "empty_state": "同僚と作成した作業項目の数がここに表示されます。" + }, + "most_work_items_closed": { + "title": "クローズされた作業項目が最も多い", + "empty_state": "同僚とクローズした作業項目の数がここに表示されます。" + }, + "tabs": { + "scope_and_demand": "スコープと需要", + "custom": "カスタムアナリティクス" + }, + "empty_state": { + "general": { + "title": "進捗、ワークロード、割り当てを追跡。傾向を把握し、ブロッカーを解消して、作業をより速く進めましょう", + "description": "スコープと需要、見積もり、スコープクリープを確認できます。チームメンバーとチームのパフォーマンスを把握し、プロジェクトが予定通りに進むようにします。", + "primary_button": { + "text": "最初のプロジェクトを開始", + "comic": { + "title": "アナリティクスはサイクル + モジュールで最も効果を発揮", + "description": "まず、作業項目をサイクルでタイムボックス化し、可能であれば、複数のサイクルにまたがる作業項目をモジュールにグループ化します。左のナビゲーションで両方を確認してください。" + } + } + } + } + }, + + "workspace_projects": { + "label": "{count, plural, one {プロジェクト} other {プロジェクト}}", + "create": { + "label": "プロジェクトを追加" + }, + "network": { + "private": { + "title": "非公開", + "description": "招待された人のみアクセス可能" + }, + "public": { + "title": "公開", + "description": "ゲスト以外のワークスペースの全員が参加可能" + } + }, + "error": { + "permission": "この操作を実行する権限がありません。", + "cycle_delete": "サイクルの削除に失敗しました", + "module_delete": "モジュールの削除に失敗しました", + "issue_delete": "作業項目の削除に失敗しました" + }, + "state": { + "backlog": "バックログ", + "unstarted": "未開始", + "started": "開始済み", + "completed": "完了", + "cancelled": "キャンセル" + }, + "sort": { + "manual": "手動", + "name": "名前", + "created_at": "作成日", + "members_length": "メンバー数" + }, + "scope": { + "my_projects": "自分のプロジェクト", + "archived_projects": "アーカイブ済み" + }, + "common": { + "months_count": "{months, plural, one{# ヶ月} other{# ヶ月}}" + }, + "empty_state": { + "general": { + "title": "アクティブなプロジェクトがありません", + "description": "各プロジェクトは目標指向の作業の親として考えてください。プロジェクトには作業、サイクル、モジュールが含まれ、同僚と共にその目標の達成を支援します。新しいプロジェクトを作成するか、アーカイブされたプロジェクトをフィルタリングしてください。", + "primary_button": { + "text": "最初のプロジェクトを開始", + "comic": { + "title": "Planeではすべてがプロジェクトから始まります", + "description": "プロジェクトは製品のロードマップ、マーケティングキャンペーン、新車の発売などになります。" + } + } + }, + "no_projects": { + "title": "プロジェクトがありません", + "description": "作業項目を作成したり作業を管理したりするには、プロジェクトを作成するか、プロジェクトのメンバーになる必要があります。", + "primary_button": { + "text": "最初のプロジェクトを開始", + "comic": { + "title": "Planeではすべてがプロジェクトから始まります", + "description": "プロジェクトは製品のロードマップ、マーケティングキャンペーン、新車の発売などになります。" + } + } + }, + "filter": { + "title": "一致するプロジェクトがありません", + "description": "条件に一致するプロジェクトが見つかりません。\n代わりに新しいプロジェクトを作成してください。" + }, + "search": { + "description": "条件に一致するプロジェクトが見つかりません。\n代わりに新しいプロジェクトを作成してください。" + } + } + }, + + "workspace_views": { + "add_view": "ビューを追加", + "empty_state": { + "all-issues": { + "title": "プロジェクトに作業項目がありません", + "description": "最初のプロジェクトが完了しました!次は、作業を追跡可能な作業項目に分割しましょう。始めましょう!", + "primary_button": { + "text": "新しい作業項目を作成" + } + }, + "assigned": { + "title": "作業項目がまだありません", + "description": "あなたに割り当てられた作業項目をここで追跡できます。", + "primary_button": { + "text": "新しい作業項目を作成" + } + }, + "created": { + "title": "作業項目がまだありません", + "description": "あなたが作成したすべての作業項目がここに表示され、直接追跡できます。", + "primary_button": { + "text": "新しい作業項目を作成" + } + }, + "subscribed": { + "title": "作業項目がまだありません", + "description": "興味のある作業項目を購読して、ここですべてを追跡できます。" + }, + "custom-view": { + "title": "作業項目がまだありません", + "description": "フィルターに該当する作業項目をここで追跡できます。" + } + } + }, + + "workspace_settings": { + "label": "ワークスペース設定", + "page_label": "{workspace} - 一般設定", + "key_created": "キーが作成されました", + "copy_key": "このシークレットキーをコピーしてPlaneページに保存してください。閉じた後はこのキーを見ることができません。キーを含むCSVファイルがダウンロードされました。", + "token_copied": "トークンがクリップボードにコピーされました。", + "settings": { + "general": { + "title": "一般", + "upload_logo": "ロゴをアップロード", + "edit_logo": "ロゴを編集", + "name": "ワークスペース名", + "company_size": "会社の規模", + "url": "ワークスペースURL", + "update_workspace": "ワークスペースを更新", + "delete_workspace": "このワークスペースを削除", + "delete_workspace_description": "ワークスペースを削除すると、そのワークスペース内のすべてのデータとリソースが完全に削除され、復元することはできません。", + "delete_btn": "このワークスペースを削除", + "delete_modal": { + "title": "このワークスペースを削除してもよろしいですか?", + "description": "有料プランの無料トライアルが有効です。続行するには、まずトライアルをキャンセルしてください。", + "dismiss": "閉じる", + "cancel": "トライアルをキャンセル", + "success_title": "ワークスペースが削除されました。", + "success_message": "まもなくプロフィールページに移動します。", + "error_title": "操作に失敗しました。", + "error_message": "もう一度お試しください。" + }, + "errors": { + "name": { + "required": "名前は必須です", + "max_length": "ワークスペース名は80文字を超えることはできません" + }, + "company_size": { + "required": "会社の規模は必須です" + } + } + }, + "members": { + "title": "メンバー", + "add_member": "メンバーを追加", + "pending_invites": "保留中の招待", + "invitations_sent_successfully": "招待が正常に送信されました", + "leave_confirmation": "ワークスペースから退出してもよろしいですか?このワークスペースにアクセスできなくなります。この操作は取り消せません。", + "details": { + "full_name": "フルネーム", + "display_name": "表示名", + "email_address": "メールアドレス", + "account_type": "アカウントタイプ", + "authentication": "認証", + "joining_date": "参加日" + }, + "modal": { + "title": "共同作業者を招待", + "description": "ワークスペースに共同作業者を招待します。", + "button": "招待を送信", + "button_loading": "招待を送信中", + "placeholder": "name@company.com", + "errors": { + "required": "招待するにはメールアドレスが必要です。", + "invalid": "メールアドレスが無効です" + } + } + }, + "billing_and_plans": { + "title": "請求とプラン", + "current_plan": "現在のプラン", + "free_plan": "現在フリープランを使用中です", + "view_plans": "プランを表示" + }, + "exports": { + "title": "エクスポート", + "exporting": "エクスポート中", + "previous_exports": "過去のエクスポート", + "export_separate_files": "データを個別のファイルにエクスポート", + "modal": { + "title": "エクスポート先", + "toasts": { + "success": { + "title": "エクスポート成功", + "message": "エクスポートした{entity}は過去のエクスポートからダウンロードできます。" + }, + "error": { + "title": "エクスポート失敗", + "message": "エクスポートに失敗しました。もう一度お試しください。" + } + } + } + }, + "webhooks": { + "title": "Webhook", + "add_webhook": "Webhookを追加", + "modal": { + "title": "Webhookを作成", + "details": "Webhook詳細", + "payload": "ペイロードURL", + "question": "このWebhookをトリガーするイベントを選択してください", + "error": "URLは必須です" + }, + "secret_key": { + "title": "シークレットキー", + "message": "Webhookペイロードにサインインするためのトークンを生成" + }, + "options": { + "all": "すべてを送信", + "individual": "個別のイベントを選択" + }, + "toasts": { + "created": { + "title": "Webhook作成完了", + "message": "Webhookが正常に作成されました" + }, + "not_created": { + "title": "Webhook作成失敗", + "message": "Webhookを作成できませんでした" + }, + "updated": { + "title": "Webhook更新完了", + "message": "Webhookが正常に更新されました" + }, + "not_updated": { + "title": "Webhook更新失敗", + "message": "Webhookを更新できませんでした" + }, + "removed": { + "title": "Webhook削除完了", + "message": "Webhookが正常に削除されました" + }, + "not_removed": { + "title": "Webhook削除失敗", + "message": "Webhookを削除できませんでした" + }, + "secret_key_copied": { + "message": "シークレットキーがクリップボードにコピーされました。" + }, + "secret_key_not_copied": { + "message": "シークレットキーのコピー中にエラーが発生しました。" + } + } + }, + "api_tokens": { + "title": "APIトークン", + "add_token": "APIトークンを追加", + "create_token": "トークンを作成", + "never_expires": "無期限", + "generate_token": "トークンを生成", + "generating": "生成中", + "delete": { + "title": "APIトークンを削除", + "description": "このトークンを使用しているアプリケーションはPlaneのデータにアクセスできなくなります。この操作は取り消せません。", + "success": { + "title": "成功!", + "message": "APIトークンが正常に削除されました" + }, + "error": { + "title": "エラー!", + "message": "APIトークンを削除できませんでした" + } + } + } + }, + "empty_state": { + "api_tokens": { + "title": "APIトークンがまだ作成されていません", + "description": "PlaneのAPIを使用して、Planeのデータを外部システムと統合できます。トークンを作成して始めましょう。" + }, + "webhooks": { + "title": "Webhookが追加されていません", + "description": "Webhookを作成してリアルタイムの更新を受け取り、アクションを自動化します。" + }, + "exports": { + "title": "エクスポートがまだありません", + "description": "エクスポートすると、参照用のコピーがここに保存されます。" + }, + "imports": { + "title": "インポートがまだありません", + "description": "過去のインポートをここで確認し、ダウンロードできます。" + } + } + }, + + "profile": { + "label": "プロフィール", + "page_label": "あなたの作業", + "work": "作業", + "details": { + "joined_on": "参加日", + "time_zone": "タイムゾーン" + }, + "stats": { + "workload": "作業負荷", + "overview": "概要", + "created": "作成した作業項目", + "assigned": "割り当てられた作業項目", + "subscribed": "購読中の作業項目", + "state_distribution": { + "title": "状態別作業項目", + "empty": "より良い分析のために、作業項目を作成してグラフで状態別に表示します。" + }, + "priority_distribution": { + "title": "優先度別作業項目", + "empty": "より良い分析のために、作業項目を作成してグラフで優先度別に表示します。" + }, + "recent_activity": { + "title": "最近のアクティビティ", + "empty": "データが見つかりませんでした。入力内容を確認してください", + "button": "今日のアクティビティをダウンロード", + "button_loading": "ダウンロード中" + } + }, + "actions": { + "profile": "プロフィール", + "security": "セキュリティ", + "activity": "アクティビティ", + "appearance": "外観", + "notifications": "通知" + }, + "tabs": { + "summary": "サマリー", + "assigned": "割り当て済み", + "created": "作成済み", + "subscribed": "購読中", + "activity": "アクティビティ" + }, + "empty_state": { + "activity": { + "title": "アクティビティがまだありません", + "description": "新しい作業項目を作成して始めましょう!詳細とプロパティを追加してください。Planeをさらに探索してアクティビティを確認しましょう。" + }, + "assigned": { + "title": "割り当てられた作業項目がありません", + "description": "あなたに割り当てられた作業項目をここで追跡できます。" + }, + "created": { + "title": "作業項目がまだありません", + "description": "あなたが作成したすべての作業項目がここに表示され、直接追跡できます。" + }, + "subscribed": { + "title": "作業項目がまだありません", + "description": "興味のある作業項目を購読して、ここですべてを追跡できます。" + } + } + }, + + "project_settings": { + "general": { + "enter_project_id": "プロジェクトIDを入力", + "please_select_a_timezone": "タイムゾーンを選択してください", + "archive_project": { + "title": "プロジェクトをアーカイブ", + "description": "プロジェクトをアーカイブすると、サイドナビゲーションから非表示になりますが、プロジェクトページからアクセスすることはできます。プロジェクトを復元または削除することもできます。", + "button": "プロジェクトをアーカイブ" + }, + "delete_project": { + "title": "プロジェクトを削除", + "description": "プロジェクトを削除すると、そのプロジェクト内のすべてのデータとリソースが永久に削除され、復元できなくなります。", + "button": "プロジェクトを削除" + }, + "toast": { + "success": "プロジェクトが正常に更新されました", + "error": "プロジェクトを更新できませんでした。もう一度お試しください。" + } + }, + "members": { + "label": "メンバー", + "project_lead": "プロジェクトリーダー", + "default_assignee": "デフォルトの担当者", + "guest_super_permissions": { + "title": "ゲストユーザーにすべての作業項目の閲覧権限を付与:", + "sub_heading": "これにより、ゲストはプロジェクトのすべての作業項目を閲覧できるようになります。" + }, + "invite_members": { + "title": "メンバーを招待", + "sub_heading": "プロジェクトに参加するメンバーを招待します。", + "select_co_worker": "共同作業者を選択" + } + }, + "states": { + "describe_this_state_for_your_members": "このステータスについてメンバーに説明してください。", + "empty_state": { + "title": "{groupKey}グループのステータスがありません", + "description": "新しいステータスを作成してください" + } + }, + "labels": { + "label_title": "ラベルタイトル", + "label_title_is_required": "ラベルタイトルは必須です", + "label_max_char": "ラベル名は255文字を超えることはできません", + "toast": { + "error": "ラベルの更新中にエラーが発生しました" + } + }, + "estimates": { + "title": "プロジェクトの見積もりを有効にする", + "description": "チームの複雑さと作業負荷を伝えるのに役立ちます。" + }, + "automations": { + "label": "自動化", + "auto-archive": { + "title": "完了した作業項目を自動アーカイブ", + "description": "Planeは完了またはキャンセルされた作業項目を自動的にアーカイブします。", + "duration": "次の期間完了している作業項目を自動アーカイブ" + }, + "auto-close": { + "title": "作業項目を自動クローズ", + "description": "Planeは完了またはキャンセルされていない作業項目を自動的にクローズします。", + "duration": "次の期間非アクティブな作業項目を自動クローズ", + "auto_close_status": "自動クローズステータス" + } + }, + + "empty_state": { + "labels": { + "title": "ラベルがまだありません", + "description": "プロジェクトの作業項目を整理してフィルタリングするためのラベルを作成します。" + }, + "estimates": { + "title": "見積もりシステムがまだありません", + "description": "作業項目ごとの作業量を伝えるための見積もりセットを作成します。", + "primary_button": "見積もりシステムを追加" + } + } + }, + + "project_cycles": { + "add_cycle": "サイクルを追加", + "more_details": "詳細情報", + "cycle": "サイクル", + "update_cycle": "サイクルを更新", + "create_cycle": "サイクルを作成", + "no_matching_cycles": "一致するサイクルがありません", + "remove_filters_to_see_all_cycles": "すべてのサイクルを表示するにはフィルターを解除してください", + "remove_search_criteria_to_see_all_cycles": "すべてのサイクルを表示するには検索条件を解除してください", + "only_completed_cycles_can_be_archived": "完了したサイクルのみアーカイブできます", + "active_cycle": { + "label": "アクティブなサイクル", + "progress": "進捗", + "chart": "バーンダウンチャート", + "priority_issue": "優先作業項目", + "assignees": "担当者", + "issue_burndown": "作業項目バーンダウン", + "ideal": "理想", + "current": "現在", + "labels": "ラベル" + }, + "upcoming_cycle": { + "label": "今後のサイクル" + }, + "completed_cycle": { + "label": "完了したサイクル" + }, + "status": { + "days_left": "残り日数", + "completed": "完了", + "yet_to_start": "開始前", + "in_progress": "進行中", + "draft": "下書き" + }, + "action": { + "restore": { + "title": "サイクルを復元", + "success": { + "title": "サイクルが復元されました", + "description": "サイクルが復元されました。" + }, + "failed": { + "title": "サイクルの復元に失敗", + "description": "サイクルを復元できませんでした。もう一度お試しください。" + } + }, + "favorite": { + "loading": "お気に入りにサイクルを追加中", + "success": { + "description": "サイクルがお気に入りに追加されました。", + "title": "成功!" + }, + "failed": { + "description": "サイクルをお気に入りに追加できませんでした。もう一度お試しください。", + "title": "エラー!" + } + }, + "unfavorite": { + "loading": "お気に入りからサイクルを削除中", + "success": { + "description": "サイクルがお気に入りから削除されました。", + "title": "成功!" + }, + "failed": { + "description": "サイクルをお気に入りから削除できませんでした。もう一度お試しください。", + "title": "エラー!" + } + }, + "update": { + "loading": "サイクルを更新中", + "success": { + "description": "サイクルが正常に更新されました。", + "title": "成功!" + }, + "failed": { + "description": "サイクルの更新中にエラーが発生しました。もう一度お試しください。", + "title": "エラー!" + }, + "error": { + "already_exists": "指定した日付のサイクルは既に存在します。下書きサイクルを作成する場合は、両方の日付を削除してください。" + } + } + }, + "empty_state": { + "general": { + "title": "サイクルで作業をグループ化してタイムボックス化します。", + "description": "作業をタイムボックス化された単位に分割し、プロジェクトの期限から逆算して日付を設定し、チームとして具体的な進捗を作ります。", + "primary_button": { + "text": "最初のサイクルを設定", + "comic": { + "title": "サイクルは繰り返されるタイムボックスです。", + "description": "スプリント、イテレーション、または週次や隔週の作業追跡に使用するその他の用語がサイクルです。" + } + } + }, + "no_issues": { + "title": "サイクルに作業項目が追加されていません", + "description": "このサイクル内でタイムボックス化して提供したい作業項目を追加または作成します", + "primary_button": { + "text": "新しい作業項目を作成" + }, + "secondary_button": { + "text": "既存の作業項目を追加" + } + }, + "completed_no_issues": { + "title": "サイクルに作業項目がありません", + "description": "サイクルに作業項目がありません。作業項目は転送されたか非表示になっています。非表示の作業項目がある場合は、表示プロパティを更新して確認してください。" + }, + "active": { + "title": "アクティブなサイクルがありません", + "description": "アクティブなサイクルには、その期間内に今日の日付が含まれるものが該当します。アクティブなサイクルの進捗と詳細をここで確認できます。" + }, + "archived": { + "title": "アーカイブされたサイクルがまだありません", + "description": "プロジェクトを整理するために、完了したサイクルをアーカイブします。アーカイブ後はここで確認できます。" + } + } + }, + + "project_issues": { + "empty_state": { + "no_issues": { + "title": "作業項目を作成して誰かに割り当てましょう。自分自身でも構いません", + "description": "作業項目は、仕事、タスク、作業、またはJTBD(私たちが好む用語)と考えてください。作業項目とそのサブ作業項目は通常、チームメンバーに割り当てられる時間ベースのアクションアイテムです。チームは作業項目を作成、割り当て、完了することでプロジェクトの目標に向かって進みます。", + "primary_button": { + "text": "最初の作業項目を作成", + "comic": { + "title": "作業項目はPlaneの構成要素です。", + "description": "PlaneのUIの再設計、会社のリブランド、新しい燃料噴射システムの立ち上げなどは、サブ作業項目を持つ可能性が高い作業項目の例です。" + } + } + }, + "no_archived_issues": { + "title": "アーカイブされた作業項目がまだありません", + "description": "手動または自動化を通じて、完了またはキャンセルされた作業項目をアーカイブできます。アーカイブ後はここで確認できます。", + "primary_button": { + "text": "自動化を設定" + } + }, + "issues_empty_filter": { + "title": "適用されたフィルターに一致する作業項目が見つかりません", + "secondary_button": { + "text": "すべてのフィルターをクリア" + } + } + } + }, + + "project_module": { + "add_module": "モジュールを追加", + "update_module": "モジュールを更新", + "create_module": "モジュールを作成", + "archive_module": "モジュールをアーカイブ", + "restore_module": "モジュールを復元", + "delete_module": "モジュールを削除", + "empty_state": { + "general": { + "title": "プロジェクトのマイルストーンをモジュールにマッピングし、集計された作業を簡単に追跡できます。", + "description": "論理的で階層的な親に属する作業項目のグループがモジュールを形成します。プロジェクトのマイルストーンで作業を追跡する方法として考えてください。期間や期限があり、マイルストーンまでの進捗状況を確認できる分析機能も備えています。", + "primary_button": { + "text": "最初のモジュールを作成", + "comic": { + "title": "モジュールは階層的に作業をグループ化するのに役立ちます。", + "description": "カートモジュール、シャーシモジュール、倉庫モジュールは、このグループ化の良い例です。" + } + } + }, + "no_issues": { + "title": "モジュールに作業項目がありません", + "description": "このモジュールの一部として達成したい作業項目を作成または追加してください", + "primary_button": { + "text": "新しい作業項目を作成" + }, + "secondary_button": { + "text": "既存の作業項目を追加" + } + }, + "archived": { + "title": "アーカイブされたモジュールがまだありません", + "description": "プロジェクトを整理するために、完了またはキャンセルされたモジュールをアーカイブします。アーカイブ後はここで確認できます。" + }, + "sidebar": { + "in_active": "このモジュールはまだアクティブではありません。", + "invalid_date": "無効な日付です。有効な日付を入力してください。" + } + }, + "quick_actions": { + "archive_module": "モジュールをアーカイブ", + "archive_module_description": "完了またはキャンセルされた\nモジュールのみアーカイブできます。", + "delete_module": "モジュールを削除" + }, + "toast": { + "copy": { + "success": "モジュールのリンクがクリップボードにコピーされました" + }, + "delete": { + "success": "モジュールが正常に削除されました", + "error": "モジュールを削除できませんでした" + } + } + }, + + "project_views": { + "empty_state": { + "general": { + "title": "プロジェクトのフィルター付きビューを保存します。必要な数だけ作成できます", + "description": "ビューは、頻繁に使用するフィルターや簡単にアクセスしたいフィルターの集合です。プロジェクト内のすべての同僚が全員のビューを確認でき、自分のニーズに最も合うものを選択できます。", + "primary_button": { + "text": "最初のビューを作成", + "comic": { + "title": "ビューは作業項目のプロパティの上で機能します。", + "description": "ここから、必要に応じて多くのプロパティやフィルターを使用してビューを作成できます。" + } + } + }, + "filter": { + "title": "一致するビューがありません", + "description": "検索条件に一致するビューがありません。\n代わりに新しいビューを作成してください。" + } + } + }, + + "project_page": { + "empty_state": { + "general": { + "title": "メモ、ドキュメント、または完全なナレッジベースを作成しましょう。PlaneのAIアシスタントGalileoが開始をサポートします", + "description": "ページはPlaneの思考整理スペースです。会議のメモを取り、簡単に整形し、作業項目を埋め込み、コンポーネントライブラリを使用してレイアウトし、すべてをプロジェクトのコンテキストに保存できます。ドキュメントを素早く作成するには、ショートカットまたはボタンのクリックでPlaneのAI、Galileoを呼び出してください。", + "primary_button": { + "text": "最初のページを作成" + } + }, + "private": { + "title": "プライベートページがまだありません", + "description": "プライベートな考えをここに保存しましょう。共有する準備ができたら、チームはクリック一つで共有できます。", + "primary_button": { + "text": "最初のページを作成" + } + }, + "public": { + "title": "公開ページがまだありません", + "description": "プロジェクト内の全員と共有されているページをここで確認できます。", + "primary_button": { + "text": "最初のページを作成" + } + }, + "archived": { + "title": "アーカイブされたページがまだありません", + "description": "注目していないページをアーカイブします。必要な時にここでアクセスできます。" + } + } + }, + + "command_k": { + "empty_state": { + "search": { + "title": "結果が見つかりません" + } + } + }, + + "issue_relation": { + "empty_state": { + "search": { + "title": "一致する作業項目が見つかりません" + }, + "no_issues": { + "title": "作業項目が見つかりません" + } + } + }, + + "issue_comment": { + "empty_state": { + "general": { + "title": "コメントがまだありません", + "description": "コメントは作業項目のディスカッションとフォローアップのスペースとして使用できます" + } + } + }, + + "notification": { + "label": "受信トレイ", + "page_label": "{workspace} - 受信トレイ", + "options": { + "mark_all_as_read": "すべて既読にする", + "mark_read": "既読にする", + "mark_unread": "未読にする", + "refresh": "更新", + "filters": "受信トレイフィルター", + "show_unread": "未読を表示", + "show_snoozed": "スヌーズを表示", + "show_archived": "アーカイブを表示", + "mark_archive": "アーカイブ", + "mark_unarchive": "アーカイブ解除", + "mark_snooze": "スヌーズ", + "mark_unsnooze": "スヌーズ解除" + }, + "toasts": { + "read": "通知を既読にしました", + "unread": "通知を未読にしました", + "archived": "通知をアーカイブしました", + "unarchived": "通知をアーカイブ解除しました", + "snoozed": "通知をスヌーズしました", + "unsnoozed": "通知のスヌーズを解除しました" + }, + "empty_state": { + "detail": { + "title": "詳細を表示するには選択してください。" + }, + "all": { + "title": "割り当てられた作業項目がありません", + "description": "あなたに割り当てられた作業項目の更新が\nここに表示されます" + }, + "mentions": { + "title": "割り当てられた作業項目がありません", + "description": "あなたに割り当てられた作業項目の更新が\nここに表示されます" + } + }, + "tabs": { + "all": "すべて", + "mentions": "メンション" + }, + "filter": { + "assigned": "自分に割り当て", + "created": "自分が作成", + "subscribed": "自分が購読" + }, + "snooze": { + "1_day": "1日", + "3_days": "3日", + "5_days": "5日", + "1_week": "1週間", + "2_weeks": "2週間", + "custom": "カスタム" + } + }, + + "active_cycle": { + "empty_state": { + "progress": { + "title": "サイクルの進捗を表示するには作業項目を追加してください" + }, + "chart": { + "title": "バーンダウンチャートを表示するには作業項目を追加してください。" + }, + "priority_issue": { + "title": "サイクルで取り組まれている優先度の高い作業項目を一目で確認できます。" + }, + "assignee": { + "title": "担当者別の作業の内訳を確認するには、作業項目に担当者を追加してください。" + }, + "label": { + "title": "ラベル別の作業の内訳を確認するには、作業項目にラベルを追加してください。" + } + } + }, + + "disabled_project": { + "empty_state": { + "inbox": { + "title": "インテークがプロジェクトで有効になっていません。", + "description": "インテークは、プロジェクトへの受信リクエストを管理し、ワークフローに作業項目として追加するのに役立ちます。リクエストを管理するには、プロジェクト設定でインテークを有効にしてください。", + "primary_button": { + "text": "機能を管理" + } + }, + "cycle": { + "title": "サイクルがこのプロジェクトで有効になっていません。", + "description": "時間枠で作業を分割し、プロジェクトの期限から逆算して日付を設定し、チームとして具体的な進捗を作ります。サイクルを使用するには、プロジェクトでサイクル機能を有効にしてください。", + "primary_button": { + "text": "機能を管理" + } + }, + "module": { + "title": "モジュールがプロジェクトで有効になっていません。", + "description": "モジュールはプロジェクトの構成要素です。モジュールを使用するには、プロジェクト設定でモジュールを有効にしてください。", + "primary_button": { + "text": "機能を管理" + } + }, + "page": { + "title": "ページがプロジェクトで有効になっていません。", + "description": "ページはプロジェクトの構成要素です。ページを使用するには、プロジェクト設定でページを有効にしてください。", + "primary_button": { + "text": "機能を管理" + } + }, + "view": { + "title": "ビューがプロジェクトで有効になっていません。", + "description": "ビューはプロジェクトの構成要素です。ビューを使用するには、プロジェクト設定でビューを有効にしてください。", + "primary_button": { + "text": "機能を管理" + } + } + } + }, + "workspace_draft_issues": { + "draft_an_issue": "作業項目の下書き", + "empty_state": { + "title": "書きかけの作業項目、そしてまもなくコメントがここに表示されます。", + "description": "試してみるには、作業項目の追加を開始して途中で中断するか、以下で最初の下書きを作成してください。😉", + "primary_button": { + "text": "最初の下書きを作成" + } + }, + "delete_modal": { + "title": "下書きを削除", + "description": "この下書きを削除してもよろしいですか?この操作は取り消せません。" + }, + "toasts": { + "created": { + "success": "下書きを作成しました", + "error": "作業項目を作成できませんでした。もう一度お試しください。" + }, + "deleted": { + "success": "下書きを削除しました" + } + } + }, + + "stickies": { + "title": "あなたの付箋", + "placeholder": "ここをクリックして入力", + "all": "すべての付箋", + "no-data": "アイデアをメモしたり、ひらめきをキャプチャしたり、閃きを記録したりしましょう。付箋を追加して始めましょう。", + "add": "付箋を追加", + "search_placeholder": "タイトルで検索", + "delete": "付箋を削除", + "delete_confirmation": "この付箋を削除してもよろしいですか?", + "empty_state": { + "simple": "アイデアをメモしたり、ひらめきをキャプチャしたり、閃きを記録したりしましょう。付箋を追加して始めましょう。", + "general": { + "title": "付箋は、その場で素早く取るメモやToDoです。", + "description": "いつでもどこからでもアクセスできる付箋を作成して、思考やアイデアを簡単にキャプチャできます。", + "primary_button": { + "text": "付箋を追加" + } + }, + "search": { + "title": "付箋に一致するものがありません。", + "description": "別の用語を試すか、検索が正しいと\n確信がある場合はお知らせください。", + "primary_button": { + "text": "付箋を追加" + } + } + }, + "toasts": { + "errors": { + "wrong_name": "付箋の名前は100文字を超えることはできません。", + "already_exists": "説明のない付箋がすでに存在します" + }, + "created": { + "title": "付箋を作成しました", + "message": "付箋が正常に作成されました" + }, + "not_created": { + "title": "付箋を作成できませんでした", + "message": "付箋を作成できませんでした" + }, + "updated": { + "title": "付箋を更新しました", + "message": "付箋が正常に更新されました" + }, + "not_updated": { + "title": "付箋を更新できませんでした", + "message": "付箋を更新できませんでした" + }, + "removed": { + "title": "付箋を削除しました", + "message": "付箋が正常に削除されました" + }, + "not_removed": { + "title": "付箋を削除できませんでした", + "message": "付箋を削除できませんでした" + } + } + }, + + "role_details": { + "guest": { + "title": "ゲスト", + "description": "組織の外部メンバーをゲストとして招待できます。" + }, + "member": { + "title": "メンバー", + "description": "プロジェクト、サイクル、モジュール内のエンティティの読み取り、書き込み、編集、削除が可能" + }, + "admin": { + "title": "管理者", + "description": "ワークスペース内のすべての権限が有効。" + } + }, + + "user_roles": { + "product_or_project_manager": "プロダクト/プロジェクトマネージャー", + "development_or_engineering": "開発/エンジニアリング", + "founder_or_executive": "創業者/エグゼクティブ", + "freelancer_or_consultant": "フリーランス/コンサルタント", + "marketing_or_growth": "マーケティング/グロース", + "sales_or_business_development": "営業/ビジネス開発", + "support_or_operations": "サポート/オペレーション", + "student_or_professor": "学生/教授", + "human_resources": "人事", + "other": "その他" + }, + + "importer": { + "github": { + "title": "GitHub", + "description": "GitHubリポジトリから作業項目をインポートして同期します。" + }, + "jira": { + "title": "Jira", + "description": "Jiraプロジェクトとエピックから作業項目とエピックをインポートします。" + } + }, + + "exporter": { + "csv": { + "title": "CSV", + "description": "作業項目をCSVファイルにエクスポートします。", + "short_description": "CSVとしてエクスポート" + }, + "excel": { + "title": "Excel", + "description": "作業項目をExcelファイルにエクスポートします。", + "short_description": "Excelとしてエクスポート" + }, + "xlsx": { + "title": "Excel", + "description": "作業項目をExcelファイルにエクスポートします。", + "short_description": "Excelとしてエクスポート" + }, + "json": { + "title": "JSON", + "description": "作業項目をJSONファイルにエクスポートします。", + "short_description": "JSONとしてエクスポート" + } + }, + "default_global_view": { + "all_issues": "すべての作業項目", + "assigned": "割り当て済み", + "created": "作成済み", + "subscribed": "購読中" + }, + + "themes": { + "theme_options": { + "system_preference": { + "label": "システム設定" + }, + "light": { + "label": "ライト" + }, + "dark": { + "label": "ダーク" + }, + "light_contrast": { + "label": "ライトハイコントラスト" + }, + "dark_contrast": { + "label": "ダークハイコントラスト" + }, + "custom": { + "label": "カスタムテーマ" + } + } + }, + "project_modules": { + "status": { + "backlog": "バックログ", + "planned": "計画済み", + "in_progress": "進行中", + "paused": "一時停止", + "completed": "完了", + "cancelled": "キャンセル" + }, + "layout": { + "list": "リスト表示", + "board": "ギャラリー表示", + "timeline": "タイムライン表示" + }, + "order_by": { + "name": "名前", + "progress": "進捗", + "issues": "作業項目数", + "due_date": "期限", + "created_at": "作成日", + "manual": "手動" + } + }, + + "cycle": { + "label": "{count, plural, one {サイクル} other {サイクル}}", + "no_cycle": "サイクルなし" + }, + + "module": { + "label": "{count, plural, one {モジュール} other {モジュール}}", + "no_module": "モジュールなし" + } +} diff --git a/packages/i18n/src/locales/zh-CN/translations.json b/packages/i18n/src/locales/zh-CN/translations.json new file mode 100644 index 000000000..aca47f21a --- /dev/null +++ b/packages/i18n/src/locales/zh-CN/translations.json @@ -0,0 +1,2362 @@ +{ + "sidebar": { + "projects": "项目", + "pages": "页面", + "new_work_item": "新工作项", + "home": "主页", + "your_work": "我的工作", + "inbox": "收件箱", + "workspace": "工作区", + "views": "视图", + "analytics": "分析", + "work_items": "工作项", + "cycles": "周期", + "modules": "模块", + "intake": "收集", + "drafts": "草稿", + "favorites": "收藏", + "pro": "专业版", + "upgrade": "升级" + }, + + "auth": { + "common": { + "email": { + "label": "邮箱", + "placeholder": "name@company.com", + "errors": { + "required": "邮箱是必填项", + "invalid": "邮箱格式无效" + } + }, + "password": { + "label": "密码", + "set_password": "设置密码", + "placeholder": "输入密码", + "confirm_password": { + "label": "确认密码", + "placeholder": "确认密码" + }, + "current_password": { + "label": "当前密码" + }, + "new_password": { + "label": "新密码", + "placeholder": "输入新密码" + }, + "change_password": { + "label": { + "default": "修改密码", + "submitting": "正在修改密码" + } + }, + "errors": { + "match": "密码不匹配", + "empty": "请输入密码", + "length": "密码长度应超过8个字符", + "strength": { + "weak": "密码强度较弱", + "strong": "密码强度较强" + } + }, + "submit": "设置密码", + "toast": { + "change_password": { + "success": { + "title": "成功!", + "message": "密码修改成功。" + }, + "error": { + "title": "错误!", + "message": "出现错误。请重试。" + } + } + } + }, + "unique_code": { + "label": "唯一码", + "placeholder": "gets-sets-flys", + "paste_code": "粘贴发送到您邮箱的验证码", + "requesting_new_code": "正在请求新验证码", + "sending_code": "正在发送验证码" + }, + "already_have_an_account": "已有账号?", + "login": "登录", + "create_account": "创建账号", + "new_to_plane": "首次使用 Plane?", + "back_to_sign_in": "返回登录", + "resend_in": "{seconds} 秒后重新发送", + "sign_in_with_unique_code": "使用唯一码登录", + "forgot_password": "忘记密码?" + }, + "sign_up": { + "header": { + "label": "创建账号以开始与团队一起管理工作。", + "step": { + "email": { + "header": "注册", + "sub_header": "" + }, + "password": { + "header": "注册", + "sub_header": "使用邮箱-密码组合注册。" + }, + "unique_code": { + "header": "注册", + "sub_header": "使用发送到上述邮箱的唯一码注册。" + } + } + }, + "errors": { + "password": { + "strength": "请设置一个强密码以继续" + } + } + }, + "sign_in": { + "header": { + "label": "登录以开始与团队一起管理工作。", + "step": { + "email": { + "header": "登录或注册", + "sub_header": "" + }, + "password": { + "header": "登录或注册", + "sub_header": "使用您的邮箱-密码组合登录。" + }, + "unique_code": { + "header": "登录或注册", + "sub_header": "使用发送到上述邮箱的唯一码登录。" + } + } + } + }, + "forgot_password": { + "title": "重置密码", + "description": "输入您的用户账号已验证的邮箱地址,我们将向您发送密码重置链接。", + "email_sent": "我们已将重置链接发送到您的邮箱", + "send_reset_link": "发送重置链接", + "errors": { + "smtp_not_enabled": "我们发现您的管理员未启用 SMTP,我们将无法发送密码重置链接" + }, + "toast": { + "success": { + "title": "邮件已发送", + "message": "请查看您的收件箱以获取重置密码的链接。如果几分钟内未收到,请检查垃圾邮件文件夹。" + }, + "error": { + "title": "错误!", + "message": "出现错误。请重试。" + } + } + }, + "reset_password": { + "title": "设置新密码", + "description": "使用强密码保护您的账号" + }, + "set_password": { + "title": "保护您的账号", + "description": "设置密码有助于您安全登录" + }, + "sign_out": { + "toast": { + "error": { + "title": "错误!", + "message": "登出失败。请重试。" + } + } + } + }, + + "submit": "提交", + "cancel": "取消", + "loading": "加载中", + "error": "错误", + "success": "成功", + "warning": "警告", + "info": "信息", + "close": "关闭", + "yes": "是", + "no": "否", + "ok": "确定", + "name": "名称", + "description": "描述", + "search": "搜索", + "add_member": "添加成员", + "adding_members": "正在添加成员", + "remove_member": "移除成员", + "add_members": "添加成员", + "adding_member": "正在添加成员", + "remove_members": "移除成员", + "add": "添加", + "adding": "添加中", + "remove": "移除", + "add_new": "添加新的", + "remove_selected": "移除所选", + "first_name": "名", + "last_name": "姓", + "email": "邮箱", + "display_name": "显示名称", + "role": "角色", + "timezone": "时区", + "avatar": "头像", + "cover_image": "封面图片", + "password": "密码", + "change_cover": "更改封面", + "language": "语言", + "saving": "保存中", + "save_changes": "保存更改", + "deactivate_account": "停用账号", + "deactivate_account_description": "停用账号后,该账号内的所有数据和资源将被永久删除且无法恢复。", + "profile_settings": "个人资料设置", + "your_account": "您的账号", + "security": "安全", + "activity": "活动", + "appearance": "外观", + "notifications": "通知", + "workspaces": "工作区", + "create_workspace": "创建工作区", + "invitations": "邀请", + "summary": "摘要", + "assigned": "已分配", + "created": "已创建", + "subscribed": "已订阅", + "you_do_not_have_the_permission_to_access_this_page": "您没有访问此页面的权限。", + "something_went_wrong_please_try_again": "出现错误。请重试。", + "load_more": "加载更多", + "select_or_customize_your_interface_color_scheme": "选择或自定义您的界面配色方案。", + "theme": "主题", + "system_preference": "系统偏好", + "light": "浅色", + "dark": "深色", + "light_contrast": "浅色高对比度", + "dark_contrast": "深色高对比度", + "custom": "自定义主题", + "select_your_theme": "选择您的主题", + "customize_your_theme": "自定义您的主题", + "background_color": "背景颜色", + "text_color": "文字颜色", + "primary_color": "主要(主题)颜色", + "sidebar_background_color": "侧边栏背景颜色", + "sidebar_text_color": "侧边栏文字颜色", + "set_theme": "设置主题", + "enter_a_valid_hex_code_of_6_characters": "输入有效的6位十六进制代码", + "background_color_is_required": "背景颜色为必填项", + "text_color_is_required": "文字颜色为必填项", + "primary_color_is_required": "主要颜色为必填项", + "sidebar_background_color_is_required": "侧边栏背景颜色为必填项", + "sidebar_text_color_is_required": "侧边栏文字颜色为必填项", + "updating_theme": "正在更新主题", + "theme_updated_successfully": "主题更新成功", + "failed_to_update_the_theme": "主题更新失败", + "email_notifications": "邮件通知", + "stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "及时了解您订阅的工作项。启用此功能以获取通知。", + "email_notification_setting_updated_successfully": "邮件通知设置更新成功", + "failed_to_update_email_notification_setting": "邮件通知设置更新失败", + "notify_me_when": "在以下情况通知我", + "property_changes": "属性变更", + "property_changes_description": "当工作项的属性(如负责人、优先级、估算等)发生变更时通知我。", + "state_change": "状态变更", + "state_change_description": "当工作项移动到不同状态时通知我", + "issue_completed": "工作项完成", + "issue_completed_description": "仅当工作项完成时通知我", + "comments": "评论", + "comments_description": "当有人在工作项上发表评论时通知我", + "mentions": "提及", + "mentions_description": "仅当有人在评论或描述中提及我时通知我", + "old_password": "旧密码", + "general_settings": "常规设置", + "sign_out": "退出登录", + "signing_out": "正在退出登录", + "active_cycles": "活动周期", + "active_cycles_description": "监控各个项目的周期,跟踪高优先级工作项,并关注需要注意的周期。", + "on_demand_snapshots_of_all_your_cycles": "所有周期的实时快照", + "upgrade": "升级", + "10000_feet_view": "所有活动周期的全局视图。", + "10000_feet_view_description": "放大视角,一次性查看所有项目中正在进行的周期,而不是在每个项目中逐个查看周期。", + "get_snapshot_of_each_active_cycle": "获取每个活动周期的快照。", + "get_snapshot_of_each_active_cycle_description": "跟踪所有活动周期的高级指标,查看其进度状态,并了解与截止日期相关的范围。", + "compare_burndowns": "比较燃尽图。", + "compare_burndowns_description": "通过查看每个周期的燃尽报告,监控每个团队的表现。", + "quickly_see_make_or_break_issues": "快速查看关键工作项。", + "quickly_see_make_or_break_issues_description": "预览每个周期中与截止日期相关的高优先级工作项。一键查看每个周期的所有工作项。", + "zoom_into_cycles_that_need_attention": "关注需要注意的周期。", + "zoom_into_cycles_that_need_attention_description": "一键调查任何不符合预期的周期状态。", + "stay_ahead_of_blockers": "提前预防阻塞。", + "stay_ahead_of_blockers_description": "发现从一个项目到另一个项目的挑战,并查看从其他视图中不易发现的周期间依赖关系。", + "analytics": "分析", + "workspace_invites": "工作区邀请", + "enter_god_mode": "进入管理员模式", + "workspace_logo": "工作区标志", + "new_issue": "新工作项", + "your_work": "我的工作", + "drafts": "草稿", + "projects": "项目", + "views": "视图", + "workspace": "工作区", + "archives": "归档", + "settings": "设置", + "failed_to_move_favorite": "移动收藏失败", + "favorites": "收藏", + "no_favorites_yet": "暂无收藏", + "create_folder": "创建文件夹", + "new_folder": "新建文件夹", + "favorite_updated_successfully": "收藏更新成功", + "favorite_created_successfully": "收藏创建成功", + "folder_already_exists": "文件夹已存在", + "folder_name_cannot_be_empty": "文件夹名称不能为空", + "something_went_wrong": "出现错误", + "failed_to_reorder_favorite": "重新排序收藏失败", + "favorite_removed_successfully": "收藏移除成功", + "failed_to_create_favorite": "创建收藏失败", + "failed_to_rename_favorite": "重命名收藏失败", + "project_link_copied_to_clipboard": "项目链接已复制到剪贴板", + "link_copied": "链接已复制", + "add_project": "添加项目", + "create_project": "创建项目", + "failed_to_remove_project_from_favorites": "无法从收藏中移除项目。请重试。", + "project_created_successfully": "项目创建成功", + "project_created_successfully_description": "项目创建成功。您现在可以开始添加工作项了。", + "project_cover_image_alt": "项目封面图片", + "name_is_required": "名称为必填项", + "title_should_be_less_than_255_characters": "标题应少于255个字符", + "project_name": "项目名称", + "project_id_must_be_at_least_1_character": "项目ID至少需要1个字符", + "project_id_must_be_at_most_5_characters": "项目ID最多只能有5个字符", + "project_id": "项目ID", + "project_id_tooltip_content": "帮助您唯一标识项目中的工作项。最多5个字符。", + "description_placeholder": "描述", + "only_alphanumeric_non_latin_characters_allowed": "仅允许字母数字和非拉丁字符。", + "project_id_is_required": "项目ID为必填项", + "project_id_allowed_char": "仅允许字母数字和非拉丁字符。", + "project_id_min_char": "项目ID至少需要1个字符", + "project_id_max_char": "项目ID最多只能有5个字符", + "project_description_placeholder": "输入项目描述", + "select_network": "选择网络", + "lead": "负责人", + "date_range": "日期范围", + "private": "私有", + "public": "公开", + "accessible_only_by_invite": "仅受邀者可访问", + "anyone_in_the_workspace_except_guests_can_join": "除访客外的工作区所有成员都可以加入", + "creating": "创建中", + "creating_project": "正在创建项目", + "adding_project_to_favorites": "正在将项目添加到收藏", + "project_added_to_favorites": "项目已添加到收藏", + "couldnt_add_the_project_to_favorites": "无法将项目添加到收藏。请重试。", + "removing_project_from_favorites": "正在从收藏中移除项目", + "project_removed_from_favorites": "项目已从收藏中移除", + "couldnt_remove_the_project_from_favorites": "无法从收藏中移除项目。请重试。", + "add_to_favorites": "添加到收藏", + "remove_from_favorites": "从收藏中移除", + "publish_settings": "发布设置", + "publish": "发布", + "copy_link": "复制链接", + "leave_project": "离开项目", + "join_the_project_to_rearrange": "加入项目以重新排列", + "drag_to_rearrange": "拖动以重新排列", + "congrats": "恭喜!", + "open_project": "打开项目", + "issues": "工作项", + "cycles": "周期", + "modules": "模块", + "pages": "页面", + "intake": "收集", + "time_tracking": "时间跟踪", + "work_management": "工作管理", + "projects_and_issues": "项目和工作项", + "projects_and_issues_description": "在此项目中开启或关闭这些功能。", + "cycles_description": "根据项目需要设置时间框,可以根据不同时期更改频率。", + "modules_description": "将工作分组为类似子项目的设置,具有各自的负责人和分配者。", + "views_description": "保存排序、筛选和显示选项以供后续使用或分享。", + "pages_description": "像写任何东西一样写任何东西。", + "intake_description": "及时了解您订阅的工作项。启用此功能以获取通知。", + "time_tracking_description": "跟踪工作项和项目的时间消耗。", + "work_management_description": "轻松管理您的工作和项目。", + "documentation": "文档", + "message_support": "联系支持", + "contact_sales": "联系销售", + "hyper_mode": "超级模式", + "keyboard_shortcuts": "键盘快捷键", + "whats_new": "新功能", + "version": "版本", + "we_are_having_trouble_fetching_the_updates": "我们在获取更新时遇到问题。", + "our_changelogs": "我们的更新日志", + "for_the_latest_updates": "获取最新更新。", + "please_visit": "请访问", + "docs": "文档", + "full_changelog": "完整更新日志", + "support": "支持", + "discord": "Discord", + "powered_by_plane_pages": "由Plane Pages提供支持", + "please_select_at_least_one_invitation": "请至少选择一个邀请。", + "please_select_at_least_one_invitation_description": "请至少选择一个加入工作区的邀请。", + "we_see_that_someone_has_invited_you_to_join_a_workspace": "我们看到有人邀请您加入工作区", + "join_a_workspace": "加入工作区", + "we_see_that_someone_has_invited_you_to_join_a_workspace_description": "我们看到有人邀请您加入工作区", + "join_a_workspace_description": "加入工作区", + "accept_and_join": "接受并加入", + "go_home": "返回首页", + "no_pending_invites": "没有待处理的邀请", + "you_can_see_here_if_someone_invites_you_to_a_workspace": "如果有人邀请您加入工作区,您可以在这里看到", + "back_to_home": "返回首页", + "workspace_name": "工作区名称", + "deactivate_your_account": "停用您的账户", + "deactivate_your_account_description": "一旦停用,您将无法被分配工作项,也不会被计入工作区的账单。要重新激活您的账户,您需要收到发送到此电子邮件地址的工作区邀请。", + "deactivating": "正在停用", + "confirm": "确认", + "confirming": "确认中", + "draft_created": "草稿已创建", + "issue_created_successfully": "工作项创建成功", + "draft_creation_failed": "草稿创建失败", + "issue_creation_failed": "工作项创建失败", + "draft_issue": "草稿工作项", + "issue_updated_successfully": "工作项更新成功", + "issue_could_not_be_updated": "工作项无法更新", + "create_a_draft": "创建草稿", + "save_to_drafts": "保存到草稿", + "save": "保存", + "update": "更新", + "updating": "更新中", + "create_new_issue": "创建新工作项", + "editor_is_not_ready_to_discard_changes": "编辑器尚未准备好放弃更改", + "failed_to_move_issue_to_project": "无法将工作项移动到项目", + "create_more": "创建更多", + "add_to_project": "添加到项目", + "discard": "放弃", + "duplicate_issue_found": "发现重复的工作项", + "duplicate_issues_found": "发现重复的工作项", + "no_matching_results": "没有匹配的结果", + "title_is_required": "标题为必填项", + "title": "标题", + "state": "状态", + "priority": "优先级", + "none": "无", + "urgent": "紧急", + "high": "高", + "medium": "中", + "low": "低", + "members": "成员", + "assignee": "负责人", + "assignees": "负责人", + "you": "您", + "labels": "标签", + "create_new_label": "创建新标签", + "start_date": "开始日期", + "end_date": "结束日期", + "due_date": "截止日期", + "estimate": "估算", + "change_parent_issue": "更改父工作项", + "remove_parent_issue": "移除父工作项", + "add_parent": "添加父项", + "loading_members": "正在加载成员", + "view_link_copied_to_clipboard": "视图链接已复制到剪贴板", + "required": "必填", + "optional": "可选", + "Cancel": "取消", + "edit": "编辑", + "archive": "归档", + "restore": "恢复", + "open_in_new_tab": "在新标签页中打开", + "delete": "删除", + "deleting": "删除中", + "make_a_copy": "创建副本", + "move_to_project": "移动到项目", + "good": "早上", + "morning": "早上", + "afternoon": "下午", + "evening": "晚上", + "show_all": "显示全部", + "show_less": "显示更少", + "no_data_yet": "暂无数据", + "syncing": "同步中", + "add_work_item": "添加工作项", + "advanced_description_placeholder": "按'/'使用命令", + "create_work_item": "创建工作项", + "attachments": "附件", + "declining": "拒绝中", + "declined": "已拒绝", + "decline": "拒绝", + "unassigned": "未分配", + "work_items": "工作项", + "add_link": "添加链接", + "points": "点数", + "no_assignee": "无负责人", + "no_assignees_yet": "暂无负责人", + "no_labels_yet": "暂无标签", + "ideal": "理想", + "current": "当前", + "no_matching_members": "没有匹配的成员", + "leaving": "离开中", + "removing": "移除中", + "leave": "离开", + "refresh": "刷新", + "refreshing": "刷新中", + "refresh_status": "刷新状态", + "prev": "上一个", + "next": "下一个", + "re_generating": "重新生成中", + "re_generate": "重新生成", + "re_generate_key": "重新生成密钥", + "export": "导出", + "member": "{count, plural, other{# 成员}}", + + "project_view": { + "sort_by": { + "created_at": "创建时间", + "updated_at": "更新时间", + "name": "名称" + } + }, + + "toast": { + "success": "成功!", + "error": "错误!" + }, + + "links": { + "toasts": { + "created": { + "title": "链接已创建", + "message": "链接已成功创建" + }, + "not_created": { + "title": "链接未创建", + "message": "无法创建链接" + }, + "updated": { + "title": "链接已更新", + "message": "链接已成功更新" + }, + "not_updated": { + "title": "链接未更新", + "message": "无法更新链接" + }, + "removed": { + "title": "链接已移除", + "message": "链接已成功移除" + }, + "not_removed": { + "title": "链接未移除", + "message": "无法移除链接" + } + } + }, + + "home": { + "empty": { + "quickstart_guide": "快速入门指南", + "not_right_now": "暂时不要", + "create_project": { + "title": "创建项目", + "description": "在Plane中,大多数事情都从项目开始。", + "cta": "开始使用" + }, + "invite_team": { + "title": "邀请您的团队", + "description": "与同事一起构建、发布和管理。", + "cta": "邀请他们加入" + }, + "configure_workspace": { + "title": "设置您的工作区", + "description": "开启或关闭功能,或进行更多设置。", + "cta": "配置此工作区" + }, + "personalize_account": { + "title": "让Plane更适合您", + "description": "选择您的头像、颜色等。", + "cta": "立即个性化" + }, + "widgets": { + "title": "没有小部件看起来很安静,开启它们吧", + "description": "看起来您的所有小部件都已关闭。现在启用它们\n来提升您的体验!", + "primary_button": { + "text": "管理小部件" + } + } + }, + "quick_links": { + "empty": "保存您想要方便访问的工作相关链接。", + "add": "添加快速链接", + "title": "快速链接", + "title_plural": "快速链接" + }, + "recents": { + "title": "最近", + "empty": { + "project": "访问项目后,您的最近项目将显示在这里。", + "page": "访问页面后,您的最近页面将显示在这里。", + "issue": "访问工作项后,您的最近工作项将显示在这里。", + "default": "您还没有任何最近项目。" + }, + "filters": { + "all": "所有项目", + "projects": "项目", + "pages": "页面", + "issues": "工作项" + } + }, + "new_at_plane": { + "title": "Plane新功能" + }, + "quick_tutorial": { + "title": "快速教程" + }, + "widget": { + "reordered_successfully": "小部件重新排序成功。", + "reordering_failed": "重新排序小部件时出错。" + }, + "manage_widgets": "管理小部件", + "title": "首页", + "star_us_on_github": "在GitHub上为我们加星" + }, + + "link": { + "modal": { + "url": { + "text": "URL", + "required": "URL无效", + "placeholder": "输入或粘贴URL" + }, + "title": { + "text": "显示标题", + "placeholder": "您希望如何显示此链接" + } + } + }, + + "common": { + "all": "全部", + "states": "状态", + "state": "状态", + "state_groups": "状态组", + "priority": "优先级", + "team_project": "团队项目", + "project": "项目", + "cycle": "周期", + "cycles": "周期", + "module": "模块", + "modules": "模块", + "labels": "标签", + "assignees": "负责人", + "assignee": "负责人", + "created_by": "创建者", + "none": "无", + "link": "链接", + "estimate": "估算", + "layout": "布局", + "filters": "筛选", + "display": "显示", + "load_more": "加载更多", + "activity": "活动", + "analytics": "分析", + "dates": "日期", + "success": "成功!", + "something_went_wrong": "出现错误", + "error": { + "label": "错误!", + "message": "发生错误。请重试。" + }, + "group_by": "分组方式", + "epic": "史诗", + "epics": "史诗", + "work_item": "工作项", + "work_items": "工作项", + "sub_work_item": "子工作项", + "add": "添加", + "warning": "警告", + "updating": "更新中", + "adding": "添加中", + "update": "更新", + "creating": "创建中", + "create": "创建", + "cancel": "取消", + "description": "描述", + "title": "标题", + "attachment": "附件", + "general": "常规", + "features": "功能", + "automation": "自动化", + "project_name": "项目名称", + "project_id": "项目ID", + "project_timezone": "项目时区", + "created_on": "创建于", + "update_project": "更新项目", + "identifier_already_exists": "标识符已存在", + "add_more": "添加更多", + "defaults": "默认值", + "add_label": "添加标签", + "estimates": "估算", + "customize_time_range": "自定义时间范围", + "loading": "加载中", + "attachments": "附件", + "property": "属性", + "properties": "属性", + "parent": "父项", + "remove": "移除", + "archiving": "归档中", + "archive": "归档", + "access": { + "public": "公开", + "private": "私有" + }, + "done": "完成", + "sub_work_items": "子工作项", + "comment": "评论", + "workspace_level": "工作区级别", + "order_by": { + "label": "排序方式", + "manual": "手动", + "last_created": "最近创建", + "last_updated": "最近更新", + "start_date": "开始日期", + "due_date": "截止日期", + "asc": "升序", + "desc": "降序", + "updated_on": "更新时间" + }, + "sort": { + "asc": "升序", + "desc": "降序", + "created_on": "创建时间", + "updated_on": "更新时间" + }, + "comments": "评论", + "updates": "更新", + "clear_all": "清除全部", + "copied": "已复制!", + "link_copied": "链接已复制!", + "link_copied_to_clipboard": "链接已复制到剪贴板", + "copied_to_clipboard": "工作项链接已复制到剪贴板", + "is_copied_to_clipboard": "工作项已复制到剪贴板", + "no_links_added_yet": "暂无添加的链接", + "add_link": "添加链接", + "links": "链接", + "go_to_workspace": "前往工作区", + "progress": "进度", + "optional": "可选", + "join": "加入", + "go_back": "返回", + "continue": "继续", + "resend": "重新发送", + "relations": "关系", + "errors": { + "default": { + "title": "错误!", + "message": "发生错误。请重试。" + }, + "required": "此字段为必填项", + "entity_required": "{entity}为必填项" + }, + "update_link": "更新链接", + "attach": "附加", + "create_new": "创建新的", + "add_existing": "添加现有", + "type_or_paste_a_url": "输入或粘贴URL", + "url_is_invalid": "URL无效", + "display_title": "显示标题", + "link_title_placeholder": "您希望如何显示此链接", + "url": "URL", + "side_peek": "侧边预览", + "modal": "模态框", + "full_screen": "全屏", + "close_peek_view": "关闭预览视图", + "toggle_peek_view_layout": "切换预览视图布局", + "options": "选项", + "duration": "持续时间", + "today": "今天", + "week": "周", + "month": "月", + "quarter": "季度", + "press_for_commands": "按'/'使用命令", + "click_to_add_description": "点击添加描述", + "search": { + "label": "搜索", + "placeholder": "输入搜索内容", + "no_matches_found": "未找到匹配项", + "no_matching_results": "没有匹配的结果" + }, + "actions": { + "edit": "编辑", + "make_a_copy": "创建副本", + "open_in_new_tab": "在新标签页中打开", + "copy_link": "复制链接", + "archive": "归档", + "delete": "删除", + "remove_relation": "移除关系", + "subscribe": "订阅", + "unsubscribe": "取消订阅", + "clear_sorting": "清除排序", + "show_weekends": "显示周末", + "enable": "启用", + "disable": "禁用" + }, + "name": "名称", + "discard": "放弃", + "confirm": "确认", + "confirming": "确认中", + "read_the_docs": "阅读文档", + "default": "默认", + "active": "活动", + "enabled": "已启用", + "disabled": "已禁用", + "mandate": "授权", + "mandatory": "必需的", + "yes": "是", + "no": "否", + "please_wait": "请稍候", + "enabling": "正在启用", + "disabling": "正在禁用", + "beta": "测试版", + "or": "或", + "next": "下一步", + "back": "返回", + "cancelling": "正在取消", + "configuring": "正在配置", + "clear": "清除", + "import": "导入", + "connect": "连接", + "authorizing": "正在授权", + "processing": "正在处理", + "no_data_available": "暂无数据", + "from": "来自 {name}", + "authenticated": "已认证", + "select": "选择", + "upgrade": "升级", + "add_seats": "添加席位", + "label": "标签", + "priorities": "优先级", + "projects": "项目", + "workspace": "工作区", + "workspaces": "工作区", + "team": "团队", + "teams": "团队", + "entity": "实体", + "entities": "实体", + "task": "任务", + "tasks": "任务", + "section": "部分", + "sections": "部分", + "edit": "编辑", + "connecting": "正在连接", + "connected": "已连接", + "disconnect": "断开连接", + "disconnecting": "正在断开连接", + "installing": "正在安装", + "install": "安装", + "reset": "重置", + "live": "实时", + "change_history": "变更历史", + "coming_soon": "即将推出", + "members": "成员", + "you": "你", + "upgrade_cta": { + "higher_subscription": "升级到更高订阅", + "talk_to_sales": "联系销售" + }, + "category": "类别", + "categories": "类别", + "saving": "保存中", + "save_changes": "保存更改", + "delete": "删除", + "deleting": "删除中", + "pending": "待处理", + "invite": "邀请" + }, + + "chart": { + "x_axis": "X轴", + "y_axis": "Y轴", + "metric": "指标" + }, + + "form": { + "title": { + "required": "标题为必填项", + "max_length": "标题应少于 {length} 个字符" + } + }, + + "entity": { + "grouping_title": "{entity}分组", + "priority": "{entity}优先级", + "all": "所有{entity}", + "drop_here_to_move": "拖放到此处以移动{entity}", + "delete": { + "label": "删除{entity}", + "success": "{entity}删除成功", + "failed": "{entity}删除失败" + }, + "update": { + "failed": "{entity}更新失败", + "success": "{entity}更新成功" + }, + "link_copied_to_clipboard": "{entity}链接已复制到剪贴板", + "fetch": { + "failed": "获取{entity}时出错" + }, + "add": { + "success": "{entity}添加成功", + "failed": "添加{entity}时出错" + } + }, + + "epic": { + "all": "所有史诗", + "label": "{count, plural, one {史诗} other {史诗}}", + "new": "新建史诗", + "adding": "正在添加史诗", + "create": { + "success": "史诗创建成功" + }, + "add": { + "press_enter": "按'Enter'添加另一个史诗", + "label": "添加史诗" + }, + "title": { + "label": "史诗标题", + "required": "史诗标题为必填项" + } + }, + + "issue": { + "label": "{count, plural, one {工作项} other {工作项}}", + "all": "所有工作项", + "edit": "编辑工作项", + "title": { + "label": "工作项标题", + "required": "工作项标题为必填项" + }, + "add": { + "press_enter": "按'Enter'添加另一个工作项", + "label": "添加工作项", + "cycle": { + "failed": "无法将工作项添加到周期。请重试。", + "success": "{count, plural, one {工作项} other {工作项}}已成功添加到周期。", + "loading": "正在将{count, plural, one {工作项} other {工作项}}添加到周期" + }, + "assignee": "添加负责人", + "start_date": "添加开始日期", + "due_date": "添加截止日期", + "parent": "添加父工作项", + "sub_issue": "添加子工作项", + "relation": "添加关系", + "link": "添加链接", + "existing": "添加现有工作项" + }, + "remove": { + "label": "移除工作项", + "cycle": { + "loading": "正在从周期中移除工作项", + "success": "已成功从周期中移除工作项。", + "failed": "无法从周期中移除工作项。请重试。" + }, + "module": { + "loading": "正在从模块中移除工作项", + "success": "已成功从模块中移除工作项。", + "failed": "无法从模块中移除工作项。请重试。" + }, + "parent": { + "label": "移除父工作项" + } + }, + "new": "新建工作项", + "adding": "正在添加工作项", + "create": { + "success": "工作项创建成功" + }, + "priority": { + "urgent": "紧急", + "high": "高", + "medium": "中", + "low": "低" + }, + "display": { + "properties": { + "label": "显示属性", + "id": "ID", + "issue_type": "工作项类型", + "sub_issue_count": "子工作项数量", + "attachment_count": "附件数量", + "created_on": "创建于", + "sub_issue": "子工作项" + }, + "extra": { + "show_sub_issues": "显示子工作项", + "show_empty_groups": "显示空组" + } + }, + "layouts": { + "ordered_by_label": "此布局按以下方式排序", + "list": "列表", + "kanban": "看板", + "calendar": "日历", + "spreadsheet": "表格", + "gantt": "时间线", + "title": { + "list": "列表布局", + "kanban": "看板布局", + "calendar": "日历布局", + "spreadsheet": "表格布局", + "gantt": "时间线布局" + } + }, + "states": { + "active": "活动", + "backlog": "待办" + }, + "comments": { + "placeholder": "添加评论", + "switch": { + "private": "切换为私密评论", + "public": "切换为公开评论" + }, + "create": { + "success": "评论创建成功", + "error": "评论创建失败。请稍后重试。" + }, + "update": { + "success": "评论更新成功", + "error": "评论更新失败。请稍后重试。" + }, + "remove": { + "success": "评论删除成功", + "error": "评论删除失败。请稍后重试。" + }, + "upload": { + "error": "资源上传失败。请稍后重试。" + } + }, + "empty_state": { + "issue_detail": { + "title": "工作项不存在", + "description": "您查找的工作项不存在、已归档或已删除。", + "primary_button": { + "text": "查看其他工作项" + } + } + }, + "sibling": { + "label": "同级工作项" + }, + "archive": { + "description": "只有已完成或已取消的\n工作项可以归档", + "label": "归档工作项", + "confirm_message": "您确定要归档此工作项吗?所有已归档的工作项稍后可以恢复。", + "success": { + "label": "归档成功", + "message": "您的归档可以在项目归档中找到。" + }, + "failed": { + "message": "无法归档工作项。请重试。" + } + }, + "restore": { + "success": { + "title": "恢复成功", + "message": "您的工作项可以在项目工作项中找到。" + }, + "failed": { + "message": "无法恢复工作项。请重试。" + } + }, + "relation": { + "relates_to": "关联到", + "duplicate": "重复于", + "blocked_by": "被阻止于", + "blocking": "阻止" + }, + "copy_link": "复制工作项链接", + "delete": { + "label": "删除工作项", + "error": "删除工作项时出错" + }, + "subscription": { + "actions": { + "subscribed": "工作项订阅成功", + "unsubscribed": "工作项取消订阅成功" + } + }, + "select": { + "error": "请至少选择一个工作项", + "empty": "未选择工作项", + "add_selected": "添加所选工作项" + }, + "open_in_full_screen": "在全屏中打开工作项" + }, + + "attachment": { + "error": "无法附加文件。请重新上传。", + "only_one_file_allowed": "一次只能上传一个文件。", + "file_size_limit": "文件大小必须小于或等于 {size}MB。", + "drag_and_drop": "拖放到任意位置以上传", + "delete": "删除附件" + }, + + "label": { + "select": "选择标签", + "create": { + "success": "标签创建成功", + "failed": "标签创建失败", + "already_exists": "标签已存在", + "type": "输入以添加新标签" + } + }, + + "sub_work_item": { + "update": { + "success": "子工作项更新成功", + "error": "更新子工作项时出错" + }, + "remove": { + "success": "子工作项移除成功", + "error": "移除子工作项时出错" + } + }, + + "view": { + "label": "{count, plural, one {视图} other {视图}}", + "create": { + "label": "创建视图" + }, + "update": { + "label": "更新视图" + } + }, + + "inbox_issue": { + "status": { + "pending": { + "title": "待处理", + "description": "待处理" + }, + "declined": { + "title": "已拒绝", + "description": "已拒绝" + }, + "snoozed": { + "title": "已暂停", + "description": "还剩{days, plural, one{# 天} other{# 天}}" + }, + "accepted": { + "title": "已接受", + "description": "已接受" + }, + "duplicate": { + "title": "重复", + "description": "重复" + } + }, + "modals": { + "decline": { + "title": "拒绝工作项", + "content": "您确定要拒绝工作项 {value} 吗?" + }, + "delete": { + "title": "删除工作项", + "content": "您确定要删除工作项 {value} 吗?", + "success": "工作项删除成功" + } + }, + "errors": { + "snooze_permission": "只有项目管理员可以暂停/取消暂停工作项", + "accept_permission": "只有项目管理员可以接受工作项", + "decline_permission": "只有项目管理员可以拒绝工作项" + }, + "actions": { + "accept": "接受", + "decline": "拒绝", + "snooze": "暂停", + "unsnooze": "取消暂停", + "copy": "复制工作项链接", + "delete": "删除", + "open": "打开工作项", + "mark_as_duplicate": "标记为重复", + "move": "将 {value} 移至项目工作项" + }, + "source": { + "in-app": "应用内" + }, + "order_by": { + "created_at": "创建时间", + "updated_at": "更新时间", + "id": "ID" + }, + "label": "收集", + "page_label": "{workspace} - 收集", + "modal": { + "title": "创建收集工作项" + }, + "tabs": { + "open": "未处理", + "closed": "已处理" + }, + "empty_state": { + "sidebar_open_tab": { + "title": "没有未处理的工作项", + "description": "在此处查找未处理的工作项。创建新工作项。" + }, + "sidebar_closed_tab": { + "title": "没有已处理的工作项", + "description": "所有已接受或已拒绝的工作项都可以在这里找到。" + }, + "sidebar_filter": { + "title": "没有匹配的工作项", + "description": "收集中没有符合筛选条件的工作项。创建新工作项。" + }, + "detail": { + "title": "选择一个工作项以查看其详细信息。" + } + } + }, + + "workspace_creation": { + "heading": "创建您的工作区", + "subheading": "要开始使用 Plane,您需要创建或加入一个工作区。", + "form": { + "name": { + "label": "为您的工作区命名", + "placeholder": "熟悉且易于识别的名称总是最好的。" + }, + "url": { + "label": "设置您的工作区 URL", + "placeholder": "输入或粘贴 URL", + "edit_slug": "您只能编辑 URL 的标识符部分" + }, + "organization_size": { + "label": "有多少人将使用这个工作区?", + "placeholder": "选择一个范围" + } + }, + "errors": { + "creation_disabled": { + "title": "只有您的实例管理员可以创建工作区", + "description": "如果您知道实例管理员的电子邮件地址,请点击下方按钮与他们联系。", + "request_button": "请求实例管理员" + }, + "validation": { + "name_alphanumeric": "工作区名称只能包含 (' '), ('-'), ('_') 和字母数字字符。", + "name_length": "名称限制在 80 个字符以内。", + "url_alphanumeric": "URL 只能包含 ('-') 和字母数字字符。", + "url_length": "URL 限制在 48 个字符以内。", + "url_already_taken": "工作区 URL 已被占用!" + } + }, + "request_email": { + "subject": "请求新工作区", + "body": "您好,实例管理员:\n\n请为 [创建工作区的目的] 创建一个 URL 为 [/workspace-name] 的新工作区。\n\n谢谢,\n{firstName} {lastName}\n{email}" + }, + "button": { + "default": "创建工作区", + "loading": "正在创建工作区" + }, + "toast": { + "success": { + "title": "成功", + "message": "工作区创建成功" + }, + "error": { + "title": "错误", + "message": "工作区创建失败。请重试。" + } + } + }, + + "workspace_dashboard": { + "empty_state": { + "general": { + "title": "项目、活动和指标概览", + "description": "欢迎使用 Plane,我们很高兴您能来到这里。创建您的第一个项目并跟踪您的工作项,这个页面将转变为帮助您进展的空间。管理员还将看到帮助团队进展的项目。", + "primary_button": { + "text": "构建您的第一个项目", + "comic": { + "title": "在 Plane 中一切都从项目开始", + "description": "项目可以是产品路线图、营销活动或新车发布。" + } + } + } + } + }, + + "workspace_analytics": { + "label": "分析", + "page_label": "{workspace} - 分析", + "open_tasks": "总开放任务", + "error": "获取数据时出现错误。", + "work_items_closed_in": "已关闭的工作项", + "selected_projects": "已选择的项目", + "total_members": "总成员数", + "total_cycles": "总周期数", + "total_modules": "总模块数", + "pending_work_items": { + "title": "待处理工作项", + "empty_state": "同事的待处理工作项分析将显示在这里。" + }, + "work_items_closed_in_a_year": { + "title": "一年内关闭的工作项", + "empty_state": "关闭工作项以查看以图表形式显示的分析。" + }, + "most_work_items_created": { + "title": "创建最多工作项", + "empty_state": "同事及其创建的工作项数量将显示在这里。" + }, + "most_work_items_closed": { + "title": "关闭最多工作项", + "empty_state": "同事及其关闭的工作项数量将显示在这里。" + }, + "tabs": { + "scope_and_demand": "范围和需求", + "custom": "自定义分析" + }, + "empty_state": { + "general": { + "title": "跟踪进度、工作量和分配。发现趋势、消除障碍并加快工作进度", + "description": "查看范围与需求、估算和范围蔓延。获取团队成员和团队的表现,确保您的项目按时运行。", + "primary_button": { + "text": "开始您的第一个项目", + "comic": { + "title": "分析在周期 + 模块中效果最佳", + "description": "首先,将您的工作项限定在周期中,如果可能的话,将跨越多个周期的工作项分组到模块中。在左侧导航栏中查看这两项。" + } + } + } + } + }, + + "workspace_projects": { + "label": "{count, plural, one {项目} other {项目}}", + "create": { + "label": "添加项目" + }, + "network": { + "private": { + "title": "私有", + "description": "仅限邀请访问" + }, + "public": { + "title": "公开", + "description": "工作区中除访客外的任何人都可以加入" + } + }, + "error": { + "permission": "您没有执行此操作的权限。", + "cycle_delete": "删除周期失败", + "module_delete": "删除模块失败", + "issue_delete": "删除工作项失败" + }, + "state": { + "backlog": "待办", + "unstarted": "未开始", + "started": "进行中", + "completed": "已完成", + "cancelled": "已取消" + }, + "sort": { + "manual": "手动", + "name": "名称", + "created_at": "创建日期", + "members_length": "成员数量" + }, + "scope": { + "my_projects": "我的项目", + "archived_projects": "已归档" + }, + "common": { + "months_count": "{months, plural, one{# 个月} other{# 个月}}" + }, + "empty_state": { + "general": { + "title": "没有活动项目", + "description": "将每个项目视为目标导向工作的父级。项目是工作项、周期和模块所在的地方,与您的同事一起帮助您实现目标。创建新项目或筛选已归档的项目。", + "primary_button": { + "text": "开始您的第一个项目", + "comic": { + "title": "在 Plane 中一切都从项目开始", + "description": "项目可以是产品路线图、营销活动或新车发布。" + } + } + }, + "no_projects": { + "title": "没有项目", + "description": "要创建工作项或管理您的工作,您需要创建一个项目或成为项目的一部分。", + "primary_button": { + "text": "开始您的第一个项目", + "comic": { + "title": "在 Plane 中一切都从项目开始", + "description": "项目可以是产品路线图、营销活动或新车发布。" + } + } + }, + "filter": { + "title": "没有匹配的项目", + "description": "未检测到符合匹配条件的项目。\n创建一个新项目。" + }, + "search": { + "description": "未检测到符合匹配条件的项目。\n创建一个新项目" + } + } + }, + + "workspace_views": { + "add_view": "添加视图", + "empty_state": { + "all-issues": { + "title": "项目中没有工作项", + "description": "第一个项目完成!现在,将您的工作分解成可跟踪的工作项。让我们开始吧!", + "primary_button": { + "text": "创建新工作项" + } + }, + "assigned": { + "title": "还没有工作项", + "description": "可以在这里跟踪分配给您的工作项。", + "primary_button": { + "text": "创建新工作项" + } + }, + "created": { + "title": "还没有工作项", + "description": "您创建的所有工作项都会出现在这里,直接在这里跟踪它们。", + "primary_button": { + "text": "创建新工作项" + } + }, + "subscribed": { + "title": "还没有工作项", + "description": "订阅您感兴趣的工作项,在这里跟踪所有这些工作项。" + }, + "custom-view": { + "title": "还没有工作项", + "description": "符合筛选条件的工作项,在这里跟踪所有这些工作项。" + } + } + }, + + "workspace_settings": { + "label": "工作区设置", + "page_label": "{workspace} - 常规设置", + "key_created": "密钥已创建", + "copy_key": "复制并将此密钥保存在 Plane Pages 中。关闭后您将无法看到此密钥。包含密钥的 CSV 文件已下载。", + "token_copied": "令牌已复制到剪贴板。", + "settings": { + "general": { + "title": "常规", + "upload_logo": "上传标志", + "edit_logo": "编辑标志", + "name": "工作区名称", + "company_size": "公司规模", + "url": "工作区网址", + "update_workspace": "更新工作区", + "delete_workspace": "删除此工作区", + "delete_workspace_description": "删除工作区时,该工作区内的所有数据和资源将被永久删除,且无法恢复。", + "delete_btn": "删除此工作区", + "delete_modal": { + "title": "确定要删除此工作区吗?", + "description": "您目前正在试用我们的付费方案。请先取消试用后再继续。", + "dismiss": "关闭", + "cancel": "取消试用", + "success_title": "工作区已删除。", + "success_message": "即将跳转到您的个人资料页面。", + "error_title": "操作失败。", + "error_message": "请重试。" + }, + "errors": { + "name": { + "required": "名称为必填项", + "max_length": "工作区名称不应超过80个字符" + }, + "company_size": { + "required": "公司规模为必填项" + } + } + }, + "members": { + "title": "成员", + "add_member": "添加成员", + "pending_invites": "待处理邀请", + "invitations_sent_successfully": "邀请发送成功", + "leave_confirmation": "您确定要离开工作区吗?您将无法再访问此工作区。此操作无法撤消。", + "details": { + "full_name": "全名", + "display_name": "显示名称", + "email_address": "电子邮件地址", + "account_type": "账户类型", + "authentication": "身份验证", + "joining_date": "加入日期" + }, + "modal": { + "title": "邀请人员协作", + "description": "邀请人员在您的工作区中协作。", + "button": "发送邀请", + "button_loading": "正在发送邀请", + "placeholder": "name@company.com", + "errors": { + "required": "我们需要一个电子邮件地址来邀请他们。", + "invalid": "电子邮件无效" + } + } + }, + "billing_and_plans": { + "title": "账单与计划", + "current_plan": "当前计划", + "free_plan": "您目前使用的是免费计划", + "view_plans": "查看计划" + }, + "exports": { + "title": "导出", + "exporting": "导出中", + "previous_exports": "以前的导出", + "export_separate_files": "将数据导出为单独的文件", + "modal": { + "title": "导出到", + "toasts": { + "success": { + "title": "导出成功", + "message": "您可以从之前的导出中下载导出的{entity}" + }, + "error": { + "title": "导出失败", + "message": "导出未成功。请重试。" + } + } + } + }, + "webhooks": { + "title": "Webhooks", + "add_webhook": "添加 webhook", + "modal": { + "title": "创建 webhook", + "details": "Webhook 详情", + "payload": "负载 URL", + "question": "您希望触发此 webhook 的事件有哪些?", + "error": "URL 为必填项" + }, + "secret_key": { + "title": "密钥", + "message": "生成令牌以登录 webhook 负载" + }, + "options": { + "all": "发送所有内容", + "individual": "选择单个事件" + }, + "toasts": { + "created": { + "title": "Webhook 已创建", + "message": "Webhook 已成功创建" + }, + "not_created": { + "title": "Webhook 未创建", + "message": "无法创建 webhook" + }, + "updated": { + "title": "Webhook 已更新", + "message": "Webhook 已成功更新" + }, + "not_updated": { + "title": "Webhook 未更新", + "message": "无法更新 webhook" + }, + "removed": { + "title": "Webhook 已移除", + "message": "Webhook 已成功移除" + }, + "not_removed": { + "title": "Webhook 未移除", + "message": "无法移除 webhook" + }, + "secret_key_copied": { + "message": "密钥已复制到剪贴板。" + }, + "secret_key_not_copied": { + "message": "复制密钥时出错。" + } + } + }, + "api_tokens": { + "title": "API 令牌", + "add_token": "添加 API 令牌", + "create_token": "创建令牌", + "never_expires": "永不过期", + "generate_token": "生成令牌", + "generating": "生成中", + "delete": { + "title": "删除 API 令牌", + "description": "使用此令牌的任何应用程序将无法再访问 Plane 数据。此操作无法撤消。", + "success": { + "title": "成功!", + "message": "API 令牌已成功删除" + }, + "error": { + "title": "错误!", + "message": "无法删除 API 令牌" + } + } + } + }, + "empty_state": { + "api_tokens": { + "title": "尚未创建 API 令牌", + "description": "Plane API 可用于将您在 Plane 中的数据与任何外部系统集成。创建令牌以开始使用。" + }, + "webhooks": { + "title": "尚未添加 webhook", + "description": "创建 webhook 以接收实时更新并自动执行操作。" + }, + "exports": { + "title": "尚无导出", + "description": "每次导出时,您都会在这里有一个副本以供参考。" + }, + "imports": { + "title": "尚无导入", + "description": "在这里查找所有以前的导入并下载它们。" + } + } + }, + + "profile": { + "label": "个人资料", + "page_label": "您的工作", + "work": "工作", + "details": { + "joined_on": "加入时间", + "time_zone": "时区" + }, + "stats": { + "workload": "工作量", + "overview": "概览", + "created": "已创建的工作项", + "assigned": "已分配的工作项", + "subscribed": "已订阅的工作项", + "state_distribution": { + "title": "按状态分类的工作项", + "empty": "创建工作项以在图表中查看按状态分类的工作项,以便更好地分析。" + }, + "priority_distribution": { + "title": "按优先级分类的工作项", + "empty": "创建工作项以在图表中查看按优先级分类的工作项,以便更好地分析。" + }, + "recent_activity": { + "title": "最近活动", + "empty": "我们找不到数据。请查看您的输入", + "button": "下载今天的活动", + "button_loading": "下载中" + } + }, + "actions": { + "profile": "个人资料", + "security": "安全", + "activity": "活动", + "appearance": "外观", + "notifications": "通知" + }, + "tabs": { + "summary": "摘要", + "assigned": "已分配", + "created": "已创建", + "subscribed": "已订阅", + "activity": "活动" + }, + "empty_state": { + "activity": { + "title": "尚无活动", + "description": "通过创建新工作项开始!为其添加详细信息和属性。在 Plane 中探索更多内容以查看您的活动。" + }, + "assigned": { + "title": "没有分配给您的工作项", + "description": "可以从这里跟踪分配给您的工作项。" + }, + "created": { + "title": "尚无工作项", + "description": "您创建的所有工作项都会出现在这里,直接在这里跟踪它们。" + }, + "subscribed": { + "title": "尚无工作项", + "description": "订阅您感兴趣的工作项,在这里跟踪所有这些工作项。" + } + } + }, + + "project_settings": { + "general": { + "enter_project_id": "输入项目 ID", + "please_select_a_timezone": "请选择时区", + "archive_project": { + "title": "归档项目", + "description": "归档项目将从您的侧边导航中取消列出您的项目,但您仍然可以从项目页面访问它。您可以随时恢复或删除项目。", + "button": "归档项目" + }, + "delete_project": { + "title": "删除项目", + "description": "删除项目时,该项目内的所有数据和资源将被永久删除且无法恢复。", + "button": "删除我的项目" + }, + "toast": { + "success": "项目更新成功", + "error": "项目无法更新。请重试。" + } + }, + "members": { + "label": "成员", + "project_lead": "项目负责人", + "default_assignee": "默认受理人", + "guest_super_permissions": { + "title": "为访客用户授予查看所有工作项的权限:", + "sub_heading": "这将允许访客查看所有项目工作项。" + }, + "invite_members": { + "title": "邀请成员", + "sub_heading": "邀请成员参与您的项目。", + "select_co_worker": "选择同事" + } + }, + "states": { + "describe_this_state_for_your_members": "为您的成员描述此状态。", + "empty_state": { + "title": "{groupKey} 组中没有状态", + "description": "请创建一个新状态" + } + }, + "labels": { + "label_title": "标签标题", + "label_title_is_required": "标签标题为必填项", + "label_max_char": "标签名称不应超过255个字符", + "toast": { + "error": "更新标签时出错" + } + }, + "estimates": { + "title": "为我的项目启用估算", + "description": "它们有助于您传达团队的复杂性和工作量。" + }, + "automations": { + "label": "自动化", + "auto-archive": { + "title": "自动归档已关闭的工作项", + "description": "Plane 将自动归档已完成或已取消的工作项。", + "duration": "自动归档已关闭" + }, + "auto-close": { + "title": "自动关闭工作项", + "description": "Plane 将自动关闭尚未完成或取消的工作项。", + "duration": "自动关闭不活跃", + "auto_close_status": "自动关闭状态" + } + }, + + "empty_state": { + "labels": { + "title": "尚无标签", + "description": "创建标签以帮助组织和筛选项目中的工作项。" + }, + "estimates": { + "title": "尚无估算系统", + "description": "创建一组估算以传达每个工作项的工作量。", + "primary_button": "添加估算系统" + } + } + }, + + "project_cycles": { + "add_cycle": "添加周期", + "more_details": "更多详情", + "cycle": "周期", + "update_cycle": "更新周期", + "create_cycle": "创建周期", + "no_matching_cycles": "没有匹配的周期", + "remove_filters_to_see_all_cycles": "移除筛选器以查看所有周期", + "remove_search_criteria_to_see_all_cycles": "移除搜索条件以查看所有周期", + "only_completed_cycles_can_be_archived": "只能归档已完成的周期", + "active_cycle": { + "label": "活动周期", + "progress": "进度", + "chart": "燃尽图", + "priority_issue": "优先工作项", + "assignees": "受理人", + "issue_burndown": "工作项燃尽", + "ideal": "理想", + "current": "当前", + "labels": "标签" + }, + "upcoming_cycle": { + "label": "即将到来的周期" + }, + "completed_cycle": { + "label": "已完成的周期" + }, + "status": { + "days_left": "剩余天数", + "completed": "已完成", + "yet_to_start": "尚未开始", + "in_progress": "进行中", + "draft": "草稿" + }, + "action": { + "restore": { + "title": "恢复周期", + "success": { + "title": "周期已恢复", + "description": "周期已被恢复。" + }, + "failed": { + "title": "周期恢复失败", + "description": "无法恢复周期。请重试。" + } + }, + "favorite": { + "loading": "正在将周期添加到收藏", + "success": { + "description": "周期已添加到收藏。", + "title": "成功!" + }, + "failed": { + "description": "无法将周期添加到收藏。请重试。", + "title": "错误!" + } + }, + "unfavorite": { + "loading": "正在从收藏中移除周期", + "success": { + "description": "周期已从收藏中移除。", + "title": "成功!" + }, + "failed": { + "description": "无法从收藏中移除周期。请重试。", + "title": "错误!" + } + }, + "update": { + "loading": "正在更新周期", + "success": { + "description": "周期更新成功。", + "title": "成功!" + }, + "failed": { + "description": "更新周期时出错。请重试。", + "title": "错误!" + }, + "error": { + "already_exists": "在给定日期范围内已存在周期,如果您想创建草稿周期,可以通过移除两个日期来实现。" + } + } + }, + "empty_state": { + "general": { + "title": "在周期中分组和时间框定您的工作。", + "description": "将工作按时间框分解,从项目截止日期倒推设置日期,并作为团队取得切实的进展。", + "primary_button": { + "text": "设置您的第一个周期", + "comic": { + "title": "周期是重复的时间框。", + "description": "冲刺、迭代或您用于每周或每两周跟踪工作的任何其他术语都是一个周期。" + } + } + }, + "no_issues": { + "title": "尚未向周期添加工作项", + "description": "添加或创建您希望在此周期内时间框定和交付的工作项", + "primary_button": { + "text": "创建新工作项" + }, + "secondary_button": { + "text": "添加现有工作项" + } + }, + "completed_no_issues": { + "title": "周期中没有工作项", + "description": "周期中没有工作项。工作项已被转移或隐藏。要查看隐藏的工作项(如果有),请相应更新您的显示属性。" + }, + "active": { + "title": "没有活动周期", + "description": "活动周期包括其范围内包含今天日期的任何时期。在这里查找活动周期的进度和详细信息。" + }, + "archived": { + "title": "尚无已归档的周期", + "description": "为了整理您的项目,归档已完成的周期。归档后可以在这里找到它们。" + } + } + }, + + "project_issues": { + "empty_state": { + "no_issues": { + "title": "创建工作项并将其分配给某人,甚至是您自己", + "description": "将工作项视为工作、任务或待完成的工作。工作项及其子工作项通常是基于时间的、分配给团队成员的可执行项。您的团队通过创建、分配和完成工作项来推动项目实现其目标。", + "primary_button": { + "text": "创建您的第一个工作项", + "comic": { + "title": "工作项是 Plane 中的基本构建块。", + "description": "重新设计 Plane 界面、重塑公司品牌或启动新的燃料喷射系统都是可能包含子工作项的工作项示例。" + } + } + }, + "no_archived_issues": { + "title": "尚无已归档的工作项", + "description": "通过手动或自动化方式,您可以归档已完成或已取消的工作项。归档后可以在这里找到它们。", + "primary_button": { + "text": "设置自动化" + } + }, + "issues_empty_filter": { + "title": "未找到符合筛选条件的工作项", + "secondary_button": { + "text": "清除所有筛选条件" + } + } + } + }, + + "project_module": { + "add_module": "添加模块", + "update_module": "更新模块", + "create_module": "创建模块", + "archive_module": "归档模块", + "restore_module": "恢复模块", + "delete_module": "删除模块", + "empty_state": { + "general": { + "title": "将项目里程碑映射到模块,轻松跟踪汇总工作。", + "description": "属于逻辑层次结构父级的一组工作项形成一个模块。将其视为按项目里程碑跟踪工作的方式。它们有自己的周期和截止日期以及分析功能,帮助您了解距离里程碑的远近。", + "primary_button": { + "text": "构建您的第一个模块", + "comic": { + "title": "模块帮助按层次结构对工作进行分组。", + "description": "购物车模块、底盘模块和仓库模块都是这种分组的好例子。" + } + } + }, + "no_issues": { + "title": "模块中没有工作项", + "description": "创建或添加您想作为此模块一部分完成的工作项", + "primary_button": { + "text": "创建新工作项" + }, + "secondary_button": { + "text": "添加现有工作项" + } + }, + "archived": { + "title": "尚无已归档的模块", + "description": "为了整理您的项目,归档已完成或已取消的模块。归档后可以在这里找到它们。" + }, + "sidebar": { + "in_active": "此模块尚未激活。", + "invalid_date": "日期无效。请输入有效日期。" + } + }, + "quick_actions": { + "archive_module": "归档模块", + "archive_module_description": "只有已完成或已取消的\n模块可以归档。", + "delete_module": "删除模块" + }, + "toast": { + "copy": { + "success": "模块链接已复制到剪贴板" + }, + "delete": { + "success": "模块删除成功", + "error": "删除模块失败" + } + } + }, + + "project_views": { + "empty_state": { + "general": { + "title": "为您的项目保存筛选视图。根据需要创建任意数量", + "description": "视图是您经常使用或想要轻松访问的一组已保存的筛选条件。项目中的所有同事都可以看到每个人的视图,并选择最适合他们需求的视图。", + "primary_button": { + "text": "创建您的第一个视图", + "comic": { + "title": "视图基于工作项属性运作。", + "description": "您可以在此处创建一个视图,根据需要使用任意数量的属性作为筛选条件。" + } + } + }, + "filter": { + "title": "没有匹配的视图", + "description": "没有符合搜索条件的视图。\n创建一个新视图。" + } + } + }, + + "project_page": { + "empty_state": { + "general": { + "title": "写笔记、文档或完整的知识库。让 Plane 的 AI 助手 Galileo 帮助您开始", + "description": "页面是 Plane 中的思维记录空间。记录会议笔记,轻松格式化,嵌入工作项,使用组件库进行布局,并将它们全部保存在项目上下文中。要快速完成任何文档,可以通过快捷键或点击按钮调用 Plane 的 AI Galileo。", + "primary_button": { + "text": "创建您的第一个页面" + } + }, + "private": { + "title": "尚无私人页面", + "description": "在这里保存您的私人想法。准备好分享时,团队就在一键之遥。", + "primary_button": { + "text": "创建您的第一个页面" + } + }, + "public": { + "title": "尚无公共页面", + "description": "在这里查看与项目中所有人共享的页面。", + "primary_button": { + "text": "创建您的第一个页面" + } + }, + "archived": { + "title": "尚无已归档的页面", + "description": "归档不在您关注范围内的页面。需要时可以在这里访问它们。" + } + } + }, + + "command_k": { + "empty_state": { + "search": { + "title": "未找到结果" + } + } + }, + + "issue_relation": { + "empty_state": { + "search": { + "title": "未找到匹配的工作项" + }, + "no_issues": { + "title": "未找到工作项" + } + } + }, + + "issue_comment": { + "empty_state": { + "general": { + "title": "尚无评论", + "description": "评论可用作工作项的讨论和跟进空间" + } + } + }, + + "notification": { + "label": "收件箱", + "page_label": "{workspace} - 收件箱", + "options": { + "mark_all_as_read": "全部标记为已读", + "mark_read": "标记为已读", + "mark_unread": "标记为未读", + "refresh": "刷新", + "filters": "收件箱筛选", + "show_unread": "显示未读", + "show_snoozed": "显示已暂停", + "show_archived": "显示已归档", + "mark_archive": "归档", + "mark_unarchive": "取消归档", + "mark_snooze": "暂停", + "mark_unsnooze": "取消暂停" + }, + "toasts": { + "read": "通知已标记为已读", + "unread": "通知已标记为未读", + "archived": "通知已标记为已归档", + "unarchived": "通知已标记为未归档", + "snoozed": "通知已暂停", + "unsnoozed": "通知已取消暂停" + }, + "empty_state": { + "detail": { + "title": "选择以查看详情。" + }, + "all": { + "title": "没有分配的工作项", + "description": "在这里可以看到分配给您的工作项的更新" + }, + "mentions": { + "title": "没有分配的工作项", + "description": "在这里可以看到分配给您的工作项的更新" + } + }, + "tabs": { + "all": "全部", + "mentions": "提及" + }, + "filter": { + "assigned": "分配给我", + "created": "由我创建", + "subscribed": "由我订阅" + }, + "snooze": { + "1_day": "1 天", + "3_days": "3 天", + "5_days": "5 天", + "1_week": "1 周", + "2_weeks": "2 周", + "custom": "自定义" + } + }, + + "active_cycle": { + "empty_state": { + "progress": { + "title": "向周期添加工作项以查看其进度" + }, + "chart": { + "title": "向周期添加工作项以查看燃尽图。" + }, + "priority_issue": { + "title": "一目了然地观察周期中处理的高优先级工作项。" + }, + "assignee": { + "title": "为工作项添加负责人以查看按负责人划分的工作明细。" + }, + "label": { + "title": "为工作项添加标签以查看按标签划分的工作明细。" + } + } + }, + + "disabled_project": { + "empty_state": { + "inbox": { + "title": "项目未启用收集功能。", + "description": "收集功能帮助您管理项目的传入请求,并将其添加为工作流中的工作项。从项目设置启用收集功能以管理请求。", + "primary_button": { + "text": "管理功能" + } + }, + "cycle": { + "title": "此项目未启用周期功能。", + "description": "按时间框将工作分解,从项目截止日期倒推设置日期,并作为团队取得切实的进展。为您的项目启用周期功能以开始使用它们。", + "primary_button": { + "text": "管理功能" + } + }, + "module": { + "title": "项目未启用模块功能。", + "description": "模块是项目的基本构建块。从项目设置启用模块以开始使用它们。", + "primary_button": { + "text": "管理功能" + } + }, + "page": { + "title": "项目未启用页面功能。", + "description": "页面是项目的基本构建块。从项目设置启用页面以开始使用它们。", + "primary_button": { + "text": "管理功能" + } + }, + "view": { + "title": "项目未启用视图功能。", + "description": "视图是项目的基本构建块。从项目设置启用视图以开始使用它们。", + "primary_button": { + "text": "管理功能" + } + } + } + }, + "workspace_draft_issues": { + "draft_an_issue": "起草工作项", + "empty_state": { + "title": "半写的工作项,以及即将推出的评论将在这里显示。", + "description": "要试用此功能,请开始添加工作项并中途离开,或在下方创建您的第一个草稿。😉", + "primary_button": { + "text": "创建您的第一个草稿" + } + }, + "delete_modal": { + "title": "删除草稿", + "description": "您确定要删除此草稿吗?此操作无法撤消。" + }, + "toasts": { + "created": { + "success": "草稿已创建", + "error": "无法创建工作项。请重试。" + }, + "deleted": { + "success": "草稿已删除" + } + } + }, + + "stickies": { + "title": "您的便签", + "placeholder": "点击此处输入", + "all": "所有便签", + "no-data": "记下一个想法,捕捉一个灵感,或记录一个突发奇想。添加便签开始使用。", + "add": "添加便签", + "search_placeholder": "按标题搜索", + "delete": "删除便签", + "delete_confirmation": "您确定要删除此便签吗?", + "empty_state": { + "simple": "记下一个想法,捕捉一个灵感,或记录一个突发奇想。添加便签开始使用。", + "general": { + "title": "便签是您随手记下的快速笔记和待办事项。", + "description": "通过创建随时随地都可以访问的便签,轻松捕捉您的想法和创意。", + "primary_button": { + "text": "添加便签" + } + }, + "search": { + "title": "这与您的任何便签都不匹配。", + "description": "尝试使用不同的术语,或如果您确定\n搜索是正确的,请告诉我们。", + "primary_button": { + "text": "添加便签" + } + } + }, + "toasts": { + "errors": { + "wrong_name": "便签名称不能超过100个字符。", + "already_exists": "已存在一个没有描述的便签" + }, + "created": { + "title": "便签已创建", + "message": "便签已成功创建" + }, + "not_created": { + "title": "便签未创建", + "message": "无法创建便签" + }, + "updated": { + "title": "便签已更新", + "message": "便签已成功更新" + }, + "not_updated": { + "title": "便签未更新", + "message": "无法更新便签" + }, + "removed": { + "title": "便签已移除", + "message": "便签已成功移除" + }, + "not_removed": { + "title": "便签未移除", + "message": "无法移除便签" + } + } + }, + + "role_details": { + "guest": { + "title": "访客", + "description": "组织的外部成员可以被邀请为访客。" + }, + "member": { + "title": "成员", + "description": "可以在项目、周期和模块内读取、写入、编辑和删除实体" + }, + "admin": { + "title": "管理员", + "description": "在工作区内所有权限均设置为允许。" + } + }, + + "user_roles": { + "product_or_project_manager": "产品/项目经理", + "development_or_engineering": "开发/工程", + "founder_or_executive": "创始人/高管", + "freelancer_or_consultant": "自由职业者/顾问", + "marketing_or_growth": "市场/增长", + "sales_or_business_development": "销售/业务发展", + "support_or_operations": "支持/运营", + "student_or_professor": "学生/教授", + "human_resources": "人力资源", + "other": "其他" + }, + + "importer": { + "github": { + "title": "GitHub", + "description": "从 GitHub 仓库导入工作项并同步。" + }, + "jira": { + "title": "Jira", + "description": "从 Jira 项目和史诗导入工作项和史诗。" + } + }, + + "exporter": { + "csv": { + "title": "CSV", + "description": "将工作项导出为 CSV 文件。", + "short_description": "导出为 CSV" + }, + "excel": { + "title": "Excel", + "description": "将工作项导出为 Excel 文件。", + "short_description": "导出为 Excel" + }, + "xlsx": { + "title": "Excel", + "description": "将工作项导出为 Excel 文件。", + "short_description": "导出为 Excel" + }, + "json": { + "title": "JSON", + "description": "将工作项导出为 JSON 文件。", + "short_description": "导出为 JSON" + } + }, + "default_global_view": { + "all_issues": "所有工作项", + "assigned": "已分配", + "created": "已创建", + "subscribed": "已订阅" + }, + + "themes": { + "theme_options": { + "system_preference": { + "label": "系统偏好" + }, + "light": { + "label": "浅色" + }, + "dark": { + "label": "深色" + }, + "light_contrast": { + "label": "浅色高对比度" + }, + "dark_contrast": { + "label": "深色高对比度" + }, + "custom": { + "label": "自定义主题" + } + } + }, + "project_modules": { + "status": { + "backlog": "待办", + "planned": "已计划", + "in_progress": "进行中", + "paused": "已暂停", + "completed": "已完成", + "cancelled": "已取消" + }, + "layout": { + "list": "列表布局", + "board": "画廊布局", + "timeline": "时间线布局" + }, + "order_by": { + "name": "名称", + "progress": "进度", + "issues": "工作项数量", + "due_date": "截止日期", + "created_at": "创建日期", + "manual": "手动" + } + }, + + "cycle": { + "label": "{count, plural, one {周期} other {周期}}", + "no_cycle": "无周期" + }, + + "module": { + "label": "{count, plural, one {模块} other {模块}}", + "no_module": "无模块" + } +} diff --git a/packages/i18n/src/store/index.ts b/packages/i18n/src/store/index.ts new file mode 100644 index 000000000..78e1311ad --- /dev/null +++ b/packages/i18n/src/store/index.ts @@ -0,0 +1,298 @@ +import IntlMessageFormat from "intl-messageformat"; +import get from "lodash/get"; +import merge from "lodash/merge"; +import { makeAutoObservable, runInAction } from "mobx"; +// constants +import { FALLBACK_LANGUAGE, SUPPORTED_LANGUAGES, STORAGE_KEY } from "../constants"; +// core translations imports +import coreEn from "../locales/en/core.json"; +// types +import { TLanguage, ILanguageOption, ITranslations } from "../types"; + +/** + * Mobx store class for handling translations and language changes in the application + * Provides methods to translate keys with params and change the language + * Uses IntlMessageFormat to format the translations + */ +export class TranslationStore { + // Core translations that are always loaded + private coreTranslations: ITranslations = { + en: coreEn, + }; + // List of translations for each language + private translations: ITranslations = {}; + // Cache for IntlMessageFormat instances + private messageCache: Map = new Map(); + // Current language + currentLocale: TLanguage = FALLBACK_LANGUAGE; + // Loading state + isLoading: boolean = true; + isInitialized: boolean = false; + // Set of loaded languages + private loadedLanguages: Set = new Set(); + + /** + * Constructor for the TranslationStore class + */ + constructor() { + makeAutoObservable(this); + // Initialize with core translations immediately + this.translations = this.coreTranslations; + // Initialize language + this.initializeLanguage(); + // Load all the translations + this.loadTranslations(); + } + + /** Initializes the language based on the local storage or browser language */ + private initializeLanguage() { + if (typeof window === "undefined") return; + + const savedLocale = localStorage.getItem(STORAGE_KEY) as TLanguage; + if (this.isValidLanguage(savedLocale)) { + this.setLanguage(savedLocale); + return; + } + + const browserLang = this.getBrowserLanguage(); + this.setLanguage(browserLang); + } + + /** Loads the translations for the current language */ + private async loadTranslations(): Promise { + try { + // Set initialized to true (Core translations are already loaded) + runInAction(() => { + this.isInitialized = true; + }); + // Load current and fallback languages in parallel + await this.loadPrimaryLanguages(); + // Load all remaining languages in parallel + this.loadRemainingLanguages(); + } catch (error) { + console.error("Failed in translation initialization:", error); + runInAction(() => { + this.isLoading = false; + }); + } + } + + private async loadPrimaryLanguages(): Promise { + try { + // Load current and fallback languages in parallel + const languagesToLoad = new Set([this.currentLocale]); + // Add fallback language only if different from current + if (this.currentLocale !== FALLBACK_LANGUAGE) { + languagesToLoad.add(FALLBACK_LANGUAGE); + } + // Load all primary languages in parallel + const loadPromises = Array.from(languagesToLoad).map((lang) => this.loadLanguageTranslations(lang)); + await Promise.all(loadPromises); + // Update loading state + runInAction(() => { + this.isLoading = false; + }); + } catch (error) { + console.error("Failed to load primary languages:", error); + runInAction(() => { + this.isLoading = false; + }); + } + } + + private loadRemainingLanguages(): void { + const remainingLanguages = SUPPORTED_LANGUAGES.map((lang) => lang.value).filter( + (lang) => + !this.loadedLanguages.has(lang as TLanguage) && lang !== this.currentLocale && lang !== FALLBACK_LANGUAGE + ); + // Load all remaining languages in parallel + Promise.all(remainingLanguages.map((lang) => this.loadLanguageTranslations(lang as TLanguage))).catch((error) => { + console.error("Failed to load some remaining languages:", error); + }); + } + + private async loadLanguageTranslations(language: TLanguage): Promise { + // Skip if already loaded + if (this.loadedLanguages.has(language)) return; + + try { + const translations = await this.importLanguageFile(language); + runInAction(() => { + // Use lodash merge for deep merging + this.translations[language] = merge({}, this.coreTranslations[language] || {}, translations.default); + // Add to loaded languages + this.loadedLanguages.add(language); + // Clear cache + this.messageCache.clear(); + }); + } catch (error) { + console.error(`Failed to load translations 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"); + default: + throw new Error(`Unsupported language: ${language}`); + } + } + + /** Checks if the language is valid based on the supported languages */ + private isValidLanguage(lang: string | null): lang is TLanguage { + return lang !== null && this.availableLanguages.some((l) => l.value === lang); + } + + /** Checks if a language code is similar to any supported language */ + private findSimilarLanguage(lang: string): TLanguage | null { + // Convert to lowercase for case-insensitive comparison + const normalizedLang = lang.toLowerCase(); + + // Find a supported language that includes or is included in the browser language + const similarLang = this.availableLanguages.find( + (l) => normalizedLang.includes(l.value.toLowerCase()) || l.value.toLowerCase().includes(normalizedLang) + ); + + return similarLang ? similarLang.value : null; + } + + /** Gets the browser language based on the navigator.language */ + private getBrowserLanguage(): TLanguage { + const browserLang = navigator.language; + + // Check exact match first + if (this.isValidLanguage(browserLang)) { + return browserLang; + } + + // Check base language without region code + const baseLang = browserLang.split("-")[0]; + if (this.isValidLanguage(baseLang)) { + return baseLang as TLanguage; + } + + // Try to find a similar language + const similarLang = this.findSimilarLanguage(browserLang) || this.findSimilarLanguage(baseLang); + + return similarLang || FALLBACK_LANGUAGE; + } + + /** + * Gets the cache key for the given key and locale + * @param key - the key to get the cache key for + * @param locale - the locale to get the cache key for + * @returns the cache key for the given key and locale + */ + private getCacheKey(key: string, locale: TLanguage): string { + return `${locale}:${key}`; + } + + /** + * Gets the IntlMessageFormat instance for the given key and locale + * Returns cached instance if available + * Throws an error if the key is not found in the translations + */ + private getMessageInstance(key: string, locale: TLanguage): IntlMessageFormat | null { + const cacheKey = this.getCacheKey(key, locale); + + // Check if the cache already has the key + if (this.messageCache.has(cacheKey)) { + return this.messageCache.get(cacheKey) || null; + } + + // Get the message from the translations + const message = get(this.translations[locale], key); + if (!message) return null; + + try { + const formatter = new IntlMessageFormat(message as any, locale); + this.messageCache.set(cacheKey, formatter); + return formatter; + } catch (error) { + console.error(`Failed to create message formatter for key "${key}":`, error); + return null; + } + } + + /** + * Translates a key with params using the current locale + * Falls back to the default language if the translation is not found + * Returns the key itself if the translation is not found + * @param key - The key to translate + * @param params - The params to format the translation with + * @returns The translated string + */ + t(key: string, params?: Record): string { + try { + // Try current locale + let formatter = this.getMessageInstance(key, this.currentLocale); + + // Fallback to default language if necessary + if (!formatter && this.currentLocale !== FALLBACK_LANGUAGE) { + formatter = this.getMessageInstance(key, FALLBACK_LANGUAGE); + } + + // If we have a formatter, use it + if (formatter) { + return formatter.format(params || {}) as string; + } + + // Last resort: return the key itself + return key; + } catch (error) { + console.error(`Translation error for key "${key}":`, error); + return key; + } + } + + /** + * Sets the current language and updates the translations + * @param lng - The new language + */ + async setLanguage(lng: TLanguage): Promise { + try { + if (!this.isValidLanguage(lng)) { + throw new Error(`Invalid language: ${lng}`); + } + + // Safeguard in case background loading failed + if (!this.loadedLanguages.has(lng)) { + await this.loadLanguageTranslations(lng); + } + + if (typeof window !== "undefined") { + localStorage.setItem(STORAGE_KEY, lng); + document.documentElement.lang = lng; + } + + runInAction(() => { + this.currentLocale = lng; + this.messageCache.clear(); // Clear cache when language changes + }); + } catch (error) { + console.error("Failed to set language:", error); + } + } + + /** + * Gets the available language options for the dropdown + * @returns An array of language options + */ + get availableLanguages(): ILanguageOption[] { + return SUPPORTED_LANGUAGES; + } +} diff --git a/packages/i18n/src/types/index.ts b/packages/i18n/src/types/index.ts new file mode 100644 index 000000000..d56ad1e16 --- /dev/null +++ b/packages/i18n/src/types/index.ts @@ -0,0 +1,2 @@ +export * from "./language"; +export * from "./translation"; diff --git a/packages/i18n/src/types/language.ts b/packages/i18n/src/types/language.ts new file mode 100644 index 000000000..86e141ff5 --- /dev/null +++ b/packages/i18n/src/types/language.ts @@ -0,0 +1,6 @@ +export type TLanguage = "en" | "fr" | "es" | "ja" | "zh-CN"; + +export interface ILanguageOption { + label: string; + value: TLanguage; +} diff --git a/packages/i18n/src/types/translation.ts b/packages/i18n/src/types/translation.ts new file mode 100644 index 000000000..b75705552 --- /dev/null +++ b/packages/i18n/src/types/translation.ts @@ -0,0 +1,7 @@ +export interface ITranslation { + [key: string]: string | ITranslation; +} + +export interface ITranslations { + [locale: string]: ITranslation; +} diff --git a/packages/i18n/tsconfig.json b/packages/i18n/tsconfig.json new file mode 100644 index 000000000..6599e6e82 --- /dev/null +++ b/packages/i18n/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@plane/typescript-config/react-library.json", + "compilerOptions": { + "jsx": "react", + "lib": ["esnext", "dom"], + "resolveJsonModule": true + }, + "include": ["./src"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/logger/.eslintignore b/packages/logger/.eslintignore new file mode 100644 index 000000000..6019047c3 --- /dev/null +++ b/packages/logger/.eslintignore @@ -0,0 +1,3 @@ +build/* +dist/* +out/* \ No newline at end of file diff --git a/packages/logger/.eslintrc.js b/packages/logger/.eslintrc.js new file mode 100644 index 000000000..558b8f76e --- /dev/null +++ b/packages/logger/.eslintrc.js @@ -0,0 +1,9 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ["@plane/eslint-config/library.js"], + parser: "@typescript-eslint/parser", + parserOptions: { + project: true, + }, +}; diff --git a/packages/logger/.prettierrc b/packages/logger/.prettierrc new file mode 100644 index 000000000..87d988f1b --- /dev/null +++ b/packages/logger/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/packages/logger/README.md b/packages/logger/README.md new file mode 100644 index 000000000..6b83eb52b --- /dev/null +++ b/packages/logger/README.md @@ -0,0 +1,59 @@ +# Logger Package + +This package provides a logger and a request logger utility built using [Winston](https://github.com/winstonjs/winston). It offers customizable log levels using env and supports structured logging for general application logs and HTTP requests. + +## Features. +- Dynamic log level configuration using env. +- Pre-configured winston logger for general usage (`logger`). +- Request logger middleware that logs incoming request + +## Usage + +### Adding as a package +Add this package as a dependency in package.json +```typescript +dependency: { + ... + @plane/logger":"*", + ... +} +``` + +### Importing the Logger +```typescript +import { logger, requestLogger } from '@plane/logger' +``` +### Usage +### `logger`: General Logger +Use this for general application logs. + +```typescript +logger.info("This is an info log"); +logger.warn("This is a warning"); +logger.error("This is an error"); +``` + +### `requestLogger`: Request Logger Middleware +Use this as a middleware for incoming requests + +```typescript +const app = express() +app.use(requestLogger) +``` + +## Available Log Levels +- `error` +- `warn` +- `info` (default) +- `http` +- `verbose` +- `debug` +- `silly` + +## Log file +- Log files are stored in logs folder of current working directory. Error logs are stored in files with format `error-%DATE%.log` and combined logs are stored with format `combined-%DATE%.log`. +- Log files have a 7 day rotation period defined. + +## Configuration +- By default, the log level is set to `info`. +- You can specify a log level by adding a LOG_LEVEL in .env. \ No newline at end of file diff --git a/packages/logger/package.json b/packages/logger/package.json new file mode 100644 index 000000000..7a374004f --- /dev/null +++ b/packages/logger/package.json @@ -0,0 +1,21 @@ +{ + "name": "@plane/logger", + "version": "0.24.1", + "description": "Logger shared across multiple apps internally", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "lint": "eslint src --ext .ts,.tsx", + "lint:errors": "eslint src --ext .ts,.tsx --quiet" + }, + "dependencies": { + "winston": "^3.17.0", + "winston-daily-rotate-file": "^5.0.0" + }, + "devDependencies": { + "@plane/eslint-config": "*", + "@types/node": "^22.5.4", + "typescript": "^5.3.3" + } +} diff --git a/packages/logger/src/config.ts b/packages/logger/src/config.ts new file mode 100644 index 000000000..fd918e59e --- /dev/null +++ b/packages/logger/src/config.ts @@ -0,0 +1,66 @@ +import winston from "winston"; +import DailyRotateFile from "winston-daily-rotate-file"; +import path from "path"; + +// Define log levels +const levels = { + error: 0, + warn: 1, + info: 2, + http: 3, + debug: 4, +}; + +// Define colors for each level +const colors = { + error: "red", + warn: "yellow", + info: "green", + http: "magenta", + debug: "white", +}; + +// Tell winston about our colors +winston.addColors(colors); + +// Custom format for logging +const format = winston.format.combine( + winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss:ms" }), + winston.format.colorize({ all: true }), + winston.format.printf( + (info: winston.Logform.TransformableInfo) => `[${info?.timestamp}] ${info.level}: ${info.message}` + ) +); + +// Define which transports to use +const transports = [ + // Console transport + new winston.transports.Console(), + + // Rotating file transport for errors + new DailyRotateFile({ + filename: path.join(process.cwd(), "logs", "error-%DATE%.log"), + datePattern: "YYYY-MM-DD", + zippedArchive: true, + maxSize: process.env.LOG_MAX_SIZE || "20m", + maxFiles: process.env.LOG_RETENTION || "7d", + level: "error", + }), + + // Rotating file transport for all logs + new DailyRotateFile({ + filename: path.join(process.cwd(), "logs", "combined-%DATE%.log"), + datePattern: "YYYY-MM-DD", + zippedArchive: true, + maxSize: process.env.LOG_MAX_SIZE || "20m", + maxFiles: process.env.LOG_RETENTION || "7d", + }), +]; + +// Create the logger +export const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || "info", + levels, + format, + transports, +}); diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts new file mode 100644 index 000000000..715301ad3 --- /dev/null +++ b/packages/logger/src/index.ts @@ -0,0 +1,2 @@ +export * from "./config"; +export * from "./middleware"; diff --git a/packages/logger/src/middleware.ts b/packages/logger/src/middleware.ts new file mode 100644 index 000000000..e251a5837 --- /dev/null +++ b/packages/logger/src/middleware.ts @@ -0,0 +1,23 @@ +import { Request, Response, NextFunction } from "express"; +import { logger } from "./config"; + +export const requestLogger = (req: Request, res: Response, next: NextFunction) => { + // Log when the request starts + const startTime = Date.now(); + + // Log request details + logger.http(`Incoming ${req.method} request to ${req.url} from ${req.ip}`); + + // Log request body if present + if (Object.keys(req.body).length > 0) { + logger.debug("Request body:", req.body); + } + + // Capture response + res.on("finish", () => { + const duration = Date.now() - startTime; + logger.http(`Completed ${req.method} ${req.url} with status ${res.statusCode} in ${duration}ms`); + }); + + next(); +}; diff --git a/packages/logger/tsconfig.json b/packages/logger/tsconfig.json new file mode 100644 index 000000000..2ba1c9799 --- /dev/null +++ b/packages/logger/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "@plane/typescript-config/base.json", + "compilerOptions": { + "module": "ESNext", + "target": "ESNext", + "moduleResolution": "node", + "esModuleInterop": true, + "outDir": "./dist", + "rootDir": "./src", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "experimentalDecorators": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/packages/propel/.eslintignore b/packages/propel/.eslintignore new file mode 100644 index 000000000..6019047c3 --- /dev/null +++ b/packages/propel/.eslintignore @@ -0,0 +1,3 @@ +build/* +dist/* +out/* \ No newline at end of file diff --git a/packages/propel/.eslintrc.js b/packages/propel/.eslintrc.js new file mode 100644 index 000000000..b11b7bb6d --- /dev/null +++ b/packages/propel/.eslintrc.js @@ -0,0 +1,6 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ["@plane/eslint-config/library.js"], + parser: "@typescript-eslint/parser", +}; diff --git a/packages/propel/.prettierignore b/packages/propel/.prettierignore new file mode 100644 index 000000000..e841c6b32 --- /dev/null +++ b/packages/propel/.prettierignore @@ -0,0 +1,5 @@ +.next +.turbo +out/ +dist/ +build/ \ No newline at end of file diff --git a/packages/propel/.prettierrc b/packages/propel/.prettierrc new file mode 100644 index 000000000..87d988f1b --- /dev/null +++ b/packages/propel/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/packages/propel/package.json b/packages/propel/package.json new file mode 100644 index 000000000..1f2ebbeea --- /dev/null +++ b/packages/propel/package.json @@ -0,0 +1,30 @@ +{ + "name": "@plane/propel", + "version": "0.24.1", + "private": true, + "scripts": { + "lint": "eslint src --ext .ts,.tsx", + "lint:errors": "eslint src --ext .ts,.tsx --quiet" + }, + "exports": { + "./ui/*": "./src/ui/*.tsx", + "./charts/*": "./src/charts/*/index.ts" + }, + "dependencies": { + "@radix-ui/react-slot": "^1.1.1", + "class-variance-authority": "^0.7.1", + "lucide-react": "^0.469.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "recharts": "^2.15.1", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@plane/eslint-config": "*", + "@plane/tailwind-config": "*", + "@plane/typescript-config": "*", + "@types/react": "18.3.1", + "@types/react-dom": "18.3.0", + "typescript": "^5.3.3" + } +} diff --git a/packages/propel/postcss.config.js b/packages/propel/postcss.config.js new file mode 100644 index 000000000..12a703d90 --- /dev/null +++ b/packages/propel/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/propel/src/charts/area-chart/index.ts b/packages/propel/src/charts/area-chart/index.ts new file mode 100644 index 000000000..1efe34c51 --- /dev/null +++ b/packages/propel/src/charts/area-chart/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/packages/propel/src/charts/area-chart/root.tsx b/packages/propel/src/charts/area-chart/root.tsx new file mode 100644 index 000000000..710c5f70d --- /dev/null +++ b/packages/propel/src/charts/area-chart/root.tsx @@ -0,0 +1,124 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +"use client"; + +import React, { useMemo } from "react"; +import { AreaChart as CoreAreaChart, Area, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +// plane imports +import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants"; +import { TAreaChartProps } from "@plane/types"; +// local components +import { CustomXAxisTick, CustomYAxisTick } from "../tick"; +import { CustomTooltip } from "../tooltip"; + +export const AreaChart = React.memo((props: TAreaChartProps) => { + const { + data, + areas, + xAxis, + yAxis, + className = "w-full h-96", + tickCount = { + x: undefined, + y: 10, + }, + showTooltip = true, + } = props; + // derived values + const itemKeys = useMemo(() => areas.map((area) => area.key), [areas]); + const itemDotClassNames = useMemo( + () => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.dotClassName }), {}), + [areas] + ); + + const renderAreas = useMemo( + () => + areas.map((area) => ( + + )), + [areas] + ); + + return ( +
    + + + } + tickLine={{ + stroke: "currentColor", + className: AXIS_LINE_CLASSNAME, + }} + axisLine={{ + stroke: "currentColor", + className: AXIS_LINE_CLASSNAME, + }} + label={{ + value: xAxis.label, + dy: 28, + className: LABEL_CLASSNAME, + }} + tickCount={tickCount.x} + /> + } + tickCount={tickCount.y} + allowDecimals={!!yAxis.allowDecimals} + /> + {showTooltip && ( + ( + + )} + /> + )} + {renderAreas} + + +
    + ); +}); +AreaChart.displayName = "AreaChart"; diff --git a/packages/propel/src/charts/bar-chart/bar.tsx b/packages/propel/src/charts/bar-chart/bar.tsx new file mode 100644 index 000000000..339be704d --- /dev/null +++ b/packages/propel/src/charts/bar-chart/bar.tsx @@ -0,0 +1,70 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from "react"; +// plane imports +import { TChartData } from "@plane/types"; +import { cn } from "@plane/utils"; + +// Helper to calculate percentage +const calculatePercentage = ( + data: TChartData, + stackKeys: T[], + currentKey: T +): number => { + const total = stackKeys.reduce((sum, key) => sum + data[key], 0); + return total === 0 ? 0 : Math.round((data[currentKey] / total) * 100); +}; + +const MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT = 14; // Minimum height needed to show text inside +const BAR_BORDER_RADIUS = 2; // Border radius for each bar + +export const CustomBar = React.memo((props: any) => { + const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage } = 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 + const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey); + 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); + + if (!height) return null; + return ( + + + {showText && ( + + {currentBarPercentage}% + + )} + + ); +}); +CustomBar.displayName = "CustomBar"; diff --git a/packages/propel/src/charts/bar-chart/index.ts b/packages/propel/src/charts/bar-chart/index.ts new file mode 100644 index 000000000..1efe34c51 --- /dev/null +++ b/packages/propel/src/charts/bar-chart/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/packages/propel/src/charts/bar-chart/root.tsx b/packages/propel/src/charts/bar-chart/root.tsx new file mode 100644 index 000000000..883a0621c --- /dev/null +++ b/packages/propel/src/charts/bar-chart/root.tsx @@ -0,0 +1,125 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +"use client"; + +import React, { useMemo } from "react"; +import { BarChart as CoreBarChart, Bar, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +// plane imports +import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants"; +import { TBarChartProps } from "@plane/types"; +// local components +import { CustomXAxisTick, CustomYAxisTick } from "../tick"; +import { CustomTooltip } from "../tooltip"; +import { CustomBar } from "./bar"; + +export const BarChart = React.memo((props: TBarChartProps) => { + const { + data, + bars, + xAxis, + yAxis, + barSize = 40, + className = "w-full h-96", + tickCount = { + x: undefined, + y: 10, + }, + showTooltip = true, + } = props; + // derived values + const stackKeys = useMemo(() => bars.map((bar) => bar.key), [bars]); + const stackDotClassNames = useMemo( + () => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.dotClassName }), {}), + [bars] + ); + + const renderBars = useMemo( + () => + bars.map((bar) => ( + ( + + )} + /> + )), + [stackKeys, bars] + ); + + return ( +
    + + + } + tickLine={{ + stroke: "currentColor", + className: AXIS_LINE_CLASSNAME, + }} + axisLine={{ + stroke: "currentColor", + className: AXIS_LINE_CLASSNAME, + }} + label={{ + value: xAxis.label, + dy: 28, + className: LABEL_CLASSNAME, + }} + tickCount={tickCount.x} + /> + } + tickCount={tickCount.y} + allowDecimals={!!yAxis.allowDecimals} + /> + {showTooltip && ( + ( + + )} + /> + )} + {renderBars} + + +
    + ); +}); +BarChart.displayName = "BarChart"; diff --git a/packages/propel/src/charts/line-chart/index.ts b/packages/propel/src/charts/line-chart/index.ts new file mode 100644 index 000000000..1efe34c51 --- /dev/null +++ b/packages/propel/src/charts/line-chart/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/packages/propel/src/charts/line-chart/root.tsx b/packages/propel/src/charts/line-chart/root.tsx new file mode 100644 index 000000000..c689fe9ba --- /dev/null +++ b/packages/propel/src/charts/line-chart/root.tsx @@ -0,0 +1,115 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +"use client"; + +import React, { useMemo } from "react"; +import { LineChart as CoreLineChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +// plane imports +import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants"; +import { TLineChartProps } from "@plane/types"; +// local components +import { CustomXAxisTick, CustomYAxisTick } from "../tick"; +import { CustomTooltip } from "../tooltip"; + +export const LineChart = React.memo((props: TLineChartProps) => { + const { + data, + lines, + xAxis, + yAxis, + className = "w-full h-96", + tickCount = { + x: undefined, + y: 10, + }, + showTooltip = true, + } = props; + // derived values + const itemKeys = useMemo(() => lines.map((line) => line.key), [lines]); + const itemDotClassNames = useMemo( + () => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.dotClassName }), {}), + [lines] + ); + + const renderLines = useMemo( + () => + lines.map((line) => ( + + )), + [lines] + ); + + return ( +
    + + + } + tickLine={{ + stroke: "currentColor", + className: AXIS_LINE_CLASSNAME, + }} + axisLine={{ + stroke: "currentColor", + className: AXIS_LINE_CLASSNAME, + }} + label={{ + value: xAxis.label, + dy: 28, + className: LABEL_CLASSNAME, + }} + tickCount={tickCount.x} + /> + } + tickCount={tickCount.y} + allowDecimals={!!yAxis.allowDecimals} + /> + {showTooltip && ( + ( + + )} + /> + )} + {renderLines} + + +
    + ); +}); +LineChart.displayName = "LineChart"; diff --git a/packages/propel/src/charts/pie-chart/index.ts b/packages/propel/src/charts/pie-chart/index.ts new file mode 100644 index 000000000..1efe34c51 --- /dev/null +++ b/packages/propel/src/charts/pie-chart/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/packages/propel/src/charts/pie-chart/root.tsx b/packages/propel/src/charts/pie-chart/root.tsx new file mode 100644 index 000000000..d9e2558ed --- /dev/null +++ b/packages/propel/src/charts/pie-chart/root.tsx @@ -0,0 +1,51 @@ +"use client"; + +import React, { useMemo } from "react"; +import { Cell, PieChart as CorePieChart, Pie, ResponsiveContainer, Tooltip } from "recharts"; +// plane imports +import { TPieChartProps } from "@plane/types"; +// local components +import { CustomPieChartTooltip } from "./tooltip"; + +export const PieChart = React.memo((props: TPieChartProps) => { + const { data, dataKey, cells, className = "w-full h-96", innerRadius, outerRadius, showTooltip = true } = props; + + const renderCells = useMemo( + () => cells.map((cell) => ), + [cells] + ); + + return ( +
    + + + + {renderCells} + + {showTooltip && ( + { + if (!active || !payload || !payload.length) return null; + const cellData = cells.find((c) => c.key === payload[0].name); + if (!cellData) return null; + return ; + }} + /> + )} + + +
    + ); +}); +PieChart.displayName = "PieChart"; diff --git a/packages/propel/src/charts/pie-chart/tooltip.tsx b/packages/propel/src/charts/pie-chart/tooltip.tsx new file mode 100644 index 000000000..56c7fa34c --- /dev/null +++ b/packages/propel/src/charts/pie-chart/tooltip.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent"; +// plane imports +import { Card, ECardSpacing } from "@plane/ui"; +import { cn } from "@plane/utils"; + +type Props = { + dotClassName?: string; + label: string; + payload: Payload[]; +}; + +export const CustomPieChartTooltip = React.memo((props: Props) => { + const { dotClassName, label, payload } = props; + + return ( + +

    + {label} +

    + {payload?.map((item) => ( +
    +
    + {item?.name}: + {item?.value} +
    + ))} + + ); +}); +CustomPieChartTooltip.displayName = "CustomPieChartTooltip"; diff --git a/packages/propel/src/charts/tick.tsx b/packages/propel/src/charts/tick.tsx new file mode 100644 index 000000000..c631d7d6e --- /dev/null +++ b/packages/propel/src/charts/tick.tsx @@ -0,0 +1,23 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from "react"; + +// Common classnames +const AXIS_TICK_CLASSNAME = "fill-custom-text-400 text-sm capitalize"; + +export const CustomXAxisTick = React.memo(({ x, y, payload }: any) => ( + + + {payload.value} + + +)); +CustomXAxisTick.displayName = "CustomXAxisTick"; + +export const CustomYAxisTick = React.memo(({ x, y, payload }: any) => ( + + + {payload.value} + + +)); +CustomYAxisTick.displayName = "CustomYAxisTick"; diff --git a/packages/propel/src/charts/tooltip.tsx b/packages/propel/src/charts/tooltip.tsx new file mode 100644 index 000000000..e7f92a9cb --- /dev/null +++ b/packages/propel/src/charts/tooltip.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent"; +// plane imports +import { Card, ECardSpacing } from "@plane/ui"; +import { cn } from "@plane/utils"; + +type Props = { + active: boolean | undefined; + label: string | undefined; + payload: Payload[] | undefined; + itemKeys: string[]; + itemDotClassNames: Record; +}; + +export const CustomTooltip = React.memo((props: Props) => { + const { active, label, payload, itemKeys, itemDotClassNames } = props; + // derived values + const filteredPayload = payload?.filter((item) => item.dataKey && itemKeys.includes(`${item.dataKey}`)); + + if (!active || !filteredPayload || !filteredPayload.length) return null; + return ( + +

    + {label} +

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

    {data?.name}

    +
    + + {data?.value.toLocaleString()} + {data.label && ` ${data.label}`} + +
    + ); +}); + +TreeMapTooltip.displayName = "TreeMapTooltip"; diff --git a/packages/propel/src/globals.css b/packages/propel/src/globals.css new file mode 100644 index 000000000..ee2896808 --- /dev/null +++ b/packages/propel/src/globals.css @@ -0,0 +1,12 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + * { + @apply border-border; + } + body { + @apply font-sans antialiased bg-background text-foreground; + } +} diff --git a/packages/propel/src/index.ts b/packages/propel/src/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/propel/tailwind.config.ts b/packages/propel/tailwind.config.ts new file mode 100644 index 000000000..859971bab --- /dev/null +++ b/packages/propel/tailwind.config.ts @@ -0,0 +1,7 @@ +import type { Config } from "tailwindcss"; +import sharedConfig from "@plane/tailwind-config/tailwind.config"; + +export default { + ...sharedConfig, + content: ["./src/**/*.{ts,tsx}"], +} satisfies Config; diff --git a/packages/propel/tsconfig.json b/packages/propel/tsconfig.json new file mode 100644 index 000000000..1f695a242 --- /dev/null +++ b/packages/propel/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@plane/typescript-config/react-library.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/services/.eslintignore b/packages/services/.eslintignore new file mode 100644 index 000000000..6019047c3 --- /dev/null +++ b/packages/services/.eslintignore @@ -0,0 +1,3 @@ +build/* +dist/* +out/* \ No newline at end of file diff --git a/packages/services/.eslintrc.js b/packages/services/.eslintrc.js new file mode 100644 index 000000000..558b8f76e --- /dev/null +++ b/packages/services/.eslintrc.js @@ -0,0 +1,9 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ["@plane/eslint-config/library.js"], + parser: "@typescript-eslint/parser", + parserOptions: { + project: true, + }, +}; diff --git a/packages/services/.prettierrc b/packages/services/.prettierrc new file mode 100644 index 000000000..87d988f1b --- /dev/null +++ b/packages/services/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/packages/services/package.json b/packages/services/package.json new file mode 100644 index 000000000..792186de7 --- /dev/null +++ b/packages/services/package.json @@ -0,0 +1,14 @@ +{ + "name": "@plane/services", + "version": "0.24.1", + "private": true, + "main": "./src/index.ts", + "scripts": { + "lint": "eslint src --ext .ts,.tsx", + "lint:errors": "eslint src --ext .ts,.tsx --quiet" + }, + "dependencies": { + "@plane/constants": "*", + "axios": "^1.7.9" + } +} diff --git a/packages/services/src/ai/ai.service.ts b/packages/services/src/ai/ai.service.ts new file mode 100644 index 000000000..261cf9df5 --- /dev/null +++ b/packages/services/src/ai/ai.service.ts @@ -0,0 +1,67 @@ +// plane web constants +import { AI_EDITOR_TASKS, API_BASE_URL } from "@plane/constants"; +// services +import { APIService } from "../api.service"; + +/** + * Payload type for AI editor tasks + * @typedef {Object} TTaskPayload + * @property {number} [casual_score] - Optional score for casual tone analysis + * @property {number} [formal_score] - Optional score for formal tone analysis + * @property {AI_EDITOR_TASKS} task - Type of AI editor task to perform + * @property {string} text_input - The input text to be processed + */ +export type TTaskPayload = { + casual_score?: number; + formal_score?: number; + task: AI_EDITOR_TASKS; + text_input: string; +}; + +/** + * Service class for handling AI-related API operations + * Extends the base APIService class to interact with AI endpoints + * @extends {APIService} + */ +export class AIService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Creates a GPT-based task for a specific workspace + * @param {string} workspaceSlug - The unique identifier for the workspace + * @param {Object} data - The data payload for the GPT task + * @param {string} data.prompt - The prompt text for the GPT model + * @param {string} data.task - The type of task to be performed + * @returns {Promise} The response data from the GPT task + * @throws {Error} Throws the response error if the request fails + */ + async prompt(workspaceSlug: string, data: { prompt: string; task: string }): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/ai-assistant/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Performs an editor-specific AI task for text processing + * @param {string} workspaceSlug - The unique identifier for the workspace + * @param {TTaskPayload} data - The task payload containing text and processing parameters + * @returns {Promise<{response: string}>} The processed text response + * @throws {Error} Throws the response data if the request fails + */ + async rephraseGrammar( + workspaceSlug: string, + data: TTaskPayload + ): Promise<{ + response: string; + }> { + return this.post(`/api/workspaces/${workspaceSlug}/rephrase-grammar/`, data) + .then((res) => res?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/ai/index.ts b/packages/services/src/ai/index.ts new file mode 100644 index 000000000..bce346aa6 --- /dev/null +++ b/packages/services/src/ai/index.ts @@ -0,0 +1 @@ +export * from "./ai.service"; diff --git a/packages/services/src/analytics/analytics.service.ts b/packages/services/src/analytics/analytics.service.ts new file mode 100644 index 000000000..c012fd26f --- /dev/null +++ b/packages/services/src/analytics/analytics.service.ts @@ -0,0 +1,93 @@ +// 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 new file mode 100644 index 000000000..7655bd442 --- /dev/null +++ b/packages/services/src/analytics/index.ts @@ -0,0 +1 @@ +export * from "./analytics.service"; diff --git a/packages/services/src/api.service.ts b/packages/services/src/api.service.ts new file mode 100644 index 000000000..619a0d4ec --- /dev/null +++ b/packages/services/src/api.service.ts @@ -0,0 +1,123 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import axios, { AxiosInstance, AxiosRequestConfig } from "axios"; +import { IndexedDBService } from "./indexedDB.service"; + +/** + * Abstract base class for making HTTP requests using axios + * @abstract + */ +export abstract class APIService { + protected baseURL: string; + private axiosInstance: AxiosInstance; + + /** + * Creates an instance of APIService + * @param {string} baseURL - The base URL for all HTTP requests + */ + constructor(baseURL: string) { + this.baseURL = baseURL; + this.axiosInstance = axios.create({ + baseURL, + withCredentials: true, + }); + + this.setupInterceptors(); + } + + /** + * Sets up axios interceptors for handling responses + * Currently handles 401 unauthorized responses by redirecting to login + * @private + */ + private setupInterceptors() { + this.axiosInstance.interceptors.response.use( + (response) => response, + (error) => { + if (error.response && error.response.status === 401) { + const currentPath = window.location.pathname; + let prefix = "/"; + let updatedPath = currentPath; + + // Check for special path prefixes + if (currentPath.startsWith("/god-mode")) { + prefix = "/god-mode"; + updatedPath = currentPath.replace("/god-mode", ""); + } else if (currentPath.startsWith("/spaces")) { + prefix = "/spaces"; + updatedPath = currentPath.replace("/spaces", ""); + } + + window.location.replace(`${prefix}${updatedPath ? `?next_path=${updatedPath}` : ""}`); + } + return Promise.reject(error); + } + ); + } + + /** + * Makes a GET request to the specified URL + * @param {string} url - The endpoint URL + * @param {object} [params={}] - URL parameters + * @param {AxiosRequestConfig} [config={}] - Additional axios configuration + * @returns {Promise} Axios response promise + */ + get(url: string, params = {}, config: AxiosRequestConfig = {}) { + return this.axiosInstance.get(url, { + ...params, + ...config, + }); + } + + /** + * Makes a POST request to the specified URL + * @param {string} url - The endpoint URL + * @param {object} [data={}] - Request body data + * @param {AxiosRequestConfig} [config={}] - Additional axios configuration + * @returns {Promise} Axios response promise + */ + post(url: string, data = {}, config: AxiosRequestConfig = {}) { + return this.axiosInstance.post(url, data, config); + } + + /** + * Makes a PUT request to the specified URL + * @param {string} url - The endpoint URL + * @param {object} [data={}] - Request body data + * @param {AxiosRequestConfig} [config={}] - Additional axios configuration + * @returns {Promise} Axios response promise + */ + put(url: string, data = {}, config: AxiosRequestConfig = {}) { + return this.axiosInstance.put(url, data, config); + } + + /** + * Makes a PATCH request to the specified URL + * @param {string} url - The endpoint URL + * @param {object} [data={}] - Request body data + * @param {AxiosRequestConfig} [config={}] - Additional axios configuration + * @returns {Promise} Axios response promise + */ + patch(url: string, data = {}, config: AxiosRequestConfig = {}) { + return this.axiosInstance.patch(url, data, config); + } + + /** + * Makes a DELETE request to the specified URL + * @param {string} url - The endpoint URL + * @param {any} [data] - Request body data + * @param {AxiosRequestConfig} [config={}] - Additional axios configuration + * @returns {Promise} Axios response promise + */ + delete(url: string, data?: any, config: AxiosRequestConfig = {}) { + return this.axiosInstance.delete(url, { data, ...config }); + } + + /** + * Makes a custom request with the provided configuration + * @param {object} [config={}] - Axios request configuration + * @returns {Promise} Axios response promise + */ + request(config = {}) { + return this.axiosInstance(config); + } +} diff --git a/packages/services/src/auth/auth.service.ts b/packages/services/src/auth/auth.service.ts new file mode 100644 index 000000000..1890533d2 --- /dev/null +++ b/packages/services/src/auth/auth.service.ts @@ -0,0 +1,125 @@ +import { API_BASE_URL } from "@plane/constants"; +// types +import { ICsrfTokenData, IEmailCheckData, IEmailCheckResponse } from "@plane/types"; +// services +import { APIService } from "../api.service"; + +/** + * Service class for handling authentication-related operations + * Provides methods for user authentication, password management, and session handling + * @extends {APIService} + */ +export class AuthService extends APIService { + /** + * Creates an instance of AuthService + * Initializes with the base API URL + */ + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Requests a CSRF token for form submission security + * @returns {Promise} Object containing the CSRF token + * @throws {Error} Throws the complete error object if the request fails + * @remarks This method uses the validateStatus: null option to bypass interceptors for unauthorized errors. + */ + async requestCSRFToken(): Promise { + return this.get("/auth/get-csrf-token/", { validateStatus: null }) + .then((response) => response.data) + .catch((error) => { + throw error; + }); + } + + /** + * Checks if an email exists in the system + * @param {IEmailCheckData} data - Email data to verify + * @returns {Promise} Response indicating email status + * @throws {Error} Throws response data if the request fails + */ + async emailCheck(data: IEmailCheckData): Promise { + return this.post("/auth/email-check/", data, { headers: {} }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Sends a password reset link to the specified email address + * @param {{ email: string }} data - Object containing the email address + * @returns {Promise} Response from the password reset request + * @throws {Error} Throws response object if the request fails + */ + async sendResetPasswordLink(data: { email: string }): Promise { + return this.post(`/auth/forgot-password/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Sets a new password using a reset token + * @param {string} token - CSRF token for form submission security + * @param {{ password: string }} data - Object containing the new password + * @returns {Promise} Response from the password update request + * @throws {Error} Throws response data if the request fails + */ + async setPassword(token: string, data: { password: string }): Promise { + return this.post(`/auth/set-password/`, data, { + headers: { + "X-CSRFTOKEN": token, + }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Generates a unique code for magic link authentication + * @param {{ email: string }} data - Object containing the email address + * @returns {Promise} Response containing the generated unique code + * @throws {Error} Throws response data if the request fails + */ + async generateUniqueCode(data: { email: string }): Promise { + return this.post("/auth/magic-generate/", data, { headers: {} }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Performs user sign out by submitting a form with CSRF token + * Creates and submits a form dynamically to handle the sign-out process + * @param {string} baseUrl - Base URL for the sign-out endpoint + * @returns {Promise} Resolves when sign-out is complete + * @throws {Error} Throws error if CSRF token is not found + */ + async signOut(baseUrl: string): Promise { + await this.requestCSRFToken().then((data) => { + const csrfToken = data?.csrf_token; + + if (!csrfToken) throw Error("CSRF token not found"); + + const form = document.createElement("form"); + const element1 = document.createElement("input"); + + form.method = "POST"; + form.action = `${baseUrl}/auth/sign-out/`; + + element1.value = csrfToken; + element1.name = "csrfmiddlewaretoken"; + element1.type = "hidden"; + form.appendChild(element1); + + document.body.appendChild(form); + + form.submit(); + }); + } +} diff --git a/packages/services/src/auth/index.ts b/packages/services/src/auth/index.ts new file mode 100644 index 000000000..340b36856 --- /dev/null +++ b/packages/services/src/auth/index.ts @@ -0,0 +1,2 @@ +export * from "./auth.service"; +export * from "./sites-auth.service"; diff --git a/packages/services/src/auth/sites-auth.service.ts b/packages/services/src/auth/sites-auth.service.ts new file mode 100644 index 000000000..638a7b6f2 --- /dev/null +++ b/packages/services/src/auth/sites-auth.service.ts @@ -0,0 +1,49 @@ +import { API_BASE_URL } from "@plane/constants"; +// types +import { IEmailCheckData, IEmailCheckResponse } from "@plane/types"; +// services +import { APIService } from "../api.service"; + +/** + * Service class for handling authentication-related operations for Plane space application + * Provides methods for user authentication, password management, and session handling + * @extends {APIService} + * @remarks This service is only available for plane sites + */ +export class SitesAuthService extends APIService { + /** + * Creates an instance of SitesAuthService + * Initializes with the base API URL + */ + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Checks if an email exists in the system + * @param {IEmailCheckData} data - Email data to verify + * @returns {Promise} Response indicating email status + * @throws {Error} Throws response data if the request fails + */ + async emailCheck(data: IEmailCheckData): Promise { + return this.post("/auth/spaces/email-check/", data, { headers: {} }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Generates a unique code for magic link authentication + * @param {{ email: string }} data - Object containing the email address + * @returns {Promise} Response containing the generated unique code + * @throws {Error} Throws response data if the request fails + */ + async generateUniqueCode(data: { email: string }): Promise { + return this.post("/auth/spaces/magic-generate/", data, { headers: {} }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/cycle/cycle-analytics.service.ts b/packages/services/src/cycle/cycle-analytics.service.ts new file mode 100644 index 000000000..c9e14441e --- /dev/null +++ b/packages/services/src/cycle/cycle-analytics.service.ts @@ -0,0 +1,78 @@ +import { API_BASE_URL } from "@plane/constants"; +import type { TCycleDistribution, TProgressSnapshot, TCycleEstimateDistribution } from "@plane/types"; +import { APIService } from "../api.service"; + +/** + * Service class for managing cycles within a workspace and project context. + * Extends APIService to handle HTTP requests to the cycle-related endpoints. + * @extends {APIService} + */ +export class CycleAnalyticsService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Retrieves analytics for active cycles in a workspace. + * @param {string} workspaceSlug - The workspace identifier + * @param {string} projectId - The project identifier + * @param {string} cycleId - The cycle identifier + * @param {string} [analytic_type="points"] - The type of analytics to retrieve + * @returns {Promise} The cycle analytics data + * @throws {Error} If the request fails + */ + async workspaceActiveCyclesAnalytics( + workspaceSlug: string, + projectId: string, + cycleId: string, + analytic_type: string = "points" + ): Promise { + return this.get( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/analytics?type=${analytic_type}` + ) + .then((res) => res?.data) + .catch((err) => { + throw err?.response?.data; + }); + } + + /** + * Retrieves progress data for active cycles. + * @param {string} workspaceSlug - The workspace identifier + * @param {string} projectId - The project identifier + * @param {string} cycleId - The cycle identifier + * @returns {Promise} The cycle progress data + * @throws {Error} If the request fails + */ + async workspaceActiveCyclesProgress( + workspaceSlug: string, + projectId: string, + cycleId: string + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/progress/`) + .then((res) => res?.data) + .catch((err) => { + throw err?.response?.data; + }); + } + + /** + * Retrieves advanced progress data for active cycles (Pro feature). + * @param {string} workspaceSlug - The workspace identifier + * @param {string} projectId - The project identifier + * @param {string} cycleId - The cycle identifier + * @returns {Promise} The detailed cycle progress data + * @throws {Error} If the request fails + */ + async workspaceActiveCyclesProgressPro( + workspaceSlug: string, + projectId: string, + cycleId: string + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/cycle-progress/`) + .then((res) => res?.data) + .catch((err) => { + throw err?.response?.data; + }); + } +} diff --git a/packages/services/src/cycle/cycle-archive.service.ts b/packages/services/src/cycle/cycle-archive.service.ts new file mode 100644 index 000000000..784fd32e7 --- /dev/null +++ b/packages/services/src/cycle/cycle-archive.service.ts @@ -0,0 +1,83 @@ +import { API_BASE_URL } from "@plane/constants"; +import { ICycle } from "@plane/types"; +import { APIService } from "../api.service"; + +/** + * Service class for managing archived cycles in a project + * Provides methods for retrieving, archiving, and restoring project cycles + * @extends {APIService} + */ +export class CycleArchiveService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Retrieves all archived cycles for a specific project + * @param {string} workspaceSlug - The unique identifier for the workspace + * @param {string} projectId - The unique identifier for the project + * @returns {Promise} Array of archived cycles + * @throws {Error} Throws response data if the request fails + */ + async list(workspaceSlug: string, projectId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/archived-cycles/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Retrieves details of a specific archived cycle + * @param {string} workspaceSlug - The unique identifier for the workspace + * @param {string} projectId - The unique identifier for the project + * @param {string} cycleId - The unique identifier for the cycle + * @returns {Promise} Details of the archived cycle + * @throws {Error} Throws response data if the request fails + */ + async retrieve(workspaceSlug: string, projectId: string, cycleId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/archived-cycles/${cycleId}/`) + .then((res) => res?.data) + .catch((err) => { + throw err?.response?.data; + }); + } + + /** + * Archives a specific cycle in a project + * @param {string} workspaceSlug - The unique identifier for the workspace + * @param {string} projectId - The unique identifier for the project + * @param {string} cycleId - The unique identifier for the cycle to archive + * @returns {Promise<{archived_at: string}>} Object containing the archive timestamp + * @throws {Error} Throws response data if the request fails + */ + async archive( + workspaceSlug: string, + projectId: string, + cycleId: string + ): Promise<{ + archived_at: string; + }> { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/archive/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Restores a previously archived cycle + * @param {string} workspaceSlug - The unique identifier for the workspace + * @param {string} projectId - The unique identifier for the project + * @param {string} cycleId - The unique identifier for the cycle to restore + * @returns {Promise} Resolves when the cycle is successfully restored + * @throws {Error} Throws response data if the request fails + */ + async restore(workspaceSlug: string, projectId: string, cycleId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/archive/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/cycle/cycle-operations.service.ts b/packages/services/src/cycle/cycle-operations.service.ts new file mode 100644 index 000000000..43c01396c --- /dev/null +++ b/packages/services/src/cycle/cycle-operations.service.ts @@ -0,0 +1,70 @@ +import { API_BASE_URL } from "@plane/constants"; +import { APIService } from "../api.service"; + +export class CycleOperationsService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Adds a cycle to user favorites. + * @param {string} workspaceSlug - The workspace identifier + * @param {string} projectId - The project identifier + * @param {{cycle: string}} data - The favorite cycle data + * @returns {Promise} The response data + * @throws {Error} If the request fails + */ + async addToFavorites( + workspaceSlug: string, + projectId: string, + data: { + cycle: string; + } + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-cycles/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Removes a cycle from user favorites. + * @param {string} workspaceSlug - The workspace identifier + * @param {string} projectId - The project identifier + * @param {string} cycleId - The cycle identifier + * @returns {Promise} The removal response + * @throws {Error} If the request fails + */ + async removeFromFavorites(workspaceSlug: string, projectId: string, cycleId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-cycles/${cycleId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Transfers issues between cycles. + * @param {string} workspaceSlug - The workspace identifier + * @param {string} projectId - The project identifier + * @param {string} cycleId - The source cycle identifier + * @param {{new_cycle_id: string}} data - The target cycle data + * @returns {Promise} The transfer response + * @throws {Error} If the request fails + */ + async transferIssues( + workspaceSlug: string, + projectId: string, + cycleId: string, + data: { + new_cycle_id: string; + } + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/transfer-issues/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/cycle/cycle.service.ts b/packages/services/src/cycle/cycle.service.ts new file mode 100644 index 000000000..961e5588a --- /dev/null +++ b/packages/services/src/cycle/cycle.service.ts @@ -0,0 +1,184 @@ +import { API_BASE_URL } from "@plane/constants"; +import type { CycleDateCheckData, ICycle, TIssuesResponse, IWorkspaceActiveCyclesResponse } from "@plane/types"; +import { APIService } from "../api.service"; + +/** + * Service class for managing cycles within a workspace and project context. + * Extends APIService to handle HTTP requests to the cycle-related endpoints. + * @extends {APIService} + */ +export class CycleService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Retrieves paginated list of active cycles in a workspace. + * @param {string} workspaceSlug - The workspace identifier + * @param {string} cursor - The pagination cursor + * @param {number} per_page - Number of items per page + * @returns {Promise} Paginated active cycles data + * @throws {Error} If the request fails + */ + async workspaceActiveCycles( + workspaceSlug: string, + cursor: string, + per_page: number + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/active-cycles/`, { + params: { + per_page, + cursor, + }, + }) + .then((res) => res?.data) + .catch((err) => { + throw err?.response?.data; + }); + } + + /** + * Gets all cycles in a workspace. + * @param {string} workspaceSlug - The workspace identifier + * @returns {Promise} Array of cycle objects + * @throws {Error} If the request fails + */ + async getWorkspaceCycles(workspaceSlug: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/cycles/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Creates a new cycle in a project. + * @param {string} workspaceSlug - The workspace identifier + * @param {string} projectId - The project identifier + * @param {any} data - The cycle creation data + * @returns {Promise} The created cycle object + * @throws {Error} If the request fails + */ + async create(workspaceSlug: string, projectId: string, data: any): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Retrieves cycles with optional filtering parameters. + * @param {string} workspaceSlug - The workspace identifier + * @param {string} projectId - The project identifier + * @param {"current"} [cycleType] - Optional filter for cycle type + * @returns {Promise} Array of filtered cycle objects + * @throws {Error} If the request fails + */ + async getWithParams(workspaceSlug: string, projectId: string, cycleType?: "current"): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/`, { + params: { + cycle_view: cycleType, + }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Retrieves detailed information for a specific cycle. + * @param {string} workspaceSlug - The workspace identifier + * @param {string} projectId - The project identifier + * @param {string} cycleId - The cycle identifier + * @returns {Promise} The cycle details + * @throws {Error} If the request fails + */ + async retrieve(workspaceSlug: string, projectId: string, cycleId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/`) + .then((res) => res?.data) + .catch((err) => { + throw err?.response?.data; + }); + } + + /** + * Retrieves issues associated with a specific cycle. + * @param {string} workspaceSlug - The workspace identifier + * @param {string} projectId - The project identifier + * @param {string} cycleId - The cycle identifier + * @param {any} [queries] - Optional query parameters + * @param {object} [config={}] - Optional request configuration + * @returns {Promise} The cycle issues data + * @throws {Error} If the request fails + */ + async getCycleIssues( + workspaceSlug: string, + projectId: string, + cycleId: string, + queries?: any, + config = {} + ): Promise { + return this.get( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/cycle-issues/`, + { + params: queries, + }, + config + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Updates a cycle with partial data. + * @param {string} workspaceSlug - The workspace identifier + * @param {string} projectId - The project identifier + * @param {string} cycleId - The cycle identifier + * @param {Partial} data - The partial cycle data to update + * @returns {Promise} The update response + * @throws {Error} If the request fails + */ + async update(workspaceSlug: string, projectId: string, cycleId: string, data: Partial): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Deletes a specific cycle. + * @param {string} workspaceSlug - The workspace identifier + * @param {string} projectId - The project identifier + * @param {string} cycleId - The cycle identifier + * @returns {Promise} The deletion response + * @throws {Error} If the request fails + */ + async destroy(workspaceSlug: string, projectId: string, cycleId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Validates cycle dates. + * @param {string} workspaceSlug - The workspace identifier + * @param {string} projectId - The project identifier + * @param {CycleDateCheckData} data - The date check data + * @returns {Promise} The validation response + * @throws {Error} If the request fails + */ + async validateDates(workspaceSlug: string, projectId: string, data: CycleDateCheckData): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/date-check/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/cycle/index.ts b/packages/services/src/cycle/index.ts new file mode 100644 index 000000000..6f5dbc679 --- /dev/null +++ b/packages/services/src/cycle/index.ts @@ -0,0 +1,5 @@ +export * from "./cycle-analytics.service"; +export * from "./cycle-archive.service"; +export * from "./cycle-operations.service"; +export * from "./cycle.service"; +export * from "./sites-cycle.service"; diff --git a/packages/services/src/cycle/sites-cycle.service.ts b/packages/services/src/cycle/sites-cycle.service.ts new file mode 100644 index 000000000..99cf361a7 --- /dev/null +++ b/packages/services/src/cycle/sites-cycle.service.ts @@ -0,0 +1,31 @@ +// plane imports +import { API_BASE_URL } from "@plane/constants"; +import { TPublicCycle } from "@plane/types"; +// api service +import { APIService } from "../api.service"; + +/** + * Service class for managing cycles within plane sites application. + * Extends APIService to handle HTTP requests to the cycle-related endpoints. + * @extends {APIService} + * @remarks This service is only available for plane sites + */ +export class SitesCycleService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Retrieves list of cycles for a specific anchor. + * @param anchor - The anchor identifier for the published entity + * @returns {Promise} The list of cycles + * @throws {Error} If the request fails + */ + async list(anchor: string): Promise { + return this.get(`/api/public/anchor/${anchor}/cycles/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/dashboard/dashboard.service.ts b/packages/services/src/dashboard/dashboard.service.ts new file mode 100644 index 000000000..6a4a6bcca --- /dev/null +++ b/packages/services/src/dashboard/dashboard.service.ts @@ -0,0 +1,79 @@ +import { API_BASE_URL } from "@plane/constants"; +import { THomeDashboardResponse, TWidget, TWidgetStatsResponse, TWidgetStatsRequestParams } from "@plane/types"; +import { APIService } from "../api.service"; + +export default class DashboardService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Retrieves home dashboard widgets for a specific workspace + * @param {string} workspaceSlug - The unique identifier for the workspace + * @returns {Promise} Promise resolving to dashboard widget data + * @throws {Error} If the API request fails + */ + async getHomeWidgets(workspaceSlug: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/dashboard/`, { + params: { + dashboard_type: "home", + }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Fetches statistics for a specific dashboard widget + * @param {string} workspaceSlug - The unique identifier for the workspace + * @param {string} dashboardId - The unique identifier for the dashboard + * @param {TWidgetStatsRequestParams} params - Parameters for filtering widget statistics + * @returns {Promise} Promise resolving to widget statistics data + * @throws {Error} If the API request fails + */ + async getWidgetStats( + workspaceSlug: string, + dashboardId: string, + params: TWidgetStatsRequestParams + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/dashboard/${dashboardId}/`, { + params, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Retrieves detailed information about a specific dashboard + * @param {string} dashboardId - The unique identifier for the dashboard + * @returns {Promise} Promise resolving to dashboard details + * @throws {Error} If the API request fails + */ + async retrieve(dashboardId: string): Promise { + return this.get(`/api/dashboard/${dashboardId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Updates a specific widget within a dashboard + * @param {string} dashboardId - The unique identifier for the dashboard + * @param {string} widgetId - The unique identifier for the widget + * @param {Partial} data - Partial widget data to update + * @returns {Promise} Promise resolving to the updated widget data + * @throws {Error} If the API request fails + */ + async updateWidget(dashboardId: string, widgetId: string, data: Partial): Promise { + return this.patch(`/api/dashboard/${dashboardId}/widgets/${widgetId}/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/dashboard/index.ts b/packages/services/src/dashboard/index.ts new file mode 100644 index 000000000..79e3f0400 --- /dev/null +++ b/packages/services/src/dashboard/index.ts @@ -0,0 +1 @@ +export * from "./dashboard.service"; diff --git a/packages/services/src/developer/api-token.service.ts b/packages/services/src/developer/api-token.service.ts new file mode 100644 index 000000000..74dc9135d --- /dev/null +++ b/packages/services/src/developer/api-token.service.ts @@ -0,0 +1,68 @@ +import { API_BASE_URL } from "@plane/constants"; +import { IApiToken } from "@plane/types"; +import { APIService } from "../api.service"; + +export class APITokenService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * 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/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * 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}`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * 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) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * 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}`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/developer/index.ts b/packages/services/src/developer/index.ts new file mode 100644 index 000000000..a78a7b092 --- /dev/null +++ b/packages/services/src/developer/index.ts @@ -0,0 +1,2 @@ +export * from "./api-token.service"; +export * from "./webhook.service"; diff --git a/packages/services/src/developer/webhook.service.ts b/packages/services/src/developer/webhook.service.ts new file mode 100644 index 000000000..e48da3430 --- /dev/null +++ b/packages/services/src/developer/webhook.service.ts @@ -0,0 +1,104 @@ +import { API_BASE_URL } from "@plane/constants"; +import { IWebhook } from "@plane/types"; +import { APIService } from "../api.service"; + +/** + * Service class for managing webhooks + * Handles CRUD operations for webhooks and secret key management + * @extends {APIService} + */ +export default class WebhookService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Retrieves all webhooks for a workspace + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @returns {Promise} Promise resolving to array of webhooks + * @throws {Error} If the API request fails + */ + async list(workspaceSlug: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/webhooks/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Retrieves details of a specific webhook + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @param {string} webhookId - The unique identifier for the webhook + * @returns {Promise} Promise resolving to webhook details + * @throws {Error} If the API request fails + */ + async retrieve(workspaceSlug: string, webhookId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/webhooks/${webhookId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Creates a new webhook in the workspace + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @param {Object} [data={}] - Webhook configuration data + * @returns {Promise} Promise resolving to the created webhook + * @throws {Error} If the API request fails + */ + async create(workspaceSlug: string, data = {}): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/webhooks/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Updates an existing webhook + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @param {string} webhookId - The unique identifier for the webhook + * @param {Object} [data={}] - Updated webhook configuration data + * @returns {Promise} Promise resolving to the updated webhook + * @throws {Error} If the API request fails + */ + async update(workspaceSlug: string, webhookId: string, data = {}): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/webhooks/${webhookId}/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Deletes a webhook from the workspace + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @param {string} webhookId - The unique identifier for the webhook + * @returns {Promise} Promise resolving when webhook is deleted + * @throws {Error} If the API request fails + */ + async destroy(workspaceSlug: string, webhookId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/webhooks/${webhookId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Regenerates the secret key for a webhook + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @param {string} webhookId - The unique identifier for the webhook + * @returns {Promise} Promise resolving to the webhook with new secret key + * @throws {Error} If the API request fails + */ + async regenerateSecretKey(workspaceSlug: string, webhookId: string): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/webhooks/${webhookId}/regenerate/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/space/core/services/file-upload.service.ts b/packages/services/src/file/file-upload.service.ts similarity index 55% rename from space/core/services/file-upload.service.ts rename to packages/services/src/file/file-upload.service.ts index 09e95f3c0..a2e5ce5e6 100644 --- a/space/core/services/file-upload.service.ts +++ b/packages/services/src/file/file-upload.service.ts @@ -1,7 +1,12 @@ import axios from "axios"; -// services -import { APIService } from "@/services/api.service"; +// api service +import { APIService } from "../api.service"; +/** + * Service class for handling file upload operations + * Handles file uploads + * @extends {APIService} + */ export class FileUploadService extends APIService { private cancelSource: any; @@ -9,7 +14,17 @@ export class FileUploadService extends APIService { super(""); } - async uploadFile(url: string, data: FormData): Promise { + /** + * Uploads a file to the specified signed URL + * @param {string} url - The URL to upload the file to + * @param {FormData} data - The form data to upload + * @returns {Promise} Promise resolving to void + * @throws {Error} If the request fails + */ + async uploadFile( + url: string, + data: FormData, + ): Promise { this.cancelSource = axios.CancelToken.source(); return this.post(url, data, { headers: { @@ -28,7 +43,10 @@ export class FileUploadService extends APIService { }); } + /** + * Cancels the upload + */ cancelUpload() { this.cancelSource.cancel("Upload canceled"); } -} +} \ No newline at end of file diff --git a/packages/services/src/file/file.service.ts b/packages/services/src/file/file.service.ts new file mode 100644 index 000000000..59c054faf --- /dev/null +++ b/packages/services/src/file/file.service.ts @@ -0,0 +1,67 @@ +// plane imports +import { API_BASE_URL } from "@plane/constants"; +// api service +import { APIService } from "../api.service"; +// helpers +import { getAssetIdFromUrl } from "./helper"; + +/** + * Service class for managing file operations within plane applications. + * Extends APIService to handle HTTP requests to the file-related endpoints. + * @extends {APIService} + */ +export class FileService extends APIService { + /** + * Creates an instance of FileService + * @param {string} BASE_URL - The base URL for API requests + */ + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Deletes a new asset + * @param {string} assetPath - The asset path + * @returns {Promise} Promise resolving to void + * @throws {Error} If the request fails + */ + async deleteNewAsset(assetPath: string): Promise { + return this.delete(assetPath) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Deletes an old editor asset + * @param {string} workspaceId - The workspace identifier + * @param {string} src - The asset source + * @returns {Promise} Promise resolving to void + * @throws {Error} If the request fails + */ + async deleteOldEditorAsset(workspaceId: string, src: string): Promise { + const assetKey = getAssetIdFromUrl(src); + return this.delete(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/`) + .then((response) => response?.status) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Restores an old editor asset + * @param {string} workspaceId - The workspace identifier + * @param {string} src - The asset source + * @returns {Promise} Promise resolving to void + * @throws {Error} If the request fails + */ + async restoreOldEditorAsset(workspaceId: string, src: string): Promise { + const assetKey = getAssetIdFromUrl(src); + return this.post(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/restore/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/file/helper.ts b/packages/services/src/file/helper.ts new file mode 100644 index 000000000..f2361290c --- /dev/null +++ b/packages/services/src/file/helper.ts @@ -0,0 +1,36 @@ +import { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types"; + +/** + * @description from the provided signed URL response, generate a payload to be used to upload the file + * @param {TFileSignedURLResponse} signedURLResponse + * @param {File} file + * @returns {FormData} file upload request payload + */ +export const generateFileUploadPayload = (signedURLResponse: TFileSignedURLResponse, file: File): FormData => { + const formData = new FormData(); + Object.entries(signedURLResponse.upload_data.fields).forEach(([key, value]) => formData.append(key, value)); + formData.append("file", file); + return formData; +}; + +/** + * @description returns the necessary file meta data to upload a file + * @param {File} file + * @returns {TFileMetaDataLite} payload with file info + */ +export const getFileMetaDataForUpload = (file: File): TFileMetaDataLite => ({ + name: file.name, + size: file.size, + type: file.type, +}); + +/** + * @description this function returns the assetId from the asset source + * @param {string} src + * @returns {string} assetId + */ +export const getAssetIdFromUrl = (src: string): string => { + const sourcePaths = src.split("/"); + const assetUrl = sourcePaths[sourcePaths.length - 1]; + return assetUrl; +}; diff --git a/packages/services/src/file/index.ts b/packages/services/src/file/index.ts new file mode 100644 index 000000000..439db5ac7 --- /dev/null +++ b/packages/services/src/file/index.ts @@ -0,0 +1,3 @@ +export * from "./file-upload.service"; +export * from "./sites-file.service"; +export * from "./file.service"; diff --git a/space/core/services/file.service.ts b/packages/services/src/file/sites-file.service.ts similarity index 54% rename from space/core/services/file.service.ts rename to packages/services/src/file/sites-file.service.ts index 0b4807aff..2d606a09e 100644 --- a/space/core/services/file.service.ts +++ b/packages/services/src/file/sites-file.service.ts @@ -1,22 +1,40 @@ +// plane imports import { API_BASE_URL } from "@plane/constants"; +// local services import { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types"; +import { FileUploadService } from "./file-upload.service"; // helpers -import { generateFileUploadPayload, getAssetIdFromUrl, getFileMetaDataForUpload } from "@/helpers/file.helper"; -// services -import { APIService } from "@/services/api.service"; -import { FileUploadService } from "@/services/file-upload.service"; +import { FileService } from "./file.service"; +import { generateFileUploadPayload, getAssetIdFromUrl, getFileMetaDataForUpload } from "./helper"; -export class FileService extends APIService { +/** + * Service class for managing file operations within plane sites application. + * Extends FileService to manage file-related operations. + * @extends {FileService} + * @remarks This service is only available for plane sites + */ +export class SitesFileService extends FileService { private cancelSource: any; fileUploadService: FileUploadService; - constructor() { - super(API_BASE_URL); + /** + * Creates an instance of SitesFileService + * @param {string} BASE_URL - The base URL for API requests + */ + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); this.cancelUpload = this.cancelUpload.bind(this); // services this.fileUploadService = new FileUploadService(); } + /** + * Updates the upload status of an asset + * @param {string} anchor - The anchor identifier + * @param {string} assetId - The asset identifier + * @returns {Promise} Promise resolving to void + * @throws {Error} If the request fails + */ private async updateAssetUploadStatus(anchor: string, assetId: string): Promise { return this.patch(`/api/public/assets/v2/anchor/${anchor}/${assetId}/`) .then((response) => response?.data) @@ -25,6 +43,14 @@ export class FileService extends APIService { }); } + /** + * Updates the upload status of multiple assets + * @param {string} anchor - The anchor identifier + * @param {string} entityId - The entity identifier + * @param {Object} data - The data payload + * @returns {Promise} Promise resolving to void + * @throws {Error} If the request fails + */ async updateBulkAssetsUploadStatus( anchor: string, entityId: string, @@ -39,6 +65,14 @@ export class FileService extends APIService { }); } + /** + * Uploads a file to the specified anchor + * @param {string} anchor - The anchor identifier + * @param {TFileEntityInfo} data - The data payload + * @param {File} file - The file to upload + * @returns {Promise} Promise resolving to the signed URL response + * @throws {Error} If the request fails + */ async uploadAsset(anchor: string, data: TFileEntityInfo, file: File): Promise { const fileMetaData = getFileMetaDataForUpload(file); return this.post(`/api/public/assets/v2/anchor/${anchor}/`, { @@ -57,23 +91,13 @@ export class FileService extends APIService { }); } - async deleteNewAsset(assetPath: string): Promise { - return this.delete(assetPath) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async deleteOldEditorAsset(workspaceId: string, src: string): Promise { - const assetKey = getAssetIdFromUrl(src); - return this.delete(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/`) - .then((response) => response?.status) - .catch((error) => { - throw error?.response?.data; - }); - } - + /** + * Restores a new asset + * @param {string} workspaceSlug - The workspace slug + * @param {string} src - The asset source + * @returns {Promise} Promise resolving to void + * @throws {Error} If the request fails + */ async restoreNewAsset(workspaceSlug: string, src: string): Promise { // remove the last slash and get the asset id const assetId = getAssetIdFromUrl(src); @@ -84,16 +108,10 @@ export class FileService extends APIService { }); } - async restoreOldEditorAsset(workspaceId: string, src: string): Promise { - const assetKey = getAssetIdFromUrl(src); - return this.post(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/restore/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - + /** + * Cancels the upload + */ cancelUpload() { - this.cancelSource.cancel("Upload cancelled"); + this.cancelSource.cancelUpload(); } } diff --git a/packages/services/src/index.ts b/packages/services/src/index.ts new file mode 100644 index 000000000..3c4908462 --- /dev/null +++ b/packages/services/src/index.ts @@ -0,0 +1,16 @@ +export * from "./ai"; +export * from "./analytics"; +export * from "./developer"; +export * from "./auth"; +export * from "./cycle"; +export * from "./dashboard"; +export * from "./instance"; +export * from "./intake"; +export * from "./module"; +export * from "./user"; +export * from "./project"; +export * from "./workspace"; +export * from "./file"; +export * from "./label"; +export * from "./state"; +export * from "./issue"; diff --git a/packages/services/src/indexedDB.service.ts b/packages/services/src/indexedDB.service.ts new file mode 100644 index 000000000..1b568f2ae --- /dev/null +++ b/packages/services/src/indexedDB.service.ts @@ -0,0 +1,62 @@ +export abstract class IndexedDBService { + private dbName: string; + private version: number; + private db: IDBDatabase | null = null; + + constructor(dbName: string, version: number) { + this.dbName = dbName; + this.version = version; + } + + async init(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.version); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + this.db = request.result; + resolve(); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains("workspaces")) { + db.createObjectStore("workspaces", { keyPath: "id" }); + } + }; + }); + } + + async save(workspaces: any[]): Promise { + if (!this.db) throw new Error("Database not initialized"); + + const transaction = this.db.transaction("workspaces", "readwrite"); + const store = transaction.objectStore("workspaces"); + + return new Promise((resolve, reject) => { + // Clear existing data + store.clear(); + + // Add new workspaces + workspaces.forEach((workspace) => { + store.add(workspace); + }); + + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + }); + } + + async query(): Promise { + if (!this.db) throw new Error("Database not initialized"); + + const transaction = this.db.transaction("workspaces", "readonly"); + const store = transaction.objectStore("workspaces"); + + return new Promise((resolve, reject) => { + const request = store.getAll(); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } +} diff --git a/packages/services/src/instance/index.ts b/packages/services/src/instance/index.ts new file mode 100644 index 000000000..dd3d4a663 --- /dev/null +++ b/packages/services/src/instance/index.ts @@ -0,0 +1 @@ +export * from "./instance.service"; diff --git a/packages/services/src/instance/instance.service.ts b/packages/services/src/instance/instance.service.ts new file mode 100644 index 000000000..637c81cad --- /dev/null +++ b/packages/services/src/instance/instance.service.ts @@ -0,0 +1,125 @@ +// plane imports +import { API_BASE_URL } from "@plane/constants"; +import type { + IFormattedInstanceConfiguration, + IInstance, + IInstanceAdmin, + IInstanceConfiguration, + IInstanceInfo, + TPage, +} from "@plane/types"; +// api service +import { APIService } from "../api.service"; + +/** + * Service class for managing instance-related operations + * Handles retrieval of instance information and changelog + * @extends {APIService} + */ +export class InstanceService extends APIService { + /** + * Creates an instance of InstanceService + * Initializes the service with the base API URL + */ + constructor() { + super(API_BASE_URL); + } + + /** + * Retrieves information about the current instance + * @returns {Promise} Promise resolving to instance information + * @throws {Error} If the API request fails + * @remarks This method uses the validateStatus: null option to bypass interceptors for unauthorized errors. + */ + async info(): Promise { + return this.get("/api/instances/", { validateStatus: null }) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Fetches the changelog for the current instance + * @returns {Promise} Promise resolving to the changelog page data + * @throws {Error} If the API request fails + */ + async changelog(): Promise { + return this.get("/api/instances/changelog/") + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Fetches the list of instance admins + * @returns {Promise} Promise resolving to an array of instance admins + * @throws {Error} If the API request fails + * @remarks This method uses the validateStatus: null option to bypass interceptors for unauthorized errors. + */ + async admins(): Promise { + return this.get("/api/instances/admins/", { validateStatus: null }) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Updates the instance information + * @param {Partial} data Data to update the instance with + * @returns {Promise} Promise resolving to the updated instance information + * @throws {Error} If the API request fails + */ + async update(data: Partial): Promise { + return this.patch("/api/instances/", data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Fetches the list of instance configurations + * @returns {Promise} Promise resolving to an array of instance configurations + * @throws {Error} If the API request fails + */ + async configurations(): Promise { + return this.get("/api/instances/configurations/") + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Updates the instance configurations + * @param {Partial} data Data to update the instance configurations with + * @returns {Promise} The updated instance configurations + * @throws {Error} If the API request fails + */ + async updateConfigurations(data: Partial): Promise { + return this.patch("/api/instances/configurations/", data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Sends a test email to the specified receiver to test SMTP configuration + * @param {string} receiverEmail Email address to send the test email to + * @returns {Promise} Promise resolving to void + * @throws {Error} If the API request fails + */ + async sendTestEmail(receiverEmail: string): Promise { + return this.post("/api/instances/email-credentials-check/", { + receiver_email: receiverEmail, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/intake/index.ts b/packages/services/src/intake/index.ts new file mode 100644 index 000000000..cde9ddbd3 --- /dev/null +++ b/packages/services/src/intake/index.ts @@ -0,0 +1,2 @@ +export * from "./intake.service"; +export * from "./issue.service"; diff --git a/packages/services/src/intake/intake.service.ts b/packages/services/src/intake/intake.service.ts new file mode 100644 index 000000000..1c4a93c18 --- /dev/null +++ b/packages/services/src/intake/intake.service.ts @@ -0,0 +1,8 @@ +import { API_BASE_URL } from "@plane/constants"; +import { APIService } from "../api.service"; + +export default class IntakeService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } +} diff --git a/packages/services/src/intake/issue.service.ts b/packages/services/src/intake/issue.service.ts new file mode 100644 index 000000000..b48328df4 --- /dev/null +++ b/packages/services/src/intake/issue.service.ts @@ -0,0 +1,18 @@ +import { API_BASE_URL } from "@plane/constants"; +import { APIService } from "../api.service"; + +export default class IntakeIssueService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + async list(workspaceSlug: string, projectId: string, params = {}) { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/`, { + params, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/issue/index.ts b/packages/services/src/issue/index.ts new file mode 100644 index 000000000..79d4537cd --- /dev/null +++ b/packages/services/src/issue/index.ts @@ -0,0 +1 @@ +export * from "./sites-issue.service"; diff --git a/packages/services/src/issue/sites-issue.service.ts b/packages/services/src/issue/sites-issue.service.ts new file mode 100644 index 000000000..9b6aa772e --- /dev/null +++ b/packages/services/src/issue/sites-issue.service.ts @@ -0,0 +1,244 @@ +// plane imports +import { API_BASE_URL } from "@plane/constants"; +import { IPublicIssue, TIssuePublicComment, TPublicIssuesResponse } from "@plane/types"; +// api service +import { APIService } from "../api.service"; + +/** + * Service class for managing issues within plane sites application + * Extends the APIService class to handle HTTP requests to the issue-related endpoints + * @extends {APIService} + * @remarks This service is only available for plane sites + */ +export class SitesIssueService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Retrieves a paginated list of issues for a specific anchor + * @param {string} anchor - The anchor identifier + * @param {any} params - Optional query parameters + * @returns {Promise} Promise resolving to a paginated list of issues + * @throws {Error} If the API request fails + */ + async list(anchor: string, params: any): Promise { + return this.get(`/api/public/anchor/${anchor}/issues/`, { + params, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Retrieves details of a specific issue + * @param {string} anchor - The anchor identifier + * @param {string} issueID - The issue identifier + * @returns {Promise} Promise resolving to the issue details + * @throws {Error} If the API request fails + */ + async retrieve(anchor: string, issueID: string): Promise { + return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Retrieves the votes associated with a specific issue + * @param {string} anchor - The anchor identifier + * @param {string} issueID - The issue identifier + * @returns {Promise} Promise resolving to the votes + * @throws {Error} If the API request fails + */ + async listVotes(anchor: string, issueID: string): Promise { + return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/votes/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Creates a new vote for a specific issue + * @param {string} anchor - The anchor identifier + * @param {string} issueID - The issue identifier + * @param {any} data - The vote data + * @returns {Promise} Promise resolving to the created vote + * @throws {Error} If the API request fails + */ + async addVote(anchor: string, issueID: string, data: any): Promise { + return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/votes/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Deletes a vote for a specific issue + * @param {string} anchor - The anchor identifier + * @param {string} issueID - The issue identifier + * @returns {Promise} Promise resolving to the deletion response + * @throws {Error} If the API request fails + */ + async removeVote(anchor: string, issueID: string): Promise { + return this.delete(`/api/public/anchor/${anchor}/issues/${issueID}/votes/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Retrieves the reactions associated with a specific issue + * @param {string} anchor - The anchor identifier + * @param {string} issueID - The issue identifier + * @returns {Promise} Promise resolving to the reactions + * @throws {Error} If the API request fails + */ + async listReactions(anchor: string, issueID: string): Promise { + return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/reactions/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Creates a new reaction for a specific issue + * @param {string} anchor - The anchor identifier + * @param {string} issueID - The issue identifier + * @param {any} data - The reaction data + * @returns {Promise} Promise resolving to the created reaction + * @throws {Error} If the API request fails + */ + async addReaction(anchor: string, issueID: string, data: any): Promise { + return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/reactions/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Deletes a reaction for a specific issue + * @param {string} anchor - The anchor identifier + * @param {string} issueID - The issue identifier + * @param {string} reactionId - The reaction identifier + * @returns {Promise} Promise resolving to the deletion response + * @throws {Error} If the API request fails + */ + async removeReaction(anchor: string, issueID: string, reactionId: string): Promise { + return this.delete(`/api/public/anchor/${anchor}/issues/${issueID}/reactions/${reactionId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Retrieves the comments associated with a specific issue + * @param {string} anchor - The anchor identifier + * @param {string} issueID - The issue identifier + * @returns {Promise} Promise resolving to the comments + * @throws {Error} If the API request fails + */ + async listComments(anchor: string, issueID: string): Promise { + return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/comments/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Creates a new comment for a specific issue + * @param {string} anchor - The anchor identifier + * @param {string} issueID - The issue identifier + * @param {any} data - The comment data + * @returns {Promise} Promise resolving to the created comment + * @throws {Error} If the API request fails + */ + async addComment(anchor: string, issueID: string, data: any): Promise { + return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/comments/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Updates a comment for a specific issue + * @param {string} anchor - The anchor identifier + * @param {string} issueID - The issue identifier + * @param {string} commentId - The comment identifier + * @param {any} data - The updated comment data + * @returns {Promise} Promise resolving to the updated comment + * @throws {Error} If the API request fails + */ + async updateComment(anchor: string, issueID: string, commentId: string, data: any): Promise { + return this.patch(`/api/public/anchor/${anchor}/issues/${issueID}/comments/${commentId}/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Deletes a comment for a specific issue + * @param {string} anchor - The anchor identifier + * @param {string} issueID - The issue identifier + * @param {string} commentId - The comment identifier + * @returns {Promise} Promise resolving to the deletion response + * @throws {Error} If the API request fails + */ + async removeComment(anchor: string, issueID: string, commentId: string): Promise { + return this.delete(`/api/public/anchor/${anchor}/issues/${issueID}/comments/${commentId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Creates a new reaction for a specific comment + * @param {string} anchor - The anchor identifier + * @param {string} commentId - The comment identifier + * @param {any} data - The reaction data + * @returns {Promise} Promise resolving to the created reaction + * @throws {Error} If the API request fails + */ + async addCommentReaction( + anchor: string, + commentId: string, + data: { + reaction: string; + } + ): Promise { + return this.post(`/api/public/anchor/${anchor}/comments/${commentId}/reactions/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Deletes a reaction for a specific comment + * @param {string} anchor - The anchor identifier + * @param {string} commentId - The comment identifier + * @param {string} reactionHex - The reaction identifier + * @returns {Promise} Promise resolving to the deletion response + * @throws {Error} If the API request fails + */ + async removeCommentReaction(anchor: string, commentId: string, reactionHex: string): Promise { + return this.delete(`/api/public/anchor/${anchor}/comments/${commentId}/reactions/${reactionHex}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } +} diff --git a/packages/services/src/label/index.ts b/packages/services/src/label/index.ts new file mode 100644 index 000000000..5bf89fa96 --- /dev/null +++ b/packages/services/src/label/index.ts @@ -0,0 +1 @@ +export * from "./sites-label.service"; diff --git a/packages/services/src/label/sites-label.service.ts b/packages/services/src/label/sites-label.service.ts new file mode 100644 index 000000000..60ad96271 --- /dev/null +++ b/packages/services/src/label/sites-label.service.ts @@ -0,0 +1,31 @@ +// plane imports +import { API_BASE_URL } from "@plane/constants"; +import { IIssueLabel } from "@plane/types"; +// api service +import { APIService } from "../api.service"; + +/** + * Service class for managing labels within plane sites application. + * Extends APIService to handle HTTP requests to the label-related endpoints. + * @extends {APIService} + * @remarks This service is only available for plane sites + */ +export class SitesLabelService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Retrieves a list of labels for a specific anchor. + * @param {string} anchor - The anchor identifier + * @returns {Promise} The list of labels + * @throws {Error} If the API request fails + */ + async list(anchor: string): Promise { + return this.get(`/api/public/anchor/${anchor}/labels/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/live.service.ts b/packages/services/src/live.service.ts new file mode 100644 index 000000000..ae4b80864 --- /dev/null +++ b/packages/services/src/live.service.ts @@ -0,0 +1,8 @@ +import { API_BASE_URL } from "@plane/constants"; +import { APIService } from "./api.service"; + +export abstract class LiveService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } +} diff --git a/packages/services/src/module/index.ts b/packages/services/src/module/index.ts new file mode 100644 index 000000000..ad4921228 --- /dev/null +++ b/packages/services/src/module/index.ts @@ -0,0 +1,4 @@ +export * from "./link.service"; +export * from "./module.service"; +export * from "./operations.service"; +export * from "./sites-module.service"; diff --git a/packages/services/src/module/link.service.ts b/packages/services/src/module/link.service.ts new file mode 100644 index 000000000..8c8ec3f47 --- /dev/null +++ b/packages/services/src/module/link.service.ts @@ -0,0 +1,86 @@ +// types +import type { ILinkDetails, ModuleLink } from "@plane/types"; +// services +import { APIService } from "../api.service"; + +/** + * Service class for handling module link related operations. + * Extends the base APIService class to interact with module link endpoints. + */ +export class ModuleLinkService extends APIService { + /** + * Creates an instance of ModuleLinkService. + * @param {string} baseURL - The base URL for the API endpoints + */ + constructor(baseURL: string) { + super(baseURL); + } + + /** + * Creates a new module link. + * @param {string} workspaceSlug - The unique identifier for the workspace + * @param {string} projectId - The unique identifier for the project + * @param {string} moduleId - The unique identifier for the module + * @param {Partial} data - The module link data to be created + * @returns {Promise} The created module link details + * @throws {Error} When the API request fails + */ + async create( + workspaceSlug: string, + projectId: string, + moduleId: string, + data: Partial + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-links/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Updates an existing module link. + * @param {string} workspaceSlug - The unique identifier for the workspace + * @param {string} projectId - The unique identifier for the project + * @param {string} moduleId - The unique identifier for the module + * @param {string} linkId - The unique identifier for the link to update + * @param {Partial} data - The module link data to be updated + * @returns {Promise} The updated module link details + * @throws {Error} When the API request fails + */ + async update( + workspaceSlug: string, + projectId: string, + moduleId: string, + linkId: string, + data: Partial + ): Promise { + return this.patch( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-links/${linkId}/`, + data + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Deletes a module link. + * @param {string} workspaceSlug - The unique identifier for the workspace + * @param {string} projectId - The unique identifier for the project + * @param {string} moduleId - The unique identifier for the module + * @param {string} linkId - The unique identifier for the link to delete + * @returns {Promise} Response data from the server + * @throws {Error} When the API request fails + */ + async destroy(workspaceSlug: string, projectId: string, moduleId: string, linkId: string): Promise { + return this.delete( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-links/${linkId}/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/module/module.service.ts b/packages/services/src/module/module.service.ts new file mode 100644 index 000000000..21321b36e --- /dev/null +++ b/packages/services/src/module/module.service.ts @@ -0,0 +1,212 @@ +// types +import type { IModule, ILinkDetails, ModuleLink, TIssuesResponse } from "@plane/types"; +// services +import { APIService } from "../api.service"; + +export class ModuleService extends APIService { + constructor(baseURL: string) { + super(baseURL); + } + + async workspaceModulesList(workspaceSlug: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/modules/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async projectModulesList(workspaceSlug: string, projectId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async create(workspaceSlug: string, projectId: string, data: any): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async retrieve(workspaceSlug: string, projectId: string, moduleId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + // async update(workspaceSlug: string, projectId: string, moduleId: string, data: any): Promise { + // return this.put(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/`, data) + // .then((response) => response?.data) + // .catch((error) => { + // throw error?.response?.data; + // }); + // } + + async update(workspaceSlug: string, projectId: string, moduleId: string, data: Partial): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async destroy(workspaceSlug: string, projectId: string, moduleId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async getModuleIssues( + workspaceSlug: string, + projectId: string, + moduleId: string, + queries?: any, + config = {} + ): Promise { + return this.get( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/`, + { + params: queries, + }, + config + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async addIssuesToModule( + workspaceSlug: string, + projectId: string, + moduleId: string, + data: { issues: string[] } + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async addModulesToIssue( + workspaceSlug: string, + projectId: string, + issueId: string, + data: { modules: string[]; removed_modules?: string[] } + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/modules/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async removeIssuesFromModuleBulk( + workspaceSlug: string, + projectId: string, + moduleId: string, + issueIds: string[] + ): Promise { + const promiseDataUrls: any = []; + issueIds.forEach((issueId) => { + promiseDataUrls.push( + this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/${issueId}/`) + ); + }); + await Promise.all(promiseDataUrls) + .then((response) => response) + .catch((error) => { + throw error?.response?.data; + }); + } + + async removeModulesFromIssueBulk( + workspaceSlug: string, + projectId: string, + issueId: string, + moduleIds: string[] + ): Promise { + const promiseDataUrls: any = []; + moduleIds.forEach((moduleId) => { + promiseDataUrls.push( + this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/${issueId}/`) + ); + }); + await Promise.all(promiseDataUrls) + .then((response) => response) + .catch((error) => { + throw error?.response?.data; + }); + } + + async createModuleLink( + workspaceSlug: string, + projectId: string, + moduleId: string, + data: Partial + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-links/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async updateModuleLink( + workspaceSlug: string, + projectId: string, + moduleId: string, + linkId: string, + data: Partial + ): Promise { + return this.patch( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-links/${linkId}/`, + data + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async deleteModuleLink(workspaceSlug: string, projectId: string, moduleId: string, linkId: string): Promise { + return this.delete( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-links/${linkId}/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async addModuleToFavorites( + workspaceSlug: string, + projectId: string, + data: { + module: string; + } + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-modules/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async removeModuleFromFavorites(workspaceSlug: string, projectId: string, moduleId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-modules/${moduleId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/module/operations.service.ts b/packages/services/src/module/operations.service.ts new file mode 100644 index 000000000..9185873da --- /dev/null +++ b/packages/services/src/module/operations.service.ts @@ -0,0 +1,146 @@ +// types +// import type { IModule, ILinkDetails, ModuleLink, TIssuesResponse } from "@plane/types"; +// services +import { APIService } from "../api.service"; + +export class ModuleOperationService extends APIService { + constructor(baseURL: string) { + super(baseURL); + } + + /** + * Add issues to a module + * @param {string} workspaceSlug - The slug of the workspace + * @param {string} projectId - The ID of the project + * @param {string} moduleId - The ID of the module + * @param {object} data - The data to be sent in the request body + * @param {string[]} data.issues - The IDs of the issues to be added + * @returns {Promise} + */ + async addIssuesToModule( + workspaceSlug: string, + projectId: string, + moduleId: string, + data: { issues: string[] } + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Add modules to an issue + * @param {string} workspaceSlug - The slug of the workspace + * @param {string} projectId - The ID of the project + * @param {string} issueId - The ID of the issue + * @param {object} data - The data to be sent in the request body + * @param {string[]} data.modules - The IDs of the modules to be added + * @param {string[]} [data.removed_modules] - The IDs of the modules to be removed + * @returns {Promise} + */ + async addModulesToIssue( + workspaceSlug: string, + projectId: string, + issueId: string, + data: { modules: string[]; removed_modules?: string[] } + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/modules/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Remove issues from a module + * @param {string} workspaceSlug - The slug of the workspace + * @param {string} projectId - The ID of the project + * @param {string} moduleId - The ID of the module + * @param {string[]} issueIds - The IDs of the issues to be removed + * @returns {Promise} + */ + async removeIssuesFromModuleBulk( + workspaceSlug: string, + projectId: string, + moduleId: string, + issueIds: string[] + ): Promise { + const promiseDataUrls: any = []; + issueIds.forEach((issueId) => { + promiseDataUrls.push( + this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/${issueId}/`) + ); + }); + await Promise.all(promiseDataUrls) + .then((response) => response) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Remove modules from an issue + * @param {string} workspaceSlug - The slug of the workspace + * @param {string} projectId - The ID of the project + * @param {string} issueId - The ID of the issue + * @param {string[]} moduleIds - The IDs of the modules to be removed + * @returns {Promise} + */ + async removeModulesFromIssueBulk( + workspaceSlug: string, + projectId: string, + issueId: string, + moduleIds: string[] + ): Promise { + const promiseDataUrls: any = []; + moduleIds.forEach((moduleId) => { + promiseDataUrls.push( + this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/${issueId}/`) + ); + }); + await Promise.all(promiseDataUrls) + .then((response) => response) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Add a module to favorites + * @param {string} workspaceSlug - The slug of the workspace + * @param {string} projectId - The ID of the project + * @param {object} data - The data to be sent in the request body + * @param {string} data.module - The ID of the module to be added + * @returns {Promise} + */ + async addModuleToFavorites( + workspaceSlug: string, + projectId: string, + data: { + module: string; + } + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-modules/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Remove a module from favorites + * @param {string} workspaceSlug - The slug of the workspace + * @param {string} projectId - The ID of the project + * @param {string} moduleId - The ID of the module to be removed + * @returns {Promise} + */ + async removeModuleFromFavorites(workspaceSlug: string, projectId: string, moduleId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-modules/${moduleId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/module/sites-module.service.ts b/packages/services/src/module/sites-module.service.ts new file mode 100644 index 000000000..333535ac3 --- /dev/null +++ b/packages/services/src/module/sites-module.service.ts @@ -0,0 +1,31 @@ +// plane imports +import { API_BASE_URL } from "@plane/constants"; +// api service +import { TPublicModule } from "@plane/types"; +import { APIService } from "../api.service"; + +/** + * Service class for managing modules within plane sites application. + * Extends APIService to handle HTTP requests to the module-related endpoints. + * @extends {APIService} + * @remarks This service is only available for plane sites + */ +export class SitesModuleService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Retrieves a list of modules for a specific anchor. + * @param {string} anchor - The anchor identifier + * @returns {Promise} The list of modules + * @throws {Error} If the API request fails + */ + async list(anchor: string): Promise { + return this.get(`/api/public/anchor/${anchor}/modules/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/project/index.ts b/packages/services/src/project/index.ts new file mode 100644 index 000000000..4cc54d2c2 --- /dev/null +++ b/packages/services/src/project/index.ts @@ -0,0 +1,2 @@ +export * from "./view.service"; +export * from "./sites-publish.service"; diff --git a/packages/services/src/project/sites-publish.service.ts b/packages/services/src/project/sites-publish.service.ts new file mode 100644 index 000000000..52d42f5ee --- /dev/null +++ b/packages/services/src/project/sites-publish.service.ts @@ -0,0 +1,46 @@ +// plane imports +import { API_BASE_URL } from "@plane/constants"; +import { TProjectPublishSettings } from "@plane/types"; +// api service +import { APIService } from "../api.service"; + +/** + * Service class for managing project publish operations within plane sites application. + * Extends APIService to handle HTTP requests to the project publish-related endpoints. + * @extends {APIService} + * @remarks This service is only available for plane sites + */ +export class SitesProjectPublishService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Retrieves publish settings for a specific anchor. + * @param {string} anchor - The anchor identifier + * @returns {Promise} The publish settings + * @throws {Error} If the API request fails + */ + async retrieveSettingsByAnchor(anchor: string): Promise { + return this.get(`/api/public/anchor/${anchor}/settings/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Retrieves publish settings for a specific project. + * @param {string} workspaceSlug - The workspace slug + * @param {string} projectID - The project identifier + * @returns {Promise} The publish settings + * @throws {Error} If the API request fails + */ + async retrieveSettingsByProjectId(workspaceSlug: string, projectID: string): Promise { + return this.get(`/api/public/workspaces/${workspaceSlug}/projects/${projectID}/anchor/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } +} diff --git a/packages/services/src/project/view.service.ts b/packages/services/src/project/view.service.ts new file mode 100644 index 000000000..7eef71911 --- /dev/null +++ b/packages/services/src/project/view.service.ts @@ -0,0 +1,14 @@ +// plane imports +import { API_BASE_URL } from "@plane/constants"; +// api services +import { APIService } from "../api.service"; + +export class ProjectViewService extends APIService { + /** + * Creates an instance of ProjectViewService + * @param {string} baseUrl - The base URL for API requests + */ + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } +} diff --git a/packages/services/src/state/index.ts b/packages/services/src/state/index.ts new file mode 100644 index 000000000..5b49aad0e --- /dev/null +++ b/packages/services/src/state/index.ts @@ -0,0 +1 @@ +export * from "./sites-state.service"; diff --git a/packages/services/src/state/sites-state.service.ts b/packages/services/src/state/sites-state.service.ts new file mode 100644 index 000000000..98ff466d0 --- /dev/null +++ b/packages/services/src/state/sites-state.service.ts @@ -0,0 +1,31 @@ +// plane imports +import { API_BASE_URL } from "@plane/constants"; +import { IState } from "@plane/types"; +// api service +import { APIService } from "../api.service"; + +/** + * Service class for managing states within plane sites application. + * Extends APIService to handle HTTP requests to the state-related endpoints. + * @extends {APIService} + * @remarks This service is only available for plane sites + */ +export class SitesStateService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Retrieves a list of states for a specific anchor. + * @param {string} anchor - The anchor identifier + * @returns {Promise} The list of states + * @throws {Error} If the API request fails + */ + async list(anchor: string): Promise { + return this.get(`/api/public/anchor/${anchor}/states/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/user/favorite.service.ts b/packages/services/src/user/favorite.service.ts new file mode 100644 index 000000000..0c6e0497f --- /dev/null +++ b/packages/services/src/user/favorite.service.ts @@ -0,0 +1,94 @@ +import { API_BASE_URL } from "@plane/constants"; +import type { IFavorite } from "@plane/types"; +import { APIService } from "../api.service"; + +/** + * Service class for managing user favorites + * Handles operations for adding, updating, removing, and retrieving user favorites within a workspace + * @extends {APIService} + */ +export class UserFavoriteService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Adds a new item to user favorites + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @param {Partial} data - Favorite item data to be added + * @returns {Promise} Promise resolving to the created favorite item + * @throws {Error} If the API request fails + */ + async add(workspaceSlug: string, data: Partial): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/user-favorites/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Updates an existing favorite item + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @param {string} favoriteId - The unique identifier for the favorite item + * @param {Partial} data - Updated favorite item data + * @returns {Promise} Promise resolving to the updated favorite item + * @throws {Error} If the API request fails + */ + async update(workspaceSlug: string, favoriteId: string, data: Partial): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/user-favorites/${favoriteId}/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Removes an item from user favorites + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @param {string} favoriteId - The unique identifier for the favorite item to remove + * @returns {Promise} Promise resolving when the favorite item is removed + * @throws {Error} If the API request fails + */ + async remove(workspaceSlug: string, favoriteId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/user-favorites/${favoriteId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Retrieves all favorite items for a user in a workspace + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @returns {Promise} Promise resolving to array of favorite items + * @throws {Error} If the API request fails + * @remarks This method includes the 'all' parameter to retrieve all favorites + */ + async list(workspaceSlug: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/user-favorites/`, { + params: { + all: true, + }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Retrieves grouped favorite items for a specific favorite in a workspace + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @param {string} favoriteId - The unique identifier for the favorite item to get grouped items for + * @returns {Promise} Promise resolving to array of grouped favorite items + * @throws {Error} If the API request fails + */ + async groupedList(workspaceSlug: string, favoriteId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/user-favorites/${favoriteId}/group/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/user/index.ts b/packages/services/src/user/index.ts new file mode 100644 index 000000000..f01a1a47b --- /dev/null +++ b/packages/services/src/user/index.ts @@ -0,0 +1,3 @@ +export * from "./favorite.service"; +export * from "./user.service"; +export * from "./sites-member.service"; diff --git a/packages/services/src/user/sites-member.service.ts b/packages/services/src/user/sites-member.service.ts new file mode 100644 index 000000000..45cb80626 --- /dev/null +++ b/packages/services/src/user/sites-member.service.ts @@ -0,0 +1,31 @@ +// plane imports +import { API_BASE_URL } from "@plane/constants"; +import { TPublicMember } from "@plane/types"; +// api service +import { APIService } from "../api.service"; + +/** + * Service class for managing members operations within plane sites application. + * Extends APIService to handle HTTP requests to the member-related endpoints. + * @extends {APIService} + * @remarks This service is only available for plane sites + */ +export class SitesMemberService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Retrieves a list of members for a specific anchor. + * @param {string} anchor - The anchor identifier + * @returns {Promise} The list of members + * @throws {Error} If the API request fails + */ + async list(anchor: string): Promise { + return this.get(`/api/public/anchor/${anchor}/members/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/user/user.service.ts b/packages/services/src/user/user.service.ts new file mode 100644 index 000000000..c302b1d4f --- /dev/null +++ b/packages/services/src/user/user.service.ts @@ -0,0 +1,88 @@ +// plane imports +import { API_BASE_URL } from "@plane/constants"; +import type { IUser, TUserProfile } from "@plane/types"; +// api service +import { APIService } from "../api.service"; + +/** + * Service class for managing user operations + * Handles operations for retrieving the current user's details and perform CRUD operations + * @extends {APIService} + */ +export class UserService extends APIService { + /** + * Constructor for UserService + * @param BASE_URL - Base URL for API requests + */ + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Retrieves the current user details + * @returns {Promise} Promise resolving to the current user details\ + * @remarks This method uses the validateStatus: null option to bypass interceptors for unauthorized errors. + */ + async me(): Promise { + return this.get("/api/users/me/", { validateStatus: null }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Updates the current user details + * @param {Partial} data Data to update the user with + * @returns {Promise} Promise resolving to the updated user details + * @throws {Error} If the API request fails + */ + async update(data: Partial): Promise { + return this.patch("/api/users/me/", data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Retrieves the current user's profile details + * @returns {Promise} Promise resolving to the current user's profile details + * @throws {Error} If the API request fails + */ + async profile(): Promise { + return this.get("/api/users/me/profile/") + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Updates the current user's profile details + * @param {Partial} data Data to update the user's profile with + * @returns {Promise} Promise resolving to the updated user's profile details + * @throws {Error} If the API request fails + */ + async updateProfile(data: Partial): Promise { + return this.patch("/api/users/me/profile/", data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Retrieves the current instance admin details + * @returns {Promise} Promise resolving to the current instance admin details + * @throws {Error} If the API request fails + * @remarks This method uses the validateStatus: null option to bypass interceptors for unauthorized errors. + */ + async adminDetails(): Promise { + return this.get("/api/instances/admins/me/", { validateStatus: null }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/workspace/index.ts b/packages/services/src/workspace/index.ts new file mode 100644 index 000000000..a48efdee2 --- /dev/null +++ b/packages/services/src/workspace/index.ts @@ -0,0 +1,6 @@ +export * from "./invitation.service"; +export * from "./member.service"; +export * from "./notification.service"; +export * from "./view.service"; +export * from "./workspace.service"; +export * from "./instance-workspace.service"; diff --git a/packages/services/src/workspace/instance-workspace.service.ts b/packages/services/src/workspace/instance-workspace.service.ts new file mode 100644 index 000000000..7ac3d4ce4 --- /dev/null +++ b/packages/services/src/workspace/instance-workspace.service.ts @@ -0,0 +1,65 @@ +import { API_BASE_URL } from "@plane/constants"; +import type { IWorkspace, TWorkspacePaginationInfo } from "@plane/types"; +import { APIService } from "../api.service"; + +/** + * Service class for managing instance workspaces + * Handles CRUD operations on instance workspaces + * @extends APIService + */ +export class InstanceWorkspaceService extends APIService { + /** + * Constructor for InstanceWorkspaceService + * @param BASE_URL - Base URL for API requests + */ + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Retrieves a paginated list of workspaces for the current instance + * @param {string} nextPageCursor - Optional cursor to retrieve the next page of results + * @returns {Promise} Promise resolving to a paginated list of workspaces + * @throws {Error} If the API request fails + */ + async list(nextPageCursor?: string): Promise { + return this.get(`/api/instances/workspaces/`, { + params: { + cursor: nextPageCursor, + }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Checks if a workspace slug is available + * @param {string} slug - The workspace slug to check + * @returns {Promise} Promise resolving to slug availability status + * @throws {Error} If the API request fails + */ + async slugCheck(slug: string): Promise { + const params = new URLSearchParams({ slug }); + return this.get(`/api/instances/workspace-slug-check/?${params.toString()}`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Creates a new workspace + * @param {Partial} data - Workspace data for creation + * @returns {Promise} Promise resolving to the created workspace + * @throws {Error} If the API request fails + */ + async create(data: Partial): Promise { + return this.post("/api/instances/workspaces/", data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/workspace/invitation.service.ts b/packages/services/src/workspace/invitation.service.ts new file mode 100644 index 000000000..46088371f --- /dev/null +++ b/packages/services/src/workspace/invitation.service.ts @@ -0,0 +1,117 @@ +import { API_BASE_URL } from "@plane/constants"; +import { IWorkspaceMemberInvitation, IWorkspaceBulkInviteFormData, IWorkspaceMember } from "@plane/types"; +import { APIService } from "../api.service"; + +/** + * Service class for managing workspace invitations + * Handles operations related to inviting users to workspaces and managing invitations + * @extends {APIService} + */ +export class WorkspaceInvitationService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Retrieves all workspace invitations for the current user + * @returns {Promise} Promise resolving to array of workspace invitations + * @throws {Error} If the API request fails + */ + async userInvitations(): Promise { + return this.get("/api/users/me/workspaces/invitations/") + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Retrieves all invitations for a specific workspace + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @returns {Promise} Promise resolving to array of workspace invitations + * @throws {Error} If the API request fails + */ + async workspaceInvitations(workspaceSlug: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/invitations/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Sends bulk invitations to users for a workspace + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @param {IWorkspaceBulkInviteFormData} data - Bulk invitation data containing user information + * @returns {Promise} Promise resolving to the invitation response + * @throws {Error} If the API request fails + */ + async invite(workspaceSlug: string, data: IWorkspaceBulkInviteFormData): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/invitations/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Update Invitation + * @param workspaceSlug + * @param invitationId + * @param data + * @returns + */ + async update(workspaceSlug: string, invitationId: string, data: Partial): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/invitations/${invitationId}/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Delete Workspace invitation + * @param workspaceSlug + * @param invitationId + * @returns + */ + async destroy(workspaceSlug: string, invitationId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/invitations/${invitationId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Accepts an invitation to join a workspace + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @param {string} invitationId - The unique identifier for the invitation + * @param {any} data - Additional data required for joining the workspace + * @returns {Promise} Promise resolving to the join response + * @throws {Error} If the API request fails + */ + async join(workspaceSlug: string, invitationId: string, data: any): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/invitations/${invitationId}/join/`, data, { + headers: {}, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Accepts multiple workspace invitations at once + * @param {any} data - Data containing information about invitations to accept + * @returns {Promise} Promise resolving to the bulk join response + * @throws {Error} If the API request fails + */ + async joinMany(data: any): Promise { + return this.post("/api/users/me/workspaces/invitations/", data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/workspace/member.service.ts b/packages/services/src/workspace/member.service.ts new file mode 100644 index 000000000..e92225af3 --- /dev/null +++ b/packages/services/src/workspace/member.service.ts @@ -0,0 +1,92 @@ +import { API_BASE_URL } from "@plane/constants"; +import { IWorkspaceMemberMe, IWorkspaceMember, IUserProjectsRole } from "@plane/types"; +import { APIService } from "../api.service"; + +/** + * Service class for managing workspace members + * Handles operations related to workspace membership, including member information, + * updates, deletions, and role management + * @extends {APIService} + */ +export class WorkspaceMemberService extends APIService { + /** + * Creates an instance of WorkspaceMemberService + * @param {string} baseUrl - The base URL for API requests + */ + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Retrieves current user's information for a specific workspace + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @returns {Promise} Promise resolving to current user's workspace member information + * @throws {Error} If the API request fails + */ + async myInfo(workspaceSlug: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/workspace-members/me/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Retrieves all members of a specific workspace + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @returns {Promise} Promise resolving to array of workspace members + * @throws {Error} If the API request fails + */ + async list(workspaceSlug: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/members/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Updates a workspace member's information + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @param {string} memberId - The unique identifier for the member + * @param {Partial} data - Updated member data + * @returns {Promise} Promise resolving to the updated member information + * @throws {Error} If the API request fails + */ + async update(workspaceSlug: string, memberId: string, data: Partial): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/members/${memberId}/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Removes a member from a workspace + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @param {string} memberId - The unique identifier for the member to remove + * @returns {Promise} Promise resolving to the deletion response + * @throws {Error} If the API request fails + */ + async destroy(workspaceSlug: string, memberId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/members/${memberId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Retrieves the current user's project roles within a workspace + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @returns {Promise} Promise resolving to user's project roles + * @throws {Error} If the API request fails + */ + async getWorkspaceUserProjectsRole(workspaceSlug: string): Promise { + return this.get(`/api/users/me/workspaces/${workspaceSlug}/project-roles/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/workspace/notification.service.ts b/packages/services/src/workspace/notification.service.ts new file mode 100644 index 000000000..a21f63222 --- /dev/null +++ b/packages/services/src/workspace/notification.service.ts @@ -0,0 +1,137 @@ +import { API_BASE_URL } from "@plane/constants"; +import { + TUnreadNotificationsCount, + TNotificationPaginatedInfo, + TNotification, + TNotificationPaginatedInfoQueryParams, +} from "@plane/types"; +// services +import { APIService } from "../api.service"; + +export class WorkspaceNotificationService extends APIService { + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + /** + * Retrieves the count of unread notifications for a workspace + * @param {string} workspaceSlug - The unique identifier for the workspace + * @returns {Promise} The count of unread notifications + */ + async getUnreadCount(workspaceSlug: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/users/notifications/unread/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Retrieves paginated notifications for a workspace + * @param {string} workspaceSlug - The unique identifier for the workspace + * @param {TNotificationPaginatedInfoQueryParams} params - Query parameters for pagination and filtering + * @returns {Promise} Paginated list of notifications + */ + async list( + workspaceSlug: string, + params: TNotificationPaginatedInfoQueryParams + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/users/notifications`, { params }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Updates a specific notification by ID + * @param {string} workspaceSlug - The unique identifier for the workspace + * @param {string} notificationId - The unique identifier for the notification + * @param {Partial} data - The notification data to update + * @returns {Promise} The updated notification + */ + async update( + workspaceSlug: string, + notificationId: string, + data: Partial + ): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Marks a notification as read + * @param {string} workspaceSlug - The unique identifier for the workspace + * @param {string} notificationId - The unique identifier for the notification + * @returns {Promise} The updated notification + */ + async markAsRead(workspaceSlug: string, notificationId: string): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/read/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Marks a notification as unread + * @param {string} workspaceSlug - The unique identifier for the workspace + * @param {string} notificationId - The unique identifier for the notification + * @returns {Promise} The updated notification + */ + async markAsUnread(workspaceSlug: string, notificationId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/read/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Archives a notification + * @param {string} workspaceSlug - The unique identifier for the workspace + * @param {string} notificationId - The unique identifier for the notification + * @returns {Promise} The updated notification + */ + async archive(workspaceSlug: string, notificationId: string): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/archive/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Unarchives a notification + * @param {string} workspaceSlug - The unique identifier for the workspace + * @param {string} notificationId - The unique identifier for the notification + * @returns {Promise} The updated notification + */ + async unarchive(workspaceSlug: string, notificationId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/archive/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Marks all notifications as read based on filter criteria + * @param {string} workspaceSlug - The unique identifier for the workspace + * @param {TNotificationPaginatedInfoQueryParams} data - Filter criteria for notifications to mark as read + * @returns {Promise} The result of the operation + */ + async markAllAsRead( + workspaceSlug: string, + data: TNotificationPaginatedInfoQueryParams + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/users/notifications/mark-all-read/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/workspace/view.service.ts b/packages/services/src/workspace/view.service.ts new file mode 100644 index 000000000..0ea21f742 --- /dev/null +++ b/packages/services/src/workspace/view.service.ts @@ -0,0 +1,67 @@ +import { API_BASE_URL } from "@plane/constants"; +import { IWorkspaceView, TIssuesResponse } from "@plane/types"; +import { APIService } from "../api.service"; + +export class WorkspaceViewService extends APIService { + /** + * Creates an instance of WorkspaceViewService + * @param {string} baseUrl - The base URL for API requests + */ + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + + async create(workspaceSlug: string, data: Partial): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/views/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async update(workspaceSlug: string, viewId: string, data: Partial): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/views/${viewId}/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async destroy(workspaceSlug: string, viewId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/views/${viewId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async list(workspaceSlug: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/views/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async retrieve(workspaceSlug: string, viewId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/views/${viewId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async getViewIssues(workspaceSlug: string, params: any, config = {}): Promise { + return this.get( + `/api/workspaces/${workspaceSlug}/issues/`, + { + params, + }, + config + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/src/workspace/workspace.service.ts b/packages/services/src/workspace/workspace.service.ts new file mode 100644 index 000000000..72ef9fcef --- /dev/null +++ b/packages/services/src/workspace/workspace.service.ts @@ -0,0 +1,141 @@ +import { API_BASE_URL } from "@plane/constants"; +import { IWorkspace, ILastActiveWorkspaceDetails, IWorkspaceSearchResults } from "@plane/types"; +import { APIService } from "../api.service"; + +/** + * Service class for managing workspace operations + * Handles CRUD operations and various workspace-related functionalities + * @extends {APIService} + */ +export class WorkspaceService extends APIService { + /** + * Creates an instance of WorkspaceService + * @param {string} baseUrl - The base URL for API requests + */ + constructor(BASE_URL?: string) { + super(BASE_URL || API_BASE_URL); + } + /** + * Retrieves all workspaces for the current user + * @returns {Promise} Promise resolving to an array of workspaces + * @throws {Error} If the API request fails + */ + async list(): Promise { + return this.get("/api/users/me/workspaces/") + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Retrieves details of a specific workspace + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @returns {Promise} Promise resolving to workspace details + * @throws {Error} If the API request fails + */ + async retrieve(workspaceSlug: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + /** + * Creates a new workspace + * @param {Partial} data - Workspace data for creation + * @returns {Promise} Promise resolving to the created workspace + * @throws {Error} If the API request fails + */ + async create(data: Partial): Promise { + return this.post("/api/workspaces/", data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Updates an existing workspace + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @param {Partial} data - Updated workspace data + * @returns {Promise} Promise resolving to the updated workspace + * @throws {Error} If the API request fails + */ + async update(workspaceSlug: string, data: Partial): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Deletes a workspace + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @returns {Promise} Promise resolving to the deletion response + * @throws {Error} If the API request fails + */ + async destroy(workspaceSlug: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Retrieves information about the user's last visited workspace + * @returns {Promise} Promise resolving to last active workspace details + * @throws {Error} If the API request fails + */ + async lastVisited(): Promise { + return this.get("/api/users/last-visited-workspace/") + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Checks if a workspace slug is available + * @param {string} slug - The workspace slug to check + * @returns {Promise} Promise resolving to slug availability status + * @throws {Error} If the API request fails + */ + async slugCheck(slug: string): Promise { + return this.get(`/api/workspace-slug-check/?slug=${slug}`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * Searches within a workspace + * @param {string} workspaceSlug - The unique slug identifier for the workspace + * @param {Object} params - Search parameters + * @param {string} [params.project_id] - Optional project ID to scope the search + * @param {string} params.search - Search query string + * @param {boolean} params.workspace_search - Whether to search across the entire workspace + * @returns {Promise} Promise resolving to search results + * @throws {Error} If the API request fails + */ + async search( + workspaceSlug: string, + params: { + project_id?: string; + search: string; + workspace_search: boolean; + } + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/search/`, { + params, + }) + .then((res) => res?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/services/tsconfig.json b/packages/services/tsconfig.json new file mode 100644 index 000000000..efce2a9fe --- /dev/null +++ b/packages/services/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@plane/typescript-config/react-library.json", + "compilerOptions": { + "jsx": "react", + "lib": ["esnext", "dom"], + }, + "include": ["./src"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/shared-state/.eslintignore b/packages/shared-state/.eslintignore new file mode 100644 index 000000000..6019047c3 --- /dev/null +++ b/packages/shared-state/.eslintignore @@ -0,0 +1,3 @@ +build/* +dist/* +out/* \ No newline at end of file diff --git a/packages/shared-state/.eslintrc.js b/packages/shared-state/.eslintrc.js new file mode 100644 index 000000000..558b8f76e --- /dev/null +++ b/packages/shared-state/.eslintrc.js @@ -0,0 +1,9 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ["@plane/eslint-config/library.js"], + parser: "@typescript-eslint/parser", + parserOptions: { + project: true, + }, +}; diff --git a/packages/shared-state/.prettierrc b/packages/shared-state/.prettierrc new file mode 100644 index 000000000..87d988f1b --- /dev/null +++ b/packages/shared-state/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/packages/shared-state/package.json b/packages/shared-state/package.json new file mode 100644 index 000000000..10e802c67 --- /dev/null +++ b/packages/shared-state/package.json @@ -0,0 +1,20 @@ +{ + "name": "@plane/shared-state", + "version": "0.24.1", + "description": "Shared state shared across multiple apps internally", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "lint": "eslint src --ext .ts,.tsx", + "lint:errors": "eslint src --ext .ts,.tsx --quiet" + }, + "dependencies": { + "zod": "^3.22.2" + }, + "devDependencies": { + "@plane/eslint-config": "*", + "@types/node": "^22.5.4", + "typescript": "^5.3.3" + } +} diff --git a/packages/shared-state/src/index.ts b/packages/shared-state/src/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/shared-state/src/store/user.store.ts b/packages/shared-state/src/store/user.store.ts new file mode 100644 index 000000000..f7f30f5ba --- /dev/null +++ b/packages/shared-state/src/store/user.store.ts @@ -0,0 +1,136 @@ +import { makeObservable, observable } from "mobx"; +import { IWorkspaceStore } from "./workspace.store"; + +export interface IUserStore { + user: any; + workspaces: Map; + isLoading: boolean; + error: any; +} + +export class UserStore implements IUserStore { + user: any = null; + workspaces: Map = new Map(); + isLoading: boolean = false; + error: any = null; + + constructor() { + makeObservable(this, { + user: observable.ref, + workspaces: observable, + isLoading: observable.ref, + error: observable.ref, + }); + } +} + +// userStore.ts + +// class UserStore { +// user: User | null = null; +// workspaces: Workspace[] = []; +// isLoading = false; +// error: string | null = null; +// private indexedDBService: IndexedDBService; + +// constructor() { +// makeAutoObservable(this); +// this.indexedDBService = new IndexedDBService(); +// this.init(); +// } + +// private async init() { +// try { +// await this.indexedDBService.init(); +// await this.loadWorkspacesFromIndexedDB(); +// } catch (error) { +// runInAction(() => { +// this.error = "Failed to initialize store"; +// console.error("Store initialization error:", error); +// }); +// } +// } + +// setUser(user: User | null) { +// this.user = user; +// } + +// async loadWorkspacesFromIndexedDB() { +// try { +// const workspaces = await this.indexedDBService.getWorkspaces(); +// runInAction(() => { +// this.workspaces = workspaces; +// }); +// } catch (error) { +// runInAction(() => { +// this.error = "Failed to load workspaces from IndexedDB"; +// console.error("Load workspaces error:", error); +// }); +// } +// } + +// async fetchAndSyncWorkspaces() { +// this.isLoading = true; +// this.error = null; + +// try { +// // Simulate API call to fetch workspaces +// const response = await fetch("/api/workspaces"); +// const workspaces = await response.json(); + +// // Save to IndexedDB +// await this.indexedDBService.saveWorkspaces(workspaces); + +// // Update MobX store +// runInAction(() => { +// this.workspaces = workspaces; +// this.isLoading = false; +// }); +// } catch (error) { +// runInAction(() => { +// this.error = "Failed to fetch workspaces"; +// this.isLoading = false; +// console.error("Fetch workspaces error:", error); +// }); +// } +// } + +// // Additional methods for workspace management +// async addWorkspace(workspace: Omit) { +// this.isLoading = true; +// this.error = null; + +// try { +// // Simulate API call to create workspace +// const response = await fetch("/api/workspaces", { +// method: "POST", +// body: JSON.stringify(workspace), +// }); +// const newWorkspace = await response.json(); + +// // Update local storage and state +// const updatedWorkspaces = [...this.workspaces, newWorkspace]; +// await this.indexedDBService.saveWorkspaces(updatedWorkspaces); + +// runInAction(() => { +// this.workspaces.push(newWorkspace); +// this.isLoading = false; +// }); +// } catch (error) { +// runInAction(() => { +// this.error = "Failed to add workspace"; +// this.isLoading = false; +// console.error("Add workspace error:", error); +// }); +// } +// } + +// logout() { +// this.user = null; +// this.workspaces = []; +// // Optionally clear IndexedDB data +// this.indexedDBService.init().then(() => { +// this.indexedDBService.saveWorkspaces([]); +// }); +// } +// } diff --git a/packages/shared-state/src/store/workspace.store.ts b/packages/shared-state/src/store/workspace.store.ts new file mode 100644 index 000000000..18af2840a --- /dev/null +++ b/packages/shared-state/src/store/workspace.store.ts @@ -0,0 +1,28 @@ +import { makeObservable, observable } from "mobx"; + +export interface IWorkspaceStore { + id: string; + name: string; + createdAt: string; + updatedAt: string; +} + +export class WorkspaceStore implements IWorkspaceStore { + id: string; + name: string; + createdAt: string; + updatedAt: string; + + constructor(data: IWorkspaceStore) { + makeObservable(this, { + id: observable.ref, + name: observable.ref, + createdAt: observable.ref, + updatedAt: observable.ref, + }); + this.id = data.id; + this.name = data.name; + this.createdAt = data.createdAt; + this.updatedAt = data.updatedAt; + } +} diff --git a/packages/shared-state/tsconfig.json b/packages/shared-state/tsconfig.json new file mode 100644 index 000000000..0c2f64d1a --- /dev/null +++ b/packages/shared-state/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@plane/typescript-config/react-library.json", + "compilerOptions": { + "jsx": "react", + "lib": ["esnext", "dom"], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["./src"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/tailwind-config-custom/package.json b/packages/tailwind-config/package.json similarity index 77% rename from packages/tailwind-config-custom/package.json rename to packages/tailwind-config/package.json index a7fac6401..a2f081a43 100644 --- a/packages/tailwind-config-custom/package.json +++ b/packages/tailwind-config/package.json @@ -1,8 +1,8 @@ { - "name": "tailwind-config-custom", + "name": "@plane/tailwind-config", "version": "0.24.1", "description": "common tailwind configuration across monorepo", - "main": "index.js", + "main": "tailwind.config.js", "private": true, "devDependencies": { "@tailwindcss/typography": "^0.5.9", @@ -10,7 +10,7 @@ "postcss": "^8.4.38", "prettier": "^2.8.8", "prettier-plugin-tailwindcss": "^0.3.0", - "tailwindcss": "^3.2.7", + "tailwindcss": "^3.4.17", "tailwindcss-animate": "^1.0.6" } } diff --git a/packages/tailwind-config-custom/postcss.config.js b/packages/tailwind-config/postcss.config.js similarity index 100% rename from packages/tailwind-config-custom/postcss.config.js rename to packages/tailwind-config/postcss.config.js diff --git a/packages/tailwind-config-custom/tailwind.config.js b/packages/tailwind-config/tailwind.config.js similarity index 98% rename from packages/tailwind-config-custom/tailwind.config.js rename to packages/tailwind-config/tailwind.config.js index 4c8563f5f..3e34ca1f0 100644 --- a/packages/tailwind-config-custom/tailwind.config.js +++ b/packages/tailwind-config/tailwind.config.js @@ -19,6 +19,7 @@ module.exports = { "./app/**/*.tsx", "./ui/**/*.tsx", "../packages/ui/src/**/*.{js,ts,jsx,tsx}", + "../packages/propel/src/**/*.{js,ts,jsx,tsx}", "../packages/editor/src/**/*.{js,ts,jsx,tsx}", "!../packages/ui/**/*.stories{js,ts,jsx,tsx}", ], @@ -202,6 +203,11 @@ module.exports = { }, }, backdrop: "rgba(0, 0, 0, 0.25)", + scrollbar: { + neutral: "rgba(96, 100, 108, 0.1)", + hover: "rgba(96, 100, 108, 0.25)", + active: "rgba(96, 100, 108, 0.7)", + }, }, onboarding: { background: { diff --git a/packages/types/.prettierrc b/packages/types/.prettierrc new file mode 100644 index 000000000..87d988f1b --- /dev/null +++ b/packages/types/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/packages/types/src/activity.d.ts b/packages/types/src/activity.d.ts new file mode 100644 index 000000000..98d54dabc --- /dev/null +++ b/packages/types/src/activity.d.ts @@ -0,0 +1,36 @@ +export type TBaseActivity< + TFieldKey extends string = string, + TVerbKey extends string = string, +> = { + id: string; + field: TFieldKey | undefined; + epoch: number; + verb: TVerbKey; + comment: string | undefined; + // updates + old_value: string | undefined; + new_value: string | undefined; + old_identifier: string | undefined; + new_identifier: string | undefined; + // actor detail + actor: string; + // timestamp + created_at: string; + updated_at: string; +}; + +export type TWorkspaceBaseActivity< + K extends string = string, + V extends string = string, +> = TBaseActivity & { + workspace: string; +}; + +export type TProjectBaseActivity< + K extends string = string, + V extends string = string, +> = TWorkspaceBaseActivity & { + project: string; +}; + +export type TBaseActivityVerbs = "created" | "updated" | "deleted"; diff --git a/packages/types/src/auth.d.ts b/packages/types/src/auth.d.ts index 576ac45b6..65000c503 100644 --- a/packages/types/src/auth.d.ts +++ b/packages/types/src/auth.d.ts @@ -7,6 +7,7 @@ export interface IEmailCheckData { export interface IEmailCheckResponse { status: "MAGIC_CODE" | "CREDENTIAL"; existing: boolean; + is_password_autoset: boolean; } export interface ILoginTokenResponse { diff --git a/packages/types/src/charts.d.ts b/packages/types/src/charts.d.ts new file mode 100644 index 000000000..473c1077e --- /dev/null +++ b/packages/types/src/charts.d.ts @@ -0,0 +1,118 @@ +export type TChartData = { + // required key + [key in K]: string | number; +} & Record; + +type TChartProps = { + data: TChartData[]; + xAxis: { + key: keyof TChartData; + label: string; + }; + yAxis: { + key: keyof TChartData; + label: string; + domain?: [number, number]; + allowDecimals?: boolean; + }; + className?: string; + tickCount?: { + x?: number; + y?: number; + }; + showTooltip?: boolean; +}; + +export type TBarItem = { + key: T; + fillClassName: string; + textClassName: string; + dotClassName?: string; + showPercentage?: boolean; + stackId: string; +}; + +export type TBarChartProps = TChartProps & { + bars: TBarItem[]; + barSize?: number; +}; + +export type TLineItem = { + key: T; + className?: string; + style?: Record; + dotClassName?: string; +}; + +export type TLineChartProps = TChartProps & { + lines: TLineItem[]; +}; + +export type TAreaItem = { + key: T; + stackId: string; + className?: string; + style?: Record; + dotClassName?: string; +}; + +export type TAreaChartProps = TChartProps & { + areas: TAreaItem[]; +}; + +export type TCellItem = { + key: T; + className?: string; + style?: Record; + dotClassName?: string; +}; + +export type TPieChartProps = Pick< + TChartProps, + "className" | "data" | "showTooltip" +> & { + dataKey: T; + cells: TCellItem[]; + innerRadius?: number; + outerRadius?: number; +}; + +export type TreeMapItem = { + name: string; + value: number; + label?: string; + textClassName?: string; + icon?: React.ReactElement; +} & ( + | { + fillColor: string; + } + | { + fillClassName: string; + } +); + +export type TreeMapChartProps = { + data: TreeMapItem[]; + className?: string; + isAnimationActive?: boolean; + showTooltip?: boolean; +}; + +export type TTopSectionConfig = { + showIcon: boolean; + showName: boolean; + nameTruncated: boolean; +}; + +export type TBottomSectionConfig = { + show: boolean; + showValue: boolean; + showLabel: boolean; + labelTruncated: boolean; +}; + +export type TContentVisibility = { + top: TTopSectionConfig; + bottom: TBottomSectionConfig; +}; diff --git a/packages/types/src/command-palette.d.ts b/packages/types/src/command-palette.d.ts new file mode 100644 index 000000000..6e072ab8f --- /dev/null +++ b/packages/types/src/command-palette.d.ts @@ -0,0 +1,15 @@ +export type TCommandPaletteActionList = Record< + string, + { title: string; description: string; action: () => void } +>; + +export type TCommandPaletteShortcutList = { + key: string; + title: string; + shortcuts: TCommandPaletteShortcut[]; +}; + +export type TCommandPaletteShortcut = { + keys: string; // comma separated keys + description: string; +}; diff --git a/packages/types/src/common.d.ts b/packages/types/src/common.d.ts index 5fe31ad00..c45236a9f 100644 --- a/packages/types/src/common.d.ts +++ b/packages/types/src/common.d.ts @@ -22,3 +22,7 @@ export type TLogoProps = { background_color?: string; }; }; + +export type TNameDescriptionLoader = "submitting" | "submitted" | "saved"; + +export type TFetchStatus = "partial" | "complete" | undefined; diff --git a/packages/types/src/cycle/cycle.d.ts b/packages/types/src/cycle/cycle.d.ts index 1c2fa273a..638d974e6 100644 --- a/packages/types/src/cycle/cycle.d.ts +++ b/packages/types/src/cycle/cycle.d.ts @@ -120,9 +120,7 @@ export interface CycleIssueResponse { sub_issues_count: number; } -export type SelectCycleType = - | (ICycle & { actionType: "edit" | "delete" | "create-issue" }) - | undefined; +export type SelectCycleType = (ICycle & { actionType: "edit" | "delete" | "create-issue" }) | undefined; export type CycleDateCheckData = { start_date: string; @@ -132,3 +130,9 @@ export type CycleDateCheckData = { export type TCycleEstimateType = "issues" | "points"; export type TCyclePlotType = "burndown" | "burnup"; + +export type TPublicCycle = { + id: string; + name: string; + status: string; +}; diff --git a/packages/types/src/enums.ts b/packages/types/src/enums.ts index df6a462b0..854c0c614 100644 --- a/packages/types/src/enums.ts +++ b/packages/types/src/enums.ts @@ -4,10 +4,7 @@ export enum EUserPermissions { GUEST = 5, } -export type TUserPermissions = - | EUserPermissions.ADMIN - | EUserPermissions.MEMBER - | EUserPermissions.GUEST; +export type TUserPermissions = EUserPermissions.ADMIN | EUserPermissions.MEMBER | EUserPermissions.GUEST; // project pages export enum EPageAccess { @@ -59,4 +56,8 @@ export enum EFileAssetType { USER_AVATAR = "USER_AVATAR", USER_COVER = "USER_COVER", WORKSPACE_LOGO = "WORKSPACE_LOGO", + TEAM_SPACE_DESCRIPTION = "TEAM_SPACE_DESCRIPTION", + INITIATIVE_DESCRIPTION = "INITIATIVE_DESCRIPTION", + PROJECT_DESCRIPTION = "PROJECT_DESCRIPTION", + TEAM_SPACE_COMMENT_DESCRIPTION = "TEAM_SPACE_COMMENT_DESCRIPTION", } diff --git a/packages/types/src/epics.d.ts b/packages/types/src/epics.d.ts new file mode 100644 index 000000000..1ba50e2f2 --- /dev/null +++ b/packages/types/src/epics.d.ts @@ -0,0 +1,16 @@ +export type TEpicAnalyticsGroup = + | "backlog_issues" + | "unstarted_issues" + | "started_issues" + | "completed_issues" + | "cancelled_issues" + | "overdue_issues"; + +export type TEpicAnalytics = { + backlog_issues: number; + unstarted_issues: number; + started_issues: number; + completed_issues: number; + cancelled_issues: number; + overdue_issues: number; +}; diff --git a/packages/types/src/home.d.ts b/packages/types/src/home.d.ts new file mode 100644 index 000000000..56089bf46 --- /dev/null +++ b/packages/types/src/home.d.ts @@ -0,0 +1,76 @@ +import { TLogoProps } from "./common"; +import { TIssuePriorities } from "./issues"; + +export type TRecentActivityFilterKeys = "all item" | "issue" | "page" | "project" | "workspace_page"; +export type THomeWidgetKeys = "quick_links" | "recents" | "my_stickies" | "quick_tutorial" | "new_at_plane"; + +export type THomeWidgetProps = { + workspaceSlug: string; +}; + +export type TPageEntityData = { + id: string; + name: string; + logo_props: TLogoProps; + project_id?: string; + owned_by: string; + project_identifier?: string; +}; + +export type TProjectEntityData = { + id: string; + name: string; + logo_props: TLogoProps; + project_members: string[]; + identifier: string; +}; + +export type TIssueEntityData = { + id: string; + name: string; + state: string; + priority: TIssuePriorities; + assignees: string[]; + type: string | null; + sequence_id: number; + project_id: string; + project_identifier: string; +}; + +export type TActivityEntityData = { + id: string; + entity_name: "page" | "project" | "issue" | "workspace_page"; + entity_identifier: string; + visited_at: string; + entity_data: TPageEntityData | TProjectEntityData | TIssueEntityData; +}; + +export type TLinkEditableFields = { + title: string; + url: string; +}; + +export type TLink = TLinkEditableFields & { + created_by_id: string; + id: string; + metadata: any; + workspace_slug: string; + + //need + created_at: Date; +}; + +export type TLinkMap = { + [workspace_slug: string]: TLink; +}; + +export type TLinkIdMap = { + [workspace_slug: string]: string[]; +}; + +export type TWidgetEntityData = { + key: THomeWidgetKeys; + name: string; + is_enabled: boolean; + sort_order: number; +}; diff --git a/packages/types/src/inbox.d.ts b/packages/types/src/inbox.d.ts index 5ae6c160e..30ad0120c 100644 --- a/packages/types/src/inbox.d.ts +++ b/packages/types/src/inbox.d.ts @@ -2,23 +2,6 @@ import { TPaginationInfo } from "./common"; import { TIssuePriorities } from "./issues"; import { TIssue } from "./issues/base"; -enum EInboxIssueCurrentTab { - OPEN = "open", - CLOSED = "closed", -} - -enum EInboxIssueStatus { - PENDING = -2, - DECLINED = -1, - SNOOZED = 0, - ACCEPTED = 1, - DUPLICATE = 2, -} - -export type TInboxIssueCurrentTab = EInboxIssueCurrentTab; - -export type TInboxIssueStatus = EInboxIssueStatus; - // filters export type TInboxIssueFilterMemberKeys = "assignees" | "created_by"; @@ -38,10 +21,7 @@ export type TInboxIssueFilter = { // sorting filters export type TInboxIssueSortingKeys = "order_by" | "sort_by"; -export type TInboxIssueSortingOrderByKeys = - | "issue__created_at" - | "issue__updated_at" - | "issue__sequence_id"; +export type TInboxIssueSortingOrderByKeys = "issue__created_at" | "issue__updated_at" | "issue__sequence_id"; export type TInboxIssueSortingSortByKeys = "asc" | "desc"; @@ -78,17 +58,6 @@ export type TInboxDuplicateIssueDetails = { name: string; }; -export type TInboxIssue = { - id: string; - status: TInboxIssueStatus; - snoozed_till: Date | null; - duplicate_to: string | undefined; - source: string; - issue: TIssue; - created_by: string; - duplicate_issue_detail: TInboxDuplicateIssueDetails | undefined; -}; - export type TInboxIssuePaginationInfo = TPaginationInfo & { total_results: number; }; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 10e519700..9ec3846b7 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -28,7 +28,15 @@ export * from "./workspace-views"; export * from "./common"; export * from "./pragmatic"; export * from "./publish"; +export * from "./search"; export * from "./workspace-notifications"; export * from "./favorite"; export * from "./file"; export * from "./workspace-draft-issues/base"; +export * from "./command-palette"; +export * from "./timezone"; +export * from "./activity"; +export * from "./epics"; +export * from "./charts"; +export * from "./home"; +export * from "./stickies"; diff --git a/packages/types/src/issues.d.ts b/packages/types/src/issues.d.ts index eff81f857..a630d0ba2 100644 --- a/packages/types/src/issues.d.ts +++ b/packages/types/src/issues.d.ts @@ -10,6 +10,12 @@ import type { Properties, IIssueDisplayFilterOptions, TIssue, + IIssueFilterOptions, + IIssueDisplayProperties, + TIssueGroupByOptions, + TIssueOrderByOptions, + TIssueGroupingFilters, + TIssueExtraOptions, } from "@plane/types"; export interface IIssueCycle { @@ -211,12 +217,13 @@ export type GroupByColumnTypes = | "priority" | "labels" | "assignees" - | "created_by"; + | "created_by" + | "team_project"; export interface IGroupByColumn { id: string; name: string; - icon: ReactElement | undefined; + icon?: ReactElement | undefined; payload: Partial; isDropDisabled?: boolean; dropErrorMessage?: string; @@ -234,3 +241,18 @@ export interface IIssueListRow { icon?: ReactElement | undefined; payload?: Partial; } + +export interface ILayoutDisplayFiltersOptions { + filters: (keyof IIssueFilterOptions)[]; + display_properties: (keyof IIssueDisplayProperties)[]; + display_filters: { + group_by?: TIssueGroupByOptions[]; + sub_group_by?: TIssueGroupByOptions[]; + order_by?: TIssueOrderByOptions[]; + type?: TIssueGroupingFilters[]; + }; + extra_options: { + access: boolean; + values: TIssueExtraOptions[]; + }; +} diff --git a/packages/types/src/issues/activity/base.d.ts b/packages/types/src/issues/activity/base.d.ts index 63f365d89..7b5653aed 100644 --- a/packages/types/src/issues/activity/base.d.ts +++ b/packages/types/src/issues/activity/base.d.ts @@ -26,7 +26,7 @@ export type TIssueActivityProjectDetail = { export type TIssueActivityIssueDetail = { id: string; - sequence_id: boolean; + sequence_id: number; sort_order: boolean; name: string; description_html: string; diff --git a/packages/types/src/issues/activity/issue_comment.d.ts b/packages/types/src/issues/activity/issue_comment.d.ts index f361ea720..aef5134c6 100644 --- a/packages/types/src/issues/activity/issue_comment.d.ts +++ b/packages/types/src/issues/activity/issue_comment.d.ts @@ -37,3 +37,30 @@ export type TIssueCommentMap = { export type TIssueCommentIdMap = { [issue_id: string]: string[]; }; + +export type TIssuePublicComment = { + actor_detail: ActorDetail; + access: string; + actor: string; + attachments: any[]; + comment_html: string; + comment_reactions: { + actor_detail: ActorDetail; + comment: string; + id: string; + reaction: string; + }[]; + comment_stripped: string; + created_at: Date; + created_by: string; + id: string; + is_member: boolean; + issue: string; + issue_detail: IssueDetail; + project: string; + project_detail: ProjectDetail; + updated_at: Date; + updated_by: string; + workspace: string; + workspace_detail: IWorkspaceLite; +}; diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index b9366cccb..e38810004 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -1,8 +1,9 @@ +import { EIssueServiceType } from "@plane/constants"; import { TIssuePriorities } from "../issues"; import { TIssueAttachment } from "./issue_attachment"; import { TIssueLink } from "./issue_link"; -import { TIssueReaction } from "./issue_reaction"; -import { TIssueRelationTypes } from "@/plane-web/types"; +import { TIssueReaction, IIssuePublicReaction, IPublicVote } from "./issue_reaction"; +import { TIssueRelationTypes, TIssuePublicComment } from "@/plane-web/types"; // new issue structure types @@ -39,6 +40,7 @@ export type TBaseIssue = { updated_by: string; is_draft: boolean; + is_epic?: boolean; }; export type IssueRelation = { @@ -116,8 +118,65 @@ export type TBulkOperationsPayload = { properties: Partial; }; -export type TIssueDetailWidget = - | "sub-issues" - | "relations" - | "links" - | "attachments"; +export type TIssueDetailWidget = "sub-issues" | "relations" | "links" | "attachments"; + +export type TIssueServiceType = EIssueServiceType.ISSUES | EIssueServiceType.EPICS; + +export interface IPublicIssue + extends Pick< + TIssue, + | "description_html" + | "created_at" + | "updated_at" + | "created_by" + | "id" + | "name" + | "priority" + | "state_id" + | "project_id" + | "sequence_id" + | "sort_order" + | "start_date" + | "target_date" + | "cycle_id" + | "module_ids" + | "label_ids" + | "assignee_ids" + | "attachment_count" + | "sub_issues_count" + | "link_count" + | "estimate_point" + > { + comments: TIssuePublicComment[]; + reaction_items: IIssuePublicReaction[]; + vote_items: IPublicVote[]; +} + +type TPublicIssueResponseResults = + | IPublicIssue[] + | { + [key: string]: { + results: + | IPublicIssue[] + | { + [key: string]: { + results: IPublicIssue[]; + total_results: number; + }; + }; + total_results: number; + }; + }; + +export type TPublicIssuesResponse = { + grouped_by: string; + next_cursor: string; + prev_cursor: string; + next_page_results: boolean; + prev_page_results: boolean; + total_count: number; + count: number; + total_pages: number; + extra_stats: null; + results: TPublicIssueResponseResults; +}; diff --git a/packages/types/src/issues/issue_attachment.d.ts b/packages/types/src/issues/issue_attachment.d.ts index 2238fa4c7..f7503b9d2 100644 --- a/packages/types/src/issues/issue_attachment.d.ts +++ b/packages/types/src/issues/issue_attachment.d.ts @@ -11,6 +11,7 @@ export type TIssueAttachment = { // required updated_at: string; updated_by: string; + created_by: string; }; export type TIssueAttachmentUploadResponse = TFileSignedURLResponse & { diff --git a/packages/types/src/issues/issue_reaction.d.ts b/packages/types/src/issues/issue_reaction.d.ts index 7fba8cd9c..49c971f4a 100644 --- a/packages/types/src/issues/issue_reaction.d.ts +++ b/packages/types/src/issues/issue_reaction.d.ts @@ -1,3 +1,5 @@ +import { IUserLite } from "../users"; + export type TIssueReaction = { actor: string; id: string; @@ -5,6 +7,11 @@ export type TIssueReaction = { reaction: string; }; +export interface IIssuePublicReaction { + actor_details: IUserLite; + reaction: string; +} + export type TIssueReactionMap = { [reaction_id: string]: TIssueReaction; }; @@ -12,3 +19,8 @@ export type TIssueReactionMap = { export type TIssueReactionIdMap = { [issue_id: string]: { [reaction: string]: string[] }; }; + +export interface IPublicVote { + vote: -1 | 1; + actor_details: IUserLite; +} diff --git a/packages/types/src/module/modules.d.ts b/packages/types/src/module/modules.d.ts index fa77a6a41..ce845e60d 100644 --- a/packages/types/src/module/modules.d.ts +++ b/packages/types/src/module/modules.d.ts @@ -117,3 +117,8 @@ export type SelectModuleType = | undefined; export type TModulePlotType = "burndown" | "points"; + +export type TPublicModule = { + id: string; + name: string; +}; diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index 011f92d69..183d015bf 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -15,7 +15,8 @@ export type TPage = { label_ids: string[] | undefined; name: string | undefined; owned_by: string | undefined; - project_ids: string[] | undefined; + project_ids?: string[] | undefined; + team: string | null | undefined; updated_at: Date | undefined; updated_by: string | undefined; workspace: string | undefined; @@ -25,11 +26,7 @@ export type TPage = { // page filters export type TPageNavigationTabs = "public" | "private" | "archived"; -export type TPageFiltersSortKey = - | "name" - | "created_at" - | "updated_at" - | "opened_at"; +export type TPageFiltersSortKey = "name" | "created_at" | "updated_at" | "opened_at"; export type TPageFiltersSortBy = "asc" | "desc"; @@ -63,10 +60,17 @@ export type TPageVersion = { updated_at: string; updated_by: string; workspace: string; -} +}; export type TDocumentPayload = { description_binary: string; description_html: string; description: object; -} \ No newline at end of file +}; + +export type TWebhookConnectionQueryParams = { + documentType: "project_page" | "team_page" | "workspace_page"; + projectId?: string; + teamId?: string; + workspaceSlug: string; +}; diff --git a/packages/types/src/project/index.ts b/packages/types/src/project/index.ts index ef7308bf7..f5478051e 100644 --- a/packages/types/src/project/index.ts +++ b/packages/types/src/project/index.ts @@ -1,2 +1,3 @@ export * from "./project_filters"; export * from "./projects"; +export * from "./project_link"; diff --git a/packages/types/src/project/project_link.d.ts b/packages/types/src/project/project_link.d.ts new file mode 100644 index 000000000..45b9dfc6a --- /dev/null +++ b/packages/types/src/project/project_link.d.ts @@ -0,0 +1,22 @@ +export type TProjectLinkEditableFields = { + title: string; + url: string; +}; + +export type TProjectLink = TProjectLinkEditableFields & { + created_by_id: string; + id: string; + metadata: any; + project_id: string; + + //need + created_at: Date; +}; + +export type TProjectLinkMap = { + [project_id: string]: TProjectLink; +}; + +export type TProjectLinkIdMap = { + [project_id: string]: string[]; +}; diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts index d48342ceb..b04b98284 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -1,3 +1,4 @@ +import { EUserProjectRoles } from "@plane/constants"; import type { IProjectViewProps, IUser, @@ -10,57 +11,64 @@ import type { } from ".."; import { TUserPermissions } from "../enums"; -export interface IProject { - archive_in: number; +export interface IPartialProject { + id: string; + name: string; + identifier: string; + sort_order: number | null; + logo_props: TLogoProps; + member_role?: TUserPermissions | EUserProjectRoles | null; archived_at: string | null; - archived_issues: number; - archived_sub_issues: number; - close_in: number; - created_at: Date; - created_by: string; - // only for uploading the cover image - cover_image_asset?: null; - cover_image?: string; - // only for rendering the cover image - cover_image_url: readonly string; + workspace: IWorkspace | string; cycle_view: boolean; issue_views_view: boolean; module_view: boolean; page_view: boolean; inbox_view: boolean; - default_assignee: IUser | string | null; - default_state: string | null; - description: string; - draft_issues: number; - draft_sub_issues: number; - estimate: string | null; - guest_view_all_features: boolean; - id: string; - identifier: string; - anchor: string | null; - is_favorite: boolean; - is_issue_type_enabled: boolean; - is_member: boolean; - is_time_tracking_enabled: boolean; - logo_props: TLogoProps; - member_role: TUserPermissions | null; - members: IProjectMemberLite[]; - name: string; - network: number; - project_lead: IUserLite | string | null; - sort_order: number | null; - sub_issues: number; - total_cycles: number; - total_issues: number; - total_members: number; - total_modules: number; - updated_at: Date; - updated_by: string; - workspace: IWorkspace | string; - workspace_detail: IWorkspaceLite; - timezone: string; + project_lead?: IUserLite | string | null; + // Timestamps + created_at?: Date; + updated_at?: Date; + // actor + created_by?: string; + updated_by?: string; } +export interface IProject extends IPartialProject { + archive_in?: number; + close_in?: number; + // only for uploading the cover image + cover_image_asset?: null; + cover_image?: string; + // only for rendering the cover image + readonly cover_image_url?: string; + default_assignee?: IUser | string | null; + default_state?: string | null; + description?: string; + estimate?: string | null; + guest_view_all_features?: boolean; + anchor?: string | null; + is_favorite?: boolean; + is_issue_type_enabled?: boolean; + is_time_tracking_enabled?: boolean; + members?: string[]; + network?: number; + timezone?: string; +} + +export type TProjectAnalyticsCountParams = { + project_ids?: string; + fields?: string; +}; + +export type TProjectAnalyticsCount = Pick & { + total_issues?: number; + completed_issues?: number; + total_cycles?: number; + total_members?: number; + total_modules?: number; +}; + export interface IProjectLite { id: string; name: string; @@ -111,7 +119,7 @@ export interface IProjectMembership { } export interface IProjectBulkAddFormData { - members: { role: TUserPermissions; member_id: string }[]; + members: { role: TUserPermissions | EUserProjectRoles; member_id: string }[]; } export interface IGithubRepository { @@ -136,6 +144,7 @@ export type TProjectIssuesSearchParams = { issue_id?: string; workspace_search: boolean; target_date?: string; + epic?: boolean; }; export interface ISearchIssueResponse { diff --git a/packages/types/src/search.d.ts b/packages/types/src/search.d.ts new file mode 100644 index 000000000..41138a46e --- /dev/null +++ b/packages/types/src/search.d.ts @@ -0,0 +1,78 @@ +import { ICycle } from "./cycle"; +import { TIssue } from "./issues/issue"; +import { IModule } from "./module"; +import { TPage } from "./pages"; +import { IProject } from "./project"; +import { IUser } from "./users"; +import { IWorkspace } from "./workspace"; + +export type TSearchEntities = "user_mention" | "issue" | "project" | "cycle" | "module" | "page"; + +export type TUserSearchResponse = { + member__avatar_url: IUser["avatar_url"]; + member__display_name: IUser["display_name"]; + member__id: IUser["id"]; +}; + +export type TProjectSearchResponse = { + name: IProject["name"]; + id: IProject["id"]; + identifier: IProject["identifier"]; + logo_props: IProject["logo_props"]; + workspace__slug: IWorkspace["slug"]; +}; + +export type TIssueSearchResponse = { + name: TIssue["name"]; + id: TIssue["id"]; + sequence_id: TIssue["sequence_id"]; + project__identifier: IProject["identifier"]; + project_id: TIssue["project_id"]; + priority: TIssue["priority"]; + state_id: TIssue["state_id"]; + type_id: TIssue["type_id"]; +}; + +export type TCycleSearchResponse = { + name: ICycle["name"]; + id: ICycle["id"]; + project_id: ICycle["project_id"]; + project__identifier: IProject["identifier"]; + status: ICycle["status"]; + workspace__slug: IWorkspace["slug"]; +}; + +export type TModuleSearchResponse = { + name: IModule["name"]; + id: IModule["id"]; + project_id: IModule["project_id"]; + project__identifier: IProject["identifier"]; + status: IModule["status"]; + workspace__slug: IWorkspace["slug"]; +}; + +export type TPageSearchResponse = { + name: TPage["name"]; + id: TPage["id"]; + logo_props: TPage["logo_props"]; + projects__id: TPage["project_ids"]; + workspace__slug: IWorkspace["slug"]; +}; + +export type TSearchResponse = { + cycle?: TCycleSearchResponse[]; + issue?: TIssueSearchResponse[]; + module?: TModuleSearchResponse[]; + page?: TPageSearchResponse[]; + project?: TProjectSearchResponse[]; + user_mention?: TUserSearchResponse[]; +}; + +export type TSearchEntityRequestPayload = { + count: number; + project_id?: string; + query_type: TSearchEntities[]; + query: string; + team_id?: string; + issue_id?: string; +}; diff --git a/packages/types/src/state.d.ts b/packages/types/src/state.d.ts index 7df658a88..120b216da 100644 --- a/packages/types/src/state.d.ts +++ b/packages/types/src/state.d.ts @@ -1,11 +1,6 @@ import { IProject, IProjectLite, IWorkspaceLite } from "@plane/types"; -export type TStateGroups = - | "backlog" - | "unstarted" - | "started" - | "completed" - | "cancelled"; +export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled"; export interface IState { readonly id: string; diff --git a/packages/types/src/stickies.d copy.ts b/packages/types/src/stickies.d copy.ts new file mode 100644 index 000000000..55f8b23c5 --- /dev/null +++ b/packages/types/src/stickies.d copy.ts @@ -0,0 +1,8 @@ +export type TSticky = { + id: string; + name?: string; + description_html?: string; + color?: string; + createdAt?: Date; + updatedAt?: Date; +}; diff --git a/packages/types/src/stickies.d.ts b/packages/types/src/stickies.d.ts new file mode 100644 index 000000000..ffa19e84f --- /dev/null +++ b/packages/types/src/stickies.d.ts @@ -0,0 +1,16 @@ +import { TLogoProps } from "./common"; + +export type TSticky = { + created_at?: string | undefined; + created_by?: string | undefined; + background_color?: string | null | undefined; + description?: object | undefined; + description_html?: string | undefined; + id: string; + logo_props: TLogoProps | undefined; + name?: string; + sort_order: number | undefined; + updated_at?: string | undefined; + updated_by?: string | undefined; + workspace: string | undefined; +}; diff --git a/packages/types/src/timezone.d.ts b/packages/types/src/timezone.d.ts new file mode 100644 index 000000000..b4df123a3 --- /dev/null +++ b/packages/types/src/timezone.d.ts @@ -0,0 +1,8 @@ +export type TTimezoneObject = { + utc_offset: string; + gmt_offset: string; + label: string; + value: string; +}; + +export type TTimezones = { timezones: TTimezoneObject[] }; diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index 452bc23c2..e5140fdef 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -25,7 +25,6 @@ export interface IUser extends IUserLite { is_password_autoset: boolean; is_tour_completed: boolean; mobile_number: string | null; - role: string | null; last_workspace_id: string; user_timezone: string; username: string; @@ -62,6 +61,7 @@ export type TUserProfile = { billing_address_country: string | undefined; billing_address: string | undefined; has_billing_address: boolean; + language: string; created_at: Date | string; updated_at: Date | string; }; @@ -182,6 +182,17 @@ export interface IUserEmailNotificationSettings { export type TProfileViews = "assigned" | "created" | "subscribed"; +export type TPublicMember = { + id: string; + member: string; + member__avatar: string; + member__first_name: string; + member__last_name: string; + member__display_name: string; + project: string; + workspace: string; +}; + // export interface ICurrentUser { // id: readonly string; // avatar: string; diff --git a/packages/types/src/view-props.d.ts b/packages/types/src/view-props.d.ts index 57baa4cfd..dca1a652c 100644 --- a/packages/types/src/view-props.d.ts +++ b/packages/types/src/view-props.d.ts @@ -1,5 +1,3 @@ -import { EIssueLayoutTypes } from "constants/issue"; - export type TIssueLayouts = | "list" | "kanban" @@ -18,6 +16,7 @@ export type TIssueGroupByOptions = | "cycle" | "module" | "target_date" + | "team_project" | null; export type TIssueOrderByOptions = @@ -69,6 +68,7 @@ export type TIssueParams = | "start_date" | "target_date" | "project" + | "team_project" | "group_by" | "sub_group_by" | "order_by" @@ -92,6 +92,7 @@ export interface IIssueFilterOptions { cycle?: string[] | null; module?: string[] | null; project?: string[] | null; + team_project?: string[] | null; start_date?: string[] | null; state?: string[] | null; state_group?: string[] | null; @@ -107,7 +108,7 @@ export interface IIssueDisplayFilterOptions { }; group_by?: TIssueGroupByOptions; sub_group_by?: TIssueGroupByOptions; - layout?: EIssueLayoutTypes; + layout?: any; // TODO: Need to fix this and set it to enum EIssueLayoutTypes order_by?: TIssueOrderByOptions; show_empty_groups?: boolean; sub_issue?: boolean; diff --git a/packages/types/src/views.d.ts b/packages/types/src/views.d.ts index 54e1a395c..32b5889ca 100644 --- a/packages/types/src/views.d.ts +++ b/packages/types/src/views.d.ts @@ -1,4 +1,4 @@ -import { EViewAccess } from "@/constants/views"; +import { EViewAccess } from "@plane/constants"; import { TLogoProps } from "./common"; import { IIssueDisplayFilterOptions, diff --git a/packages/types/src/workspace-notifications.d.ts b/packages/types/src/workspace-notifications.d.ts index 7d960015b..0e0e15af1 100644 --- a/packages/types/src/workspace-notifications.d.ts +++ b/packages/types/src/workspace-notifications.d.ts @@ -35,7 +35,7 @@ export type TNotificationData = { }; export type TNotification = { - id: string | undefined; + id: string; title: string | undefined; data: TNotificationData | undefined; entity_identifier: string | undefined; diff --git a/packages/types/src/workspace-views.d.ts b/packages/types/src/workspace-views.d.ts index 5bc900767..eb207128d 100644 --- a/packages/types/src/workspace-views.d.ts +++ b/packages/types/src/workspace-views.d.ts @@ -1,4 +1,4 @@ -import { EViewAccess } from "@/constants/views"; +import { EViewAccess } from "@plane/constants"; import { IWorkspaceViewProps, IIssueDisplayFilterOptions, diff --git a/packages/types/src/workspace.d.ts b/packages/types/src/workspace.d.ts index 500eaa7b5..4393a911f 100644 --- a/packages/types/src/workspace.d.ts +++ b/packages/types/src/workspace.d.ts @@ -1,10 +1,5 @@ -import type { - ICycle, - IProjectMember, - IUser, - IUserLite, - IWorkspaceViewProps, -} from "@plane/types"; +import type { ICycle, IProjectMember, 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"; export interface IWorkspace { @@ -20,8 +15,8 @@ export interface IWorkspace { readonly created_by: string; readonly updated_by: string; organization_size: string; - total_issues: number; total_projects?: number; + role: number; } export interface IWorkspaceLite { @@ -38,9 +33,10 @@ export interface IWorkspaceMemberInvitation { responded_at: Date; role: TUserPermissions; token: string; + invite_link: string; workspace: { id: string; - logo: string; + logo_url: string; name: string; slug: string; }; @@ -69,7 +65,7 @@ export type Properties = { export interface IWorkspaceMember { id: string; member: IUserLite; - role: TUserPermissions; + role: TUserPermissions | EUserWorkspaceRoles; created_at?: string; avatar_url?: string; email?: string; @@ -87,7 +83,7 @@ export interface IWorkspaceMemberMe { default_props: IWorkspaceViewProps; id: string; member: string; - role: TUserPermissions; + role: TUserPermissions | EUserWorkspaceRoles; updated_at: Date; updated_by: string; view_props: IWorkspaceViewProps; @@ -227,3 +223,13 @@ export interface IWorkspaceAnalyticsResponse { export type TWorkspacePaginationInfo = TPaginationInfo & { results: IWorkspace[]; }; + +export interface IWorkspaceSidebarNavigationItem { + key?: string; + is_pinned: boolean; + sort_order: number; +} + +export interface IWorkspaceSidebarNavigation { + [key: string]: IWorkspaceSidebarNavigationItem; +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 2cfcc3643..dee65e02e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -34,11 +34,13 @@ "@plane/hooks": "*", "@plane/utils": "*", "@popperjs/core": "^2.11.8", + "@radix-ui/react-scroll-area": "^1.2.3", "clsx": "^2.0.0", "emoji-picker-react": "^4.5.16", "lodash": "^4.17.21", - "lucide-react": "^0.379.0", + "lucide-react": "^0.469.0", "react-color": "^2.19.3", + "react-day-picker": "9.5.0", "react-popper": "^2.3.0", "sonner": "^1.4.41", "tailwind-merge": "^2.0.0", @@ -47,6 +49,7 @@ "devDependencies": { "@chromatic-com/storybook": "^1.4.0", "@plane/eslint-config": "*", + "@plane/tailwind-config": "*", "@plane/typescript-config": "*", "@storybook/addon-essentials": "^8.1.1", "@storybook/addon-interactions": "^8.1.1", @@ -68,8 +71,6 @@ "postcss-cli": "^11.0.0", "postcss-nested": "^6.0.1", "storybook": "^8.1.1", - "tailwind-config-custom": "*", - "tailwindcss": "^3.4.3", "tsup": "^7.2.0", "typescript": "5.3.3" }, diff --git a/packages/ui/postcss.config.js b/packages/ui/postcss.config.js index 129aa7f59..9b1e55fc4 100644 --- a/packages/ui/postcss.config.js +++ b/packages/ui/postcss.config.js @@ -1 +1,2 @@ -module.exports = require("tailwind-config-custom/postcss.config"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = require("@plane/tailwind-config/postcss.config.js"); diff --git a/packages/ui/src/breadcrumbs/index.ts b/packages/ui/src/breadcrumbs/index.ts index 669f55757..05a8bdbf1 100644 --- a/packages/ui/src/breadcrumbs/index.ts +++ b/packages/ui/src/breadcrumbs/index.ts @@ -1 +1,2 @@ export * from "./breadcrumbs"; +export * from "./navigation-dropdown"; diff --git a/packages/ui/src/breadcrumbs/navigation-dropdown.tsx b/packages/ui/src/breadcrumbs/navigation-dropdown.tsx new file mode 100644 index 000000000..a716ca65e --- /dev/null +++ b/packages/ui/src/breadcrumbs/navigation-dropdown.tsx @@ -0,0 +1,96 @@ +"use client"; + +import * as React from "react"; +import { CheckIcon, ChevronDownIcon } from "lucide-react"; +// ui +import { CustomMenu, TContextMenuItem } from "../dropdowns"; +// helpers +import { cn } from "../../helpers"; + +type TBreadcrumbNavigationDropdownProps = { + selectedItemKey: string; + navigationItems: TContextMenuItem[]; + navigationDisabled?: boolean; +}; + +export const BreadcrumbNavigationDropdown = (props: TBreadcrumbNavigationDropdownProps) => { + const { selectedItemKey, navigationItems, navigationDisabled = false } = props; + // 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 }) => ( +
  1. + {selectedItemIcon && ( +
    {selectedItemIcon}
    + )} +
    {selectedItem.title}
    +
  2. + ); + + if (navigationDisabled) { + return ; + } + + return ( + + + +
    + } + placement="bottom-start" + closeOnSelect + > + {navigationItems.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + if (item.key === selectedItemKey) return; + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + disabled={item.disabled} + > + {item.icon && } +
    +
    {item.title}
    + {item.description && ( +

    + {item.description} +

    + )} +
    + {item.key === selectedItemKey && } +
    + ); + })} + + ); +}; diff --git a/packages/ui/src/calendar.tsx b/packages/ui/src/calendar.tsx new file mode 100644 index 000000000..87a7859f7 --- /dev/null +++ b/packages/ui/src/calendar.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { ChevronLeft } from "lucide-react"; +import * as React from "react"; +import { DayPicker } from "react-day-picker"; + +import { cn } from "../helpers"; + +export type CalendarProps = React.ComponentProps; + +export const Calendar = ({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) => ( + .day-range-end)]:rounded-r-full [&:has(>.day-range-start)]:rounded-l-full first:[&:has([aria-selected])]:rounded-l-full last:[&:has([aria-selected])]:rounded-r-full" + // : "[&:has([aria-selected])]:rounded-full [&:has([aria-selected])]:bg-custom-primary-100 [&:has([aria-selected])]:text-white" + // ), + // // day_button: + // // "size-10 flex items-center justify-center overflow-hidden box-border m-0 border-2 border-transparent rounded-full", + // day: "size-10 p-0 font-normal aria-selected:opacity-100 rounded-full hover:bg-custom-primary-100/60", + // day_range_start: "day-range-start bg-custom-primary-100 text-white", + // day_range_end: "day-range-end bg-custom-primary-100 text-white", + // day_selected: "", + // day_today: + // "relative after:content-[''] after:absolute after:m-auto after:left-1/3 after:bottom-[6px] after:w-[6px] after:h-[6px] after:bg-custom-primary-100/50 after:rounded-full after:translate-x-1/2 after:translate-y-1/2", + // day_outside: "day-outside", + // day_disabled: "opacity-50 hover:!bg-transparent", + // day_range_middle: "text-black", + // day_hidden: "invisible", + // caption_dropdowns: "inline-flex bg-transparent", + // dropdown_root: "m-0 relative inline-flex items-center", + // dropdowns: "relative inline-flex items-center", + // dropdown: + // "appearance-none absolute z-[2] top-0 bottom-0 left-0 w-full m-0 p-0 opacity-0 border-none text-[1rem] cursor-pointer bg-transparent hover:bg-custom-background-80", + // months_dropdown: "capitalize", + // caption_label: + // "z-[1] inline-flex items-center gap-[0.25rem] m-0 py-0 px-[0.25rem] whitespace-nowrap border-2 border-transparent font-semibold bg-transparent rounded", + // ...classNames, + // }} + components={{ + Chevron: ({ className, ...props }) => ( + + ), + }} + {...props} + /> +); diff --git a/packages/ui/src/dropdown/single-select.tsx b/packages/ui/src/dropdown/single-select.tsx index 123c9e698..bcdff40c1 100644 --- a/packages/ui/src/dropdown/single-select.tsx +++ b/packages/ui/src/dropdown/single-select.tsx @@ -110,7 +110,7 @@ export const Dropdown: FC = (props) => { // hooks const handleKeyDown = useDropdownKeyPressed(toggleDropdown, handleClose); - useOutsideClickDetector(dropdownRef, handleClose); + useOutsideClickDetector(dropdownRef, handleClose, true); return ( = (props) => { onMouseEnter={handleActiveItem} disabled={item.disabled} > - {item.icon && } -
    -
    {item.title}
    - {item.description && ( -

    - {item.description} -

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

    + {item.description} +

    + )} +
    + + )} ); }; diff --git a/packages/ui/src/dropdowns/context-menu/root.tsx b/packages/ui/src/dropdowns/context-menu/root.tsx index f251696d2..e4265f100 100644 --- a/packages/ui/src/dropdowns/context-menu/root.tsx +++ b/packages/ui/src/dropdowns/context-menu/root.tsx @@ -11,7 +11,8 @@ import { usePlatformOS } from "../../hooks/use-platform-os"; export type TContextMenuItem = { key: string; - title: string; + customContent?: React.ReactNode; + title?: string; description?: string; icon?: React.FC; action: () => void; diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 39f01d1ed..f21da4381 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -54,7 +54,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { if (referenceElement) referenceElement.focus(); }; const closeDropdown = () => { - isOpen && onMenuClose && onMenuClose(); + if (isOpen) onMenuClose?.(); setIsOpen(false); }; @@ -216,7 +216,7 @@ const MenuItem: React.FC = (props) => { )} onClick={(e) => { close(); - onClick && onClick(e); + onClick?.(e); }} disabled={disabled} > diff --git a/packages/ui/src/icons/activity-icon.tsx b/packages/ui/src/icons/activity-icon.tsx new file mode 100644 index 000000000..2ac482836 --- /dev/null +++ b/packages/ui/src/icons/activity-icon.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const ActivityIcon: React.FC = ({ className = "text-current", ...rest }) => ( + + + + + + + + + + +); diff --git a/packages/ui/src/icons/at-risk-icon.tsx b/packages/ui/src/icons/at-risk-icon.tsx new file mode 100644 index 000000000..bb4437e6d --- /dev/null +++ b/packages/ui/src/icons/at-risk-icon.tsx @@ -0,0 +1,30 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const AtRiskIcon: React.FC = ({ width = "16", height = "16" }) => ( + + + + + + + + + + + + +); diff --git a/packages/ui/src/icons/bar-icon.tsx b/packages/ui/src/icons/bar-icon.tsx new file mode 100644 index 000000000..b20d5ebea --- /dev/null +++ b/packages/ui/src/icons/bar-icon.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const BarIcon: React.FC = ({ className = "", ...rest }) => ( + + + + + +); diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index f8a2b1c84..f274a3414 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -16,6 +16,7 @@ export * from "./epic-icon"; export * from "./full-screen-panel-icon"; export * from "./github-icon"; export * from "./gitlab-icon"; +export * from "./info-fill-icon"; export * from "./info-icon"; export * from "./layer-stack"; export * from "./layers-icon"; @@ -38,3 +39,15 @@ export * from "./done-icon"; export * from "./pending-icon"; export * from "./pi-chat"; export * from "./workspace-icon"; +export * from "./teams"; +export * from "./lead-icon"; +export * from "./activity-icon"; +export * from "./updates-icon"; +export * from "./overview-icon"; +export * from "./on-track-icon"; +export * from "./off-track-icon"; +export * from "./at-risk-icon"; +export * from "./multiple-sticky"; +export * from "./sticky-note-icon"; +export * from "./bar-icon"; +export * from "./tree-map-icon"; diff --git a/packages/ui/src/icons/lead-icon.tsx b/packages/ui/src/icons/lead-icon.tsx new file mode 100644 index 000000000..75575d35e --- /dev/null +++ b/packages/ui/src/icons/lead-icon.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const LeadIcon: React.FC = ({ className = "text-current", ...rest }) => ( + + + + + + + + + + + + +); diff --git a/packages/ui/src/icons/multiple-sticky.tsx b/packages/ui/src/icons/multiple-sticky.tsx new file mode 100644 index 000000000..9c33205e9 --- /dev/null +++ b/packages/ui/src/icons/multiple-sticky.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const RecentStickyIcon: React.FC = ({ className = "text-current", ...rest }) => ( + + + + +); diff --git a/packages/ui/src/icons/off-track-icon.tsx b/packages/ui/src/icons/off-track-icon.tsx new file mode 100644 index 000000000..0d93d1b60 --- /dev/null +++ b/packages/ui/src/icons/off-track-icon.tsx @@ -0,0 +1,30 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const OffTrackIcon: React.FC = ({ width = "16", height = "16" }) => ( + + + + + + + + + + + + +); diff --git a/packages/ui/src/icons/on-track-icon.tsx b/packages/ui/src/icons/on-track-icon.tsx new file mode 100644 index 000000000..c384d4c8d --- /dev/null +++ b/packages/ui/src/icons/on-track-icon.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const OnTrackIcon: React.FC = ({ width = "16", height = "16" }) => ( + + + + + + + + + + + + + + + +); diff --git a/packages/ui/src/icons/overview-icon.tsx b/packages/ui/src/icons/overview-icon.tsx new file mode 100644 index 000000000..bcf371244 --- /dev/null +++ b/packages/ui/src/icons/overview-icon.tsx @@ -0,0 +1,14 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const OverviewIcon: React.FC = ({ className = "text-current", ...rest }) => ( + + + +); diff --git a/packages/ui/src/icons/sticky-note-icon.tsx b/packages/ui/src/icons/sticky-note-icon.tsx new file mode 100644 index 000000000..871950289 --- /dev/null +++ b/packages/ui/src/icons/sticky-note-icon.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const StickyNoteIcon: React.FC = ({ width = "17", height = "17", className, color }) => ( + + + + + + +); diff --git a/packages/ui/src/icons/teams.tsx b/packages/ui/src/icons/teams.tsx new file mode 100644 index 000000000..b73055598 --- /dev/null +++ b/packages/ui/src/icons/teams.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const TeamsIcon: React.FC = ({ className = "text-current", ...rest }) => ( + + + + +); diff --git a/packages/ui/src/icons/transfer-icon.tsx b/packages/ui/src/icons/transfer-icon.tsx index 9a5286f94..f762f9611 100644 --- a/packages/ui/src/icons/transfer-icon.tsx +++ b/packages/ui/src/icons/transfer-icon.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { ISvgIcons } from "./type"; export const TransferIcon: React.FC = ({ className = "fill-current", ...rest }) => ( - + ); diff --git a/packages/ui/src/icons/tree-map-icon.tsx b/packages/ui/src/icons/tree-map-icon.tsx new file mode 100644 index 000000000..b8de01069 --- /dev/null +++ b/packages/ui/src/icons/tree-map-icon.tsx @@ -0,0 +1,16 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const TreeMapIcon: React.FC = ({ className = "", ...rest }) => ( + + + + + + + + + + +); diff --git a/packages/ui/src/icons/updates-icon.tsx b/packages/ui/src/icons/updates-icon.tsx new file mode 100644 index 000000000..978eb5f05 --- /dev/null +++ b/packages/ui/src/icons/updates-icon.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const UpdatesIcon: React.FC = ({ className = "text-current", ...rest }) => ( + + + + +); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 279e19a3e..19edba780 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -25,7 +25,9 @@ export * from "./popovers"; export * from "./tables"; export * from "./header"; export * from "./row"; +export * from "./scroll-area"; export * from "./content-wrapper"; export * from "./card"; export * from "./tag"; export * from "./tabs"; +export * from "./calendar"; diff --git a/packages/ui/src/modals/constants.ts b/packages/ui/src/modals/constants.ts index fe72ef7ae..e1eccd941 100644 --- a/packages/ui/src/modals/constants.ts +++ b/packages/ui/src/modals/constants.ts @@ -13,4 +13,5 @@ export enum EModalWidth { XXXXL = "sm:max-w-4xl", VXL = "sm:max-w-5xl", VIXL = "sm:max-w-6xl", + VIIXL = "sm:max-w-7xl", } diff --git a/packages/ui/src/scroll-area.tsx b/packages/ui/src/scroll-area.tsx new file mode 100644 index 000000000..0f6cc33c6 --- /dev/null +++ b/packages/ui/src/scroll-area.tsx @@ -0,0 +1,66 @@ +"use client"; +import * as RadixScrollArea from "@radix-ui/react-scroll-area"; +import React, { FC } from "react"; +import { cn } from "../helpers"; + +type TScrollAreaProps = { + type?: "auto" | "always" | "scroll" | "hover"; + className?: string; + scrollHideDelay?: number; + size?: "sm" | "md" | "lg"; + children: React.ReactNode; +}; + +const sizeStyles = { + sm: "p-[0.112rem] data-[orientation=vertical]:w-2.5 data-[orientation=horizontal]:h-2.5", + md: "p-[0.152rem] data-[orientation=vertical]:w-3 data-[orientation=horizontal]:h-3", + lg: "p-[0.225rem] data-[orientation=vertical]:w-4 data-[orientation=horizontal]:h-4", +}; + +const thumbSizeStyles = { + sm: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-11 before:min-w-11 before:-translate-x-1/2 before:-translate-y-1/2", + md: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-14 before:min-w-14 before:-translate-x-1/2 before:-translate-y-1/2", + lg: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-17 before:min-w-17 before:-translate-x-1/2 before:-translate-y-1/2", +}; + +export const ScrollArea: FC = (props) => { + const { type = "always", className = "", scrollHideDelay = 600, size = "md", children } = props; + + return ( + + {children} + + + + + + + + ); +}; diff --git a/packages/ui/src/tabs/index.ts b/packages/ui/src/tabs/index.ts index 811d3d4a7..c232b43ab 100644 --- a/packages/ui/src/tabs/index.ts +++ b/packages/ui/src/tabs/index.ts @@ -1 +1,2 @@ export * from "./tabs"; +export * from "./tab-list"; diff --git a/packages/ui/src/tabs/tab-list.tsx b/packages/ui/src/tabs/tab-list.tsx new file mode 100644 index 000000000..0e9eb2dbd --- /dev/null +++ b/packages/ui/src/tabs/tab-list.tsx @@ -0,0 +1,71 @@ +import { Tab } from "@headlessui/react"; +import { LucideProps } from "lucide-react"; +import React, { FC } from "react"; +// helpers +import { cn } from "../../helpers"; + +export type TabListItem = { + key: string; + icon?: FC; + label?: React.ReactNode; + disabled?: boolean; + onClick?: () => void; +}; + +type TTabListProps = { + tabs: TabListItem[]; + tabListClassName?: string; + tabClassName?: string; + size?: "sm" | "md" | "lg"; + selectedTab?: string; + onTabChange?: (key: string) => void; +}; + +export const TabList: FC = ({ + tabs, + tabListClassName, + tabClassName, + size = "md", + selectedTab, + onTabChange, +}) => ( + + {tabs.map((tab) => ( + + cn( + "flex items-center justify-center p-1 min-w-fit w-full font-medium text-custom-text-100 outline-none focus:outline-none cursor-pointer transition-all rounded", + (selectedTab ? selectedTab === tab.key : selected) + ? "bg-custom-background-100 text-custom-text-100 shadow-sm" + : tab.disabled + ? "text-custom-text-400 cursor-not-allowed" + : "text-custom-text-400 hover:text-custom-text-300 hover:bg-custom-background-80/60", + { + "text-xs": size === "sm", + "text-sm": size === "md", + "text-base": size === "lg", + }, + tabClassName + ) + } + key={tab.key} + onClick={() => { + if (!tab.disabled) { + onTabChange?.(tab.key); + tab.onClick?.(); + } + }} + disabled={tab.disabled} + > + {tab.icon && } + {tab.label} + + ))} + +); diff --git a/packages/ui/src/tabs/tabs.tsx b/packages/ui/src/tabs/tabs.tsx index a323d9721..b2791c0b8 100644 --- a/packages/ui/src/tabs/tabs.tsx +++ b/packages/ui/src/tabs/tabs.tsx @@ -1,21 +1,20 @@ -import React, { FC, Fragment } from "react"; import { Tab } from "@headlessui/react"; -import { LucideProps } from "lucide-react"; +import React, { FC, Fragment, useEffect, useState } from "react"; // helpers import { useLocalStorage } from "@plane/hooks"; import { cn } from "../../helpers"; +// types +import { TabList, TabListItem } from "./tab-list"; -type TabItem = { - key: string; - icon?: FC; - label?: React.ReactNode; +export type TabContent = { content: React.ReactNode; - disabled?: boolean; }; +export type TabItem = TabListItem & TabContent; + type TTabsProps = { tabs: TabItem[]; - storageKey: string; + storageKey?: string; actions?: React.ReactNode; defaultTab?: string; containerClassName?: string; @@ -23,6 +22,8 @@ type TTabsProps = { tabListClassName?: string; tabClassName?: string; tabPanelClassName?: string; + size?: "sm" | "md" | "lg"; + storeInLocalStorage?: boolean; }; export const Tabs: FC = (props: TTabsProps) => { @@ -36,48 +37,41 @@ export const Tabs: FC = (props: TTabsProps) => { tabListClassName = "", tabClassName = "", tabPanelClassName = "", + size = "md", + storeInLocalStorage = true, } = props; // local storage - const { storedValue, setValue } = useLocalStorage(`tab-${storageKey}`, defaultTab); + const { storedValue, setValue } = useLocalStorage( + storeInLocalStorage && storageKey ? `tab-${storageKey}` : `tab-${tabs[0]?.key}`, + defaultTab + ); + // state + const [selectedTab, setSelectedTab] = useState(storedValue ?? defaultTab); + + useEffect(() => { + if (storeInLocalStorage) { + setValue(selectedTab); + } + }, [selectedTab, setValue, storeInLocalStorage, storageKey]); const currentTabIndex = (tabKey: string): number => tabs.findIndex((tab) => tab.key === tabKey); + const handleTabChange = (key: string) => { + setSelectedTab(key); + }; + return (
    - +
    - - {tabs.map((tab) => ( - - cn( - `flex items-center justify-center p-1 min-w-fit w-full font-medium text-custom-text-100 outline-none focus:outline-none cursor-pointer transition-all rounded`, - selected - ? "bg-custom-background-100 text-custom-text-100 shadow-sm" - : tab.disabled - ? "text-custom-text-400 cursor-not-allowed" - : "text-custom-text-400 hover:text-custom-text-300 hover:bg-custom-background-80/60", - tabClassName - ) - } - key={tab.key} - onClick={() => { - if (!tab.disabled) setValue(tab.key); - }} - disabled={tab.disabled} - > - {tab.icon && } - {tab.label} - - ))} - + {actions &&
    {actions}
    }
    diff --git a/packages/ui/src/tooltip/tooltip.tsx b/packages/ui/src/tooltip/tooltip.tsx index e485166eb..d013414cd 100644 --- a/packages/ui/src/tooltip/tooltip.tsx +++ b/packages/ui/src/tooltip/tooltip.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useRef, useState } from "react"; import { Tooltip2 } from "@blueprintjs/popover2"; +import React, { useEffect, useRef, useState } from "react"; // helpers import { cn } from "../../helpers"; @@ -23,7 +23,6 @@ export type TPosition = interface ITooltipProps { tooltipHeading?: string; tooltipContent: string | React.ReactNode; - jsxContent?: string | React.ReactNode; position?: TPosition; children: JSX.Element; disabled?: boolean; @@ -39,14 +38,13 @@ export const Tooltip: React.FC = ({ tooltipContent, position = "top", children, - jsxContent, disabled = false, className = "", openDelay = 200, closeDelay, isMobile = false, renderByDefault = true, //FIXME: tooltip should always render on hover and not by default, this is a temporary fix -}: ITooltipProps) => { +}) => { const toolTipRef = useRef(null); const [shouldRender, setShouldRender] = useState(renderByDefault); @@ -81,22 +79,18 @@ export const Tooltip: React.FC = ({ hoverOpenDelay={openDelay} hoverCloseDelay={closeDelay} content={ - jsxContent ? ( - <>{jsxContent} - ) : ( -
    - {tooltipHeading &&
    {tooltipHeading}
    } - {tooltipContent} -
    - ) +
    + {tooltipHeading &&
    {tooltipHeading}
    } + {tooltipContent} +
    } position={position} renderTarget={({ diff --git a/packages/ui/tailwind.config.js b/packages/ui/tailwind.config.js index 79a219c22..5970ea08e 100644 --- a/packages/ui/tailwind.config.js +++ b/packages/ui/tailwind.config.js @@ -1,4 +1,5 @@ -const config = require("tailwind-config-custom/tailwind.config"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const config = require("@plane/tailwind-config/tailwind.config.js"); config.content.files = ["./src/**/*.{js,ts,jsx,tsx}"]; diff --git a/packages/utils/package.json b/packages/utils/package.json index a0e0ecca1..f1c2cdd9b 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -15,13 +15,19 @@ "lint:errors": "eslint src --ext .ts,.tsx --quiet" }, "dependencies": { + "clsx": "^2.1.1", + "date-fns": "^4.1.0", "isomorphic-dompurify": "^2.16.0", - "react": "^18.3.1" + "lodash": "^4.17.21", + "react": "^18.3.1", + "tailwind-merge": "^2.5.5", + "zxcvbn": "^4.4.2" }, "devDependencies": { "@plane/eslint-config": "*", "@types/node": "^22.5.4", "@types/react": "^18.3.11", + "@types/zxcvbn": "^4.4.5", "tsup": "^7.2.0", "typescript": "^5.3.3" } diff --git a/packages/utils/src/array.ts b/packages/utils/src/array.ts new file mode 100644 index 000000000..12727d3a0 --- /dev/null +++ b/packages/utils/src/array.ts @@ -0,0 +1,197 @@ +import isEmpty from "lodash/isEmpty"; +import { IIssueLabel, IIssueLabelTree } from "@plane/types"; + +/** + * @description Groups an array of objects by a specified key + * @param {any[]} array Array to group + * @param {string} key Key to group by (supports dot notation for nested objects) + * @returns {Object} Grouped object with keys being the grouped values + * @example + * const array = [{type: 'A', value: 1}, {type: 'B', value: 2}, {type: 'A', value: 3}]; + * groupBy(array, 'type') // returns { A: [{type: 'A', value: 1}, {type: 'A', value: 3}], B: [{type: 'B', value: 2}] } + */ +export const groupBy = (array: any[], key: string) => { + const innerKey = key.split("."); // split the key by dot + return array.reduce((result, currentValue) => { + const key = innerKey.reduce((obj, i) => obj?.[i], currentValue) ?? "None"; // get the value of the inner key + (result[key] = result[key] || []).push(currentValue); + return result; + }, {}); +}; + +/** + * @description Orders an array by a specified key in ascending or descending order + * @param {any[]} orgArray Original array to order + * @param {string} key Key to order by (supports dot notation for nested objects) + * @param {"ascending" | "descending"} ordering Sort order + * @returns {any[]} Ordered array + * @example + * const array = [{value: 2}, {value: 1}, {value: 3}]; + * orderArrayBy(array, 'value', 'ascending') // returns [{value: 1}, {value: 2}, {value: 3}] + */ +export const orderArrayBy = (orgArray: any[], key: string, ordering: "ascending" | "descending" = "ascending") => { + if (!orgArray || !Array.isArray(orgArray) || orgArray.length === 0) return []; + + const array = [...orgArray]; + + if (key[0] === "-") { + ordering = "descending"; + key = key.slice(1); + } + + const innerKey = key.split("."); // split the key by dot + + return array.sort((a, b) => { + const keyA = innerKey.reduce((obj, i) => obj[i], a); // get the value of the inner key + const keyB = innerKey.reduce((obj, i) => obj[i], b); // get the value of the inner key + if (keyA < keyB) { + return ordering === "ascending" ? -1 : 1; + } + if (keyA > keyB) { + return ordering === "ascending" ? 1 : -1; + } + return 0; + }); +}; + +/** + * @description Checks if an array contains duplicate values + * @param {any[]} array Array to check for duplicates + * @returns {boolean} True if duplicates exist, false otherwise + * @example + * checkDuplicates([1, 2, 2, 3]) // returns true + * checkDuplicates([1, 2, 3]) // returns false + */ +export const checkDuplicates = (array: any[]) => new Set(array).size !== array.length; + +/** + * @description Finds the string with the most characters in an array of strings + * @param {string[]} strings Array of strings to check + * @returns {string} String with the most characters + * @example + * findStringWithMostCharacters(['a', 'bb', 'ccc']) // returns 'ccc' + */ +export const findStringWithMostCharacters = (strings: string[]): string => { + if (!strings || strings.length === 0) return ""; + + return strings.reduce((longestString, currentString) => + currentString.length > longestString.length ? currentString : longestString + ); +}; + +/** + * @description Checks if two arrays have the same elements regardless of order + * @param {any[] | null} arr1 First array + * @param {any[] | null} arr2 Second array + * @returns {boolean} True if arrays have same elements, false otherwise + * @example + * checkIfArraysHaveSameElements([1, 2], [2, 1]) // returns true + * checkIfArraysHaveSameElements([1, 2], [1, 3]) // returns false + */ +export const checkIfArraysHaveSameElements = (arr1: any[] | null, arr2: any[] | null): boolean => { + if (!arr1 || !arr2) return false; + if (!Array.isArray(arr1) || !Array.isArray(arr2)) return false; + if (arr1.length === 0 && arr2.length === 0) return true; + + return arr1.length === arr2.length && arr1.every((e) => arr2.includes(e)); +}; + + +type GroupedItems = { [key: string]: T[] }; + +/** + * @description Groups an array of objects by a specified field + * @param {T[]} array Array to group + * @param {keyof T} field Field to group by + * @returns {GroupedItems} Grouped object + * @example + * const array = [{type: 'A', value: 1}, {type: 'B', value: 2}]; + * groupByField(array, 'type') // returns { A: [{type: 'A', value: 1}], B: [{type: 'B', value: 2}] } + */ +export const groupByField = (array: T[], field: keyof T): GroupedItems => + array.reduce((grouped: GroupedItems, item: T) => { + const key = String(item[field]); + grouped[key] = (grouped[key] || []).concat(item); + return grouped; + }, {}); + +/** + * @description Sorts an array of objects by a specified field + * @param {any[]} array Array to sort + * @param {string} field Field to sort by + * @returns {any[]} Sorted array + * @example + * const array = [{value: 2}, {value: 1}]; + * sortByField(array, 'value') // returns [{value: 1}, {value: 2}] + */ +export const sortByField = (array: any[], field: string): any[] => + array.sort((a, b) => (a[field] < b[field] ? -1 : a[field] > b[field] ? 1 : 0)); + +/** + * @description Orders grouped data by a specified field + * @param {GroupedItems} groupedData Grouped data object + * @param {keyof T} orderBy Field to order by + * @returns {GroupedItems} Ordered grouped data + */ +export const orderGroupedDataByField = (groupedData: GroupedItems, orderBy: keyof T): GroupedItems => { + for (const key in groupedData) { + if (groupedData.hasOwnProperty(key)) { + groupedData[key] = groupedData[key].sort((a, b) => { + if (a[orderBy] < b[orderBy]) return -1; + if (a[orderBy] > b[orderBy]) return 1; + return 0; + }); + } + } + return groupedData; +}; + +/** + * @description Builds a tree structure from an array of labels + * @param {IIssueLabel[]} array Array of labels + * @param {any} parent Parent ID + * @returns {IIssueLabelTree[]} Tree structure + */ +export const buildTree = (array: IIssueLabel[], parent = null) => { + const tree: IIssueLabelTree[] = []; + + array.forEach((item: any) => { + if (item.parent === parent) { + const children = buildTree(array, item.id); + item.children = children; + tree.push(item); + } + }); + + return tree; +}; + +/** + * @description Returns valid keys from object whose value is not falsy + * @param {any} obj Object to check + * @returns {string[]} Array of valid keys + * @example + * getValidKeysFromObject({a: 1, b: 0, c: null}) // returns ['a'] + */ +export const getValidKeysFromObject = (obj: any) => { + if (!obj || isEmpty(obj) || typeof obj !== "object" || Array.isArray(obj)) return []; + + return Object.keys(obj).filter((key) => !!obj[key]); +}; + +/** + * @description Converts an array of strings into an object with boolean true values + * @param {string[]} arrayStrings Array of strings + * @returns {Object} Object with string keys and boolean values + * @example + * convertStringArrayToBooleanObject(['a', 'b']) // returns {a: true, b: true} + */ +export const convertStringArrayToBooleanObject = (arrayStrings: string[]) => { + const obj: { [key: string]: boolean } = {}; + + for (const arrayString of arrayStrings) { + obj[arrayString] = true; + } + + return obj; +}; diff --git a/packages/utils/src/auth.ts b/packages/utils/src/auth.ts new file mode 100644 index 000000000..297b4c9ed --- /dev/null +++ b/packages/utils/src/auth.ts @@ -0,0 +1,387 @@ +import { ReactNode } from "react"; +import zxcvbn from "zxcvbn"; +import { + E_PASSWORD_STRENGTH, + SPACE_PASSWORD_CRITERIA, + PASSWORD_MIN_LENGTH, + EErrorAlertType, + EAuthErrorCodes, +} from "@plane/constants"; + +/** + * @description Password strength levels + */ +export enum PasswordStrength { + EMPTY = "empty", + WEAK = "weak", + FAIR = "fair", + GOOD = "good", + STRONG = "strong", +} + +/** + * @description Password strength criteria type + */ +export type PasswordCriterion = { + regex: RegExp; + description: string; +}; + +/** + * @description Password strength criteria + */ +export const PASSWORD_CRITERIA: PasswordCriterion[] = [ + { regex: /[a-z]/, description: "lowercase" }, + { regex: /[A-Z]/, description: "uppercase" }, + { regex: /[0-9]/, description: "number" }, + { regex: /[^a-zA-Z0-9]/, description: "special character" }, +]; + +/** + * @description Checks if password meets all criteria + * @param {string} password - Password to check + * @returns {boolean} Whether password meets all criteria + */ +export const checkPasswordCriteria = (password: string): boolean => + PASSWORD_CRITERIA.every((criterion) => criterion.regex.test(password)); + +/** + * @description Checks password strength against criteria + * @param {string} password - Password to check + * @returns {PasswordStrength} Password strength level + * @example + * checkPasswordStrength("abc") // returns PasswordStrength.WEAK + * checkPasswordStrength("Abc123!@#") // returns PasswordStrength.STRONG + */ +export const checkPasswordStrength = (password: string): PasswordStrength => { + if (!password || password.length === 0) return PasswordStrength.EMPTY; + if (password.length < PASSWORD_MIN_LENGTH) return PasswordStrength.WEAK; + + const criteriaCount = PASSWORD_CRITERIA.filter((criterion) => criterion.regex.test(password)).length; + + const zxcvbnScore = zxcvbn(password).score; + + if (criteriaCount <= 1 || zxcvbnScore <= 1) return PasswordStrength.WEAK; + if (criteriaCount === 2 || zxcvbnScore === 2) return PasswordStrength.FAIR; + if (criteriaCount === 3 || zxcvbnScore === 3) return PasswordStrength.GOOD; + return PasswordStrength.STRONG; +}; + +export type TAuthErrorInfo = { + type: EErrorAlertType; + code: EAuthErrorCodes; + title: string; + message: ReactNode; +}; + +// Password strength check +export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => { + let passwordStrength: E_PASSWORD_STRENGTH = E_PASSWORD_STRENGTH.EMPTY; + + if (!password || password === "" || password.length <= 0) { + return passwordStrength; + } + + if (password.length >= PASSWORD_MIN_LENGTH) { + passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID; + } else { + passwordStrength = E_PASSWORD_STRENGTH.LENGTH_NOT_VALID; + return passwordStrength; + } + + const passwordCriteriaValidation = SPACE_PASSWORD_CRITERIA.map((criteria) => + criteria.isCriteriaValid(password) + ).every((criterion) => criterion); + const passwordStrengthScore = zxcvbn(password).score; + + if (passwordCriteriaValidation === false || passwordStrengthScore <= 2) { + passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID; + return passwordStrength; + } + + if (passwordCriteriaValidation === true && passwordStrengthScore >= 3) { + passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_VALID; + } + + return passwordStrength; +}; + +// Error code messages +const errorCodeMessages: { + [key in EAuthErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode }; +} = { + // global + [EAuthErrorCodes.INSTANCE_NOT_CONFIGURED]: { + title: `Instance not configured`, + message: () => `Instance not configured. Please contact your administrator.`, + }, + [EAuthErrorCodes.SIGNUP_DISABLED]: { + title: `Sign up disabled`, + message: () => `Sign up disabled. Please contact your administrator.`, + }, + [EAuthErrorCodes.INVALID_PASSWORD]: { + title: `Invalid password`, + message: () => `Invalid password. Please try again.`, + }, + [EAuthErrorCodes.SMTP_NOT_CONFIGURED]: { + title: `SMTP not configured`, + message: () => `SMTP not configured. Please contact your administrator.`, + }, + // email check in both sign up and sign in + [EAuthErrorCodes.INVALID_EMAIL]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + [EAuthErrorCodes.EMAIL_REQUIRED]: { + title: `Email required`, + message: () => `Email required. Please try again.`, + }, + // sign up + [EAuthErrorCodes.USER_ALREADY_EXIST]: { + title: `User already exists`, + message: () => `Your account is already registered. Sign in now.`, + }, + [EAuthErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP]: { + title: `Email and password required`, + message: () => `Email and password required. Please try again.`, + }, + [EAuthErrorCodes.AUTHENTICATION_FAILED_SIGN_UP]: { + title: `Authentication failed`, + message: () => `Authentication failed. Please try again.`, + }, + [EAuthErrorCodes.INVALID_EMAIL_SIGN_UP]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + [EAuthErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED]: { + title: `Email and code required`, + message: () => `Email and code required. Please try again.`, + }, + [EAuthErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + // sign in + [EAuthErrorCodes.USER_ACCOUNT_DEACTIVATED]: { + title: `User account deactivated`, + message: () => `User account deactivated. Please contact administrator.`, + }, + [EAuthErrorCodes.USER_DOES_NOT_EXIST]: { + title: `User does not exist`, + message: () => `No account found. Create one to get started.`, + }, + [EAuthErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN]: { + title: `Email and password required`, + message: () => `Email and password required. Please try again.`, + }, + [EAuthErrorCodes.AUTHENTICATION_FAILED_SIGN_IN]: { + title: `Authentication failed`, + message: () => `Authentication failed. Please try again.`, + }, + [EAuthErrorCodes.INVALID_EMAIL_SIGN_IN]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + [EAuthErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED]: { + title: `Email and code required`, + message: () => `Email and code required. Please try again.`, + }, + [EAuthErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN]: { + title: `Invalid email`, + message: () => `Invalid email. Please try again.`, + }, + // Both Sign in and Sign up + [EAuthErrorCodes.INVALID_MAGIC_CODE_SIGN_IN]: { + title: `Authentication failed`, + message: () => `Invalid magic code. Please try again.`, + }, + [EAuthErrorCodes.INVALID_MAGIC_CODE_SIGN_UP]: { + title: `Authentication failed`, + message: () => `Invalid magic code. Please try again.`, + }, + [EAuthErrorCodes.EXPIRED_MAGIC_CODE_SIGN_IN]: { + title: `Expired magic code`, + message: () => `Expired magic code. Please try again.`, + }, + [EAuthErrorCodes.EXPIRED_MAGIC_CODE_SIGN_UP]: { + title: `Expired magic code`, + message: () => `Expired magic code. Please try again.`, + }, + [EAuthErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN]: { + title: `Expired magic code`, + message: () => `Expired magic code. Please try again.`, + }, + [EAuthErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP]: { + title: `Expired magic code`, + message: () => `Expired magic code. Please try again.`, + }, + // Oauth + [EAuthErrorCodes.OAUTH_NOT_CONFIGURED]: { + title: `OAuth not configured`, + message: () => `OAuth not configured. Please contact your administrator.`, + }, + [EAuthErrorCodes.GOOGLE_NOT_CONFIGURED]: { + title: `Google not configured`, + message: () => `Google not configured. Please contact your administrator.`, + }, + [EAuthErrorCodes.GITHUB_NOT_CONFIGURED]: { + title: `GitHub not configured`, + message: () => `GitHub not configured. Please contact your administrator.`, + }, + [EAuthErrorCodes.GITLAB_NOT_CONFIGURED]: { + title: `GitLab not configured`, + message: () => `GitLab not configured. Please contact your administrator.`, + }, + [EAuthErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR]: { + title: `Google OAuth provider error`, + message: () => `Google OAuth provider error. Please try again.`, + }, + [EAuthErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR]: { + title: `GitHub OAuth provider error`, + message: () => `GitHub OAuth provider error. Please try again.`, + }, + [EAuthErrorCodes.GITLAB_OAUTH_PROVIDER_ERROR]: { + title: `GitLab OAuth provider error`, + message: () => `GitLab OAuth provider error. Please try again.`, + }, + // Reset Password + [EAuthErrorCodes.INVALID_PASSWORD_TOKEN]: { + title: `Invalid password token`, + message: () => `Invalid password token. Please try again.`, + }, + [EAuthErrorCodes.EXPIRED_PASSWORD_TOKEN]: { + title: `Expired password token`, + message: () => `Expired password token. Please try again.`, + }, + // Change password + [EAuthErrorCodes.MISSING_PASSWORD]: { + title: `Password required`, + message: () => `Password required. Please try again.`, + }, + [EAuthErrorCodes.INCORRECT_OLD_PASSWORD]: { + title: `Incorrect old password`, + message: () => `Incorrect old password. Please try again.`, + }, + [EAuthErrorCodes.INVALID_NEW_PASSWORD]: { + title: `Invalid new password`, + message: () => `Invalid new password. Please try again.`, + }, + // set password + [EAuthErrorCodes.PASSWORD_ALREADY_SET]: { + title: `Password already set`, + message: () => `Password already set. Please try again.`, + }, + // admin + [EAuthErrorCodes.ADMIN_ALREADY_EXIST]: { + title: `Admin already exists`, + message: () => `Admin already exists. Please try again.`, + }, + [EAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: { + title: `Email, password and first name required`, + message: () => `Email, password and first name required. Please try again.`, + }, + [EAuthErrorCodes.INVALID_ADMIN_EMAIL]: { + title: `Invalid admin email`, + message: () => `Invalid admin email. Please try again.`, + }, + [EAuthErrorCodes.INVALID_ADMIN_PASSWORD]: { + title: `Invalid admin password`, + message: () => `Invalid admin password. Please try again.`, + }, + [EAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: { + title: `Email and password required`, + message: () => `Email and password required. Please try again.`, + }, + [EAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED]: { + title: `Authentication failed`, + message: () => `Authentication failed. Please try again.`, + }, + [EAuthErrorCodes.ADMIN_USER_ALREADY_EXIST]: { + title: `Admin user already exists`, + message: () => `Admin user already exists. Sign in now.`, + }, + [EAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: { + title: `Admin user does not exist`, + message: () => `Admin user does not exist. Sign in now.`, + }, + [EAuthErrorCodes.MAGIC_LINK_LOGIN_DISABLED]: { + title: `Magic link login disabled`, + message: () => `Magic link login is disabled. Please use password to login.`, + }, + [EAuthErrorCodes.PASSWORD_LOGIN_DISABLED]: { + title: `Password login disabled`, + message: () => `Password login is disabled. Please use magic link to login.`, + }, + [EAuthErrorCodes.ADMIN_USER_DEACTIVATED]: { + title: `Admin user deactivated`, + message: () => `Admin user account has been deactivated. Please contact administrator.`, + }, + [EAuthErrorCodes.RATE_LIMIT_EXCEEDED]: { + title: `Rate limit exceeded`, + message: () => `Too many requests. Please try again later.`, + }, +}; + +// Error handler +export const authErrorHandler = ( + errorCode: EAuthErrorCodes, + email?: string | undefined +): TAuthErrorInfo | undefined => { + const bannerAlertErrorCodes = [ + EAuthErrorCodes.INSTANCE_NOT_CONFIGURED, + EAuthErrorCodes.INVALID_EMAIL, + EAuthErrorCodes.EMAIL_REQUIRED, + EAuthErrorCodes.SIGNUP_DISABLED, + EAuthErrorCodes.INVALID_PASSWORD, + EAuthErrorCodes.SMTP_NOT_CONFIGURED, + EAuthErrorCodes.USER_ALREADY_EXIST, + EAuthErrorCodes.AUTHENTICATION_FAILED_SIGN_UP, + EAuthErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP, + EAuthErrorCodes.INVALID_EMAIL_SIGN_UP, + EAuthErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP, + EAuthErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED, + EAuthErrorCodes.USER_DOES_NOT_EXIST, + EAuthErrorCodes.AUTHENTICATION_FAILED_SIGN_IN, + EAuthErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN, + EAuthErrorCodes.INVALID_EMAIL_SIGN_IN, + EAuthErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN, + EAuthErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED, + EAuthErrorCodes.INVALID_MAGIC_CODE_SIGN_IN, + EAuthErrorCodes.INVALID_MAGIC_CODE_SIGN_UP, + EAuthErrorCodes.EXPIRED_MAGIC_CODE_SIGN_IN, + EAuthErrorCodes.EXPIRED_MAGIC_CODE_SIGN_UP, + EAuthErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN, + EAuthErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP, + EAuthErrorCodes.OAUTH_NOT_CONFIGURED, + EAuthErrorCodes.GOOGLE_NOT_CONFIGURED, + EAuthErrorCodes.GITHUB_NOT_CONFIGURED, + EAuthErrorCodes.GITLAB_NOT_CONFIGURED, + EAuthErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR, + EAuthErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR, + EAuthErrorCodes.GITLAB_OAUTH_PROVIDER_ERROR, + EAuthErrorCodes.INVALID_PASSWORD_TOKEN, + EAuthErrorCodes.EXPIRED_PASSWORD_TOKEN, + EAuthErrorCodes.INCORRECT_OLD_PASSWORD, + EAuthErrorCodes.INVALID_NEW_PASSWORD, + EAuthErrorCodes.PASSWORD_ALREADY_SET, + EAuthErrorCodes.ADMIN_ALREADY_EXIST, + EAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME, + EAuthErrorCodes.INVALID_ADMIN_EMAIL, + EAuthErrorCodes.INVALID_ADMIN_PASSWORD, + EAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD, + EAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED, + EAuthErrorCodes.ADMIN_USER_ALREADY_EXIST, + EAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST, + EAuthErrorCodes.USER_ACCOUNT_DEACTIVATED, + ]; + + if (bannerAlertErrorCodes.includes(errorCode)) + return { + type: EErrorAlertType.BANNER_ALERT, + code: errorCode, + title: errorCodeMessages[errorCode]?.title || "Error", + message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.", + }; + + return undefined; +}; diff --git a/packages/utils/src/color.ts b/packages/utils/src/color.ts index 702719c79..f97910efa 100644 --- a/packages/utils/src/color.ts +++ b/packages/utils/src/color.ts @@ -7,10 +7,16 @@ */ export type RGB = { r: number; g: number; b: number }; +export type HSL = { h: number; s: number; l: number }; + /** - * Validates and clamps color values to RGB range (0-255) + * @description Validates and clamps color values to RGB range (0-255) * @param {number} value - The color value to validate * @returns {number} Clamped and floored value between 0-255 + * @example + * validateColor(-10) // returns 0 + * validateColor(300) // returns 255 + * validateColor(128) // returns 128 */ export const validateColor = (value: number) => { if (value < 0) return 0; @@ -58,3 +64,81 @@ export const hexToRgb = (hex: string): RGB => { * rgbToHex({ r: 0, g: 0, b: 255 }) // returns "#0000ff" */ export const rgbToHex = ({ r, g, b }: RGB): string => `#${toHex(r)}${toHex(g)}${toHex(b)}`; + +/** + * Converts Hex values to HSL values + * @param {string} hex - The hexadecimal color code (e.g., "#ff0000" for red) + * @returns {HSL} An object containing the HSL values + * @example + * hexToHsl("#ff0000") // returns { h: 0, s: 100, l: 50 } + * hexToHsl("#00ff00") // returns { h: 120, s: 100, l: 50 } + * hexToHsl("#0000ff") // returns { h: 240, s: 100, l: 50 } + */ +export const hexToHsl = (hex: string): HSL => { + // return default value for invalid hex + if (!/^#[0-9A-Fa-f]{6}$/.test(hex)) return { h: 0, s: 0, l: 0 }; + + const r = parseInt(hex.slice(1, 3), 16) / 255; + const g = parseInt(hex.slice(3, 5), 16) / 255; + const b = parseInt(hex.slice(5, 7), 16) / 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h = 0; + let s = 0; + const l = (max + min) / 2; + + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + break; + } + h /= 6; + } + + return { + h: h * 360, + s: s * 100, + l: l * 100, + }; +}; + +/** + * Converts HSL values to a hexadecimal color code + * @param {HSL} hsl - An object containing HSL values + * @param {number} hsl.h - Hue component (0-360) + * @param {number} hsl.s - Saturation component (0-100) + * @param {number} hsl.l - Lightness component (0-100) + * @returns {string} The hexadecimal color code (e.g., "#ff0000" for red) + * @example + * hslToHex({ h: 0, s: 100, l: 50 }) // returns "#ff0000" + * hslToHex({ h: 120, s: 100, l: 50 }) // returns "#00ff00" + * hslToHex({ h: 240, s: 100, l: 50 }) // returns "#0000ff" + */ +export const hslToHex = ({ h, s, l }: HSL): string => { + if (h < 0 || h > 360) return "#000000"; + if (s < 0 || s > 100) return "#000000"; + if (l < 0 || l > 100) return "#000000"; + + l /= 100; + const a = (s * Math.min(l, 1 - l)) / 100; + + const f = (n: number) => { + const k = (n + h / 30) % 12; + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + return Math.round(255 * color) + .toString(16) + .padStart(2, "0"); + }; + + return `#${f(0)}${f(8)}${f(4)}`; +}; diff --git a/packages/utils/src/common.ts b/packages/utils/src/common.ts new file mode 100644 index 000000000..fb47656d3 --- /dev/null +++ b/packages/utils/src/common.ts @@ -0,0 +1,7 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +// Support email can be configured by the application +export const getSupportEmail = (defaultEmail: string = ""): string => defaultEmail; + +export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); diff --git a/packages/utils/src/datetime.ts b/packages/utils/src/datetime.ts new file mode 100644 index 000000000..0a12a2270 --- /dev/null +++ b/packages/utils/src/datetime.ts @@ -0,0 +1,335 @@ +import { differenceInDays, format, formatDistanceToNow, isAfter, isEqual, isValid, parseISO } from "date-fns"; + +/** + * This method returns a date from string of type yyyy-mm-dd + * This method is recommended to use instead of new Date() as this does not introduce any timezone offsets + * @param date + * @returns date or undefined + */ +export const getDate = (date: string | Date | undefined | null): Date | undefined => { + try { + if (!date || date === "") return; + + if (typeof date !== "string" && !(date instanceof String)) return date; + + const [yearString, monthString, dayString] = date.substring(0, 10).split("-"); + const year = parseInt(yearString); + const month = parseInt(monthString); + const day = parseInt(dayString); + // Using Number.isInteger instead of lodash's isNumber for better specificity and no external dependency + if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return; + + return new Date(year, month - 1, day); + } catch (e) { + return undefined; + } +}; + +/** + * @returns {string | null} formatted date in the format of MMM dd, yyyy + * @description Returns date in the formatted format + * @param {Date | string} date + * @example renderFormattedDate("2024-01-01") // Jan 01, 2024 + */ +/** + * @description Returns date in the formatted format + * @param {Date | string} date Date to format + * @param {string} formatToken Format token (optional, default: MMM dd, yyyy) + * @returns {string | undefined} Formatted date in the desired format + * @example + * renderFormattedDate("2024-01-01") // returns "Jan 01, 2024" + * renderFormattedDate("2024-01-01", "MM-DD-YYYY") // returns "01-01-2024" + */ +export const renderFormattedDate = ( + date: string | Date | undefined | null, + formatToken: string = "MMM dd, yyyy" +): string | undefined => { + // Parse the date to check if it is valid + const parsedDate = getDate(date); + // return if undefined + if (!parsedDate) return; + // Check if the parsed date is valid before formatting + if (!isValid(parsedDate)) return; // Return undefined for invalid dates + let formattedDate; + try { + // Format the date in the format provided or default format (MMM dd, yyyy) + formattedDate = format(parsedDate, formatToken); + } catch (e) { + // Format the date in format (MMM dd, yyyy) in case of any error + formattedDate = format(parsedDate, "MMM dd, yyyy"); + } + return formattedDate; +}; + +/** + * @description Returns total number of days in range + * @param {string | Date} startDate - Start date + * @param {string | Date} endDate - End date + * @param {boolean} inclusive - Include start and end dates (optional, default: true) + * @returns {number | undefined} Total number of days + * @example + * findTotalDaysInRange("2024-01-01", "2024-01-08") // returns 8 + */ +export const findTotalDaysInRange = ( + startDate: Date | string | undefined | null, + endDate: Date | string | undefined | null, + inclusive: boolean = true +): number | undefined => { + // Parse the dates to check if they are valid + const parsedStartDate = getDate(startDate); + const parsedEndDate = getDate(endDate); + // return if undefined + if (!parsedStartDate || !parsedEndDate) return; + // Check if the parsed dates are valid before calculating the difference + if (!isValid(parsedStartDate) || !isValid(parsedEndDate)) return 0; // Return 0 for invalid dates + // Calculate the difference in days + const diffInDays = differenceInDays(parsedEndDate, parsedStartDate); + // Return the difference in days based on inclusive flag + return inclusive ? diffInDays + 1 : diffInDays; +}; + +/** + * @description Add number of days to the provided date + * @param {string | Date} startDate - Start date + * @param {number} numberOfDays - Number of days to add + * @returns {Date | undefined} Resulting date + * @example + * addDaysToDate("2024-01-01", 7) // returns Date(2024-01-08) + */ +export const addDaysToDate = (startDate: Date | string | undefined | null, numberOfDays: number): Date | undefined => { + // Parse the dates to check if they are valid + const parsedStartDate = getDate(startDate); + // return if undefined + if (!parsedStartDate) return; + const newDate = new Date(parsedStartDate); + newDate.setDate(newDate.getDate() + numberOfDays); + return newDate; +}; + +/** + * @description Returns number of days left from today + * @param {string | Date} date - Target date + * @param {boolean} inclusive - Include today (optional, default: true) + * @returns {number | undefined} Number of days left + * @example + * findHowManyDaysLeft("2024-01-08") // returns days between today and Jan 8, 2024 + */ +export const findHowManyDaysLeft = ( + date: Date | string | undefined | null, + inclusive: boolean = true +): number | undefined => { + if (!date) return undefined; + return findTotalDaysInRange(new Date(), date, inclusive); +}; + +/** + * @description Returns time passed since the event happened + * @param {string | number | Date} time - Time to calculate from + * @returns {string} Formatted time ago string + * @example + * calculateTimeAgo("2023-01-01") // returns "1 year ago" + */ +export const calculateTimeAgo = (time: string | number | Date | null): string => { + if (!time) return ""; + const parsedTime = typeof time === "string" || typeof time === "number" ? parseISO(String(time)) : time; + if (!parsedTime) return ""; + const distance = formatDistanceToNow(parsedTime, { addSuffix: true }); + return distance; +}; + +/** + * @description Returns short form of time passed (e.g., 1y, 2mo, 3d) + * @param {string | number | Date} date - Date to calculate from + * @returns {string} Short form time ago + * @example + * calculateTimeAgoShort("2023-01-01") // returns "1y" + */ +export const calculateTimeAgoShort = (date: string | number | Date | null): string => { + if (!date) return ""; + + const parsedDate = typeof date === "string" ? parseISO(date) : new Date(date); + const now = new Date(); + const diffInSeconds = (now.getTime() - parsedDate.getTime()) / 1000; + + if (diffInSeconds < 60) return `${Math.floor(diffInSeconds)}s`; + const diffInMinutes = diffInSeconds / 60; + if (diffInMinutes < 60) return `${Math.floor(diffInMinutes)}m`; + const diffInHours = diffInMinutes / 60; + if (diffInHours < 24) return `${Math.floor(diffInHours)}h`; + const diffInDays = diffInHours / 24; + if (diffInDays < 30) return `${Math.floor(diffInDays)}d`; + const diffInMonths = diffInDays / 30; + if (diffInMonths < 12) return `${Math.floor(diffInMonths)}mo`; + const diffInYears = diffInMonths / 12; + return `${Math.floor(diffInYears)}y`; +}; + +/** + * @description Checks if a date is greater than today + * @param {string} dateStr - Date string to check + * @returns {boolean} True if date is greater than today + * @example + * isDateGreaterThanToday("2024-12-31") // returns true + */ +export const isDateGreaterThanToday = (dateStr: string): boolean => { + if (!dateStr) return false; + const date = parseISO(dateStr); + const today = new Date(); + if (!isValid(date)) return false; + return isAfter(date, today); +}; + +/** + * @description Returns week number of date + * @param {Date} date - Date to get week number from + * @returns {number} Week number (1-52) + * @example + * getWeekNumberOfDate(new Date("2023-09-01")) // returns 35 + */ +export const getWeekNumberOfDate = (date: Date): number => { + const currentDate = date; + const startDate = new Date(currentDate.getFullYear(), 0, 1); + const days = Math.floor((currentDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000)); + const weekNumber = Math.ceil((days + 1) / 7); + return weekNumber; +}; + +/** + * @description Checks if two dates are equal + * @param {Date | string} date1 - First date + * @param {Date | string} date2 - Second date + * @returns {boolean} True if dates are equal + * @example + * checkIfDatesAreEqual("2024-01-01", "2024-01-01") // returns true + */ +export const checkIfDatesAreEqual = ( + date1: Date | string | null | undefined, + date2: Date | string | null | undefined +): boolean => { + const parsedDate1 = getDate(date1); + const parsedDate2 = getDate(date2); + if (!parsedDate1 && !parsedDate2) return true; + if (!parsedDate1 || !parsedDate2) return false; + return isEqual(parsedDate1, parsedDate2); +}; + +/** + * @description Checks if a string matches date format YYYY-MM-DD + * @param {string} date - Date string to check + * @returns {boolean} True if string matches date format + * @example + * isInDateFormat("2024-01-01") // returns true + */ +export const isInDateFormat = (date: string): boolean => { + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + return datePattern.test(date); +}; + +/** + * @description Converts date string to ISO format + * @param {string} dateString - Date string to convert + * @returns {string | undefined} ISO date string + * @example + * convertToISODateString("2024-01-01") // returns "2024-01-01T00:00:00.000Z" + */ +export const convertToISODateString = (dateString: string | undefined): string | undefined => { + if (!dateString) return dateString; + const date = new Date(dateString); + return date.toISOString(); +}; + +/** + * @description Converts date string to epoch timestamp + * @param {string} dateString - Date string to convert + * @returns {number | undefined} Epoch timestamp + * @example + * convertToEpoch("2024-01-01") // returns 1704067200000 + */ +export const convertToEpoch = (dateString: string | undefined): number | undefined => { + if (!dateString) return undefined; + const date = new Date(dateString); + return date.getTime(); +}; + +/** + * @description Gets current date time in ISO format + * @returns {string} Current date time in ISO format + * @example + * getCurrentDateTimeInISO() // returns "2024-01-01T12:00:00.000Z" + */ +export const getCurrentDateTimeInISO = (): string => { + const date = new Date(); + return date.toISOString(); +}; + +/** + * @description Converts hours and minutes to total minutes + * @param {number} hours - Number of hours + * @param {number} minutes - Number of minutes + * @returns {number} Total minutes + * @example + * convertHoursMinutesToMinutes(2, 30) // returns 150 + */ +export const convertHoursMinutesToMinutes = (hours: number, minutes: number): number => hours * 60 + minutes; + +/** + * @description Converts total minutes to hours and minutes + * @param {number} mins - Total minutes + * @returns {{ hours: number; minutes: number }} Hours and minutes + * @example + * convertMinutesToHoursAndMinutes(150) // returns { hours: 2, minutes: 30 } + */ +export const convertMinutesToHoursAndMinutes = (mins: number): { hours: number; minutes: number } => { + const hours = Math.floor(mins / 60); + const minutes = Math.floor(mins % 60); + return { hours, minutes }; +}; + +/** + * @description Converts minutes to hours and minutes string + * @param {number} totalMinutes - Total minutes + * @returns {string} Formatted string (e.g., "2h 30m") + * @example + * convertMinutesToHoursMinutesString(150) // returns "2h 30m" + */ +export const convertMinutesToHoursMinutesString = (totalMinutes: number): string => { + const { hours, minutes } = convertMinutesToHoursAndMinutes(totalMinutes); + return `${hours ? `${hours}h ` : ``}${minutes ? `${minutes}m ` : ``}`; +}; + +/** + * @description Calculates read time in seconds from word count + * @param {number} wordsCount - Number of words + * @returns {number} Read time in seconds + * @example + * getReadTimeFromWordsCount(400) // returns 120 + */ +export const getReadTimeFromWordsCount = (wordsCount: number): number => { + const wordsPerMinute = 200; + const minutes = wordsCount / wordsPerMinute; + return minutes * 60; +}; + +/** + * @description Generates array of dates between start and end dates + * @param {string | Date} startDate - Start date + * @param {string | Date} endDate - End date + * @returns {Array<{ date: string }>} Array of dates + * @example + * generateDateArray("2024-01-01", "2024-01-03") + * // returns [{ date: "2024-01-02" }, { date: "2024-01-03" }] + */ +export const generateDateArray = (startDate: string | Date, endDate: string | Date): Array<{ date: string }> => { + const start = new Date(startDate); + const end = new Date(endDate); + end.setDate(end.getDate() + 1); + + const dateArray = []; + while (start <= end) { + start.setDate(start.getDate() + 1); + dateArray.push({ + date: new Date(start).toISOString().split("T")[0], + }); + } + return dateArray; +}; diff --git a/packages/utils/src/emoji.ts b/packages/utils/src/emoji.ts index 306d4afef..9b796575a 100644 --- a/packages/utils/src/emoji.ts +++ b/packages/utils/src/emoji.ts @@ -38,3 +38,27 @@ export const emojiCodeToUnicode = (emoji: string): string => { return uniCodeEmoji; }; + +/** + * Groups reactions by a specified key + * @param {T[]} reactions - Array of reaction objects + * @param {string} key - Key to group reactions by + * @returns {Object} Object with reactions grouped by the specified key + * @example + * const reactions = [{ reaction: "👍", id: 1 }, { reaction: "👍", id: 2 }, { reaction: "❤️", id: 3 }]; + * groupReactions(reactions, "reaction") // returns { "👍": [{ reaction: "👍", id: 1 }, { reaction: "👍", id: 2 }], "❤️": [{ reaction: "❤️", id: 3 }] } + */ +export const groupReactions = (reactions: T[], key: string): { [key: string]: T[] } => { + const groupedReactions = reactions.reduce( + (acc: { [key: string]: T[] }, reaction: T) => { + if (!acc[reaction[key as keyof T] as string]) { + acc[reaction[key as keyof T] as string] = []; + } + acc[reaction[key as keyof T] as string].push(reaction); + return acc; + }, + {} as { [key: string]: T[] } + ); + + return groupedReactions; +}; diff --git a/packages/utils/src/file.ts b/packages/utils/src/file.ts new file mode 100644 index 000000000..42b52bf48 --- /dev/null +++ b/packages/utils/src/file.ts @@ -0,0 +1,49 @@ +import { API_BASE_URL } from "@plane/constants"; +import { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types"; + +/** + * @description combine the file path with the base URL + * @param {string} path + * @returns {string} final URL with the base URL + */ +export const getFileURL = (path: string): string | undefined => { + if (!path) return undefined; + const isValidURL = path.startsWith("http"); + if (isValidURL) return path; + return `${API_BASE_URL}${path}`; +}; + +/** + * @description from the provided signed URL response, generate a payload to be used to upload the file + * @param {TFileSignedURLResponse} signedURLResponse + * @param {File} file + * @returns {FormData} file upload request payload + */ +export const generateFileUploadPayload = (signedURLResponse: TFileSignedURLResponse, file: File): FormData => { + const formData = new FormData(); + Object.entries(signedURLResponse.upload_data.fields).forEach(([key, value]) => formData.append(key, value)); + formData.append("file", file); + return formData; +}; + +/** + * @description returns the necessary file meta data to upload a file + * @param {File} file + * @returns {TFileMetaDataLite} payload with file info + */ +export const getFileMetaDataForUpload = (file: File): TFileMetaDataLite => ({ + name: file.name, + size: file.size, + type: file.type, +}); + +/** + * @description this function returns the assetId from the asset source + * @param {string} src + * @returns {string} assetId + */ +export const getAssetIdFromUrl = (src: string): string => { + const sourcePaths = src.split("/"); + const assetUrl = sourcePaths[sourcePaths.length - 1]; + return assetUrl; +}; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 7e63eed79..7ae269318 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,3 +1,13 @@ +export * from "./array"; +export * from "./auth"; +export * from "./datetime"; export * from "./color"; +export * from "./common"; +export * from "./datetime"; export * from "./emoji"; +export * from "./file"; +export * from "./issue"; +export * from "./state"; export * from "./string"; +export * from "./theme"; +export * from "./workspace"; diff --git a/packages/utils/src/issue.ts b/packages/utils/src/issue.ts new file mode 100644 index 000000000..0fc5d5261 --- /dev/null +++ b/packages/utils/src/issue.ts @@ -0,0 +1,37 @@ +import { differenceInCalendarDays } from "date-fns/differenceInCalendarDays"; +import { ISSUE_PRIORITY_FILTERS, STATE_GROUPS, TIssuePriorities, TIssueFilterPriorityObject } from "@plane/constants"; +import { TStateGroups } from "@plane/types"; +import { getDate } from "./datetime"; + +export const getIssuePriorityFilters = (priorityKey: TIssuePriorities): TIssueFilterPriorityObject | undefined => { + const currentIssuePriority: TIssueFilterPriorityObject | undefined = + ISSUE_PRIORITY_FILTERS && ISSUE_PRIORITY_FILTERS.length > 0 + ? ISSUE_PRIORITY_FILTERS.find((_priority) => _priority.key === priorityKey) + : undefined; + + if (currentIssuePriority) return currentIssuePriority; + return undefined; +}; + +/** + * @description check if the issue due date should be highlighted + * @param date + * @param stateGroup + * @returns boolean + */ +export const shouldHighlightIssueDueDate = ( + date: string | Date | null, + stateGroup: TStateGroups | undefined +): boolean => { + if (!date || !stateGroup) return false; + // if the issue is completed or cancelled, don't highlight the due date + if ([STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateGroup)) return false; + + const parsedDate = getDate(date); + if (!parsedDate) return false; + + const targetDateDistance = differenceInCalendarDays(parsedDate, new Date()); + + // if the issue is overdue, highlight the due date + return targetDateDistance <= 0; +}; diff --git a/packages/utils/src/state.ts b/packages/utils/src/state.ts new file mode 100644 index 000000000..8d97c39f6 --- /dev/null +++ b/packages/utils/src/state.ts @@ -0,0 +1,13 @@ +import { STATE_GROUPS } from "@plane/constants"; +import { IState } from "@plane/types"; + +export const sortStates = (states: IState[]) => { + if (!states || states.length === 0) return; + + return states.sort((stateA, stateB) => { + if (stateA.group === stateB.group) { + return stateA.sequence - stateB.sequence; + } + return Object.keys(STATE_GROUPS).indexOf(stateA.group) - Object.keys(STATE_GROUPS).indexOf(stateB.group); + }); +}; diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index c3c8b1541..2fc52a254 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -1,5 +1,184 @@ import DOMPurify from "isomorphic-dompurify"; +/** + * @description Adds space between camelCase words + * @param {string} str - String to add spaces to + * @returns {string} String with spaces between camelCase words + * @example + * addSpaceIfCamelCase("camelCase") // returns "camel Case" + * addSpaceIfCamelCase("thisIsATest") // returns "this Is A Test" + */ +export const addSpaceIfCamelCase = (str: string) => { + if (str === undefined || str === null) return ""; + + if (typeof str !== "string") str = `${str}`; + + return str.replace(/([a-z])([A-Z])/g, "$1 $2"); +}; + +/** + * @description Replaces underscores with spaces in snake_case strings + * @param {string} str - String to replace underscores in + * @returns {string} String with underscores replaced by spaces + * @example + * replaceUnderscoreIfSnakeCase("snake_case") // returns "snake case" + */ +export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " "); + +/** + * @description Truncates text to specified length and adds ellipsis + * @param {string} str - String to truncate + * @param {number} length - Maximum length before truncation + * @returns {string} Truncated string with ellipsis if needed + * @example + * truncateText("This is a long text", 7) // returns "This is..." + */ +export const truncateText = (str: string, length: number) => { + if (!str || str === "") return ""; + + return str.length > length ? `${str.substring(0, length)}...` : str; +}; + +/** + * @description Creates a similar string by randomly shuffling characters + * @param {string} str - String to shuffle + * @returns {string} Shuffled string with same characters + * @example + * createSimilarString("hello") // might return "olleh" or "lehol" + */ +export const createSimilarString = (str: string) => { + const shuffled = str + .split("") + .sort(() => Math.random() - 0.5) + .join(""); + + return shuffled; +}; + +/** + * @description Copies full URL (origin + path) to clipboard + * @param {string} path - URL path to copy + * @returns {Promise} Promise that resolves when copying is complete + * @example + * await copyUrlToClipboard("issues/123") // copies "https://example.com/issues/123" + */ +/** + * @description Copies text to clipboard + * @param {string} text - Text to copy + * @returns {Promise} Promise that resolves when copying is complete + * @example + * await copyTextToClipboard("Hello, World!") // copies "Hello, World!" to clipboard + */ +export const copyTextToClipboard = async (text: string): Promise => { + if (typeof navigator === "undefined") return; + try { + await navigator.clipboard.writeText(text); + } catch (err) { + console.error("Failed to copy text: ", err); + } +}; + +/** + * @description Copies full URL (origin + path) to clipboard + * @param {string} path - URL path to copy + * @returns {Promise} Promise that resolves when copying is complete + * @example + * await copyUrlToClipboard("issues/123") // copies "https://example.com/issues/123" + */ +export const copyUrlToClipboard = async (path: string) => { + const originUrl = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + await copyTextToClipboard(`${originUrl}/${path}`); +}; + +/** + * @description Generates a deterministic HSL color based on input string + * @param {string} string - Input string to generate color from + * @returns {string} HSL color string + * @example + * generateRandomColor("hello") // returns consistent HSL color for "hello" + * generateRandomColor("") // returns "rgb(var(--color-primary-100))" + */ +export const generateRandomColor = (string: string): string => { + if (!string) return "rgb(var(--color-primary-100))"; + + string = `${string}`; + + const uniqueId = string.length.toString() + string; + const combinedString = uniqueId + string; + + const hash = Array.from(combinedString).reduce((acc, char) => { + const charCode = char.charCodeAt(0); + return (acc << 5) - acc + charCode; + }, 0); + + const hue = hash % 360; + const saturation = 70; + const lightness = 60; + + const randomColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`; + + return randomColor; +}; + +/** + * @description Gets first character of first word or first characters of first two words + * @param {string} str - Input string + * @returns {string} First character(s) + * @example + * getFirstCharacters("John") // returns "J" + * getFirstCharacters("John Doe") // returns "JD" + */ +export const getFirstCharacters = (str: string) => { + const words = str.trim().split(" "); + if (words.length === 1) { + return words[0].charAt(0); + } else { + return words[0].charAt(0) + words[1].charAt(0); + } +}; + +/** + * @description Formats number count, showing "99+" for numbers over 99 + * @param {number} number - Number to format + * @returns {string} Formatted number string + * @example + * getNumberCount(50) // returns "50" + * getNumberCount(100) // returns "99+" + */ +export const getNumberCount = (number: number): string => { + if (number > 99) { + return "99+"; + } + return number.toString(); +}; + +/** + * @description Converts object to URL query parameters string + * @param {Object} obj - Object to convert + * @returns {string} URL query parameters string + * @example + * objToQueryParams({ page: 1, search: "test" }) // returns "page=1&search=test" + * objToQueryParams({ a: null, b: "test" }) // returns "b=test" + */ +export const objToQueryParams = (obj: any) => { + const params = new URLSearchParams(); + + if (!obj) return params.toString(); + + for (const [key, value] of Object.entries(obj)) { + if (value !== undefined && value !== null) params.append(key, value as string); + } + + return params.toString(); +}; + +/** + * @description: This function will capitalize the first letter of a string + * @param str String + * @returns String + */ +export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); + /** * @description: This function will remove all the HTML tags from the string * @param {string} html @@ -9,7 +188,134 @@ import DOMPurify from "isomorphic-dompurify"; * const text = stripHTML(html); * console.log(text); // Some text */ +/** + * @description Sanitizes HTML string by removing tags and properly escaping entities + * @param {string} htmlString - HTML string to sanitize + * @returns {string} Sanitized string with escaped HTML entities + * @example + * sanitizeHTML("

    Hello & 'world'

    ") // returns "Hello & 'world'" + */ export const sanitizeHTML = (htmlString: string) => { - const sanitizedText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: [] }); // sanitize the string to remove all HTML tags - return sanitizedText.trim(); // trim the string to remove leading and trailing whitespaces + if (!htmlString) return ""; + + // First use DOMPurify to remove all HTML tags while preserving text content + const sanitizedText = DOMPurify.sanitize(htmlString, { + ALLOWED_TAGS: [], + ALLOWED_ATTR: [], + USE_PROFILES: { + html: false, + svg: false, + svgFilters: false, + mathMl: false, + }, + }); + + // Additional escaping for quotes and apostrophes + return sanitizedText.trim().replace(/'/g, "'").replace(/"/g, """); }; + +/** + * @returns {boolean} true if email is valid, false otherwise + * @description Returns true if email is valid, false otherwise + * @param {string} email string to check if it is a valid email + * @example checkEmailValidity("hello world") => false + * @example checkEmailValidity("example@plane.so") => true + */ +export const checkEmailValidity = (email: string): boolean => { + if (!email) return false; + + const isEmailValid = + /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + email + ); + + return isEmailValid; +}; + +export const isEmptyHtmlString = (htmlString: string, allowedHTMLTags: string[] = []) => { + // Remove HTML tags using DOMPurify + const cleanText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: allowedHTMLTags }); + // Trim the string and check if it's empty + return cleanText.trim() === ""; +}; + +/** + * @description this function returns whether a comment is empty or not by checking for the following conditions- + * 1. If comment is undefined + * 2. If comment is an empty string + * 3. If comment is "

    " + * @param {string | undefined} comment + * @returns {boolean} + */ +export const isCommentEmpty = (comment: string | undefined): boolean => { + // return true if comment is undefined + if (!comment) return true; + return ( + comment?.trim() === "" || + comment === "

    " || + isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component"]) + ); +}; + +/** + * @description + * This function test whether a URL is valid or not. + * + * It accepts URLs with or without the protocol. + * @param {string} url + * @returns {boolean} + * @example + * checkURLValidity("https://example.com") => true + * checkURLValidity("example.com") => true + * checkURLValidity("example") => false + */ +export const checkURLValidity = (url: string): boolean => { + if (!url) return false; + + // regex to support complex query parameters and fragments + const urlPattern = + /^(https?:\/\/)?((([a-z\d-]+\.)*[a-z\d-]+\.[a-z]{2,6})|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(:\d+)?(\/[\w.-]*)*(\?[^#\s]*)?(#[\w-]*)?$/i; + + return urlPattern.test(url); +}; + +// Browser-only clipboard functions +// let copyTextToClipboard: (text: string) => Promise; + +// if (typeof window !== "undefined") { +// const fallbackCopyTextToClipboard = (text: string) => { +// const textArea = document.createElement("textarea"); +// textArea.value = text; + +// // Avoid scrolling to bottom +// textArea.style.top = "0"; +// textArea.style.left = "0"; +// textArea.style.position = "fixed"; + +// document.body.appendChild(textArea); +// textArea.focus(); +// textArea.select(); + +// try { +// // FIXME: Even though we are using this as a fallback, execCommand is deprecated 👎. We should find a better way to do this. +// // https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand +// document.execCommand("copy"); +// } catch (err) {} + +// document.body.removeChild(textArea); +// }; + +// copyTextToClipboard = async (text: string) => { +// if (!navigator.clipboard) { +// fallbackCopyTextToClipboard(text); +// return; +// } +// await navigator.clipboard.writeText(text); +// }; +// } else { +// copyTextToClipboard = async () => { +// throw new Error("copyTextToClipboard is only available in browser environments"); +// }; +// } + +// export { copyTextToClipboard }; diff --git a/packages/utils/src/theme.ts b/packages/utils/src/theme.ts new file mode 100644 index 000000000..1f2c22b02 --- /dev/null +++ b/packages/utils/src/theme.ts @@ -0,0 +1,2 @@ +export const resolveGeneralTheme = (resolvedTheme: string | undefined) => + resolvedTheme?.includes("light") ? "light" : resolvedTheme?.includes("dark") ? "dark" : "system"; diff --git a/packages/utils/src/workspace.ts b/packages/utils/src/workspace.ts new file mode 100644 index 000000000..1fb74e89c --- /dev/null +++ b/packages/utils/src/workspace.ts @@ -0,0 +1,5 @@ +// plane imports +import { IWorkspace } from "@plane/types"; + +export const orderWorkspacesList = (workspaces: IWorkspace[]): IWorkspace[] => + workspaces.sort((a, b) => a.name.localeCompare(b.name)); diff --git a/space/.eslintrc.js b/space/.eslintrc.js index 58822f90b..5a6f06067 100644 --- a/space/.eslintrc.js +++ b/space/.eslintrc.js @@ -3,8 +3,4 @@ module.exports = { root: true, extends: ["@plane/eslint-config/next.js"], parser: "@typescript-eslint/parser", - parserOptions: { - project: true, - }, - rules: {}, }; diff --git a/space/Dockerfile.dev b/space/Dockerfile.dev index 213f3fb3c..b7e42dab8 100644 --- a/space/Dockerfile.dev +++ b/space/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM node:18-alpine +FROM node:20-alpine RUN apk add --no-cache libc6-compat # Set working directory WORKDIR /app diff --git a/space/Dockerfile.space b/space/Dockerfile.space index 4e53cfc8a..ecb3fbec7 100644 --- a/space/Dockerfile.space +++ b/space/Dockerfile.space @@ -1,7 +1,8 @@ +FROM node:20-alpine as base # ***************************************************************************** # STAGE 1: Build the project # ***************************************************************************** -FROM node:18-alpine AS builder +FROM base AS builder RUN apk add --no-cache libc6-compat WORKDIR /app @@ -13,7 +14,7 @@ RUN turbo prune --scope=space --docker # ***************************************************************************** # STAGE 2: Install dependencies & build the project # ***************************************************************************** -FROM node:18-alpine AS installer +FROM base AS installer RUN apk add --no-cache libc6-compat WORKDIR /app @@ -49,7 +50,7 @@ RUN yarn turbo run build --filter=space # ***************************************************************************** # STAGE 3: Copy the project and start it # ***************************************************************************** -FROM node:18-alpine AS runner +FROM base AS runner WORKDIR /app COPY --from=installer /app/space/next.config.js . diff --git a/space/app/[workspaceSlug]/[projectId]/page.ts b/space/app/[workspaceSlug]/[projectId]/page.ts index 1f8b8345d..5fbb835dc 100644 --- a/space/app/[workspaceSlug]/[projectId]/page.ts +++ b/space/app/[workspaceSlug]/[projectId]/page.ts @@ -1,10 +1,9 @@ import { notFound, redirect } from "next/navigation"; -// types +// plane imports +import { SitesProjectPublishService } from "@plane/services"; import { TProjectPublishSettings } from "@plane/types"; -// services -import PublishService from "@/services/publish.service"; -const publishService = new PublishService(); +const publishService = new SitesProjectPublishService(); type Props = { params: { @@ -22,7 +21,7 @@ export default async function IssuesPage(props: Props) { let response: TProjectPublishSettings | undefined = undefined; try { - response = await publishService.fetchAnchorFromProjectDetails(workspaceSlug, projectId); + response = await publishService.retrieveSettingsByProjectId(workspaceSlug, projectId); } catch (error) { // redirect to 404 page on error notFound(); diff --git a/space/app/layout.tsx b/space/app/layout.tsx index e457ae5d1..60a7287eb 100644 --- a/space/app/layout.tsx +++ b/space/app/layout.tsx @@ -15,7 +15,7 @@ export const metadata: Metadata = { url: "https://sites.plane.so/", }, keywords: - "software development, customer feedback, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration", + "software development, customer feedback, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration", twitter: { site: "@planepowers", }, @@ -32,7 +32,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - {children} + + <>{children} + ); diff --git a/space/app/provider.tsx b/space/app/provider.tsx index c3ab6673f..e8566bc9d 100644 --- a/space/app/provider.tsx +++ b/space/app/provider.tsx @@ -2,6 +2,7 @@ import { FC, ReactNode } from "react"; // components +import { TranslationProvider } from "@plane/i18n"; import { InstanceProvider } from "@/lib/instance-provider"; import { StoreProvider } from "@/lib/store-provider"; import { ToastProvider } from "@/lib/toast-provider"; @@ -15,9 +16,11 @@ export const AppProvider: FC = (props) => { return ( - - {children} - + + + {children} + + ); }; diff --git a/space/ce/components/editor/embeds/index.ts b/space/ce/components/editor/embeds/index.ts new file mode 100644 index 000000000..8146e94d9 --- /dev/null +++ b/space/ce/components/editor/embeds/index.ts @@ -0,0 +1 @@ +export * from "./mentions"; diff --git a/space/ce/components/editor/embeds/mentions/index.ts b/space/ce/components/editor/embeds/mentions/index.ts new file mode 100644 index 000000000..1efe34c51 --- /dev/null +++ b/space/ce/components/editor/embeds/mentions/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/space/ce/components/editor/embeds/mentions/root.tsx b/space/ce/components/editor/embeds/mentions/root.tsx new file mode 100644 index 000000000..16e21f848 --- /dev/null +++ b/space/ce/components/editor/embeds/mentions/root.tsx @@ -0,0 +1,4 @@ +// plane editor +import { TMentionComponentProps } from "@plane/editor"; + +export const EditorAdditionalMentionsRoot: React.FC = () => null; diff --git a/space/ce/components/editor/index.ts b/space/ce/components/editor/index.ts new file mode 100644 index 000000000..cf8352ae4 --- /dev/null +++ b/space/ce/components/editor/index.ts @@ -0,0 +1 @@ +export * from "./embeds"; diff --git a/space/core/components/account/auth-forms/auth-header.tsx b/space/core/components/account/auth-forms/auth-header.tsx index 8921c70b8..95a539ddf 100644 --- a/space/core/components/account/auth-forms/auth-header.tsx +++ b/space/core/components/account/auth-forms/auth-header.tsx @@ -25,7 +25,7 @@ const Titles: TAuthHeaderDetails = { }, [EAuthModes.SIGN_UP]: { header: "View, comment, and do more", - subHeader: "Sign up or log in to work with Plane Issues and Pages.", + subHeader: "Sign up or log in to work with Plane work items and Pages.", }, }; @@ -38,7 +38,7 @@ export const AuthHeader: FC = (props) => { } return { - header: "Comment or react to issues", + header: "Comment or react to work itemss", subHeader: "Use plane to add your valuable inputs to features.", }; }; diff --git a/space/core/components/account/auth-forms/auth-root.tsx b/space/core/components/account/auth-forms/auth-root.tsx index afa3bd3a5..2ce944a25 100644 --- a/space/core/components/account/auth-forms/auth-root.tsx +++ b/space/core/components/account/auth-forms/auth-root.tsx @@ -3,6 +3,8 @@ import React, { FC, useEffect, useState } from "react"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; +// plane imports +import { SitesAuthService } from "@plane/services"; import { IEmailCheckData } from "@plane/types"; // components import { @@ -23,12 +25,10 @@ import { } from "@/helpers/authentication.helper"; // hooks import { useInstance } from "@/hooks/store"; -// services -import { AuthService } from "@/services/auth.service"; // types import { EAuthModes, EAuthSteps } from "@/types/auth"; -const authService = new AuthService(); +const authService = new SitesAuthService(); export const AuthRoot: FC = observer(() => { // router params diff --git a/space/core/components/account/auth-forms/password.tsx b/space/core/components/account/auth-forms/password.tsx index 5f0384f9b..08ff7f142 100644 --- a/space/core/components/account/auth-forms/password.tsx +++ b/space/core/components/account/auth-forms/password.tsx @@ -3,14 +3,14 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { observer } from "mobx-react"; import { Eye, EyeOff, XCircle } from "lucide-react"; +// plane imports import { API_BASE_URL } from "@plane/constants"; +import { AuthService } from "@plane/services"; import { Button, Input, Spinner } from "@plane/ui"; // components import { PasswordStrengthMeter } from "@/components/account"; // helpers import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper"; -// services -import { AuthService } from "@/services/auth.service"; // types import { EAuthModes, EAuthSteps } from "@/types/auth"; diff --git a/space/core/components/account/auth-forms/unique-code.tsx b/space/core/components/account/auth-forms/unique-code.tsx index f16796d05..750b52ccd 100644 --- a/space/core/components/account/auth-forms/unique-code.tsx +++ b/space/core/components/account/auth-forms/unique-code.tsx @@ -2,12 +2,12 @@ import React, { useEffect, useState } from "react"; import { CircleCheck, XCircle } from "lucide-react"; +// plane imports import { API_BASE_URL } from "@plane/constants"; +import { AuthService } from "@plane/services"; import { Button, Input, Spinner } from "@plane/ui"; // hooks import useTimer from "@/hooks/use-timer"; -// services -import { AuthService } from "@/services/auth.service"; // types import { EAuthModes } from "@/types/auth"; diff --git a/space/core/components/editor/embeds/index.ts b/space/core/components/editor/embeds/index.ts new file mode 100644 index 000000000..8146e94d9 --- /dev/null +++ b/space/core/components/editor/embeds/index.ts @@ -0,0 +1 @@ +export * from "./mentions"; diff --git a/space/core/components/editor/embeds/mentions/index.ts b/space/core/components/editor/embeds/mentions/index.ts new file mode 100644 index 000000000..1efe34c51 --- /dev/null +++ b/space/core/components/editor/embeds/mentions/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/space/core/components/editor/embeds/mentions/root.tsx b/space/core/components/editor/embeds/mentions/root.tsx new file mode 100644 index 000000000..9ea5ef6fb --- /dev/null +++ b/space/core/components/editor/embeds/mentions/root.tsx @@ -0,0 +1,17 @@ +// plane editor +import { TMentionComponentProps } from "@plane/editor"; +// plane web components +import { EditorAdditionalMentionsRoot } from "@/plane-web/components/editor"; +// local components +import { EditorUserMention } from "./user"; + +export const EditorMentionsRoot: React.FC = (props) => { + const { entity_identifier, entity_name } = props; + + switch (entity_name) { + case "user_mention": + return ; + default: + return ; + } +}; diff --git a/space/core/components/editor/embeds/mentions/user.tsx b/space/core/components/editor/embeds/mentions/user.tsx new file mode 100644 index 000000000..5a178396b --- /dev/null +++ b/space/core/components/editor/embeds/mentions/user.tsx @@ -0,0 +1,39 @@ +import { observer } from "mobx-react"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useMember, useUser } from "@/hooks/store"; + +type Props = { + id: string; +}; + +export const EditorUserMention: React.FC = observer((props) => { + const { id } = props; + // store hooks + const { data: currentUser } = useUser(); + const { getMemberById } = useMember(); + // derived values + const userDetails = getMemberById(id); + + if (!userDetails) { + return ( +
    + @deactivated user +
    + ); + } + + return ( +
    + @{userDetails?.member__display_name} +
    + ); +}); diff --git a/space/core/components/editor/index.ts b/space/core/components/editor/index.ts index 4ec0141e2..894daf224 100644 --- a/space/core/components/editor/index.ts +++ b/space/core/components/editor/index.ts @@ -1,3 +1,4 @@ +export * from "./embeds"; export * from "./lite-text-editor"; export * from "./lite-text-read-only-editor"; export * from "./rich-text-read-only-editor"; diff --git a/space/core/components/editor/lite-text-editor.tsx b/space/core/components/editor/lite-text-editor.tsx index 5d5027135..9f2cda4ad 100644 --- a/space/core/components/editor/lite-text-editor.tsx +++ b/space/core/components/editor/lite-text-editor.tsx @@ -1,14 +1,12 @@ import React from "react"; // editor -import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor"; +import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TFileHandler } from "@plane/editor"; // components -import { IssueCommentToolbar } from "@/components/editor"; +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"; -// hooks -import { useMention } from "@/hooks/use-mention"; interface LiteTextEditorWrapperProps extends Omit { @@ -16,7 +14,7 @@ interface LiteTextEditorWrapperProps workspaceId: string; isSubmitting?: boolean; showSubmitButton?: boolean; - uploadFile: (file: File) => Promise; + uploadFile: TFileHandler["upload"]; } export const LiteTextEditor = React.forwardRef((props, ref) => { @@ -29,8 +27,6 @@ export const LiteTextEditor = React.forwardRef(ref: React.ForwardedRef): ref is React.MutableRefObject { return !!ref && typeof ref === "object" && "current" in ref; } @@ -49,8 +45,7 @@ export const LiteTextEditor = React.forwardRef , }} {...rest} // overriding the containerClassName to add relative class passed diff --git a/space/core/components/editor/lite-text-read-only-editor.tsx b/space/core/components/editor/lite-text-read-only-editor.tsx index 014f4010c..f9889f2ab 100644 --- a/space/core/components/editor/lite-text-read-only-editor.tsx +++ b/space/core/components/editor/lite-text-read-only-editor.tsx @@ -1,39 +1,37 @@ import React from "react"; // editor import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef } from "@plane/editor"; +// components +import { EditorMentionsRoot } from "@/components/editor"; // helpers import { cn } from "@/helpers/common.helper"; import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper"; -// hooks -import { useMention } from "@/hooks/use-mention"; type LiteTextReadOnlyEditorWrapperProps = Omit< ILiteTextReadOnlyEditor, "disabledExtensions" | "fileHandler" | "mentionHandler" > & { anchor: string; + workspaceId: string; }; export const LiteTextReadOnlyEditor = React.forwardRef( - ({ anchor, ...props }, ref) => { - const { mentionHighlights } = useMention(); - - return ( - - ); - } + ({ anchor, workspaceId, ...props }, ref) => ( + , + }} + {...props} + // overriding the customClassName to add relative class passed + containerClassName={cn(props.containerClassName, "relative p-2")} + /> + ) ); LiteTextReadOnlyEditor.displayName = "LiteTextReadOnlyEditor"; diff --git a/space/core/components/editor/rich-text-editor.tsx b/space/core/components/editor/rich-text-editor.tsx index cfe2e1b7f..c9e795d7b 100644 --- a/space/core/components/editor/rich-text-editor.tsx +++ b/space/core/components/editor/rich-text-editor.tsx @@ -1,36 +1,32 @@ import React, { forwardRef } from "react"; // editor -import { EditorRefApi, IMentionHighlight, IRichTextEditor, RichTextEditorWithRef } from "@plane/editor"; +import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef, TFileHandler } from "@plane/editor"; +// components +import { EditorMentionsRoot } from "@/components/editor"; // helpers import { getEditorFileHandlers } from "@/helpers/editor.helper"; interface RichTextEditorWrapperProps extends Omit { - uploadFile: (file: File) => Promise; + anchor: string; + uploadFile: TFileHandler["upload"]; + workspaceId: string; } export const RichTextEditor = forwardRef((props, ref) => { - const { containerClassName, uploadFile, ...rest } = props; - // store hooks - - // use-mention - - // file size + const { anchor, containerClassName, uploadFile, workspaceId, ...rest } = props; return ( { - throw new Error("Function not implemented."); - }, - suggestions: undefined, + renderComponent: (props) => , }} ref={ref} disabledExtensions={[]} fileHandler={getEditorFileHandlers({ + anchor, uploadFile, - workspaceId: "", - anchor: "", + workspaceId, })} {...rest} containerClassName={containerClassName} diff --git a/space/core/components/editor/rich-text-read-only-editor.tsx b/space/core/components/editor/rich-text-read-only-editor.tsx index fca1776e2..c2d8c746f 100644 --- a/space/core/components/editor/rich-text-read-only-editor.tsx +++ b/space/core/components/editor/rich-text-read-only-editor.tsx @@ -1,37 +1,37 @@ import React from "react"; // editor import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef } from "@plane/editor"; +// components +import { EditorMentionsRoot } from "@/components/editor"; // helpers import { cn } from "@/helpers/common.helper"; import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper"; -// hooks -import { useMention } from "@/hooks/use-mention"; type RichTextReadOnlyEditorWrapperProps = Omit< IRichTextReadOnlyEditor, "disabledExtensions" | "fileHandler" | "mentionHandler" > & { anchor: string; + workspaceId: string; }; export const RichTextReadOnlyEditor = React.forwardRef( - ({ anchor, ...props }, ref) => { - const { mentionHighlights } = useMention(); - - return ( - - ); - } + ({ anchor, workspaceId, ...props }, ref) => ( + , + }} + {...props} + // overriding the customClassName to add relative class passed + containerClassName={cn("relative p-0 border-none", props.containerClassName)} + /> + ) ); RichTextReadOnlyEditor.displayName = "RichTextReadOnlyEditor"; diff --git a/space/core/components/editor/toolbar.tsx b/space/core/components/editor/toolbar.tsx index 4593aaf65..0d6931af5 100644 --- a/space/core/components/editor/toolbar.tsx +++ b/space/core/components/editor/toolbar.tsx @@ -2,11 +2,9 @@ import React, { useEffect, useState, useCallback } from "react"; // editor -import { EditorRefApi } from "@plane/editor"; +import { TOOLBAR_ITEMS, ToolbarMenuItem, EditorRefApi } from "@plane/editor"; // ui import { Button, Tooltip } from "@plane/ui"; -// constants -import { TOOLBAR_ITEMS, ToolbarMenuItem } from "@/constants/editor"; // helpers import { cn } from "@/helpers/common.helper"; diff --git a/space/core/components/instance/instance-failure-view.tsx b/space/core/components/instance/instance-failure-view.tsx index 50f677c10..ed4c36f35 100644 --- a/space/core/components/instance/instance-failure-view.tsx +++ b/space/core/components/instance/instance-failure-view.tsx @@ -25,7 +25,7 @@ export const InstanceFailureView: FC = () => {

    Unable to fetch instance details.

    We were unable to fetch the details of the instance.
    - Fret not, it might just be a connectivity issue. + Fret not, it might just be a connectivity work items.

    diff --git a/space/core/components/issues/filters/applied-filters/filters-list.tsx b/space/core/components/issues/filters/applied-filters/filters-list.tsx index 4a4ed4eda..65ac29602 100644 --- a/space/core/components/issues/filters/applied-filters/filters-list.tsx +++ b/space/core/components/issues/filters/applied-filters/filters-list.tsx @@ -3,6 +3,7 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; // types +import { useTranslation } from "@plane/i18n"; import { TFilters } from "@/types/issue"; // components import { AppliedPriorityFilters } from "./priority"; @@ -18,6 +19,7 @@ export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " export const AppliedFiltersList: React.FC = observer((props) => { const { appliedFilters = {}, handleRemoveAllFilters, handleRemoveFilter } = props; + const { t } = useTranslation(); return (
    @@ -72,7 +74,7 @@ export const AppliedFiltersList: React.FC = observer((props) => { onClick={handleRemoveAllFilters} className="flex items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 text-xs text-custom-text-300 hover:text-custom-text-200" > - Clear all + {t("common.clear_all")}
    diff --git a/space/core/components/issues/filters/priority.tsx b/space/core/components/issues/filters/priority.tsx index 51c1a7519..d3b7dd67f 100644 --- a/space/core/components/issues/filters/priority.tsx +++ b/space/core/components/issues/filters/priority.tsx @@ -2,10 +2,11 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; +import { ISSUE_PRIORITY_FILTERS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; // ui import { PriorityIcon } from "@plane/ui"; // components -import { issuePriorityFilters } from "@/constants/issue"; import { FilterHeader, FilterOption } from "./helpers"; // constants @@ -18,11 +19,14 @@ type Props = { export const FilterPriority: React.FC = observer((props) => { const { appliedFilters, handleUpdate, searchQuery } = props; + // hooks + const { t } = useTranslation(); + const [previewEnabled, setPreviewEnabled] = useState(true); const appliedFiltersCount = appliedFilters?.length ?? 0; - const filteredOptions = issuePriorityFilters.filter((p) => p.key.includes(searchQuery.toLowerCase())); + const filteredOptions = ISSUE_PRIORITY_FILTERS.filter((p) => p.key.includes(searchQuery.toLowerCase())); return ( <> @@ -40,11 +44,11 @@ export const FilterPriority: React.FC = observer((props) => { isChecked={appliedFilters?.includes(priority.key) ? true : false} onClick={() => handleUpdate(priority.key)} icon={} - title={priority.title} + title={t(priority.titleTranslationKey)} /> )) ) : ( -

    No matches found

    +

    {t("common.search.no_matches_found")}

    )}
    )} diff --git a/space/core/components/issues/filters/root.tsx b/space/core/components/issues/filters/root.tsx index 641cf007c..ff0a13b89 100644 --- a/space/core/components/issues/filters/root.tsx +++ b/space/core/components/issues/filters/root.tsx @@ -4,11 +4,11 @@ import { FC, useCallback } from "react"; import cloneDeep from "lodash/cloneDeep"; import { observer } from "mobx-react"; import { useRouter } from "next/navigation"; +// constants +import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@plane/constants"; // components import { FiltersDropdown } from "@/components/issues/filters/helpers/dropdown"; import { FilterSelection } from "@/components/issues/filters/selection"; -// constants -import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; // helpers import { queryParamGenerator } from "@/helpers/query-param-generator"; // hooks @@ -32,9 +32,9 @@ export const IssueFiltersDropdown: FC = observer((pro const updateRouteParams = useCallback( (key: keyof TIssueQueryFilters, value: string[]) => { - const state = key === "state" ? value : issueFilters?.filters?.state ?? []; - const priority = key === "priority" ? value : issueFilters?.filters?.priority ?? []; - const labels = key === "labels" ? value : issueFilters?.filters?.labels ?? []; + const state = key === "state" ? value : (issueFilters?.filters?.state ?? []); + const priority = key === "priority" ? value : (issueFilters?.filters?.priority ?? []); + const labels = key === "labels" ? value : (issueFilters?.filters?.labels ?? []); const { queryParam } = queryParamGenerator({ board: activeLayout, priority, state, labels }); router.push(`/issues/${anchor}?${queryParam}`); diff --git a/space/core/components/issues/issue-layouts/issue-layout-HOC.tsx b/space/core/components/issues/issue-layouts/issue-layout-HOC.tsx index cbb8aa551..193d7c551 100644 --- a/space/core/components/issues/issue-layouts/issue-layout-HOC.tsx +++ b/space/core/components/issues/issue-layouts/issue-layout-HOC.tsx @@ -26,7 +26,7 @@ export const IssueLayoutHOC = observer((props: Props) => { } if (getGroupIssueCount(undefined, undefined, false) === 0) { - return
    No Issues Found
    ; + return
    No work items Found
    ; } return <>{props.children}; diff --git a/space/core/components/issues/issue-layouts/kanban/block-reactions.tsx b/space/core/components/issues/issue-layouts/kanban/block-reactions.tsx index 241a087e7..a7ffd38ae 100644 --- a/space/core/components/issues/issue-layouts/kanban/block-reactions.tsx +++ b/space/core/components/issues/issue-layouts/kanban/block-reactions.tsx @@ -1,7 +1,7 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -//plane -import { cn } from "@plane/editor"; +// plane utils +import { cn } from "@plane/utils"; // components import { IssueEmojiReactions, IssueVotes } from "@/components/issues/reactions"; // hooks diff --git a/space/core/components/issues/issue-layouts/kanban/block.tsx b/space/core/components/issues/issue-layouts/kanban/block.tsx index 7c246cc33..1975963cc 100644 --- a/space/core/components/issues/issue-layouts/kanban/block.tsx +++ b/space/core/components/issues/issue-layouts/kanban/block.tsx @@ -4,10 +4,12 @@ import { MutableRefObject } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams, useSearchParams } from "next/navigation"; -// plane -import { cn } from "@plane/editor"; +// plane types import { IIssueDisplayProperties } from "@plane/types"; +// plane ui import { Tooltip } from "@plane/ui"; +// plane utils +import { cn } from "@plane/utils"; // components import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC"; // helpers diff --git a/space/core/components/issues/issue-layouts/kanban/default.tsx b/space/core/components/issues/issue-layouts/kanban/default.tsx index 50f7b0f71..6ad7b2e0f 100644 --- a/space/core/components/issues/issue-layouts/kanban/default.tsx +++ b/space/core/components/issues/issue-layouts/kanban/default.tsx @@ -94,7 +94,7 @@ export const KanBan: React.FC = observer((props) => {
    diff --git a/space/core/components/issues/issue-layouts/kanban/swimlanes.tsx b/space/core/components/issues/issue-layouts/kanban/swimlanes.tsx index 902dff670..48dd4047c 100644 --- a/space/core/components/issues/issue-layouts/kanban/swimlanes.tsx +++ b/space/core/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -131,10 +131,14 @@ const SubGroupSwimlaneHeader: React.FC = observer( const subGroupByVisibilityToggle = visibilitySubGroupByGroupCount(groupCount, showEmptyGroup); if (subGroupByVisibilityToggle === false) return <>; - return (
    - +
    ); })} @@ -262,7 +266,7 @@ const SubGroup: React.FC = observer((props) => {
    { } = props; const [isExpanded, setIsExpanded] = useState(true); const groupRef = useRef(null); + // hooks + const { t } = useTranslation(); const [intersectionElement, setIntersectionElement] = useState(null); @@ -84,7 +88,7 @@ export const ListGroup = observer((props: Props) => { } onClick={() => loadMoreIssues(group.id)} > - Load More ↓ + {t("common.load_more")} ↓
    ); diff --git a/space/core/components/issues/issue-layouts/properties/all-properties.tsx b/space/core/components/issues/issue-layouts/properties/all-properties.tsx index 3c596cb53..813a261ce 100644 --- a/space/core/components/issues/issue-layouts/properties/all-properties.tsx +++ b/space/core/components/issues/issue-layouts/properties/all-properties.tsx @@ -2,11 +2,12 @@ import { observer } from "mobx-react"; import { Layers, Link, Paperclip } from "lucide-react"; -// types -import { cn } from "@plane/editor"; +// plane types import { IIssueDisplayProperties } from "@plane/types"; +// plane ui import { Tooltip } from "@plane/ui"; -// ui +// plane utils +import { cn } from "@plane/utils"; // components import { IssueBlockDate, @@ -136,7 +137,7 @@ export const IssueProperties: React.FC = observer((props) => { displayPropertyKey="sub_issue_count" shouldRenderProperty={(properties) => !!properties.sub_issue_count && !!issue.sub_issues_count} > - +
    { - const priority_detail = priority != null ? issuePriorityFilter(priority) : null; + // hooks + const { t } = useTranslation(); + const priority_detail = priority != null ? getIssuePriorityFilters(priority) : null; if (priority_detail === null) return <>; return ( - +
    {priority_detail?.icon}
    - {shouldShowName && {priority_detail?.title}} + {shouldShowName && {t(priority_detail?.titleTranslationKey || "")}}
    ); diff --git a/space/core/components/issues/issue-layouts/properties/state.tsx b/space/core/components/issues/issue-layouts/properties/state.tsx index 56a09bcd9..1d2d1c6fe 100644 --- a/space/core/components/issues/issue-layouts/properties/state.tsx +++ b/space/core/components/issues/issue-layouts/properties/state.tsx @@ -1,9 +1,10 @@ "use client"; import { observer } from "mobx-react"; -// ui -import { cn } from "@plane/editor"; +// plane ui import { StateGroupIcon, Tooltip } from "@plane/ui"; +// plane utils +import { cn } from "@plane/utils"; //hooks import { useStates } from "@/hooks/store"; diff --git a/space/core/components/issues/issue-layouts/utils.tsx b/space/core/components/issues/issue-layouts/utils.tsx index 992f6367c..97021feb4 100644 --- a/space/core/components/issues/issue-layouts/utils.tsx +++ b/space/core/components/issues/issue-layouts/utils.tsx @@ -3,6 +3,7 @@ import isNil from "lodash/isNil"; import { ContrastIcon } from "lucide-react"; // types +import { ISSUE_PRIORITIES } from "@plane/constants"; import { GroupByColumnTypes, IGroupByColumn, @@ -14,7 +15,6 @@ import { import { Avatar, CycleGroupIcon, DiceIcon, PriorityIcon, StateGroupIcon } from "@plane/ui"; // components // constants -import { ISSUE_PRIORITIES } from "@/constants/issue"; // stores import { ICycleStore } from "@/store/cycle.store"; import { IIssueLabelStore } from "@/store/label.store"; @@ -50,7 +50,7 @@ export const getGroupByColumns = ( case "created_by": return getCreatedByColumns(member) as any; default: - if (includeNone) return [{ id: `All Issues`, name: `All Issues`, payload: {}, icon: undefined }]; + if (includeNone) return [{ id: `All Issues`, name: `All work items`, payload: {}, icon: undefined }]; } }; diff --git a/space/core/components/issues/navbar/layout-icon.tsx b/space/core/components/issues/navbar/layout-icon.tsx new file mode 100644 index 000000000..cf3b76093 --- /dev/null +++ b/space/core/components/issues/navbar/layout-icon.tsx @@ -0,0 +1,13 @@ +import { List, Kanban, LucideProps } from "lucide-react"; +import { TIssueLayout } from "@plane/constants"; + +export const IssueLayoutIcon = ({ layout, ...props }: { layout: TIssueLayout } & LucideProps) => { + switch (layout) { + case "list": + return ; + case "kanban": + return ; + default: + return null; + } +}; diff --git a/space/core/components/issues/navbar/layout-selection.tsx b/space/core/components/issues/navbar/layout-selection.tsx index 2d1465c78..3676f0f92 100644 --- a/space/core/components/issues/navbar/layout-selection.tsx +++ b/space/core/components/issues/navbar/layout-selection.tsx @@ -4,15 +4,17 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { useRouter, useSearchParams } from "next/navigation"; // ui +import { SITES_ISSUE_LAYOUTS } from "@plane/constants"; +// plane i18n +import { useTranslation } from "@plane/i18n"; import { Tooltip } from "@plane/ui"; -// constants -import { ISSUE_LAYOUTS } from "@/constants/issue"; // helpers import { queryParamGenerator } from "@/helpers/query-param-generator"; // hooks import { useIssueFilter } from "@/hooks/store"; // mobx import { TIssueLayout } from "@/types/issue"; +import { IssueLayoutIcon } from "./layout-icon"; type Props = { anchor: string; @@ -20,6 +22,8 @@ type Props = { export const IssuesLayoutSelection: FC = observer((props) => { const { anchor } = props; + // hooks + const { t } = useTranslation(); // router const router = useRouter(); const searchParams = useSearchParams(); @@ -42,11 +46,11 @@ export const IssuesLayoutSelection: FC = observer((props) => { return (
    - {ISSUE_LAYOUTS.map((layout) => { + {SITES_ISSUE_LAYOUTS.map((layout) => { if (!layoutOptions[layout.key]) return; return ( - + diff --git a/space/core/components/issues/navbar/user-avatar.tsx b/space/core/components/issues/navbar/user-avatar.tsx index 4c41677fc..0d0706478 100644 --- a/space/core/components/issues/navbar/user-avatar.tsx +++ b/space/core/components/issues/navbar/user-avatar.tsx @@ -7,15 +7,15 @@ import { usePathname, useSearchParams } from "next/navigation"; import { usePopper } from "react-popper"; import { LogOut } from "lucide-react"; import { Popover, Transition } from "@headlessui/react"; +// plane imports import { API_BASE_URL } from "@plane/constants"; +import { AuthService } from "@plane/services"; import { Avatar, Button } from "@plane/ui"; // helpers import { getFileURL } from "@/helpers/file.helper"; import { queryParamGenerator } from "@/helpers/query-param-generator"; // hooks import { useUser } from "@/hooks/store"; -// services -import { AuthService } from "@/services/auth.service"; const authService = new AuthService(); diff --git a/space/core/components/issues/peek-overview/comment/add-comment.tsx b/space/core/components/issues/peek-overview/comment/add-comment.tsx index 3623f3986..d746d7766 100644 --- a/space/core/components/issues/peek-overview/comment/add-comment.tsx +++ b/space/core/components/issues/peek-overview/comment/add-comment.tsx @@ -3,21 +3,19 @@ import React, { useRef, useState } from "react"; import { observer } from "mobx-react"; import { useForm, Controller } from "react-hook-form"; -// editor +// plane imports import { EditorRefApi } from "@plane/editor"; -// ui +import { SitesFileService } from "@plane/services"; +import { TIssuePublicComment } from "@plane/types"; import { TOAST_TYPE, setToast } from "@plane/ui"; // editor components import { LiteTextEditor } from "@/components/editor/lite-text-editor"; // hooks import { useIssueDetails, usePublish, useUser } from "@/hooks/store"; // services -import { FileService } from "@/services/file.service"; -const fileService = new FileService(); -// types -import { Comment } from "@/types/issue"; +const fileService = new SitesFileService(); -const defaultValues: Partial = { +const defaultValues: Partial = { comment_html: "", }; @@ -43,9 +41,9 @@ export const AddComment: React.FC = observer((props) => { watch, formState: { isSubmitting }, reset, - } = useForm({ defaultValues }); + } = useForm({ defaultValues }); - const onSubmit = async (formData: Comment) => { + const onSubmit = async (formData: TIssuePublicComment) => { if (!anchor || !issueId || isSubmitting || !formData.comment_html) return; await addIssueComment(anchor, issueId, formData) @@ -92,7 +90,7 @@ export const AddComment: React.FC = observer((props) => { onChange={(comment_json, comment_html) => onChange(comment_html)} isSubmitting={isSubmitting} placeholder="Add comment..." - uploadFile={async (file) => { + uploadFile={async (blockId, file) => { const { asset_id } = await uploadCommentAsset(file, anchor); setUploadAssetIds((prev) => [...prev, asset_id]); return asset_id; diff --git a/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx b/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx index 1b228dfb3..70fcedd0a 100644 --- a/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx +++ b/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx @@ -3,8 +3,10 @@ import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; import { Check, MessageSquare, MoreVertical, X } from "lucide-react"; import { Menu, Transition } from "@headlessui/react"; -// components +// plane imports import { EditorRefApi } from "@plane/editor"; +import { TIssuePublicComment } from "@plane/types"; +// components import { LiteTextEditor, LiteTextReadOnlyEditor } from "@/components/editor"; import { CommentReactions } from "@/components/issues/peek-overview"; // helpers @@ -13,12 +15,10 @@ import { getFileURL } from "@/helpers/file.helper"; // hooks import { useIssueDetails, usePublish, useUser } from "@/hooks/store"; import useIsInIframe from "@/hooks/use-is-in-iframe"; -// types -import { Comment } from "@/types/issue"; type Props = { anchor: string; - comment: Comment; + comment: TIssuePublicComment; }; export const CommentCard: React.FC = observer((props) => { @@ -48,7 +48,7 @@ export const CommentCard: React.FC = observer((props) => { deleteIssueComment(anchor, peekId, comment.id); }; - const handleCommentUpdate = async (formData: Comment) => { + const handleCommentUpdate = async (formData: TIssuePublicComment) => { if (!anchor || !peekId) return; updateIssueComment(anchor, peekId, comment.id, formData); setIsEditing(false); @@ -112,7 +112,7 @@ export const CommentCard: React.FC = observer((props) => { onChange={(comment_json, comment_html) => onChange(comment_html)} isSubmitting={isSubmitting} showSubmitButton={false} - uploadFile={async (file) => { + uploadFile={async (blockId, file) => { const { asset_id } = await uploadCommentAsset(file, anchor, comment.id); return asset_id; }} @@ -140,6 +140,7 @@ export const CommentCard: React.FC = observer((props) => {
    = observer((props) => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Link copied!", - message: "Issue link copied to clipboard.", + message: "Work item link copied to clipboard.", }); }); }; diff --git a/space/core/components/issues/peek-overview/issue-details.tsx b/space/core/components/issues/peek-overview/issue-details.tsx index 36bad2fad..32bcbc9bd 100644 --- a/space/core/components/issues/peek-overview/issue-details.tsx +++ b/space/core/components/issues/peek-overview/issue-details.tsx @@ -13,9 +13,9 @@ type Props = { export const PeekOverviewIssueDetails: React.FC = observer((props) => { const { anchor, issueDetails } = props; - - const { project_details } = usePublish(anchor); - + // store hooks + const { project_details, workspace: workspaceID } = usePublish(anchor); + // derived values const description = issueDetails.description_html; return ( @@ -35,6 +35,7 @@ export const PeekOverviewIssueDetails: React.FC = observer((props) => { ? "

    " : description } + workspaceId={workspaceID?.toString() ?? ""} /> )} diff --git a/space/core/components/issues/peek-overview/issue-properties.tsx b/space/core/components/issues/peek-overview/issue-properties.tsx index 0749f8519..596993bc2 100644 --- a/space/core/components/issues/peek-overview/issue-properties.tsx +++ b/space/core/components/issues/peek-overview/issue-properties.tsx @@ -3,12 +3,12 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { CalendarCheck2, Signal } from "lucide-react"; +import { useTranslation } from "@plane/i18n"; // ui import { DoubleCircleIcon, StateGroupIcon, TOAST_TYPE, setToast } from "@plane/ui"; +import { getIssuePriorityFilters } from "@plane/utils"; // components import { Icon } from "@/components/ui"; -// constants -import { issuePriorityFilter } from "@/constants/issue"; // helpers import { cn } from "@/helpers/common.helper"; import { renderFormattedDate } from "@/helpers/date-time.helper"; @@ -25,6 +25,8 @@ type Props = { }; export const PeekOverviewIssueProperties: React.FC = observer(({ issueDetails, mode }) => { + // hooks + const { t } = useTranslation(); const { getStateById } = useStates(); const state = getStateById(issueDetails?.state_id ?? undefined); @@ -32,7 +34,7 @@ export const PeekOverviewIssueProperties: React.FC = observer(({ issueDet const { project_details } = usePublish(anchor?.toString()); - const priority = issueDetails.priority ? issuePriorityFilter(issueDetails.priority) : null; + const priority = issueDetails.priority ? getIssuePriorityFilters(issueDetails.priority) : null; const handleCopyLink = () => { const urlToCopy = window.location.href; @@ -41,7 +43,7 @@ export const PeekOverviewIssueProperties: React.FC = observer(({ issueDet setToast({ type: TOAST_TYPE.INFO, title: "Link copied!", - message: "Issue link copied to clipboard", + message: "Work item link copied to clipboard", }); }); }; @@ -96,7 +98,7 @@ export const PeekOverviewIssueProperties: React.FC = observer(({ issueDet )} - {priority?.title ?? "None"} + {t(priority?.titleTranslationKey || "common.none")}
    diff --git a/space/core/constants/editor.ts b/space/core/constants/editor.ts deleted file mode 100644 index 6089c5604..000000000 --- a/space/core/constants/editor.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { - AlignCenter, - AlignLeft, - AlignRight, - Bold, - CaseSensitive, - Code2, - Heading1, - Heading2, - Heading3, - Heading4, - Heading5, - Heading6, - Image, - Italic, - List, - ListOrdered, - ListTodo, - LucideIcon, - Strikethrough, - Table, - TextQuote, - Underline, -} from "lucide-react"; -// editor -import { TCommandExtraProps, TEditorCommands } from "@plane/editor"; - -type TEditorTypes = "lite" | "document"; - -// Utility type to enforce the necessary extra props or make extraProps optional -type ExtraPropsForCommand = T extends keyof TCommandExtraProps - ? TCommandExtraProps[T] - : object; // Default to empty object for commands without extra props - -export type ToolbarMenuItem = { - itemKey: T; - renderKey: string; - name: string; - icon: LucideIcon; - shortcut?: string[]; - editors: TEditorTypes[]; - extraProps?: ExtraPropsForCommand; -}; - -export const TYPOGRAPHY_ITEMS: ToolbarMenuItem<"text" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6">[] = [ - { itemKey: "text", renderKey: "text", name: "Text", icon: CaseSensitive, editors: ["document"] }, - { itemKey: "h1", renderKey: "h1", name: "Heading 1", icon: Heading1, editors: ["document"] }, - { itemKey: "h2", renderKey: "h2", name: "Heading 2", icon: Heading2, editors: ["document"] }, - { itemKey: "h3", renderKey: "h3", name: "Heading 3", icon: Heading3, editors: ["document"] }, - { itemKey: "h4", renderKey: "h4", name: "Heading 4", icon: Heading4, editors: ["document"] }, - { itemKey: "h5", renderKey: "h5", name: "Heading 5", icon: Heading5, editors: ["document"] }, - { itemKey: "h6", renderKey: "h6", name: "Heading 6", icon: Heading6, editors: ["document"] }, -]; - -export const TEXT_ALIGNMENT_ITEMS: ToolbarMenuItem<"text-align">[] = [ - { - itemKey: "text-align", - renderKey: "text-align-left", - name: "Left align", - icon: AlignLeft, - shortcut: ["Cmd", "Shift", "L"], - editors: ["lite", "document"], - extraProps: { - alignment: "left", - }, - }, - { - itemKey: "text-align", - renderKey: "text-align-center", - name: "Center align", - icon: AlignCenter, - shortcut: ["Cmd", "Shift", "E"], - editors: ["lite", "document"], - extraProps: { - alignment: "center", - }, - }, - { - itemKey: "text-align", - renderKey: "text-align-right", - name: "Right align", - icon: AlignRight, - shortcut: ["Cmd", "Shift", "R"], - editors: ["lite", "document"], - extraProps: { - alignment: "right", - }, - }, -]; - -const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strikethrough">[] = [ - { - itemKey: "bold", - renderKey: "bold", - name: "Bold", - icon: Bold, - shortcut: ["Cmd", "B"], - editors: ["lite", "document"], - }, - { - itemKey: "italic", - renderKey: "italic", - name: "Italic", - icon: Italic, - shortcut: ["Cmd", "I"], - editors: ["lite", "document"], - }, - { - itemKey: "underline", - renderKey: "underline", - name: "Underline", - icon: Underline, - shortcut: ["Cmd", "U"], - editors: ["lite", "document"], - }, - { - itemKey: "strikethrough", - renderKey: "strikethrough", - name: "Strikethrough", - icon: Strikethrough, - shortcut: ["Cmd", "Shift", "S"], - editors: ["lite", "document"], - }, -]; - -const LIST_ITEMS: ToolbarMenuItem<"bulleted-list" | "numbered-list" | "to-do-list">[] = [ - { - itemKey: "bulleted-list", - renderKey: "bulleted-list", - name: "Bulleted list", - icon: List, - shortcut: ["Cmd", "Shift", "7"], - editors: ["lite", "document"], - }, - { - itemKey: "numbered-list", - renderKey: "numbered-list", - name: "Numbered list", - icon: ListOrdered, - shortcut: ["Cmd", "Shift", "8"], - editors: ["lite", "document"], - }, - { - itemKey: "to-do-list", - renderKey: "to-do-list", - name: "To-do list", - icon: ListTodo, - shortcut: ["Cmd", "Shift", "9"], - editors: ["lite", "document"], - }, -]; - -export const USER_ACTION_ITEMS: ToolbarMenuItem<"quote" | "code">[] = [ - { itemKey: "quote", renderKey: "quote", name: "Quote", icon: TextQuote, editors: ["lite", "document"] }, - { itemKey: "code", renderKey: "code", name: "Code", icon: Code2, editors: ["lite", "document"] }, -]; - -export const COMPLEX_ITEMS: ToolbarMenuItem<"table" | "image">[] = [ - { itemKey: "table", renderKey: "table", name: "Table", icon: Table, editors: ["document"] }, - { itemKey: "image", renderKey: "image", name: "Image", icon: Image, editors: ["lite", "document"] }, -]; - -export const TOOLBAR_ITEMS: { - [editorType in TEditorTypes]: { - [key: string]: ToolbarMenuItem[]; - }; -} = { - lite: { - basic: BASIC_MARK_ITEMS.filter((item) => item.editors.includes("lite")), - alignment: TEXT_ALIGNMENT_ITEMS.filter((item) => item.editors.includes("lite")), - list: LIST_ITEMS.filter((item) => item.editors.includes("lite")), - userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("lite")), - complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("lite")), - }, - document: { - basic: BASIC_MARK_ITEMS.filter((item) => item.editors.includes("document")), - alignment: TEXT_ALIGNMENT_ITEMS.filter((item) => item.editors.includes("document")), - list: LIST_ITEMS.filter((item) => item.editors.includes("document")), - userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("document")), - complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("document")), - }, -}; diff --git a/space/core/constants/issue.ts b/space/core/constants/issue.ts deleted file mode 100644 index 1d9ebbb19..000000000 --- a/space/core/constants/issue.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Kanban, List } from "lucide-react"; -// types -import { TIssuePriorities } from "@plane/types"; -import { TIssueLayout, TIssueFilterKeys, TIssueFilterPriorityObject } from "@/types/issue"; - -// issue filters -export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { [key in TIssueLayout]: Record<"filters", TIssueFilterKeys[]> } = { - list: { - filters: ["priority", "state", "labels"], - }, - kanban: { - filters: ["priority", "state", "labels"], - }, - calendar: { - filters: ["priority", "state", "labels"], - }, - spreadsheet: { - filters: ["priority", "state", "labels"], - }, - gantt: { - filters: ["priority", "state", "labels"], - }, -}; - -export const ISSUE_LAYOUTS: { - key: TIssueLayout; - title: string; - icon: any; -}[] = [ - { key: "list", title: "List", icon: List }, - { key: "kanban", title: "Kanban", icon: Kanban }, - // { key: "calendar", title: "Calendar", icon: Calendar }, - // { key: "spreadsheet", title: "Spreadsheet", icon: Sheet }, - // { key: "gantt", title: "Gantt chart", icon: GanttChartSquare }, -]; - -export const issuePriorityFilters: TIssueFilterPriorityObject[] = [ - { - key: "urgent", - title: "Urgent", - className: "bg-red-500 border-red-500 text-white", - icon: "error", - }, - { - key: "high", - title: "High", - className: "text-orange-500 border-custom-border-300", - icon: "signal_cellular_alt", - }, - { - key: "medium", - title: "Medium", - className: "text-yellow-500 border-custom-border-300", - icon: "signal_cellular_alt_2_bar", - }, - { - key: "low", - title: "Low", - className: "text-green-500 border-custom-border-300", - icon: "signal_cellular_alt_1_bar", - }, - { - key: "none", - title: "None", - className: "text-gray-500 border-custom-border-300", - icon: "block", - }, -]; - -export const issuePriorityFilter = (priorityKey: TIssuePriorities): TIssueFilterPriorityObject | undefined => { - const currentIssuePriority: TIssueFilterPriorityObject | undefined = - issuePriorityFilters && issuePriorityFilters.length > 0 - ? issuePriorityFilters.find((_priority) => _priority.key === priorityKey) - : undefined; - - if (currentIssuePriority) return currentIssuePriority; - return undefined; -}; - -export const ISSUE_PRIORITIES: { - key: TIssuePriorities; - title: string; -}[] = [ - { key: "urgent", title: "Urgent" }, - { key: "high", title: "High" }, - { key: "medium", title: "Medium" }, - { key: "low", title: "Low" }, - { key: "none", title: "None" }, -]; \ No newline at end of file diff --git a/space/core/constants/seo.ts b/space/core/constants/seo.ts deleted file mode 100644 index f681ab8b2..000000000 --- a/space/core/constants/seo.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const SITE_NAME = "Plane Publish | Make your Plane boards and roadmaps pubic with just one-click. "; -export const SITE_TITLE = "Plane Publish | Make your Plane boards public with one-click"; -export const SITE_DESCRIPTION = "Plane Publish is a customer feedback management tool built on top of plane.so"; -export const SITE_KEYWORDS = - "software development, customer feedback, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration"; -export const SITE_URL = "https://app.plane.so/"; -export const TWITTER_USER_NAME = "planepowers"; diff --git a/space/core/constants/state.ts b/space/core/constants/state.ts deleted file mode 100644 index b0fd622be..000000000 --- a/space/core/constants/state.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { TStateGroups } from "@plane/types"; - -export const STATE_GROUPS: { - [key in TStateGroups]: { - key: TStateGroups; - label: string; - color: string; - }; -} = { - backlog: { - key: "backlog", - label: "Backlog", - color: "#d9d9d9", - }, - unstarted: { - key: "unstarted", - label: "Unstarted", - color: "#3f76ff", - }, - started: { - key: "started", - label: "Started", - color: "#f59e0b", - }, - completed: { - key: "completed", - label: "Completed", - color: "#16a34a", - }, - cancelled: { - key: "cancelled", - label: "Canceled", - color: "#dc2626", - }, -}; - -export const ARCHIVABLE_STATE_GROUPS = [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key]; diff --git a/space/core/hooks/use-mention.tsx b/space/core/hooks/use-mention.tsx index 9e33f7d90..e3819d805 100644 --- a/space/core/hooks/use-mention.tsx +++ b/space/core/hooks/use-mention.tsx @@ -1,13 +1,12 @@ import { useRef, useEffect } from "react"; import useSWR from "swr"; -// types +// plane imports +import { UserService } from "@plane/services"; import { IUser } from "@plane/types"; -// services -import { UserService } from "@/services/user.service"; export const useMention = () => { const userService = new UserService(); - const { data: user, isLoading: userDataLoading } = useSWR("currentUser", async () => userService.currentUser()); + const { data: user, isLoading: userDataLoading } = useSWR("currentUser", async () => userService.me()); const userRef = useRef(); diff --git a/space/core/services/api.service.ts b/space/core/services/api.service.ts deleted file mode 100644 index ff5af7aca..000000000 --- a/space/core/services/api.service.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import axios, { AxiosInstance } from "axios"; -// store -// import { rootStore } from "@/lib/store-context"; - -export abstract class APIService { - protected baseURL: string | undefined; - private axiosInstance: AxiosInstance; - - constructor(baseURL: string | undefined) { - this.baseURL = baseURL; - this.axiosInstance = axios.create({ - baseURL: baseURL || "", - withCredentials: true, - }); - - this.setupInterceptors(); - } - - private setupInterceptors() { - // this.axiosInstance.interceptors.response.use( - // (response) => response, - // (error) => { - // const store = rootStore; - // if (error.response && error.response.status === 401 && store.user.data) store.user.reset(); - // return Promise.reject(error); - // } - // ); - } - - get(url: string, params = {}) { - return this.axiosInstance.get(url, params); - } - - post(url: string, data = {}, config = {}) { - return this.axiosInstance.post(url, data, config); - } - - put(url: string, data = {}, config = {}) { - return this.axiosInstance.put(url, data, config); - } - - patch(url: string, data = {}, config = {}) { - return this.axiosInstance.patch(url, data, config); - } - - delete(url: string, data?: any, config = {}) { - return this.axiosInstance.delete(url, { data, ...config }); - } - - request(config = {}) { - return this.axiosInstance(config); - } -} diff --git a/space/core/services/auth.service.ts b/space/core/services/auth.service.ts deleted file mode 100644 index 3bbfd149e..000000000 --- a/space/core/services/auth.service.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { API_BASE_URL } from "@plane/constants"; -// services -import { APIService } from "@/services/api.service"; -// types -import { ICsrfTokenData, IEmailCheckData, IEmailCheckResponse } from "@/types/auth"; - -export class AuthService extends APIService { - constructor() { - super(API_BASE_URL); - } - - async requestCSRFToken(): Promise { - return this.get("/auth/get-csrf-token/") - .then((response) => response.data) - .catch((error) => { - throw error; - }); - } - - async emailCheck(data: IEmailCheckData): Promise { - return this.post("/auth/spaces/email-check/", data, { headers: {} }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async generateUniqueCode(data: { email: string }): Promise { - return this.post("/auth/spaces/magic-generate/", data, { headers: {} }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } -} diff --git a/space/core/services/cycle.service.ts b/space/core/services/cycle.service.ts deleted file mode 100644 index 7d4ff9a10..000000000 --- a/space/core/services/cycle.service.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { API_BASE_URL } from "@plane/constants"; -// services -import { APIService } from "@/services/api.service"; -// types -import { TPublicCycle } from "@/types/cycle"; - -export class CycleService extends APIService { - constructor() { - super(API_BASE_URL); - } - - async getCycles(anchor: string): Promise { - return this.get(`/api/public/anchor/${anchor}/cycles/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } -} diff --git a/space/core/services/instance.service.ts b/space/core/services/instance.service.ts deleted file mode 100644 index 100929955..000000000 --- a/space/core/services/instance.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { API_BASE_URL } from "@plane/constants"; -import type { IInstanceInfo } from "@plane/types"; -// services -import { APIService } from "@/services/api.service"; - -export class InstanceService extends APIService { - constructor() { - super(API_BASE_URL); - } - - async getInstanceInfo(): Promise { - return this.get("/api/instances/") - .then((response) => response.data) - .catch((error) => { - throw error; - }); - } -} diff --git a/space/core/services/issue.service.ts b/space/core/services/issue.service.ts deleted file mode 100644 index 8ec67ee45..000000000 --- a/space/core/services/issue.service.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { API_BASE_URL } from "@plane/constants"; -// services -import { APIService } from "@/services/api.service"; -// types -import { Comment, TIssuesResponse, IIssue } from "@/types/issue"; - -class IssueService extends APIService { - constructor() { - super(API_BASE_URL); - } - - async fetchPublicIssues(anchor: string, params: any): Promise { - return this.get(`/api/public/anchor/${anchor}/issues/`, { - params, - }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async getIssueById(anchor: string, issueID: string): Promise { - return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async getIssueVotes(anchor: string, issueID: string): Promise { - return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/votes/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async createIssueVote(anchor: string, issueID: string, data: any): Promise { - return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/votes/`, data) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async deleteIssueVote(anchor: string, issueID: string): Promise { - return this.delete(`/api/public/anchor/${anchor}/issues/${issueID}/votes/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async getIssueReactions(anchor: string, issueID: string): Promise { - return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/reactions/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async createIssueReaction(anchor: string, issueID: string, data: any): Promise { - return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/reactions/`, data) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async deleteIssueReaction(anchor: string, issueID: string, reactionId: string): Promise { - return this.delete(`/api/public/anchor/${anchor}/issues/${issueID}/reactions/${reactionId}/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async getIssueComments(anchor: string, issueID: string): Promise { - return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/comments/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async createIssueComment(anchor: string, issueID: string, data: any): Promise { - return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/comments/`, data) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async updateIssueComment(anchor: string, issueID: string, commentId: string, data: any): Promise { - return this.patch(`/api/public/anchor/${anchor}/issues/${issueID}/comments/${commentId}/`, data) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async deleteIssueComment(anchor: string, issueID: string, commentId: string): Promise { - return this.delete(`/api/public/anchor/${anchor}/issues/${issueID}/comments/${commentId}/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async createCommentReaction( - anchor: string, - commentId: string, - data: { - reaction: string; - } - ): Promise { - return this.post(`/api/public/anchor/${anchor}/comments/${commentId}/reactions/`, data) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async deleteCommentReaction(anchor: string, commentId: string, reactionHex: string): Promise { - return this.delete(`/api/public/anchor/${anchor}/comments/${commentId}/reactions/${reactionHex}/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } -} - -export default IssueService; diff --git a/space/core/services/label.service.ts b/space/core/services/label.service.ts deleted file mode 100644 index 3b5585578..000000000 --- a/space/core/services/label.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { API_BASE_URL } from "@plane/constants"; -import { IIssueLabel } from "@plane/types"; -// services -import { APIService } from "./api.service"; - -export class LabelService extends APIService { - constructor() { - super(API_BASE_URL); - } - - async getLabels(anchor: string): Promise { - return this.get(`/api/public/anchor/${anchor}/labels/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } -} diff --git a/space/core/services/member.service.ts b/space/core/services/member.service.ts deleted file mode 100644 index 9de19455b..000000000 --- a/space/core/services/member.service.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { API_BASE_URL } from "@plane/constants"; -// services -import { APIService } from "@/services/api.service"; -// types -import { TPublicMember } from "@/types/member"; - -export class MemberService extends APIService { - constructor() { - super(API_BASE_URL); - } - - async getAnchorMembers(anchor: string): Promise { - return this.get(`/api/public/anchor/${anchor}/members/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } -} diff --git a/space/core/services/module.service.ts b/space/core/services/module.service.ts deleted file mode 100644 index 30d6ebecf..000000000 --- a/space/core/services/module.service.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { API_BASE_URL } from "@plane/constants"; -// services -import { APIService } from "@/services/api.service"; -// types -import { TPublicModule } from "@/types/modules"; - -export class ModuleService extends APIService { - constructor() { - super(API_BASE_URL); - } - - async getModules(anchor: string): Promise { - return this.get(`/api/public/anchor/${anchor}/modules/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } -} diff --git a/space/core/services/project-member.service.ts b/space/core/services/project-member.service.ts deleted file mode 100644 index bac52e751..000000000 --- a/space/core/services/project-member.service.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { API_BASE_URL } from "@plane/constants"; -import type { IProjectMember, IProjectMembership } from "@plane/types"; -// services -import { APIService } from "@/services/api.service"; - -export class ProjectMemberService extends APIService { - constructor() { - super(API_BASE_URL); - } - - async fetchProjectMembers(anchor: string): Promise { - return this.get(`/api/anchor/${anchor}/members/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async getProjectMember(anchor: string, memberID: string): Promise { - return this.get(`/api/anchor/${anchor}/members/${memberID}/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } -} diff --git a/space/core/services/publish.service.ts b/space/core/services/publish.service.ts deleted file mode 100644 index 3da72f59a..000000000 --- a/space/core/services/publish.service.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { API_BASE_URL } from "@plane/constants"; -import { TProjectPublishSettings } from "@plane/types"; -// services -import { APIService } from "@/services/api.service"; - -class PublishService extends APIService { - constructor() { - super(API_BASE_URL); - } - - async fetchPublishSettings(anchor: string): Promise { - return this.get(`/api/public/anchor/${anchor}/settings/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async fetchAnchorFromProjectDetails(workspaceSlug: string, projectID: string): Promise { - return this.get(`/api/public/workspaces/${workspaceSlug}/projects/${projectID}/anchor/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } -} - -export default PublishService; diff --git a/space/core/services/state.service.ts b/space/core/services/state.service.ts deleted file mode 100644 index b877ac530..000000000 --- a/space/core/services/state.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { API_BASE_URL } from "@plane/constants"; -import { IState } from "@plane/types"; -// services -import { APIService } from "./api.service"; - -export class StateService extends APIService { - constructor() { - super(API_BASE_URL); - } - - async getStates(anchor: string): Promise { - return this.get(`/api/public/anchor/${anchor}/states/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } -} diff --git a/space/core/services/user.service.ts b/space/core/services/user.service.ts deleted file mode 100644 index a00b1a350..000000000 --- a/space/core/services/user.service.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { API_BASE_URL } from "@plane/constants"; -import { IUser, TUserProfile } from "@plane/types"; -// services -import { APIService } from "@/services/api.service"; - -export class UserService extends APIService { - constructor() { - super(API_BASE_URL); - } - - async currentUser(): Promise { - return this.get("/api/users/me/") - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async updateUser(data: Partial): Promise { - return this.patch("/api/users/me/", data) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async getCurrentUserProfile(): Promise { - return this.get("/api/users/me/profile/") - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - async updateCurrentUserProfile(data: Partial): Promise { - return this.patch("/api/users/me/profile/", data) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } -} diff --git a/space/core/store/cycle.store.ts b/space/core/store/cycle.store.ts index a7310290b..57af963ae 100644 --- a/space/core/store/cycle.store.ts +++ b/space/core/store/cycle.store.ts @@ -1,6 +1,8 @@ import { action, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { SitesCycleService } from "@plane/services"; import { TPublicCycle } from "@/types/cycle"; -import { CycleService } from "../services/cycle.service"; +// store import { CoreRootStore } from "./root.store"; export interface ICycleStore { @@ -14,7 +16,7 @@ export interface ICycleStore { export class CycleStore implements ICycleStore { cycles: TPublicCycle[] | undefined = undefined; - cycleService: CycleService; + cycleService: SitesCycleService; rootStore: CoreRootStore; constructor(_rootStore: CoreRootStore) { @@ -24,14 +26,14 @@ export class CycleStore implements ICycleStore { // fetch action fetchCycles: action, }); - this.cycleService = new CycleService(); + this.cycleService = new SitesCycleService(); this.rootStore = _rootStore; } getCycleById = (cycleId: string | undefined) => this.cycles?.find((cycle) => cycle.id === cycleId); fetchCycles = async (anchor: string) => { - const cyclesResponse = await this.cycleService.getCycles(anchor); + const cyclesResponse = await this.cycleService.list(anchor); runInAction(() => { this.cycles = cyclesResponse; }); diff --git a/space/core/store/helpers/base-issues.store.ts b/space/core/store/helpers/base-issues.store.ts index 004aa06c6..867f2962c 100644 --- a/space/core/store/helpers/base-issues.store.ts +++ b/space/core/store/helpers/base-issues.store.ts @@ -5,9 +5,9 @@ import uniq from "lodash/uniq"; import update from "lodash/update"; import { action, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; -// plane constants +// plane imports import { ALL_ISSUES } from "@plane/constants"; -// types +import { SitesIssueService } from "@plane/services"; import { TIssueGroupByOptions, TGroupedIssues, @@ -19,14 +19,13 @@ import { TGroupedIssueCount, TPaginationData, } from "@plane/types"; -// services -import IssueService from "@/services/issue.service"; +// types import { IIssue, TIssuesResponse } from "@/types/issue"; import { CoreRootStore } from "../root.store"; // constants // helpers -export type TIssueDisplayFilterOptions = Exclude | "target_date"; +export type TIssueDisplayFilterOptions = Exclude | "target_date"; export enum EIssueGroupedAction { ADD = "ADD", @@ -98,7 +97,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { setLoader: action.bound, }); this.rootIssueStore = _rootStore; - this.issueService = new IssueService(); + this.issueService = new SitesIssueService(); } getIssueIds = (groupId?: string, subGroupId?: string) => { diff --git a/space/core/store/instance.store.ts b/space/core/store/instance.store.ts index 970824404..4b6c5a22e 100644 --- a/space/core/store/instance.store.ts +++ b/space/core/store/instance.store.ts @@ -1,9 +1,8 @@ import set from "lodash/set"; import { observable, action, makeObservable, runInAction } from "mobx"; -// types +// plane imports +import { InstanceService } from "@plane/services"; import { IInstance, IInstanceConfig } from "@plane/types"; -// services -import { InstanceService } from "@/services/instance.service"; // store import { CoreRootStore } from "@/store/root.store"; @@ -59,7 +58,7 @@ export class InstanceStore implements IInstanceStore { try { this.isLoading = true; this.error = undefined; - const instanceInfo = await this.instanceService.getInstanceInfo(); + const instanceInfo = await this.instanceService.info(); runInAction(() => { this.isLoading = false; this.instance = instanceInfo.instance; diff --git a/space/core/store/issue-detail.store.ts b/space/core/store/issue-detail.store.ts index 9abed2966..715836946 100644 --- a/space/core/store/issue-detail.store.ts +++ b/space/core/store/issue-detail.store.ts @@ -3,16 +3,14 @@ import set from "lodash/set"; import { makeObservable, observable, action, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; import { v4 as uuidv4 } from "uuid"; -// plane types -import { TFileSignedURLResponse } from "@plane/types"; +// plane imports +import { SitesFileService, SitesIssueService } from "@plane/services"; +import { TFileSignedURLResponse, TIssuePublicComment } from "@plane/types"; import { EFileAssetType } from "@plane/types/src/enums"; -// services -import { FileService } from "@/services/file.service"; -import IssueService from "@/services/issue.service"; // store import { CoreRootStore } from "@/store/root.store"; // types -import { Comment, IIssue, IPeekMode, IVote } from "@/types/issue"; +import { IIssue, IPeekMode, IVote } from "@/types/issue"; export interface IIssueDetailStore { loader: boolean; @@ -32,7 +30,7 @@ export interface IIssueDetailStore { // issue actions fetchIssueDetails: (anchor: string, issueID: string) => void; // comment actions - addIssueComment: (anchor: string, issueID: string, data: any) => Promise; + addIssueComment: (anchor: string, issueID: string, data: any) => Promise; updateIssueComment: (anchor: string, issueID: string, commentID: string, data: any) => Promise; deleteIssueComment: (anchor: string, issueID: string, commentID: string) => void; uploadCommentAsset: (file: File, anchor: string, commentID?: string) => Promise; @@ -59,8 +57,8 @@ export class IssueDetailStore implements IIssueDetailStore { // root store rootStore: CoreRootStore; // services - issueService: IssueService; - fileService: FileService; + issueService: SitesIssueService; + fileService: SitesFileService; constructor(_rootStore: CoreRootStore) { makeObservable(this, { @@ -91,8 +89,8 @@ export class IssueDetailStore implements IIssueDetailStore { removeIssueVote: action, }); this.rootStore = _rootStore; - this.issueService = new IssueService(); - this.fileService = new FileService(); + this.issueService = new SitesIssueService(); + this.fileService = new SitesFileService(); } setPeekId = (issueID: string | null) => { @@ -123,7 +121,7 @@ export class IssueDetailStore implements IIssueDetailStore { */ fetchIssueById = async (anchorId: string, issueId: string) => { try { - const issueDetails = await this.issueService.getIssueById(anchorId, issueId); + const issueDetails = await this.issueService.retrieve(anchorId, issueId); runInAction(() => { set(this.details, [issueId], issueDetails); @@ -146,7 +144,7 @@ export class IssueDetailStore implements IIssueDetailStore { this.error = null; const issueDetails = await this.fetchIssueById(anchor, issueID); - const commentsResponse = await this.issueService.getIssueComments(anchor, issueID); + const commentsResponse = await this.issueService.listComments(anchor, issueID); if (issueDetails) { runInAction(() => { @@ -168,7 +166,7 @@ export class IssueDetailStore implements IIssueDetailStore { addIssueComment = async (anchor: string, issueID: string, data: any) => { try { const issueDetails = this.getIssueById(issueID); - const issueCommentResponse = await this.issueService.createIssueComment(anchor, issueID, data); + const issueCommentResponse = await this.issueService.addComment(anchor, issueID, data); if (issueDetails) { runInAction(() => { set(this.details, [issueID, "comments"], [...(issueDetails?.comments ?? []), issueCommentResponse]); @@ -196,9 +194,9 @@ export class IssueDetailStore implements IIssueDetailStore { }; }); - await this.issueService.updateIssueComment(anchor, issueID, commentID, data); + await this.issueService.updateComment(anchor, issueID, commentID, data); } catch (error) { - const issueComments = await this.issueService.getIssueComments(anchor, issueID); + const issueComments = await this.issueService.listComments(anchor, issueID); runInAction(() => { this.details = { @@ -214,7 +212,7 @@ export class IssueDetailStore implements IIssueDetailStore { deleteIssueComment = async (anchor: string, issueID: string, commentID: string) => { try { - await this.issueService.deleteIssueComment(anchor, issueID, commentID); + await this.issueService.removeComment(anchor, issueID, commentID); const remainingComments = this.details[issueID].comments.filter((c) => c.id != commentID); runInAction(() => { this.details = { @@ -288,11 +286,11 @@ export class IssueDetailStore implements IIssueDetailStore { }; }); - await this.issueService.createCommentReaction(anchor, commentID, { + await this.issueService.addCommentReaction(anchor, commentID, { reaction: reactionHex, }); } catch (error) { - const issueComments = await this.issueService.getIssueComments(anchor, issueID); + const issueComments = await this.issueService.listComments(anchor, issueID); runInAction(() => { this.details = { @@ -324,9 +322,9 @@ export class IssueDetailStore implements IIssueDetailStore { }; }); - await this.issueService.deleteCommentReaction(anchor, commentID, reactionHex); + await this.issueService.removeCommentReaction(anchor, commentID, reactionHex); } catch (error) { - const issueComments = await this.issueService.getIssueComments(anchor, issueID); + const issueComments = await this.issueService.listComments(anchor, issueID); runInAction(() => { this.details = { @@ -356,12 +354,12 @@ export class IssueDetailStore implements IIssueDetailStore { ); }); - await this.issueService.createIssueReaction(anchor, issueID, { + await this.issueService.addReaction(anchor, issueID, { reaction: reactionHex, }); } catch (error) { console.log("Failed to add issue vote"); - const issueReactions = await this.issueService.getIssueReactions(anchor, issueID); + const issueReactions = await this.issueService.listReactions(anchor, issueID); runInAction(() => { set(this.details, [issueID, "reaction_items"], issueReactions); }); @@ -378,10 +376,10 @@ export class IssueDetailStore implements IIssueDetailStore { set(this.details, [issueID, "reaction_items"], newReactions); }); - await this.issueService.deleteIssueReaction(anchor, issueID, reactionHex); + await this.issueService.removeReaction(anchor, issueID, reactionHex); } catch (error) { console.log("Failed to remove issue reaction"); - const reactions = await this.issueService.getIssueReactions(anchor, issueID); + const reactions = await this.issueService.listReactions(anchor, issueID); runInAction(() => { set(this.details, [issueID, "reaction_items"], reactions); }); @@ -410,10 +408,10 @@ export class IssueDetailStore implements IIssueDetailStore { }); }); - await this.issueService.createIssueVote(anchor, issueID, data); + await this.issueService.addVote(anchor, issueID, data); } catch (error) { console.log("Failed to add issue vote"); - const issueVotes = await this.issueService.getIssueVotes(anchor, issueID); + const issueVotes = await this.issueService.listVotes(anchor, issueID); runInAction(() => { set(this.details, [issueID, "vote_items"], issueVotes); @@ -431,10 +429,10 @@ export class IssueDetailStore implements IIssueDetailStore { set(this.details, [issueID, "vote_items"], newVotes); }); - await this.issueService.deleteIssueVote(anchor, issueID); + await this.issueService.removeVote(anchor, issueID); } catch (error) { console.log("Failed to remove issue vote"); - const issueVotes = await this.issueService.getIssueVotes(anchor, issueID); + const issueVotes = await this.issueService.listVotes(anchor, issueID); runInAction(() => { set(this.details, [issueID, "vote_items"], issueVotes); diff --git a/space/core/store/issue-filters.store.ts b/space/core/store/issue-filters.store.ts index 0c589dc4d..be9ca438b 100644 --- a/space/core/store/issue-filters.store.ts +++ b/space/core/store/issue-filters.store.ts @@ -3,10 +3,9 @@ import isEqual from "lodash/isEqual"; import set from "lodash/set"; import { action, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; -// plane types +// plane internal +import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@plane/constants"; import { IssuePaginationOptions, TIssueParams } from "@plane/types"; -// constants -import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; // store import { CoreRootStore } from "@/store/root.store"; // types @@ -75,14 +74,12 @@ export class IssueFilterStore implements IIssueFilterStore { Object.keys(filters).map((key) => { const currentFilterKey = key as TIssueFilterKeys; + const filterValue = filters[currentFilterKey] as any; - if (filters[currentFilterKey] != undefined && filteredParams.includes(currentFilterKey)) { - if (Array.isArray(filters[currentFilterKey])) - computedFilters[currentFilterKey] = filters[currentFilterKey]?.join(","); - else if (filters[currentFilterKey] && typeof filters[currentFilterKey] === "string") - computedFilters[currentFilterKey] = filters[currentFilterKey]?.toString(); - else if (typeof filters[currentFilterKey] === "boolean") - computedFilters[currentFilterKey] = filters[currentFilterKey]?.toString(); + if (filterValue !== undefined && filteredParams.includes(currentFilterKey)) { + if (Array.isArray(filterValue)) computedFilters[currentFilterKey] = filterValue.join(","); + else if (typeof filterValue === "string" || typeof filterValue === "boolean") + computedFilters[currentFilterKey] = filterValue.toString(); } }); diff --git a/space/core/store/issue.store.ts b/space/core/store/issue.store.ts index ca5154df7..c65e84d11 100644 --- a/space/core/store/issue.store.ts +++ b/space/core/store/issue.store.ts @@ -1,8 +1,7 @@ import { action, makeObservable, runInAction } from "mobx"; -// types +// plane imports +import { SitesIssueService } from "@plane/services"; import { IssuePaginationOptions, TLoader } from "@plane/types"; -// services -import IssueService from "@/services/issue.service"; // store import { CoreRootStore } from "@/store/root.store"; // types @@ -24,7 +23,7 @@ export class IssueStore extends BaseIssuesStore implements IIssueStore { // root store rootStore: CoreRootStore; // services - issueService: IssueService; + issueService: SitesIssueService; constructor(_rootStore: CoreRootStore) { super(_rootStore); @@ -36,7 +35,7 @@ export class IssueStore extends BaseIssuesStore implements IIssueStore { }); this.rootStore = _rootStore; - this.issueService = new IssueService(); + this.issueService = new SitesIssueService(); } /** @@ -59,7 +58,7 @@ export class IssueStore extends BaseIssuesStore implements IIssueStore { const params = this.rootStore.issueFilter.getFilterParams(options, anchor, undefined, undefined, undefined); - const response = await this.issueService.fetchPublicIssues(anchor, params); + const response = await this.issueService.list(anchor, params); // after fetching issues, call the base method to process the response further this.onfetchIssues(response, options); @@ -86,7 +85,7 @@ export class IssueStore extends BaseIssuesStore implements IIssueStore { subGroupId ); // call the fetch issues API with the params for next page in issues - const response = await this.issueService.fetchPublicIssues(anchor, params); + const response = await this.issueService.list(anchor, params); // after the next page of issues are fetched, call the base method to process the response this.onfetchNexIssues(response, groupId, subGroupId); diff --git a/space/core/store/label.store.ts b/space/core/store/label.store.ts index e705aa4d2..e51e59f4e 100644 --- a/space/core/store/label.store.ts +++ b/space/core/store/label.store.ts @@ -1,7 +1,9 @@ import set from "lodash/set"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { SitesLabelService } from "@plane/services"; import { IIssueLabel } from "@plane/types"; -import { LabelService } from "@/services/label.service"; +// store import { CoreRootStore } from "./root.store"; export interface IIssueLabelStore { @@ -16,7 +18,7 @@ export interface IIssueLabelStore { export class LabelStore implements IIssueLabelStore { labelMap: Record = {}; - labelService: LabelService; + labelService: SitesLabelService; rootStore: CoreRootStore; constructor(_rootStore: CoreRootStore) { @@ -28,7 +30,7 @@ export class LabelStore implements IIssueLabelStore { // fetch action fetchLabels: action, }); - this.labelService = new LabelService(); + this.labelService = new SitesLabelService(); this.rootStore = _rootStore; } @@ -51,7 +53,7 @@ export class LabelStore implements IIssueLabelStore { }; fetchLabels = async (anchor: string) => { - const labelsResponse = await this.labelService.getLabels(anchor); + const labelsResponse = await this.labelService.list(anchor); runInAction(() => { this.labelMap = {}; for (const label of labelsResponse) { diff --git a/space/core/store/members.store.ts b/space/core/store/members.store.ts index 3de021e2c..1328f5259 100644 --- a/space/core/store/members.store.ts +++ b/space/core/store/members.store.ts @@ -1,7 +1,9 @@ import set from "lodash/set"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { SitesMemberService } from "@plane/services"; + import { TPublicMember } from "@/types/member"; -import { MemberService } from "../services/member.service"; import { CoreRootStore } from "./root.store"; export interface IIssueMemberStore { @@ -16,7 +18,7 @@ export interface IIssueMemberStore { export class MemberStore implements IIssueMemberStore { memberMap: Record = {}; - memberService: MemberService; + memberService: SitesMemberService; rootStore: CoreRootStore; constructor(_rootStore: CoreRootStore) { @@ -28,7 +30,7 @@ export class MemberStore implements IIssueMemberStore { // fetch action fetchMembers: action, }); - this.memberService = new MemberService(); + this.memberService = new SitesMemberService(); this.rootStore = _rootStore; } @@ -52,7 +54,7 @@ export class MemberStore implements IIssueMemberStore { fetchMembers = async (anchor: string) => { try { - const membersResponse = await this.memberService.getAnchorMembers(anchor); + const membersResponse = await this.memberService.list(anchor); runInAction(() => { this.memberMap = {}; for (const member of membersResponse) { diff --git a/space/core/store/mentions.store.ts b/space/core/store/mentions.store.ts deleted file mode 100644 index 977df4221..000000000 --- a/space/core/store/mentions.store.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { computed, makeObservable } from "mobx"; -// editor -import { IMentionHighlight } from "@plane/editor"; -// store -import { CoreRootStore } from "@/store/root.store"; - -export interface IMentionsStore { - // mentionSuggestions: IMentionSuggestion[]; - mentionHighlights: IMentionHighlight[]; -} - -export class MentionsStore implements IMentionsStore { - // root store - rootStore; - - constructor(_rootStore: CoreRootStore) { - // rootStore - this.rootStore = _rootStore; - - makeObservable(this, { - mentionHighlights: computed, - // mentionSuggestions: computed - }); - } - - // get mentionSuggestions() { - // const projectMembers = this.rootStore.project.project. - - // const suggestions = projectMembers === null ? [] : projectMembers.map((member) => ({ - // id: member.member.id, - // type: "User", - // title: member.member.display_name, - // subtitle: member.member.email ?? "", - // avatar: member.member.avatar, - // redirect_uri: `/${member.workspace.slug}/profile/${member.member.id}`, - // })) - - // return suggestions - // } - - get mentionHighlights() { - const user = this.rootStore.user.data; - return user ? [user.id] : []; - } -} diff --git a/space/core/store/module.store.ts b/space/core/store/module.store.ts index 6da1ab1f8..c996b3a33 100644 --- a/space/core/store/module.store.ts +++ b/space/core/store/module.store.ts @@ -1,7 +1,10 @@ import set from "lodash/set"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { SitesModuleService } from "@plane/services"; +// types import { TPublicModule } from "@/types/modules"; -import { ModuleService } from "../services/module.service"; +// root store import { CoreRootStore } from "./root.store"; export interface IIssueModuleStore { @@ -16,7 +19,7 @@ export interface IIssueModuleStore { export class ModuleStore implements IIssueModuleStore { moduleMap: Record = {}; - moduleService: ModuleService; + moduleService: SitesModuleService; rootStore: CoreRootStore; constructor(_rootStore: CoreRootStore) { @@ -28,7 +31,7 @@ export class ModuleStore implements IIssueModuleStore { // fetch action fetchModules: action, }); - this.moduleService = new ModuleService(); + this.moduleService = new SitesModuleService(); this.rootStore = _rootStore; } @@ -52,7 +55,7 @@ export class ModuleStore implements IIssueModuleStore { fetchModules = async (anchor: string) => { try { - const modulesResponse = await this.moduleService.getModules(anchor); + const modulesResponse = await this.moduleService.list(anchor); runInAction(() => { this.moduleMap = {}; for (const issueModule of modulesResponse) { diff --git a/space/core/store/profile.store.ts b/space/core/store/profile.store.ts index d03328058..5523e8dad 100644 --- a/space/core/store/profile.store.ts +++ b/space/core/store/profile.store.ts @@ -1,9 +1,8 @@ import set from "lodash/set"; import { action, makeObservable, observable, runInAction } from "mobx"; -// types +// plane imports +import { UserService } from "@plane/services"; import { TUserProfile } from "@plane/types"; -// services -import { UserService } from "@/services/user.service"; // store import { CoreRootStore } from "@/store/root.store"; @@ -54,6 +53,7 @@ export class ProfileStore implements IProfileStore { has_billing_address: false, created_at: "", updated_at: "", + language: "", }; // services @@ -84,7 +84,7 @@ export class ProfileStore implements IProfileStore { this.isLoading = true; this.error = undefined; }); - const userProfile = await this.userService.getCurrentUserProfile(); + const userProfile = await this.userService.profile(); runInAction(() => { this.isLoading = false; this.data = userProfile; @@ -115,7 +115,7 @@ export class ProfileStore implements IProfileStore { if (this.data) set(this.data, userKey, data[userKey]); }); } - const userProfile = await this.userService.updateCurrentUserProfile(data); + const userProfile = await this.userService.updateProfile(data); return userProfile; } catch (error) { if (currentUserProfileData) { diff --git a/space/core/store/publish/publish_list.store.ts b/space/core/store/publish/publish_list.store.ts index d4a59f62d..60f705b00 100644 --- a/space/core/store/publish/publish_list.store.ts +++ b/space/core/store/publish/publish_list.store.ts @@ -1,9 +1,8 @@ import set from "lodash/set"; import { makeObservable, observable, runInAction, action } from "mobx"; -// types +// plane imports +import { SitesProjectPublishService } from "@plane/services"; import { TProjectPublishSettings } from "@plane/types"; -// services -import PublishService from "@/services/publish.service"; // store import { PublishStore } from "@/store/publish/publish.store"; import { CoreRootStore } from "@/store/root.store"; @@ -29,7 +28,7 @@ export class PublishListStore implements IPublishListStore { fetchPublishSettings: action, }); // services - this.publishService = new PublishService(); + this.publishService = new SitesProjectPublishService(); } /** @@ -37,7 +36,7 @@ export class PublishListStore implements IPublishListStore { * @param {string} anchor */ fetchPublishSettings = async (anchor: string) => { - const response = await this.publishService.fetchPublishSettings(anchor); + const response = await this.publishService.retrieveSettingsByAnchor(anchor); runInAction(() => { if (response.anchor) { set(this.publishMap, [response.anchor], new PublishStore(this.rootStore, response)); diff --git a/space/core/store/root.store.ts b/space/core/store/root.store.ts index de43001d2..db9e26566 100644 --- a/space/core/store/root.store.ts +++ b/space/core/store/root.store.ts @@ -8,7 +8,6 @@ import { CycleStore, ICycleStore } from "./cycle.store"; import { IssueFilterStore, IIssueFilterStore } from "./issue-filters.store"; import { IIssueLabelStore, LabelStore } from "./label.store"; import { IIssueMemberStore, MemberStore } from "./members.store"; -import { IMentionsStore, MentionsStore } from "./mentions.store"; import { IIssueModuleStore, ModuleStore } from "./module.store"; import { IPublishListStore, PublishListStore } from "./publish/publish_list.store"; import { IStateStore, StateStore } from "./state.store"; @@ -20,7 +19,6 @@ export class CoreRootStore { user: IUserStore; issue: IIssueStore; issueDetail: IIssueDetailStore; - mentionStore: IMentionsStore; state: IStateStore; label: IIssueLabelStore; module: IIssueModuleStore; @@ -34,7 +32,6 @@ export class CoreRootStore { this.user = new UserStore(this); this.issue = new IssueStore(this); this.issueDetail = new IssueDetailStore(this); - this.mentionStore = new MentionsStore(this); this.state = new StateStore(this); this.label = new LabelStore(this); this.module = new ModuleStore(this); @@ -57,7 +54,6 @@ export class CoreRootStore { this.user = new UserStore(this); this.issue = new IssueStore(this); this.issueDetail = new IssueDetailStore(this); - this.mentionStore = new MentionsStore(this); this.state = new StateStore(this); this.label = new LabelStore(this); this.module = new ModuleStore(this); diff --git a/space/core/store/state.store.ts b/space/core/store/state.store.ts index aff22a22a..655cdee5d 100644 --- a/space/core/store/state.store.ts +++ b/space/core/store/state.store.ts @@ -1,8 +1,11 @@ import clone from "lodash/clone"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { SitesStateService } from "@plane/services"; import { IState } from "@plane/types"; +// helpers import { sortStates } from "@/helpers/state.helper"; -import { StateService } from "@/services/state.service"; +// store import { CoreRootStore } from "./root.store"; export interface IStateStore { @@ -18,7 +21,7 @@ export interface IStateStore { export class StateStore implements IStateStore { states: IState[] | undefined = undefined; - stateService: StateService; + stateService: SitesStateService; rootStore: CoreRootStore; constructor(_rootStore: CoreRootStore) { @@ -30,7 +33,7 @@ export class StateStore implements IStateStore { // fetch action fetchStates: action, }); - this.stateService = new StateService(); + this.stateService = new SitesStateService(); this.rootStore = _rootStore; } @@ -42,7 +45,7 @@ export class StateStore implements IStateStore { getStateById = (stateId: string | undefined) => this.states?.find((state) => state.id === stateId); fetchStates = async (anchor: string) => { - const statesResponse = await this.stateService.getStates(anchor); + const statesResponse = await this.stateService.list(anchor); runInAction(() => { this.states = statesResponse; }); diff --git a/space/core/store/user.store.ts b/space/core/store/user.store.ts index 6616b10b0..6765961f5 100644 --- a/space/core/store/user.store.ts +++ b/space/core/store/user.store.ts @@ -1,10 +1,8 @@ import set from "lodash/set"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; -// types +// plane imports +import { UserService } from "@plane/services"; import { IUser } from "@plane/types"; -// services -import { AuthService } from "@/services/auth.service"; -import { UserService } from "@/services/user.service"; // store types import { ProfileStore, IProfileStore } from "@/store/profile.store"; // store @@ -45,14 +43,12 @@ export class UserStore implements IUserStore { profile: IProfileStore; // service userService: UserService; - authService: AuthService; constructor(private store: CoreRootStore) { // stores this.profile = new ProfileStore(store); // service this.userService = new UserService(); - this.authService = new AuthService(); // observables makeObservable(this, { // observables @@ -95,7 +91,7 @@ export class UserStore implements IUserStore { if (this.data === undefined) this.isLoading = true; this.error = undefined; }); - const user = await this.userService.currentUser(); + const user = await this.userService.me(); if (user && user?.id) { await this.profile.fetchUserProfile(); runInAction(() => { @@ -137,7 +133,7 @@ export class UserStore implements IUserStore { if (this.data) set(this.data, userKey, data[userKey]); }); } - const user = await this.userService.updateUser(data); + const user = await this.userService.update(data); return user; } catch (error) { if (currentUserData) { diff --git a/space/core/types/issue.d.ts b/space/core/types/issue.d.ts index 3041a188d..d57dd84de 100644 --- a/space/core/types/issue.d.ts +++ b/space/core/types/issue.d.ts @@ -1,4 +1,4 @@ -import { IWorkspaceLite, TIssue, TIssuePriorities, TStateGroups } from "@plane/types"; +import { IWorkspaceLite, TIssue, TIssuePriorities, TStateGroups, TIssuePublicComment } from "@plane/types"; export type TIssueLayout = "list" | "kanban" | "calendar" | "spreadsheet" | "gantt"; export type TIssueLayoutOptions = { @@ -58,7 +58,7 @@ export interface IIssue | "link_count" | "estimate_point" > { - comments: Comment[]; + comments: TIssuePublicComment[]; reaction_items: IIssueReaction[]; vote_items: IVote[]; } @@ -106,33 +106,6 @@ export interface IVote { actor_details: ActorDetail; } -export interface Comment { - actor_detail: ActorDetail; - access: string; - actor: string; - attachments: any[]; - comment_html: string; - comment_reactions: { - actor_detail: ActorDetail; - comment: string; - id: string; - reaction: string; - }[]; - comment_stripped: string; - created_at: Date; - created_by: string; - id: string; - is_member: boolean; - issue: string; - issue_detail: IssueDetail; - project: string; - project_detail: ProjectDetail; - updated_at: Date; - updated_by: string; - workspace: string; - workspace_detail: IWorkspaceLite; -} - export interface IIssueReaction { actor_details: ActorDetail; reaction: string; diff --git a/space/ee/components/editor/index.ts b/space/ee/components/editor/index.ts new file mode 100644 index 000000000..f8506c1d6 --- /dev/null +++ b/space/ee/components/editor/index.ts @@ -0,0 +1 @@ +export * from "ce/components/editor"; diff --git a/space/helpers/editor.helper.ts b/space/helpers/editor.helper.ts index b037055af..5126b99c7 100644 --- a/space/helpers/editor.helper.ts +++ b/space/helpers/editor.helper.ts @@ -1,12 +1,11 @@ -// plane editor -import { TFileHandler } from "@plane/editor"; -// constants -import { MAX_FILE_SIZE } from "@/constants/common"; +// plane internal +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"; // services -import { FileService } from "@/services/file.service"; -const fileService = new FileService(); +const sitesFileService = new SitesFileService(); /** * @description generate the file source using assetId @@ -19,10 +18,35 @@ export const getEditorAssetSrc = (anchor: string, assetId: string): string | und type TArgs = { anchor: string; - uploadFile: (file: File) => Promise; + uploadFile: TFileHandler["upload"]; workspaceId: string; }; +/** + * @description this function returns the file handler required by the read-only editors + */ +export const getReadOnlyEditorFileHandlers = (args: Pick): TReadOnlyFileHandler => { + const { anchor, workspaceId } = args; + + return { + getAssetSrc: async (path) => { + if (!path) return ""; + if (path?.startsWith("http")) { + return path; + } else { + return getEditorAssetSrc(anchor, path) ?? ""; + } + }, + restore: async (src: string) => { + if (src?.startsWith("http")) { + await sitesFileService.restoreOldEditorAsset(workspaceId, src); + } else { + await sitesFileService.restoreNewAsset(anchor, src); + } + }, + }; +}; + /** * @description this function returns the file handler required by the editors * @param {TArgs} args @@ -31,52 +55,22 @@ export const getEditorFileHandlers = (args: TArgs): TFileHandler => { const { anchor, uploadFile, workspaceId } = args; return { - getAssetSrc: async (path) => { - if (!path) return ""; - if (path?.startsWith("http")) { - return path; - } else { - return getEditorAssetSrc(anchor, path) ?? ""; - } - }, + ...getReadOnlyEditorFileHandlers({ + anchor, + workspaceId, + }), + assetsUploadStatus: {}, upload: uploadFile, delete: async (src: string) => { if (src?.startsWith("http")) { - await fileService.deleteOldEditorAsset(workspaceId, src); + await sitesFileService.deleteOldEditorAsset(workspaceId, src); } else { - await fileService.deleteNewAsset(getEditorAssetSrc(anchor, src) ?? ""); + await sitesFileService.deleteNewAsset(getEditorAssetSrc(anchor, src) ?? ""); } }, - restore: async (src: string) => { - if (src?.startsWith("http")) { - await fileService.restoreOldEditorAsset(workspaceId, src); - } else { - await fileService.restoreNewAsset(anchor, src); - } - }, - cancel: fileService.cancelUpload, + cancel: sitesFileService.cancelUpload, validation: { maxFileSize: MAX_FILE_SIZE, }, }; }; - -/** - * @description this function returns the file handler required by the read-only editors - */ -export const getReadOnlyEditorFileHandlers = ( - args: Pick -): { getAssetSrc: TFileHandler["getAssetSrc"] } => { - const { anchor } = args; - - return { - getAssetSrc: async (path) => { - if (!path) return ""; - if (path?.startsWith("http")) { - return path; - } else { - return getEditorAssetSrc(anchor, path) ?? ""; - } - }, - }; -}; diff --git a/space/helpers/issue.helper.ts b/space/helpers/issue.helper.ts index a5159edef..a7129ca49 100644 --- a/space/helpers/issue.helper.ts +++ b/space/helpers/issue.helper.ts @@ -1,8 +1,7 @@ -import { differenceInCalendarDays } from "date-fns"; -// types +import { differenceInCalendarDays } from "date-fns/differenceInCalendarDays"; +// plane internal +import { STATE_GROUPS } from "@plane/constants"; import { TStateGroups } from "@plane/types"; -// constants -import { STATE_GROUPS } from "@/constants/state"; // helpers import { getDate } from "@/helpers/date-time.helper"; diff --git a/space/helpers/state.helper.ts b/space/helpers/state.helper.ts index 81bffdef9..8d97c39f6 100644 --- a/space/helpers/state.helper.ts +++ b/space/helpers/state.helper.ts @@ -1,5 +1,5 @@ +import { STATE_GROUPS } from "@plane/constants"; import { IState } from "@plane/types"; -import { STATE_GROUPS } from "@/constants/state"; export const sortStates = (states: IState[]) => { if (!states || states.length === 0) return; diff --git a/space/package.json b/space/package.json index 941f8419f..7e7f0429b 100644 --- a/space/package.json +++ b/space/package.json @@ -20,17 +20,19 @@ "@mui/material": "^5.14.1", "@plane/constants": "*", "@plane/editor": "*", + "@plane/i18n": "*", "@plane/types": "*", "@plane/ui": "*", - "@sentry/nextjs": "^8.32.0", - "axios": "^1.7.4", + "@plane/services": "*", + "@sentry/nextjs": "^8.54.0", + "axios": "^1.7.9", "clsx": "^2.0.0", - "date-fns": "^3.6.0", + "date-fns": "^4.1.0", "dompurify": "^3.0.11", "dotenv": "^16.3.1", "lodash": "^4.17.21", "lowlight": "^2.9.0", - "lucide-react": "^0.378.0", + "lucide-react": "^0.469.0", "mobx": "^6.10.0", "mobx-react": "^9.1.1", "mobx-utils": "^6.0.8", @@ -49,6 +51,7 @@ }, "devDependencies": { "@plane/eslint-config": "*", + "@plane/tailwind-config": "*", "@plane/typescript-config": "*", "@types/dompurify": "^3.0.5", "@types/lodash": "^4.17.1", @@ -59,7 +62,6 @@ "@types/uuid": "^9.0.1", "@types/zxcvbn": "^4.4.4", "@typescript-eslint/eslint-plugin": "^5.48.2", - "tailwind-config-custom": "*", "typescript": "5.3.3" } } diff --git a/space/postcss.config.js b/space/postcss.config.js index 129aa7f59..9b1e55fc4 100644 --- a/space/postcss.config.js +++ b/space/postcss.config.js @@ -1 +1,2 @@ -module.exports = require("tailwind-config-custom/postcss.config"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = require("@plane/tailwind-config/postcss.config.js"); diff --git a/space/public/site.webmanifest.json b/space/public/site.webmanifest.json index 4c32ec6e3..8885d137b 100644 --- a/space/public/site.webmanifest.json +++ b/space/public/site.webmanifest.json @@ -1,7 +1,7 @@ { "name": "Plane Space", "short_name": "Plane Space", - "description": "Plane helps you plan your issues, cycles, and product modules.", + "description": "Plane helps you plan your work items, cycles, and product modules.", "start_url": ".", "display": "standalone", "background_color": "#f9fafb", diff --git a/space/styles/globals.css b/space/styles/globals.css index 511f6ad1f..8976e83c2 100644 --- a/space/styles/globals.css +++ b/space/styles/globals.css @@ -300,6 +300,53 @@ --color-sidebar-border-300: var(--color-border-300); /* strong sidebar border- 1 */ --color-sidebar-border-400: var(--color-border-400); /* strong sidebar border- 2 */ } + + /* stickies and editor colors */ + :root { + /* text colors */ + --editor-colors-gray-text: #5c5e63; + --editor-colors-peach-text: #ff5b59; + --editor-colors-pink-text: #f65385; + --editor-colors-orange-text: #fd9038; + --editor-colors-green-text: #0fc27b; + --editor-colors-light-blue-text: #17bee9; + --editor-colors-dark-blue-text: #266df0; + --editor-colors-purple-text: #9162f9; + /* end text colors */ + + /* background colors */ + --editor-colors-gray-background: #d6d6d8; + --editor-colors-peach-background: #ffd5d7; + --editor-colors-pink-background: #fdd4e3; + --editor-colors-orange-background: #ffe3cd; + --editor-colors-green-background: #c3f0de; + --editor-colors-light-blue-background: #c5eff9; + --editor-colors-dark-blue-background: #c9dafb; + --editor-colors-purple-background: #e3d8fd; + /* end background colors */ + } + /* background colors */ + [data-theme*="light"] { + --editor-colors-gray-background: #d6d6d8; + --editor-colors-peach-background: #ffd5d7; + --editor-colors-pink-background: #fdd4e3; + --editor-colors-orange-background: #ffe3cd; + --editor-colors-green-background: #c3f0de; + --editor-colors-light-blue-background: #c5eff9; + --editor-colors-dark-blue-background: #c9dafb; + --editor-colors-purple-background: #e3d8fd; + } + [data-theme*="dark"] { + --editor-colors-gray-background: #404144; + --editor-colors-peach-background: #593032; + --editor-colors-pink-background: #562e3d; + --editor-colors-orange-background: #583e2a; + --editor-colors-green-background: #1d4a3b; + --editor-colors-light-blue-background: #1f495c; + --editor-colors-dark-blue-background: #223558; + --editor-colors-purple-background: #3d325a; + } + /* end background colors */ } * { @@ -355,7 +402,6 @@ body { -webkit-background-clip: text; } - @-moz-document url-prefix() { * { scrollbar-width: none; diff --git a/space/tailwind.config.js b/space/tailwind.config.js index 1e1e59826..2232bb29a 100644 --- a/space/tailwind.config.js +++ b/space/tailwind.config.js @@ -1 +1,2 @@ -module.exports = require("tailwind-config-custom/tailwind.config"); +/* eslint-disable @typescript-eslint/no-require-imports */ +module.exports = require("@plane/tailwind-config/tailwind.config.js"); diff --git a/turbo.json b/turbo.json index edfcd6771..1113926ce 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,6 @@ { "$schema": "https://turbo.build/schema.json", + "ui": "tui", "globalEnv": [ "NODE_ENV", "NEXT_PUBLIC_API_BASE_URL", diff --git a/web/.eslintrc.js b/web/.eslintrc.js index afa7562fd..5a6f06067 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -3,7 +3,4 @@ module.exports = { root: true, extends: ["@plane/eslint-config/next.js"], parser: "@typescript-eslint/parser", - parserOptions: { - project: true, - }, }; diff --git a/web/Dockerfile.dev b/web/Dockerfile.dev index 5fa751338..64465755e 100644 --- a/web/Dockerfile.dev +++ b/web/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM node:18-alpine +FROM node:20-alpine RUN apk add --no-cache libc6-compat # Set working directory WORKDIR /app diff --git a/web/Dockerfile.web b/web/Dockerfile.web index d7d924d7a..56f931adc 100644 --- a/web/Dockerfile.web +++ b/web/Dockerfile.web @@ -1,7 +1,9 @@ +FROM node:20-alpine as base + # ***************************************************************************** # STAGE 1: Build the project # ***************************************************************************** -FROM node:18-alpine AS builder +FROM base AS builder RUN apk add --no-cache libc6-compat # Set working directory WORKDIR /app @@ -15,7 +17,7 @@ RUN turbo prune --scope=web --docker # STAGE 2: Install dependencies & build the project # ***************************************************************************** # Add lockfile and package.json's of isolated subworkspace -FROM node:18-alpine AS installer +FROM base AS installer RUN apk add --no-cache libc6-compat WORKDIR /app @@ -62,7 +64,7 @@ RUN yarn turbo run build --filter=web # ***************************************************************************** # STAGE 3: Copy the project and start it # ***************************************************************************** -FROM node:18-alpine AS runner +FROM base AS runner WORKDIR /app COPY --from=installer /app/web/next.config.js . diff --git a/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx b/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx index 4edf41bbd..6416aee12 100644 --- a/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx @@ -1,6 +1,6 @@ "use client"; - import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; // ui import { Breadcrumbs, ContrastIcon, Header } from "@plane/ui"; // components @@ -8,15 +8,17 @@ import { BreadcrumbLink } from "@/components/common"; // plane web components import { UpgradeBadge } from "@/plane-web/components/workspace"; -export const WorkspaceActiveCycleHeader = observer(() => ( -
    - - +export const WorkspaceActiveCycleHeader = observer(() => { + const { t } = useTranslation(); + return ( +
    + + } /> } @@ -25,4 +27,5 @@ export const WorkspaceActiveCycleHeader = observer(() => (
    -)); + ); +}); diff --git a/web/app/[workspaceSlug]/(projects)/analytics/header.tsx b/web/app/[workspaceSlug]/(projects)/analytics/header.tsx index 4aa66e2a4..2c3247bd7 100644 --- a/web/app/[workspaceSlug]/(projects)/analytics/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/analytics/header.tsx @@ -3,8 +3,8 @@ import { useEffect } from "react"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; -// icons import { BarChart2, PanelRight } from "lucide-react"; +import { useTranslation } from "@plane/i18n"; // ui import { Breadcrumbs, Header } from "@plane/ui"; // components @@ -13,8 +13,8 @@ import { BreadcrumbLink } from "@/components/common"; import { cn } from "@/helpers/common.helper"; // hooks import { useAppTheme } from "@/hooks/store"; - export const WorkspaceAnalyticsHeader = observer(() => { + const { t } = useTranslation(); const searchParams = useSearchParams(); const analytics_tab = searchParams.get("analytics_tab"); // store hooks @@ -41,7 +41,12 @@ export const WorkspaceAnalyticsHeader = observer(() => { } />} + link={ + } + /> + } /> {analytics_tab === "custom" ? ( diff --git a/web/app/[workspaceSlug]/(projects)/analytics/page.tsx b/web/app/[workspaceSlug]/(projects)/analytics/page.tsx index b66c0d19e..8875e1465 100644 --- a/web/app/[workspaceSlug]/(projects)/analytics/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/analytics/page.tsx @@ -4,27 +4,41 @@ import React, { Fragment } from "react"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; import { Tab } from "@headlessui/react"; -// components +// plane package imports +import { ANALYTICS_TABS, EUserPermissionsLevel, EUserPermissions } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { Header, EHeaderVariant } from "@plane/ui"; +// components import { CustomAnalytics, ScopeAndDemand } from "@/components/analytics"; import { PageHead } from "@/components/core"; -import { EmptyState } from "@/components/empty-state"; -// constants -import { ANALYTICS_TABS } from "@/constants/analytics"; -import { EmptyStateType } from "@/constants/empty-state"; +import { ComicBoxButton, DetailedEmptyState } from "@/components/empty-state"; // hooks -import { useCommandPalette, useEventTracker, useProject, useWorkspace } from "@/hooks/store"; +import { useCommandPalette, useEventTracker, useProject, useUserPermissions, useWorkspace } from "@/hooks/store"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; 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 ? `${currentWorkspace?.name} - Analytics` : undefined; + const pageTitle = currentWorkspace?.name + ? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name }) + : undefined; + + // permissions + const canPerformEmptyStateActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); // TODO: refactor loader implementation return ( @@ -32,7 +46,7 @@ const AnalyticsPage = observer(() => { {workspaceProjectIds && ( <> - {workspaceProjectIds.length > 0 || loader ? ( + {workspaceProjectIds.length > 0 || loader === "init-loader" ? (
    @@ -45,7 +59,7 @@ const AnalyticsPage = observer(() => { selected ? "text-custom-primary-100 " : "hover:text-custom-text-200" }`} > - {tab.title} + {t(tab.i18n_title)}
    @@ -66,12 +80,22 @@ const AnalyticsPage = observer(() => {
    ) : ( - { - setTrackElement("Analytics empty state"); - toggleCreateProjectModal(true); - }} + { + setTrackElement("Analytics empty state"); + toggleCreateProjectModal(true); + }} + disabled={!canPerformEmptyStateActions} + /> + } /> )} diff --git a/web/app/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx b/web/app/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx new file mode 100644 index 000000000..ec0d8b03a --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx @@ -0,0 +1,96 @@ +"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)/projects/(detail)/[projectId]/issues/(detail)/layout.tsx b/web/app/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/layout.tsx rename to web/app/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx b/web/app/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx new file mode 100644 index 000000000..e315f3549 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx @@ -0,0 +1,115 @@ +"use client"; + +import React, { useEffect } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { useTheme } from "next-themes"; +import useSWR from "swr"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { Loader } from "@plane/ui"; +// components +import { EmptyState } from "@/components/common"; +import { PageHead } from "@/components/core"; +import { IssueDetailRoot } from "@/components/issues"; +// hooks +import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store"; +// assets +import { useAppRouter } from "@/hooks/use-app-router"; +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"; + +const IssueDetailsPage = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug, workItem } = useParams(); + // hooks + const { resolvedTheme } = useTheme(); + // store hooks + const { t } = useTranslation(); + const { + fetchIssueWithIdentifier, + issue: { getIssueById }, + } = useIssueDetail(); + const { getProjectById } = useProject(); + const { toggleIssueDetailSidebar, issueDetailSidebarCollapsed } = useAppTheme(); + + const projectIdentifier = workItem?.toString().split("-")[0]; + const sequence_id = workItem?.toString().split("-")[1]; + + // fetching issue details + const { data, isLoading, error } = useSWR( + workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null, + workspaceSlug && workItem + ? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id) + : null + ); + const issueId = data?.id; + const projectId = data?.project_id; + // derived values + const issue = getIssueById(issueId?.toString() || "") || undefined; + const project = (issue?.project_id && getProjectById(issue?.project_id)) || undefined; + const issueLoader = !issue || isLoading; + const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined; + + useEffect(() => { + const handleToggleIssueDetailSidebar = () => { + if (window && window.innerWidth < 768) { + toggleIssueDetailSidebar(true); + } + if (window && issueDetailSidebarCollapsed && window.innerWidth >= 768) { + toggleIssueDetailSidebar(false); + } + }; + window.addEventListener("resize", handleToggleIssueDetailSidebar); + handleToggleIssueDetailSidebar(); + return () => window.removeEventListener("resize", handleToggleIssueDetailSidebar); + }, [issueDetailSidebarCollapsed, toggleIssueDetailSidebar]); + + return ( + <> + + {error ? ( + router.push(`/${workspaceSlug}/workspace-views/all-issues/`), + }} + /> + ) : issueLoader ? ( + +
    + + + + +
    +
    + + + + +
    +
    + ) : ( + workspaceSlug && + projectId && + issueId && ( + + + + ) + )} + + ); +}); + +export default IssueDetailsPage; diff --git a/web/app/[workspaceSlug]/(projects)/drafts/header.tsx b/web/app/[workspaceSlug]/(projects)/drafts/header.tsx index f77e61c31..5e1cc66ef 100644 --- a/web/app/[workspaceSlug]/(projects)/drafts/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/drafts/header.tsx @@ -3,17 +3,16 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { PenSquare } from "lucide-react"; +import { EIssuesStoreType, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; // ui import { Breadcrumbs, Button, Header } from "@plane/ui"; // components import { BreadcrumbLink, CountChip } from "@/components/common"; import { CreateUpdateIssueModal } from "@/components/issues"; -// constants -import { EIssuesStoreType } from "@/constants/issue"; + // hooks import { useProject, useUserPermissions, useWorkspaceDraftIssues } from "@/hooks/store"; -// plane-web -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; export const WorkspaceDraftHeader = observer(() => { // state @@ -22,7 +21,9 @@ export const WorkspaceDraftHeader = observer(() => { const { allowPermissions } = useUserPermissions(); const { paginationInfo } = useWorkspaceDraftIssues(); const { joinedProjectIds } = useProject(); - // check if user is authorized to create draft issue + + const { t } = useTranslation(); + // check if user is authorized to create draft work item const isAuthorizedUser = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.WORKSPACE @@ -42,7 +43,9 @@ export const WorkspaceDraftHeader = observer(() => { } />} + link={ + } /> + } /> {paginationInfo?.total_count && paginationInfo?.total_count > 0 ? ( @@ -62,7 +65,7 @@ export const WorkspaceDraftHeader = observer(() => { onClick={() => setIsDraftIssueModalOpen(true)} disabled={!isAuthorizedUser} > - Draft an issue + {t("workspace_draft_issues.draft_an_issue")} )} diff --git a/web/app/[workspaceSlug]/(projects)/extended-project-sidebar.tsx b/web/app/[workspaceSlug]/(projects)/extended-project-sidebar.tsx new file mode 100644 index 000000000..3f3e21cfc --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/extended-project-sidebar.tsx @@ -0,0 +1,166 @@ +"use client"; + +import React, { useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +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 } from "@plane/utils"; +// components +import { CreateProjectModal } from "@/components/project"; +import { SidebarProjectsListItem } from "@/components/workspace"; +// hooks +import { orderJoinedProjects } from "@/helpers/project.helper"; +import { copyUrlToClipboard } from "@/helpers/string.helper"; +import { useAppTheme, useProject, useUserPermissions } from "@/hooks/store"; +import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click"; +import { TProject } from "@/plane-web/types"; + +export const ExtendedProjectSidebar = observer(() => { + // refs + const extendedProjectSidebarRef = useRef(null); + const [searchQuery, setSearchQuery] = useState(""); + // states + const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); + // routers + const { workspaceSlug } = useParams(); + // store hooks + const { t } = useTranslation(); + const { sidebarCollapsed, extendedProjectSidebarCollapsed, toggleExtendedProjectSidebar } = useAppTheme(); + const { getPartialProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject(); + const { allowPermissions } = useUserPermissions(); + + const handleOnProjectDrop = ( + sourceId: string | undefined, + destinationId: string | undefined, + shouldDropAtEnd: boolean + ) => { + if (!sourceId || !destinationId || !workspaceSlug) return; + if (sourceId === destinationId) return; + + const joinedProjectsList: TProject[] = []; + joinedProjects.map((projectId) => { + const projectDetails = getPartialProjectById(projectId); + if (projectDetails) joinedProjectsList.push(projectDetails); + }); + + const sourceIndex = joinedProjects.indexOf(sourceId); + const destinationIndex = shouldDropAtEnd ? joinedProjects.length : joinedProjects.indexOf(destinationId); + + if (joinedProjectsList.length <= 0) return; + + const updatedSortOrder = orderJoinedProjects(sourceIndex, destinationIndex, sourceId, joinedProjectsList); + if (updatedSortOrder != undefined) + updateProjectView(workspaceSlug.toString(), sourceId, { sort_order: updatedSortOrder }).catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: t("something_went_wrong"), + }); + }); + }; + + // filter projects based on search query + const filteredProjects = joinedProjects.filter((projectId) => { + const project = getPartialProjectById(projectId); + if (!project) return false; + return project.name.toLowerCase().includes(searchQuery.toLowerCase()) || project.identifier.includes(searchQuery); + }); + + // auth + const isAuthorizedUser = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + useExtendedSidebarOutsideClickDetector( + extendedProjectSidebarRef, + () => { + if (!isProjectModalOpen) { + toggleExtendedProjectSidebar(false); + } + }, + "extended-project-sidebar-toggle" + ); + + const handleCopyText = (projectId: string) => { + copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("link_copied"), + message: t("project_link_copied_to_clipboard"), + }); + }); + }; + return ( + <> + {workspaceSlug && ( + setIsProjectModalOpen(false)} + setToFavorite={false} + workspaceSlug={workspaceSlug.toString()} + /> + )} +
    +
    +
    + Projects + {isAuthorizedUser && ( + + + + )} +
    +
    + + setSearchQuery(e.target.value)} + /> +
    +
    +
    + {filteredProjects.map((projectId, index) => ( + handleCopyText(projectId)} + projectListType={"JOINED"} + disableDrag={false} + disableDrop={false} + isLastChild={index === joinedProjects.length - 1} + handleOnProjectDrop={handleOnProjectDrop} + renderInExtendedSidebar + /> + ))} +
    +
    + + ); +}); diff --git a/web/app/[workspaceSlug]/(projects)/extended-sidebar.tsx b/web/app/[workspaceSlug]/(projects)/extended-sidebar.tsx new file mode 100644 index 000000000..65061ff43 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/extended-sidebar.tsx @@ -0,0 +1,126 @@ +"use client"; + +import React, { useMemo, useRef } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { EUserWorkspaceRoles, WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS } from "@plane/constants"; +import { cn } from "@plane/utils"; +// hooks +import { useAppTheme, useWorkspace } from "@/hooks/store"; +import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click"; +// plane-web imports +import { ExtendedSidebarItem } from "@/plane-web/components/workspace/sidebar"; + +export const ExtendedAppSidebar = observer(() => { + // refs + const extendedSidebarRef = useRef(null); + // routers + const { workspaceSlug } = useParams(); + // store hooks + const { sidebarCollapsed, extendedSidebarCollapsed, toggleExtendedSidebar } = useAppTheme(); + const { updateSidebarPreference, getNavigationPreferences } = useWorkspace(); + + // derived values + const currentWorkspaceNavigationPreferences = getNavigationPreferences(workspaceSlug.toString()); + + const sortedNavigationItems = useMemo( + () => + WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.map((item) => { + const preference = currentWorkspaceNavigationPreferences?.[item.key]; + return { + ...item, + sort_order: preference ? preference.sort_order : 0, + }; + }).sort((a, b) => a.sort_order - b.sort_order), + [currentWorkspaceNavigationPreferences] + ); + + const sortedNavigationItemsKeys = sortedNavigationItems.map((item) => item.key); + + const orderNavigationItem = ( + sourceIndex: number, + destinationIndex: number, + navigationList: { + sort_order: number; + key: string; + labelTranslationKey: string; + href: string; + access: EUserWorkspaceRoles[]; + }[] + ): number | undefined => { + if (sourceIndex < 0 || destinationIndex < 0 || navigationList.length <= 0) return undefined; + + let updatedSortOrder: number | undefined = undefined; + const sortOrderDefaultValue = 10000; + + if (destinationIndex === 0) { + // updating project at the top of the project + const currentSortOrder = navigationList[destinationIndex].sort_order || 0; + updatedSortOrder = currentSortOrder - sortOrderDefaultValue; + } else if (destinationIndex === navigationList.length) { + // updating project at the bottom of the project + const currentSortOrder = navigationList[destinationIndex - 1].sort_order || 0; + updatedSortOrder = currentSortOrder + sortOrderDefaultValue; + } else { + // updating project in the middle of the project + const destinationTopProjectSortOrder = navigationList[destinationIndex - 1].sort_order || 0; + const destinationBottomProjectSortOrder = navigationList[destinationIndex].sort_order || 0; + const updatedValue = (destinationTopProjectSortOrder + destinationBottomProjectSortOrder) / 2; + updatedSortOrder = updatedValue; + } + + return updatedSortOrder; + }; + + const handleOnNavigationItemDrop = ( + sourceId: string | undefined, + destinationId: string | undefined, + shouldDropAtEnd: boolean + ) => { + if (!sourceId || !destinationId || !workspaceSlug) return; + if (sourceId === destinationId) return; + + const sourceIndex = sortedNavigationItemsKeys.indexOf(sourceId); + const destinationIndex = shouldDropAtEnd + ? sortedNavigationItemsKeys.length + : sortedNavigationItemsKeys.indexOf(destinationId); + + const updatedSortOrder = orderNavigationItem(sourceIndex, destinationIndex, sortedNavigationItems); + + if (updatedSortOrder != undefined) + updateSidebarPreference(workspaceSlug.toString(), sourceId, { + sort_order: updatedSortOrder, + }); + }; + + useExtendedSidebarOutsideClickDetector( + extendedSidebarRef, + () => toggleExtendedSidebar(false), + "extended-sidebar-toggle" + ); + + return ( +
    + {sortedNavigationItems.map((item, index) => ( + + ))} +
    + ); +}); diff --git a/web/app/[workspaceSlug]/(projects)/header.tsx b/web/app/[workspaceSlug]/(projects)/header.tsx index a7a19afe6..16c106dad 100644 --- a/web/app/[workspaceSlug]/(projects)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/header.tsx @@ -7,11 +7,12 @@ 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 { useTranslation } from "@plane/i18n"; import { Breadcrumbs, Header } from "@plane/ui"; // components import { BreadcrumbLink } from "@/components/common"; // constants -import { GITHUB_REDIRECTED } from "@/constants/event-tracker"; // hooks import { useEventTracker } from "@/hooks/store"; @@ -19,6 +20,7 @@ export const WorkspaceDashboardHeader = () => { // hooks const { captureEvent } = useEventTracker(); const { resolvedTheme } = useTheme(); + const { t } = useTranslation(); return ( <> @@ -28,7 +30,9 @@ export const WorkspaceDashboardHeader = () => { } />} + link={ + } /> + } />
    @@ -51,7 +55,7 @@ export const WorkspaceDashboardHeader = () => { width={16} alt="GitHub Logo" /> - Star us on GitHub + {t("home.star_us_on_github")}
    diff --git a/web/app/[workspaceSlug]/(projects)/layout.tsx b/web/app/[workspaceSlug]/(projects)/layout.tsx index f8fe0f8f9..340ec57d0 100644 --- a/web/app/[workspaceSlug]/(projects)/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/layout.tsx @@ -1,8 +1,9 @@ "use client"; import { CommandPalette } from "@/components/command-palette"; -import { WorkspaceAuthWrapper } from "@/layouts/auth-layout"; import { AuthenticationWrapper } from "@/lib/wrappers"; +// plane web components +import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper"; import { AppSidebar } from "./sidebar"; export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { diff --git a/web/app/[workspaceSlug]/(projects)/notifications/layout.tsx b/web/app/[workspaceSlug]/(projects)/notifications/layout.tsx index 7d71948d8..e3d730363 100644 --- a/web/app/[workspaceSlug]/(projects)/notifications/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/notifications/layout.tsx @@ -1,7 +1,7 @@ "use client"; // components -import { NotificationsSidebarRoot } from "@/plane-web/components/workspace-notifications"; +import { NotificationsSidebarRoot } from "@/components/workspace-notifications"; export default function ProjectInboxIssuesLayout({ children }: { children: React.ReactNode }) { return ( diff --git a/web/app/[workspaceSlug]/(projects)/notifications/page.tsx b/web/app/[workspaceSlug]/(projects)/notifications/page.tsx index 4ea0c8e42..8afe768d8 100644 --- a/web/app/[workspaceSlug]/(projects)/notifications/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/notifications/page.tsx @@ -4,21 +4,24 @@ 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 { EmptyState } from "@/components/empty-state"; +import { SimpleEmptyState } from "@/components/empty-state"; import { InboxContentRoot } from "@/components/inbox"; import { IssuePeekOverview } from "@/components/issues"; -// constants -import { EmptyStateType } from "@/constants/empty-state"; -import { ENotificationLoader, ENotificationQueryParamType } from "@/constants/notification"; // 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 { @@ -31,11 +34,14 @@ const WorkspaceDashboardPage = observer(() => { const { fetchUserProjectInfo } = useUserPermissions(); const { setPeekIssue } = useIssueDetail(); // derived values - const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Inbox` : undefined; + 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 issue properties + // fetching workspace work item properties useWorkspaceIssueProperties(workspaceSlug); // fetch workspace notifications @@ -82,7 +88,7 @@ const WorkspaceDashboardPage = observer(() => {
    {!currentSelectedNotificationId ? (
    - +
    ) : ( <> diff --git a/web/app/[workspaceSlug]/(projects)/page.tsx b/web/app/[workspaceSlug]/(projects)/page.tsx index d684b923e..7a808decd 100644 --- a/web/app/[workspaceSlug]/(projects)/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/page.tsx @@ -2,8 +2,9 @@ import { observer } from "mobx-react"; // components +import { useTranslation } from "@plane/i18n"; import { PageHead, AppHeader, ContentWrapper } from "@/components/core"; -import { WorkspaceDashboardView } from "@/components/page-views"; +import { WorkspaceHomeView } from "@/components/home"; // hooks import { useWorkspace } from "@/hooks/store"; // local components @@ -11,15 +12,16 @@ import { WorkspaceDashboardHeader } from "./header"; const WorkspaceDashboardPage = observer(() => { const { currentWorkspace } = useWorkspace(); + const { t } = useTranslation(); // derived values - const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Home` : undefined; + const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - ${t("home.title")}` : undefined; return ( <> } /> - + ); diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx index bf1f88d15..f3b719904 100644 --- a/web/app/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx @@ -3,6 +3,8 @@ import { useState } from "react"; import { observer } from "mobx-react"; // ui +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/ui"; // components import { PageHead } from "@/components/core"; @@ -10,7 +12,6 @@ import { DownloadActivityButton, WorkspaceActivityListPage } from "@/components/ // hooks import { useUserPermissions } from "@/hooks/store"; // plane-web constants -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const PER_PAGE = 100; @@ -21,6 +22,8 @@ const ProfileActivityPage = observer(() => { const [resultsCount, setResultsCount] = useState(0); // router const { allowPermissions } = useUserPermissions(); + //hooks + const { t } = useTranslation(); const updateTotalPages = (count: number) => setTotalPages(count); @@ -50,7 +53,7 @@ const ProfileActivityPage = observer(() => {
    -

    Recent activity

    +

    {t("profile.stats.recent_activity.title")}

    {canDownloadActivity && }
    @@ -58,7 +61,7 @@ const ProfileActivityPage = observer(() => { {pageCount < totalPages && resultsCount !== 0 && (
    )} diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/header.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/header.tsx index 13a944c88..e97b4751f 100644 --- a/web/app/[workspaceSlug]/(projects)/profile/[userId]/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/header.tsx @@ -6,15 +6,15 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { ChevronDown, PanelRight } from "lucide-react"; +import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { IUserProfileProjectSegregation } from "@plane/types"; import { Breadcrumbs, Header, CustomMenu, UserActivityIcon } from "@plane/ui"; import { BreadcrumbLink } from "@/components/common"; // components import { ProfileIssuesFilter } from "@/components/profile"; -import { PROFILE_ADMINS_TAB, PROFILE_VIEWER_TAB } from "@/constants/profile"; import { cn } from "@/helpers/common.helper"; import { useAppTheme, useUser, useUserPermissions } from "@/hooks/store"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; type TUserProfileHeader = { userProjectsData: IUserProfileProjectSegregation | undefined; @@ -30,6 +30,7 @@ export const UserProfileHeader: FC = observer((props) => { const { toggleProfileSidebar, profileSidebarCollapsed } = useAppTheme(); const { data: currentUser } = useUser(); const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { t } = useTranslation(); // derived values const isAuthorized = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], @@ -44,7 +45,7 @@ export const UserProfileHeader: FC = observer((props) => { const isCurrentUser = currentUser?.id === userId; - const breadcrumbLabel = `${isCurrentUser ? "Your" : userName} Work`; + const breadcrumbLabel = isCurrentUser ? t("profile.page_label") : `${userName} ${t("profile.work")}`; return (
    @@ -86,7 +87,7 @@ export const UserProfileHeader: FC = observer((props) => { href={`/${workspaceSlug}/profile/${userId}/${tab.route}`} className="w-full text-custom-text-300" > - {tab.label} + {t(tab.i18n_label)} ))} diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx index f31ff959d..fb223f2dd 100644 --- a/web/app/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx @@ -4,15 +4,15 @@ import { observer } from "mobx-react"; import { useParams, usePathname } from "next/navigation"; import useSWR from "swr"; // components +import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { AppHeader, ContentWrapper } from "@/components/core"; import { ProfileSidebar } from "@/components/profile"; // constants import { USER_PROFILE_PROJECT_SEGREGATION } from "@/constants/fetch-keys"; -import { PROFILE_ADMINS_TAB, PROFILE_VIEWER_TAB } from "@/constants/profile"; // hooks import { useUserPermissions } from "@/hooks/store"; import useSize from "@/hooks/use-window-size"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; // local components import { UserService } from "@/services/user.service"; import { UserProfileHeader } from "./header"; @@ -32,6 +32,7 @@ const UseProfileLayout: React.FC = observer((props) => { const pathname = usePathname(); // store hooks const { allowPermissions } = useUserPermissions(); + const { t } = useTranslation(); // derived values const isAuthorized = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], @@ -64,7 +65,7 @@ const UseProfileLayout: React.FC = observer((props) => { @@ -79,7 +80,7 @@ const UseProfileLayout: React.FC = observer((props) => {
    {children}
    ) : (
    - You do not have the permission to access this page. + {t("you_do_not_have_the_permission_to_access_this_page")}
    )}
    diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx index b963ca147..49fa5d2e5 100644 --- a/web/app/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx @@ -5,26 +5,31 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // icons import { ChevronDown } from "lucide-react"; +// plane constants +import { + EIssueLayoutTypes, + EIssueFilterType, + EIssuesStoreType, + ISSUE_LAYOUTS, + ISSUE_DISPLAY_FILTERS_BY_PAGE, +} from "@plane/constants"; +// plane i18n +import { useTranslation } from "@plane/i18n"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; // ui import { CustomMenu } from "@plane/ui"; // components -import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues"; -// constants -import { - EIssueFilterType, - EIssueLayoutTypes, - EIssuesStoreType, - ISSUE_DISPLAY_FILTERS_BY_LAYOUT, - ISSUE_LAYOUTS, -} from "@/constants/issue"; +import { DisplayFiltersSelection, FilterSelection, FiltersDropdown, IssueLayoutIcon } from "@/components/issues"; + // helpers import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks import { useIssues, useLabel } from "@/hooks/store"; export const ProfileIssuesMobileHeader = observer(() => { + // plane i18n + const { t } = useTranslation(); // router const { workspaceSlug, userId } = useParams(); // store hook @@ -116,7 +121,7 @@ export const ProfileIssuesMobileHeader = observer(() => { placement="bottom-start" customButton={
    - Layout + {t("common.layout")}
    } @@ -133,19 +138,19 @@ export const ProfileIssuesMobileHeader = observer(() => { }} className="flex items-center gap-2" > - -
    {layout.title}
    + +
    {t(layout.i18n_title)}
    ); })}
    - Filters + {t("common.filters")}
    } @@ -153,7 +158,7 @@ export const ProfileIssuesMobileHeader = observer(() => { > {
    - Display + {t("common.display")}
    } > = (props) => { const { isAuthorized } = props; - + const { t } = useTranslation(); const { workspaceSlug, userId } = useParams(); const pathname = usePathname(); @@ -32,7 +33,7 @@ export const ProfileNavbar: React.FC = (props) => { : "border-transparent" }`} > - {tab.label} + {t(tab.i18n_label)} ))} diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/page.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/page.tsx index 480e30aed..0db37129c 100644 --- a/web/app/[workspaceSlug]/(projects)/profile/[userId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/page.tsx @@ -3,6 +3,8 @@ import { useParams } from "next/navigation"; import useSWR from "swr"; // types +import { GROUP_CHOICES } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { IUserStateDistribution, TStateGroups } from "@plane/types"; // components import { ContentWrapper } from "@plane/ui"; @@ -16,7 +18,6 @@ import { } from "@/components/profile"; // constants import { USER_PROFILE_DATA } from "@/constants/fetch-keys"; -import { GROUP_CHOICES } from "@/constants/project"; // services import { UserService } from "@/services/user.service"; @@ -26,6 +27,7 @@ const userService = new UserService(); export default function ProfileOverviewPage() { const { workspaceSlug, userId } = useParams(); + const { t } = useTranslation(); const { data: userProfile } = useSWR( workspaceSlug && userId ? USER_PROFILE_DATA(workspaceSlug.toString(), userId.toString()) : null, workspaceSlug && userId ? () => userService.getUserProfileData(workspaceSlug.toString(), userId.toString()) : null @@ -40,7 +42,7 @@ export default function ProfileOverviewPage() { return ( <> - + diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx index f95611fbc..7f2799cd6 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx @@ -3,22 +3,46 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; +import { EIssuesStoreType } from "@plane/constants"; // ui -import { ArchiveIcon, Breadcrumbs, Tooltip, Header } from "@plane/ui"; +import { ArchiveIcon, Breadcrumbs, Tooltip, Header, ContrastIcon, DiceIcon, LayersIcon } from "@plane/ui"; // components -import { BreadcrumbLink, Logo } from "@/components/common"; -// constants -import { PROJECT_ARCHIVES_BREADCRUMB_LIST } from "@/constants/archives"; -import { EIssuesStoreType } from "@/constants/issue"; +import { BreadcrumbLink } from "@/components/common"; // hooks import { useIssues, useProject } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web +import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; type TProps = { activeTab: "issues" | "cycles" | "modules"; }; +const PROJECT_ARCHIVES_BREADCRUMB_LIST: { + [key: string]: { + label: string; + href: string; + icon: React.FC & { className?: string }>; + }; +} = { + issues: { + label: "Work items", + href: "/issues", + icon: LayersIcon, + }, + cycles: { + label: "Cycles", + href: "/cycles", + icon: ContrastIcon, + }, + modules: { + label: "Modules", + href: "/modules", + icon: DiceIcon, + }, +}; + export const ProjectArchivesHeader: FC = observer((props: TProps) => { const { activeTab } = props; // router @@ -28,7 +52,7 @@ export const ProjectArchivesHeader: FC = observer((props: TProps) => { const { issues: { getGroupIssueCount }, } = useIssues(EIssuesStoreType.ARCHIVED); - const { currentProjectDetails, loader } = useProject(); + const { loader } = useProject(); // hooks const { isMobile } = usePlatformOS(); @@ -41,22 +65,8 @@ export const ProjectArchivesHeader: FC = observer((props: TProps) => {
    - - - - - ) - } - /> - } - /> + + = observer((props: TProps) => { {activeTab === "issues" && issueCount && issueCount > 0 ? ( 1 ? "issues" : "issue"} in project's archived`} + tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "work items" : "work item"} in project's archived`} position="bottom" > diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx index 276f9ff25..7b9e84a9e 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx @@ -6,12 +6,14 @@ import useSWR from "swr"; // ui import { ArchiveIcon, Breadcrumbs, LayersIcon, Header } from "@plane/ui"; // components -import { BreadcrumbLink, Logo } from "@/components/common"; +import { BreadcrumbLink } from "@/components/common"; import { IssueDetailQuickActions } from "@/components/issues"; // constants import { ISSUE_DETAILS } from "@/constants/fetch-keys"; // hooks import { useProject } from "@/hooks/store"; +// plane web +import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; // services import { IssueService } from "@/services/issue"; @@ -33,22 +35,8 @@ export const ProjectArchivedIssueDetailsHeader = observer(() => { return (
    - - - - - ) - } - /> - } - /> + + { link={ } /> } diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx index b2298d540..afa6c0d72 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx @@ -15,7 +15,7 @@ const ProjectArchivedIssuesPage = observer(() => { const { getProjectById } = useProject(); // derived values const project = projectId ? getProjectById(projectId.toString()) : undefined; - const pageTitle = project?.name && `${project?.name} - Archived issues`; + const pageTitle = project?.name && `${project?.name} - Archived work items`; return ( <> diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx index a1f7071a4..381b567df 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx @@ -8,8 +8,6 @@ import { PageHead } from "@/components/core"; import { CycleDetailsSidebar } from "@/components/cycles"; import useCyclesDetails from "@/components/cycles/active-cycle/use-cycles-details"; import { CycleLayoutRoot } from "@/components/issues/issue-layouts"; -// constants -// import { EIssuesStoreType } from "@/constants/issue"; // helpers import { cn } from "@/helpers/common.helper"; // hooks @@ -31,7 +29,7 @@ const CycleDetailPage = observer(() => { const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", "false"); useCyclesDetails({ - workspaceSlug: workspaceSlug.toString(), + workspaceSlug: workspaceSlug?.toString(), projectId: projectId.toString(), cycleId: cycleId.toString(), }); @@ -77,7 +75,12 @@ const CycleDetailPage = observer(() => { "0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)", }} > - +
    )}
    diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx index 678c50c2a..508da58a2 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx @@ -6,21 +6,25 @@ import Link from "next/link"; import { useParams } from "next/navigation"; // icons import { ArrowRight, PanelRight } from "lucide-react"; +// plane constants +import { + EIssueLayoutTypes, + EIssueFilterType, + EIssuesStoreType, + ISSUE_DISPLAY_FILTERS_BY_PAGE, + EUserPermissions, + EUserPermissionsLevel, +} from "@plane/constants"; +// i18n +import { useTranslation } from "@plane/i18n"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip, Header } from "@plane/ui"; // components import { ProjectAnalyticsModal } from "@/components/analytics"; -import { BreadcrumbLink, Logo } from "@/components/common"; +import { BreadcrumbLink } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; -// constants -import { - EIssueFilterType, - EIssueLayoutTypes, - EIssuesStoreType, - ISSUE_DISPLAY_FILTERS_BY_LAYOUT, -} from "@/constants/issue"; // helpers import { cn } from "@/helpers/common.helper"; import { isIssueFilterActive } from "@/helpers/filter.helper"; @@ -40,7 +44,8 @@ import { import { useAppRouter } from "@/hooks/use-app-router"; import useLocalStorage from "@/hooks/use-local-storage"; import { usePlatformOS } from "@/hooks/use-platform-os"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; +// plane web +import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; const CycleDropdownOption: React.FC<{ cycleId: string }> = ({ cycleId }) => { // router @@ -73,6 +78,8 @@ export const CycleIssuesHeader: React.FC = observer(() => { projectId: string; cycleId: string; }; + // i18n + const { t } = useTranslation(); // store hooks const { issuesFilter: { issueFilters, updateFilters }, @@ -164,22 +171,13 @@ export const CycleIssuesHeader: React.FC = observer(() => {
    - + - - - - ) - } - /> + { type="text" link={ } /> @@ -213,7 +211,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { 1 ? "issues" : "issue" + issuesCount > 1 ? "work items" : "work item" } in this cycle`} position="bottom" > @@ -249,7 +247,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { selectedLayout={activeLayout} /> @@ -257,7 +255,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { filters={issueFilters?.filters ?? {}} handleFiltersUpdate={handleFiltersUpdate} layoutDisplayFiltersOptions={ - activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined + activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined } displayFilters={issueFilters?.displayFilters ?? {}} handleDisplayFiltersUpdate={handleDisplayFilters} @@ -268,10 +266,10 @@ export const CycleIssuesHeader: React.FC = observer(() => { moduleViewDisabled={!currentProjectDetails?.module_view} /> - + { {canUserCreateIssue && ( <> {!isCompletedCycle && ( )} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx index aa81ae581..31eb5b249 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx @@ -4,37 +4,43 @@ import { useCallback, useState } from "react"; import { useParams } from "next/navigation"; // icons import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; +// plane constants +import { + EIssueLayoutTypes, + EIssueFilterType, + EIssuesStoreType, + 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"; // components import { ProjectAnalyticsModal } from "@/components/analytics"; -import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues"; -// constants -import { - EIssueFilterType, - EIssueLayoutTypes, - EIssuesStoreType, - ISSUE_DISPLAY_FILTERS_BY_LAYOUT, - ISSUE_LAYOUTS, -} from "@/constants/issue"; +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"; export const CycleIssuesMobileHeader = () => { + // i18n + const { t } = useTranslation(); + const [analyticsModal, setAnalyticsModal] = useState(false); const { getCycleById } = useCycle(); const layouts = [ - { key: "list", title: "List", icon: List }, - { key: "kanban", title: "Board", icon: Kanban }, - { key: "calendar", title: "Calendar", icon: Calendar }, + { key: "list", titleTranslationKey: "issue.layouts.list", icon: List }, + { key: "kanban", titleTranslationKey: "issue.layouts.kanban", icon: Kanban }, + { key: "calendar", titleTranslationKey: "issue.layouts.calendar", icon: Calendar }, ]; const { workspaceSlug, projectId, cycleId } = useParams(); const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; + // store hooks const { currentProjectDetails } = useProject(); const { @@ -127,7 +133,9 @@ export const CycleIssuesMobileHeader = () => { maxHeight={"md"} className="flex flex-grow justify-center text-custom-text-200 text-sm" placement="bottom-start" - customButton={Layout} + customButton={ + {t("common.layout")} + } customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" closeOnSelect > @@ -139,18 +147,18 @@ export const CycleIssuesMobileHeader = () => { }} className="flex items-center gap-2" > - -
    {layout.title}
    + +
    {t(layout.titleTranslationKey)}
    ))}
    - Filters + {t("common.filters")} } @@ -160,7 +168,7 @@ export const CycleIssuesMobileHeader = () => { filters={issueFilters?.filters ?? {}} handleFiltersUpdate={handleFiltersUpdate} layoutDisplayFiltersOptions={ - activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined + activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined } displayFilters={issueFilters?.displayFilters ?? {}} handleDisplayFiltersUpdate={handleDisplayFilters} @@ -174,18 +182,18 @@ export const CycleIssuesMobileHeader = () => {
    - Display + {t("common.display")} } > { onClick={() => setAnalyticsModal(true)} className="flex flex-grow justify-center text-custom-text-200 text-sm border-l border-custom-border-200" > - Analytics + {t("common.analytics")}
    diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx index ca3189a1f..2339c2731 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx @@ -2,27 +2,29 @@ import { FC } from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // ui +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { Breadcrumbs, Button, ContrastIcon, Header } from "@plane/ui"; // components -import { BreadcrumbLink, Logo } from "@/components/common"; +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"; // constants -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; export const CyclesListHeader: FC = observer(() => { // router const router = useAppRouter(); - const { workspaceSlug } = useParams(); // store hooks const { toggleCreateCycleModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); const { allowPermissions } = useUserPermissions(); const { currentProjectDetails, loader } = useProject(); + const { t } = useTranslation(); const canUserCreateCycle = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], @@ -32,26 +34,17 @@ export const CyclesListHeader: FC = observer(() => { return (
    - + + - - - ) - } + label={t("cycle.label", { count: 2 })} + icon={} /> } /> - } />} - /> {canUserCreateCycle && currentProjectDetails ? ( @@ -65,7 +58,8 @@ export const CyclesListHeader: FC = observer(() => { toggleCreateCycleModal(true); }} > -
    Add
    Cycle +
    {t("add")}
    +
    {t("project_cycles.add_cycle")}
    ) : ( diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx index ecb1d5b90..eb65d928b 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx @@ -2,14 +2,35 @@ import { observer } from "mobx-react"; // ui -import { List } from "lucide-react"; +import { GanttChartSquare, LayoutGrid, List } from "lucide-react"; +// plane package imports +import { TCycleLayoutOptions } from "@plane/types"; import { CustomMenu } from "@plane/ui"; -// icon -// constants -import { CYCLE_VIEW_LAYOUTS } from "@/constants/cycle"; // hooks import { useCycleFilter, useProject } from "@/hooks/store"; +const CYCLE_VIEW_LAYOUTS: { + key: TCycleLayoutOptions; + icon: any; + title: string; +}[] = [ + { + key: "list", + icon: List, + title: "List layout", + }, + { + key: "board", + icon: LayoutGrid, + title: "Gallery layout", + }, + { + key: "gantt", + icon: GanttChartSquare, + title: "Timeline layout", + }, +]; + export const CyclesListMobileHeader = observer(() => { const { currentProjectDetails } = useProject(); // hooks diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx index 5b1793d5d..d6b4eea15 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx @@ -3,20 +3,22 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -// types +// plane imports +import { EUserPermissionsLevel, EUserProjectRoles } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { TCycleFilters } from "@plane/types"; // components import { Header, EHeaderVariant } from "@plane/ui"; import { PageHead } from "@/components/core"; import { CyclesView, CycleCreateUpdateModal, CycleAppliedFiltersList } from "@/components/cycles"; -import { EmptyState } from "@/components/empty-state"; +import { ComicBoxButton, DetailedEmptyState } from "@/components/empty-state"; import { CycleModuleListLayout } from "@/components/ui"; -// constants -import { EmptyStateType } from "@/constants/empty-state"; // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks -import { useEventTracker, useCycle, useProject, useCycleFilter } from "@/hooks/store"; +import { useEventTracker, useCycle, useProject, useCycleFilter, useUserPermissions } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; const ProjectCyclesPage = observer(() => { // states @@ -26,13 +28,23 @@ const ProjectCyclesPage = observer(() => { const { currentProjectCycleIds, loader } = useCycle(); const { getProjectById, currentProjectDetails } = useProject(); // router + const router = useAppRouter(); const { workspaceSlug, projectId } = useParams(); + // plane hooks + const { t } = useTranslation(); // cycle filters hook const { clearAllFilters, currentProjectFilters, updateFilters } = useCycleFilter(); + const { allowPermissions } = useUserPermissions(); // derived values const totalCycles = currentProjectCycleIds?.length ?? 0; const project = projectId ? getProjectById(projectId?.toString()) : undefined; - const pageTitle = project?.name ? `${project?.name} - Cycles` : undefined; + const pageTitle = project?.name ? `${project?.name} - ${t("cycles.label", { count: 2 })}` : undefined; + const hasAdminLevelPermission = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const hasMemberLevelPermission = allowPermissions( + [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER], + EUserPermissionsLevel.PROJECT + ); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/cycles" }); const handleRemoveFilter = (key: keyof TCycleFilters, value: string | null) => { if (!projectId) return; @@ -50,9 +62,17 @@ const ProjectCyclesPage = observer(() => { if (currentProjectDetails?.cycle_view === false) return (
    - { + router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`); + }, + disabled: !hasAdminLevelPermission, + }} />
    ); @@ -71,12 +91,22 @@ const ProjectCyclesPage = observer(() => { /> {totalCycles === 0 ? (
    - { - setTrackElement("Cycle empty state"); - setCreateModal(true); - }} + { + setTrackElement("Cycle empty state"); + setCreateModal(true); + }} + disabled={!hasMemberLevelPermission} + /> + } />
    ) : ( diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx index c1e866d03..2200b31f1 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx @@ -3,27 +3,29 @@ import { FC, useCallback } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; +// plane constants +import { EIssueLayoutTypes, EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; +// i18n +import { useTranslation } from "@plane/i18n"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui"; // components -import { BreadcrumbLink, Logo } from "@/components/common"; +import { BreadcrumbLink } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; -// constants -import { - EIssueFilterType, - EIssuesStoreType, - EIssueLayoutTypes, - ISSUE_DISPLAY_FILTERS_BY_LAYOUT, -} from "@/constants/issue"; // helpers import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web +import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; +// FIXME: Deprecated. Remove it export const ProjectDraftIssueHeader: FC = observer(() => { + // i18n + const { t } = useTranslation(); // router const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; // store hooks @@ -84,44 +86,29 @@ export const ProjectDraftIssueHeader: FC = observer(() => { [workspaceSlug, projectId, updateFilters] ); - const issueCount = currentProjectDetails - ? !issueFilters?.displayFilters?.sub_issue && currentProjectDetails.draft_sub_issues - ? currentProjectDetails.draft_issues - currentProjectDetails.draft_sub_issues - : currentProjectDetails.draft_issues - : undefined; + const issueCount = undefined; return (
    - - - - - ) - } - /> - } - /> + + } /> + } + /> } /> {issueCount && issueCount > 0 ? ( 1 ? "issues" : "issue"} in project's draft`} + tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "work items" : "work item"} in project's draft`} position="bottom" > @@ -137,14 +124,18 @@ export const ProjectDraftIssueHeader: FC = observer(() => { onChange={(layout) => handleLayoutChange(layout)} selectedLayout={activeLayout} /> - + { moduleViewDisabled={!currentProjectDetails?.module_view} /> - + { const { getProjectById } = useProject(); // derived values const project = projectId ? getProjectById(projectId.toString()) : undefined; - const pageTitle = project?.name ? `${project?.name} - Draft Issues` : undefined; + const pageTitle = project?.name ? `${project?.name} - Draft work items` : undefined; return ( <> @@ -30,7 +30,7 @@ const ProjectDraftIssuesPage = observer(() => { className="flex items-center gap-1.5 rounded-full border border-custom-border-200 px-3 py-1.5 text-xs" > - Draft Issues + Draft work items
    @@ -40,4 +40,4 @@ const ProjectDraftIssuesPage = observer(() => { ); }); -export default ProjectDraftIssuesPage; \ No newline at end of file +export default ProjectDraftIssuesPage; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/page.tsx index 36aa37e30..5cb8509e0 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/page.tsx @@ -2,41 +2,62 @@ import { observer } from "mobx-react"; // components import { useParams, useSearchParams } from "next/navigation"; +import { EUserPermissionsLevel } from "@plane/constants"; +import { EUserProjectRoles } from "@plane/constants/src/user"; +import { useTranslation } from "@plane/i18n"; import { PageHead } from "@/components/core"; -import { EmptyState } from "@/components/empty-state"; +import { DetailedEmptyState } from "@/components/empty-state"; import { InboxIssueRoot } from "@/components/inbox"; -// constants -import { EmptyStateType } from "@/constants/empty-state"; // helpers import { EInboxIssueCurrentTab } from "@/helpers/inbox.helper"; // hooks -import { useProject } from "@/hooks/store"; +import { useProject, useUserPermissions } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; const ProjectInboxPage = observer(() => { /// router + const router = useAppRouter(); const { workspaceSlug, projectId } = useParams(); - const searchParams = useSearchParams(); - const navigationTab = searchParams.get("currentTab"); const inboxIssueId = searchParams.get("inboxIssueId"); - + // plane hooks + const { t } = useTranslation(); // hooks const { currentProjectDetails } = useProject(); + const { allowPermissions } = useUserPermissions(); + // derived values + const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/intake" }); // No access to inbox if (currentProjectDetails?.inbox_view === false) return (
    - { + router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`); + }, + disabled: !canPerformEmptyStateActions, + }} />
    ); // derived values - const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Intake` : "Plane - Intake"; + const pageTitle = currentProjectDetails?.name + ? t("inbox_issue.page_label", { + workspace: currentProjectDetails?.name, + }) + : t("inbox_issue.page_label", { + workspace: "Plane", + }); const currentNavigationTab = navigationTab ? navigationTab === "open" diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx index 5b68ae688..cee22b0cf 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx @@ -1,103 +1,62 @@ "use client"; -import React, { useEffect } from "react"; +import { useEffect } from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; +import { redirect, useParams } from "next/navigation"; import { useTheme } from "next-themes"; import useSWR from "swr"; -// ui -import { Loader } from "@plane/ui"; +import { useTranslation } from "@plane/i18n"; // components -import { EmptyState } from "@/components/common"; -import { PageHead } from "@/components/core"; -import { IssueDetailRoot } from "@/components/issues"; +import { EmptyState, LogoSpinner } from "@/components/common"; // hooks -import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store"; -// assets import { useAppRouter } from "@/hooks/use-app-router"; +// assets import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp"; import emptyIssueLight from "@/public/empty-state/search/issues-light.webp"; +// services +import { IssueService } from "@/services/issue/issue.service"; + +const issueService = new IssueService(); const IssueDetailsPage = observer(() => { - // router const router = useAppRouter(); + const { t } = useTranslation(); const { workspaceSlug, projectId, issueId } = useParams(); - // hooks const { resolvedTheme } = useTheme(); - // store hooks - const { - fetchIssue, - issue: { getIssueById }, - } = useIssueDetail(); - const { getProjectById } = useProject(); - const { toggleIssueDetailSidebar, issueDetailSidebarCollapsed } = useAppTheme(); - // fetching issue details - const { isLoading, error } = useSWR( - workspaceSlug && projectId && issueId ? `ISSUE_DETAIL_${workspaceSlug}_${projectId}_${issueId}` : null, + + const { data, isLoading, error } = useSWR( + workspaceSlug && projectId && issueId ? `ISSUE_DETAIL_META_${workspaceSlug}_${projectId}_${issueId}` : null, workspaceSlug && projectId && issueId - ? () => fetchIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString()) + ? () => issueService.getIssueMetaFromURL(workspaceSlug.toString(), projectId.toString(), issueId.toString()) : null ); - // derived values - const issue = getIssueById(issueId?.toString() || "") || undefined; - const project = (issue?.project_id && getProjectById(issue?.project_id)) || undefined; - const issueLoader = !issue || isLoading; - const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined; useEffect(() => { - const handleToggleIssueDetailSidebar = () => { - if (window && window.innerWidth < 768) { - toggleIssueDetailSidebar(true); - } - if (window && issueDetailSidebarCollapsed && window.innerWidth >= 768) { - toggleIssueDetailSidebar(false); - } - }; - window.addEventListener("resize", handleToggleIssueDetailSidebar); - handleToggleIssueDetailSidebar(); - return () => window.removeEventListener("resize", handleToggleIssueDetailSidebar); - }, [issueDetailSidebarCollapsed, toggleIssueDetailSidebar]); + if (data) { + redirect(`/${workspaceSlug}/browse/${data.project_identifier}-${data.sequence_id}`); + } + }, [workspaceSlug, data]); return ( - <> - +
    {error ? ( router.push(`/${workspaceSlug}/projects/${projectId}/issues`), + text: t("issue.empty_state.issue_detail.primary_button.text"), + onClick: () => router.push(`/${workspaceSlug}/workspace-views/all-issues/`), }} /> - ) : issueLoader ? ( - -
    - - - - -
    -
    - - - - -
    -
    + ) : isLoading ? ( + <> + + ) : ( - workspaceSlug && - projectId && - issueId && ( - - ) + <> )} - +
    ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/header.tsx deleted file mode 100644 index 8bb1f0693..000000000 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/header.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// ui -import { Breadcrumbs, LayersIcon, Header } from "@plane/ui"; -// components -import { BreadcrumbLink, Logo } 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, projectId, issueId } = useParams(); - // store hooks - const { currentProjectDetails, loader } = useProject(); - const { - issue: { getIssueById }, - } = useIssueDetail(); - // derived values - const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined; - - return ( -
    - -
    - - - - - ) - } - /> - } - /> - - } - /> - } - /> - - - } - /> - -
    -
    - - - -
    - ); -}); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx index 1b1cffcc6..c3aacaebb 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx @@ -1,130 +1,3 @@ -"use client"; +import { IssuesHeader } from "@/plane-web/components/issues"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// icons -import { Briefcase, Circle, ExternalLink } from "lucide-react"; -// ui -import { Breadcrumbs, Button, LayersIcon, Tooltip, Header } from "@plane/ui"; -// components -import { BreadcrumbLink, CountChip, Logo } from "@/components/common"; -// constants -import HeaderFilters from "@/components/issues/filters"; -import { EIssuesStoreType } from "@/constants/issue"; -// 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"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; - -export const ProjectIssuesHeader = observer(() => { - // router - const router = useAppRouter(); - const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; - // store hooks - const { - issues: { getGroupIssueCount }, - } = useIssues(EIssuesStoreType.PROJECT); - - const { currentProjectDetails, loader } = useProject(); - - const { toggleCreateIssueModal } = useCommandPalette(); - const { setTrackElement } = useEventTracker(); - const { allowPermissions } = useUserPermissions(); - const { isMobile } = usePlatformOS(); - - const SPACE_APP_URL = (SPACE_BASE_URL.trim() === "" ? window.location.origin : SPACE_BASE_URL) + SPACE_BASE_PATH; - const publishedURL = `${SPACE_APP_URL}/issues/${currentProjectDetails?.anchor}`; - - const issuesCount = getGroupIssueCount(undefined, undefined, false); - const canUserCreateIssue = allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.PROJECT - ); - - return ( -
    - -
    - router.back()} isLoading={loader}> - - - - ) - ) : ( - - - - ) - } - /> - } - /> - - } />} - /> - - {issuesCount && issuesCount > 0 ? ( - 1 ? "issues" : "issue"} in this project`} - position="bottom" - > - - - ) : null} -
    - {currentProjectDetails?.anchor ? ( - - - Public - - - ) : ( - <> - )} -
    - -
    - -
    - {canUserCreateIssue ? ( - - ) : ( - <> - )} -
    -
    - ); -}); +export const ProjectIssuesHeader = () => ; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx index 025c9fab0..6b86cd88d 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx @@ -5,31 +5,40 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // icons import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; +// plane constants +import { + EIssueLayoutTypes, + EIssueFilterType, + EIssuesStoreType, + 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"; // components import { ProjectAnalyticsModal } from "@/components/analytics"; -import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues/issue-layouts"; -// constants import { - EIssueFilterType, - EIssueLayoutTypes, - EIssuesStoreType, - ISSUE_DISPLAY_FILTERS_BY_LAYOUT, - ISSUE_LAYOUTS, -} from "@/constants/issue"; + DisplayFiltersSelection, + FilterSelection, + FiltersDropdown, + IssueLayoutIcon, +} from "@/components/issues/issue-layouts"; // helpers import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/hooks/store"; export const ProjectIssuesMobileHeader = observer(() => { + // i18n + const { t } = useTranslation(); const layouts = [ - { key: "list", title: "List", icon: List }, - { key: "kanban", title: "Board", icon: Kanban }, - { key: "calendar", title: "Calendar", icon: Calendar }, + { key: "list", titleTranslationKey: "issue.layouts.list", icon: List }, + { key: "kanban", titleTranslationKey: "issue.layouts.kanban", icon: Kanban }, + { key: "calendar", titleTranslationKey: "issue.layouts.calendar", icon: Calendar }, ]; const [analyticsModal, setAnalyticsModal] = useState(false); const { workspaceSlug, projectId } = useParams() as { @@ -108,7 +117,7 @@ export const ProjectIssuesMobileHeader = observer(() => { placement="bottom-start" customButton={
    - Layout + {t("common.layout")}
    } @@ -123,18 +132,18 @@ export const ProjectIssuesMobileHeader = observer(() => { }} className="flex items-center gap-2" > - -
    {layout.title}
    + +
    {t(layout.titleTranslationKey)}
    ))}
    - Filters + {t("common.filters")} } @@ -146,7 +155,7 @@ export const ProjectIssuesMobileHeader = observer(() => { displayFilters={issueFilters?.displayFilters ?? {}} handleDisplayFiltersUpdate={handleDisplayFilters} layoutDisplayFiltersOptions={ - activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined + activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined } labels={projectLabels} memberIds={projectMemberIds ?? undefined} @@ -158,18 +167,18 @@ export const ProjectIssuesMobileHeader = observer(() => {
    - Display + {t("common.display")} } > { onClick={() => setAnalyticsModal(true)} className="flex flex-grow justify-center border-l border-custom-border-200 text-sm text-custom-text-200" > - Analytics + {t("common.analytics")}
    diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx index 7ea73bb1c..6b83f367b 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx @@ -3,6 +3,8 @@ import { observer } from "mobx-react"; import Head from "next/head"; import { useParams } from "next/navigation"; +// i18n +import { useTranslation } from "@plane/i18n"; // components import { PageHead } from "@/components/core"; import { ProjectLayoutRoot } from "@/components/issues"; @@ -11,6 +13,8 @@ import { useProject } from "@/hooks/store"; const ProjectIssuesPage = observer(() => { const { projectId } = useParams(); + // i18n + const { t } = useTranslation(); // store const { getProjectById } = useProject(); @@ -20,13 +24,15 @@ const ProjectIssuesPage = observer(() => { // derived values const project = getProjectById(projectId.toString()); - const pageTitle = project?.name ? `${project?.name} - Issues` : undefined; + const pageTitle = project?.name ? `${project?.name} - ${t("issue.label", { count: 2 })}` : undefined; // Count is for pluralization return ( <> - {project?.name} - Issues + + {project?.name} - {t("issue.label", { count: 2 })} +
    diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx index 38c2f4453..3c5eb7cc7 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx @@ -6,21 +6,23 @@ import Link from "next/link"; import { useParams } from "next/navigation"; // icons import { ArrowRight, PanelRight } from "lucide-react"; +// plane constants +import { + EIssueLayoutTypes, + EIssuesStoreType, + EIssueFilterType, + ISSUE_DISPLAY_FILTERS_BY_PAGE, + EUserPermissions, + EUserPermissionsLevel, +} from "@plane/constants"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { Breadcrumbs, Button, CustomMenu, DiceIcon, Tooltip, Header } from "@plane/ui"; // components import { ProjectAnalyticsModal } from "@/components/analytics"; -import { BreadcrumbLink, Logo } from "@/components/common"; +import { BreadcrumbLink } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; -// constants -import { - EIssuesStoreType, - EIssueFilterType, - EIssueLayoutTypes, - ISSUE_DISPLAY_FILTERS_BY_LAYOUT, -} from "@/constants/issue"; // helpers import { cn } from "@/helpers/common.helper"; import { isIssueFilterActive } from "@/helpers/filter.helper"; @@ -41,7 +43,8 @@ import { useAppRouter } from "@/hooks/use-app-router"; import { useIssuesActions } from "@/hooks/use-issues-actions"; import useLocalStorage from "@/hooks/use-local-storage"; import { usePlatformOS } from "@/hooks/use-platform-os"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; +// plane web +import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; const ModuleDropdownOption: React.FC<{ moduleId: string }> = ({ moduleId }) => { // router @@ -163,22 +166,13 @@ export const ModuleIssuesHeader: React.FC = observer(() => { />
    - + - - - - ) - } - /> + { 1 ? "issues" : "issue" + issuesCount > 1 ? "work items" : "work item" } in this module`} position="bottom" > @@ -257,7 +251,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => { displayFilters={issueFilters?.displayFilters ?? {}} handleDisplayFiltersUpdate={handleDisplayFilters} layoutDisplayFiltersOptions={ - activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined + activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined } labels={projectLabels} memberIds={projectMemberIds ?? undefined} @@ -269,7 +263,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => { { ) : ( diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx index 0edfa2b5a..1f000bae2 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx @@ -5,21 +5,28 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // icons import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; +// plane constants +import { + EIssueLayoutTypes, + EIssueFilterType, + EIssuesStoreType, + 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"; // components import { ProjectAnalyticsModal } from "@/components/analytics"; -import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues"; -// constants import { - EIssueFilterType, - EIssueLayoutTypes, - EIssuesStoreType, - ISSUE_DISPLAY_FILTERS_BY_LAYOUT, - ISSUE_LAYOUTS, -} from "@/constants/issue"; + DisplayFiltersSelection, + FilterSelection, + FiltersDropdown, + IssueLayoutIcon, +} from "@/components/issues/issue-layouts"; // helpers import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks @@ -29,10 +36,11 @@ export const ModuleIssuesMobileHeader = observer(() => { const [analyticsModal, setAnalyticsModal] = useState(false); const { currentProjectDetails } = useProject(); const { getModuleById } = useModule(); + const { t } = useTranslation(); const layouts = [ - { key: "list", title: "List", icon: List }, - { key: "kanban", title: "Board", icon: Kanban }, - { key: "calendar", title: "Calendar", icon: Calendar }, + { key: "list", i18n_title: "issue.layouts.list", icon: List }, + { key: "kanban", i18n_title: "issue.layouts.kanban", icon: Kanban }, + { key: "calendar", i18n_title: "issue.layouts.calendar", icon: Calendar }, ]; const { workspaceSlug, projectId, moduleId } = useParams() as { workspaceSlug: string; @@ -120,8 +128,8 @@ export const ModuleIssuesMobileHeader = observer(() => { }} className="flex items-center gap-2" > - -
    {layout.title}
    + +
    {t(layout.i18n_title)}
    ))} @@ -143,7 +151,7 @@ export const ModuleIssuesMobileHeader = observer(() => { displayFilters={issueFilters?.displayFilters ?? {}} handleDisplayFiltersUpdate={handleDisplayFilters} layoutDisplayFiltersOptions={ - activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined + activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined } labels={projectLabels} memberIds={projectMemberIds ?? undefined} @@ -166,7 +174,7 @@ export const ModuleIssuesMobileHeader = observer(() => { > { // router const router = useAppRouter(); - const { workspaceSlug } = useParams(); // store hooks const { toggleCreateModuleModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); const { allowPermissions } = useUserPermissions(); - const { currentProjectDetails, loader } = useProject(); + const { loader } = useProject(); + + const { t } = useTranslation(); // auth const canUserCreateModule = allowPermissions( @@ -34,26 +38,14 @@ export const ModulesListHeader: React.FC = observer(() => {
    - + + - - - ) - } - /> + } /> } /> - } />} - />
    @@ -68,7 +60,8 @@ export const ModulesListHeader: React.FC = observer(() => { toggleCreateModuleModal(true); }} > -
    Add
    Module +
    {t("add")}
    +
    {t("project_module.add_module")}
    ) : ( <> diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx index 12301c9d4..629dca36a 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx @@ -2,13 +2,16 @@ import { observer } from "mobx-react"; import { ChevronDown } from "lucide-react"; +import { MODULE_VIEW_LAYOUTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { CustomMenu, Row } from "@plane/ui"; -import { MODULE_VIEW_LAYOUTS } from "@/constants/module"; +import { ModuleLayoutIcon } from "@/components/modules"; import { useModuleFilter, useProject } from "@/hooks/store"; export const ModulesListMobileHeader = observer(() => { const { currentProjectDetails } = useProject(); const { updateDisplayFilters } = useModuleFilter(); + const { t } = useTranslation(); return (
    @@ -34,8 +37,8 @@ export const ModulesListMobileHeader = observer(() => { }} className="flex items-center gap-2" > - -
    {layout.title}
    + +
    {t(layout.i18n_title)}
    ); })} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx index 9417016e3..572cb3862 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx @@ -4,27 +4,36 @@ import { useCallback } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // types +import { EUserPermissionsLevel, EUserProjectRoles } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { TModuleFilters } from "@plane/types"; // components import { PageHead } from "@/components/core"; -import { EmptyState } from "@/components/empty-state"; +import { DetailedEmptyState } from "@/components/empty-state"; import { ModuleAppliedFiltersList, ModulesListView } from "@/components/modules"; -// constants -import { EmptyStateType } from "@/constants/empty-state"; // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks -import { useModuleFilter, useProject } from "@/hooks/store"; +import { useModuleFilter, useProject, useUserPermissions } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; const ProjectModulesPage = observer(() => { + // router + const router = useAppRouter(); const { workspaceSlug, projectId } = useParams(); + // plane hooks + const { t } = useTranslation(); // store const { getProjectById, currentProjectDetails } = useProject(); const { currentProjectFilters, currentProjectDisplayFilters, clearAllFilters, updateFilters, updateDisplayFilters } = useModuleFilter(); + const { allowPermissions } = useUserPermissions(); // derived values const project = projectId ? getProjectById(projectId.toString()) : undefined; const pageTitle = project?.name ? `${project?.name} - Modules` : undefined; + const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/modules" }); const handleRemoveFilter = useCallback( (key: keyof TModuleFilters, value: string | null) => { @@ -45,9 +54,17 @@ const ProjectModulesPage = observer(() => { if (currentProjectDetails?.module_view === false) return (
    - { + router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`); + }, + disabled: !canPerformEmptyStateActions, + }} />
    ); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx index 4d3f395ea..2e45b9ba3 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -1,34 +1,64 @@ "use client"; +import { useCallback, useMemo } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams } from "next/navigation"; import useSWR from "swr"; -// ui +// plane types +import { TSearchEntityRequestPayload, TWebhookConnectionQueryParams } from "@plane/types"; +import { EFileAssetType } from "@plane/types/src/enums"; +// plane ui import { getButtonStyling } from "@plane/ui"; +// plane utils +import { cn } from "@plane/utils"; // components import { LogoSpinner } from "@/components/common"; import { PageHead } from "@/components/core"; import { IssuePeekOverview } from "@/components/issues"; -import { PageRoot } from "@/components/pages"; -// helpers -import { cn } from "@/helpers/common.helper"; +import { PageRoot, TPageRootConfig, TPageRootHandlers } from "@/components/pages"; // hooks -import { usePage, useProjectPages } from "@/hooks/store"; +import { useEditorConfig } from "@/hooks/editor"; +import { useEditorAsset, useWorkspace } from "@/hooks/store"; +// plane web hooks +import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store"; +// plane web services +import { WorkspaceService } from "@/plane-web/services"; +// services +import { ProjectPageService, ProjectPageVersionService } from "@/services/page"; +const workspaceService = new WorkspaceService(); +const projectPageService = new ProjectPageService(); +const projectPageVersionService = new ProjectPageVersionService(); const PageDetailsPage = observer(() => { const { workspaceSlug, projectId, pageId } = useParams(); - // store hooks - const { getPageById } = useProjectPages(); - const page = usePage(pageId?.toString() ?? ""); - const { id, name } = page; - + const { createPage, fetchPageDetails } = usePageStore(EPageStoreType.PROJECT); + const page = usePage({ + pageId: pageId?.toString() ?? "", + storeType: EPageStoreType.PROJECT, + }); + const { getWorkspaceBySlug } = useWorkspace(); + const { uploadEditorAsset } = useEditorAsset(); + // derived values + const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : ""; + const { canCurrentUserAccessPage, id, name, updateDescription } = page ?? {}; + // entity search handler + const fetchEntityCallback = useCallback( + async (payload: TSearchEntityRequestPayload) => + await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", { + ...payload, + project_id: projectId?.toString() ?? "", + }), + [projectId, workspaceSlug] + ); + // editor config + const { getEditorFileHandlers } = useEditorConfig(); // fetch page details const { error: pageDetailsError } = useSWR( workspaceSlug && projectId && pageId ? `PAGE_DETAILS_${pageId}` : null, workspaceSlug && projectId && pageId - ? () => getPageById(workspaceSlug?.toString(), projectId?.toString(), pageId.toString()) + ? () => fetchPageDetails(workspaceSlug?.toString(), projectId?.toString(), pageId.toString()) : null, { revalidateIfStale: true, @@ -36,6 +66,66 @@ const PageDetailsPage = observer(() => { revalidateOnReconnect: true, } ); + // page root handlers + const pageRootHandlers: TPageRootHandlers = useMemo( + () => ({ + create: createPage, + fetchAllVersions: async (pageId) => { + if (!workspaceSlug || !projectId) return; + return await projectPageVersionService.fetchAllVersions(workspaceSlug.toString(), projectId.toString(), pageId); + }, + fetchDescriptionBinary: async () => { + if (!workspaceSlug || !projectId || !id) return; + return await projectPageService.fetchDescriptionBinary(workspaceSlug.toString(), projectId.toString(), id); + }, + fetchEntity: fetchEntityCallback, + fetchVersionDetails: async (pageId, versionId) => { + if (!workspaceSlug || !projectId) return; + return await projectPageVersionService.fetchVersionById( + workspaceSlug.toString(), + projectId.toString(), + pageId, + versionId + ); + }, + getRedirectionLink: (pageId) => `/${workspaceSlug}/projects/${projectId}/pages/${pageId}`, + updateDescription: updateDescription ?? (async () => {}), + }), + [createPage, fetchEntityCallback, id, projectId, updateDescription, workspaceSlug] + ); + // page root config + const pageRootConfig: TPageRootConfig = useMemo( + () => ({ + fileHandler: getEditorFileHandlers({ + projectId: projectId?.toString() ?? "", + uploadFile: async (blockId, file) => { + const { asset_id } = await uploadEditorAsset({ + blockId, + data: { + entity_identifier: id ?? "", + entity_type: EFileAssetType.PAGE_DESCRIPTION, + }, + file, + projectId: projectId?.toString() ?? "", + workspaceSlug: workspaceSlug?.toString() ?? "", + }); + return asset_id; + }, + workspaceId, + workspaceSlug: workspaceSlug?.toString() ?? "", + }), + }), + [getEditorFileHandlers, id, projectId, uploadEditorAsset, workspaceId, workspaceSlug] + ); + + const webhookConnectionParams: TWebhookConnectionQueryParams = useMemo( + () => ({ + documentType: "project_page", + projectId: projectId?.toString() ?? "", + workspaceSlug: workspaceSlug?.toString() ?? "", + }), + [projectId, workspaceSlug] + ); if ((!page || !id) && !pageDetailsError) return ( @@ -44,7 +134,7 @@ const PageDetailsPage = observer(() => {
    ); - if (pageDetailsError) + if (pageDetailsError || !canCurrentUserAccessPage) return (

    Page not found

    @@ -60,12 +150,21 @@ const PageDetailsPage = observer(() => {
    ); + if (!page) return null; + return ( <>
    - +
    diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx index 1c3d96b57..f8292f442 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx @@ -15,11 +15,13 @@ import { PageEditInformationPopover } from "@/components/pages"; import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper"; import { getPageName } from "@/helpers/page.helper"; // hooks -import { usePage, useProject, useUser, useUserPermissions } from "@/hooks/store"; +import { useProject } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web components +import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages"; -import { EUserPermissions, EUserPermissionsLevel } from "ee/constants/user-permissions"; +// plane web hooks +import { EPageStoreType, usePage } from "@/plane-web/hooks/store"; export interface IPagesHeaderProps { showButton?: boolean; @@ -32,18 +34,16 @@ export const PageDetailsHeader = observer(() => { const [isOpen, setIsOpen] = useState(false); // store hooks const { currentProjectDetails, loader } = useProject(); - const page = usePage(pageId?.toString() ?? ""); - const { name, logo_props, updatePageLogo, owned_by } = page; - const { allowPermissions } = useUserPermissions(); - const { data: currentUser } = useUser(); + const page = usePage({ + pageId: pageId?.toString() ?? "", + storeType: EPageStoreType.PROJECT, + }); + if (!page) return null; + // derived values + const { name, logo_props, updatePageLogo, isContentEditable } = page; // use platform const { isMobile } = usePlatformOS(); - const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); - const isOwner = owned_by === currentUser?.id; - - const isEditable = isAdmin || isOwner; - const handlePageLogoUpdate = async (data: TLogoProps) => { if (data) { updatePageLogo(data) @@ -70,22 +70,13 @@ export const PageDetailsHeader = observer(() => {
    - + - - - - ) - } - /> + { ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON } - disabled={!isEditable} + disabled={!isContentEditable} />
    @@ -169,7 +160,7 @@ export const PageDetailsHeader = observer(() => {
    - +
    ); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx index d3646b31b..ca15df8f5 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx @@ -4,16 +4,20 @@ 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"; // plane types import { TPage } from "@plane/types"; // plane ui import { Breadcrumbs, Button, Header, setToast, TOAST_TYPE } from "@plane/ui"; // helpers -import { BreadcrumbLink, Logo } from "@/components/common"; -// constants -import { EPageAccess } from "@/constants/page"; +import { BreadcrumbLink } from "@/components/common"; // hooks -import { useEventTracker, useProject, useProjectPages } from "@/hooks/store"; +import { useEventTracker, useProject } from "@/hooks/store"; +// plane web +import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; +// plane web hooks +import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; export const PagesListHeader = observer(() => { // states @@ -25,7 +29,7 @@ export const PagesListHeader = observer(() => { const pageType = searchParams.get("type"); // store hooks const { currentProjectDetails, loader } = useProject(); - const { canCurrentUserCreatePage, createPage } = useProjectPages(); + const { canCurrentUserCreatePage, createPage } = usePageStore(EPageStoreType.PROJECT); const { setTrackElement } = useEventTracker(); // handle page create const handleCreatePage = async () => { @@ -55,22 +59,8 @@ export const PagesListHeader = observer(() => {
    - - - - - ) - } - /> - } - /> + + } />} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx index 4171e1f33..56d89d7f4 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx @@ -2,27 +2,37 @@ import { observer } from "mobx-react"; import { useParams, useSearchParams } from "next/navigation"; -// types +// plane imports +import { EUserPermissionsLevel, EUserProjectRoles } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { TPageNavigationTabs } from "@plane/types"; // components import { PageHead } from "@/components/core"; -import { EmptyState } from "@/components/empty-state"; +import { DetailedEmptyState } from "@/components/empty-state"; import { PagesListRoot, PagesListView } from "@/components/pages"; -// constants -import { EmptyStateType } from "@/constants/empty-state"; // hooks -import { useProject } from "@/hooks/store"; +import { useProject, useUserPermissions } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +// plane web hooks +import { EPageStoreType } from "@/plane-web/hooks/store"; const ProjectPagesPage = observer(() => { // router + const router = useAppRouter(); const searchParams = useSearchParams(); const type = searchParams.get("type"); const { workspaceSlug, projectId } = useParams(); + // plane hooks + const { t } = useTranslation(); // store hooks const { getProjectById, currentProjectDetails } = useProject(); + const { allowPermissions } = useUserPermissions(); // derived values const project = projectId ? getProjectById(projectId.toString()) : undefined; const pageTitle = project?.name ? `${project?.name} - Pages` : undefined; + const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/pages" }); const currentPageType = (): TPageNavigationTabs => { const pageType = type?.toString(); @@ -37,9 +47,17 @@ const ProjectPagesPage = observer(() => { if (currentProjectDetails?.page_view === false) return (
    - { + router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`); + }, + disabled: !canPerformEmptyStateActions, + }} />
    ); @@ -47,15 +65,12 @@ const ProjectPagesPage = observer(() => { <> - + ); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/automations/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/automations/page.tsx index 141cc39fe..5fc536d91 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/automations/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/automations/page.tsx @@ -3,6 +3,8 @@ import React from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { IProject } from "@plane/types"; // ui import { TOAST_TYPE, setToast } from "@plane/ui"; @@ -12,7 +14,6 @@ import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automat import { PageHead } from "@/components/core"; // hooks import { useProject, useUserPermissions } from "@/hooks/store"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const AutomationSettingsPage = observer(() => { // router @@ -21,6 +22,8 @@ const AutomationSettingsPage = observer(() => { const { workspaceUserInfo, allowPermissions } = useUserPermissions(); const { currentProjectDetails: projectDetails, updateProject } = useProject(); + const { t } = useTranslation(); + // derived values const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); @@ -48,7 +51,7 @@ const AutomationSettingsPage = observer(() => {
    -

    Automations

    +

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

    diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/estimates/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/estimates/page.tsx index 14f770767..0a19713e8 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/estimates/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/estimates/page.tsx @@ -3,12 +3,12 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { EstimateRoot } from "@/components/estimates"; // hooks import { useProject, useUserPermissions } from "@/hooks/store"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const EstimatesSettingsPage = observer(() => { const { workspaceSlug, projectId } = useParams(); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/features/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/features/page.tsx index 05bde8c9e..23aa8ad45 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/features/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/features/page.tsx @@ -3,12 +3,12 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectFeaturesList } from "@/components/project"; // hooks import { useProject, useUserPermissions } from "@/hooks/store"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const FeaturesSettingsPage = observer(() => { const { workspaceSlug, projectId } = useParams(); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx index 404a11f27..6fa36db34 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx @@ -5,16 +5,17 @@ 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, Logo } from "@/components/common"; -// constants +import { BreadcrumbLink } from "@/components/common"; // hooks import { useProject, useUserPermissions } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; -// plane web constants +// plane web +import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project"; -import { EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; export const ProjectSettingHeader: FC = observer(() => { // router @@ -22,29 +23,17 @@ export const ProjectSettingHeader: FC = observer(() => { const { workspaceSlug, projectId } = useParams(); // store hooks const { allowPermissions } = useUserPermissions(); - const { currentProjectDetails, loader } = useProject(); + const { loader } = useProject(); + + const { t } = useTranslation(); return (
    - - - - - ) - } - /> - } - /> + +
    { key={item.key} onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}${item.href}`)} > - {item.label} + {t(item.i18n_label)} ) )} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/labels/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/labels/page.tsx index 2705ff490..17a466a80 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/labels/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/labels/page.tsx @@ -5,12 +5,12 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; import { observer } from "mobx-react"; // components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectSettingsLabelList } from "@/components/labels"; // hooks import { useProject, useUserPermissions } from "@/hooks/store"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const LabelsSettingsPage = observer(() => { // store hooks diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/members/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/members/page.tsx index 0d05187b1..565e18754 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/members/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/members/page.tsx @@ -2,12 +2,12 @@ import { observer } from "mobx-react"; // components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectMemberList, ProjectSettingsMemberDefaults } from "@/components/project"; // hooks import { useProject, useUserPermissions } from "@/hooks/store"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const MembersSettingsPage = observer(() => { // store diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/page.tsx index e21532c64..96ff1bcc3 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/page.tsx @@ -5,6 +5,7 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; // components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { PageHead } from "@/components/core"; import { ArchiveRestoreProjectModal, @@ -16,7 +17,6 @@ import { } from "@/components/project"; // hooks import { useProject, useUserPermissions } from "@/hooks/store"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const GeneralSettingsPage = observer(() => { // states @@ -43,8 +43,6 @@ const GeneralSettingsPage = observer(() => { ); const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - General Settings` : undefined; - // const currentNetwork = NETWORK_CHOICES.find((n) => n.key === projectDetails?.network); - // const selectedNetwork = NETWORK_CHOICES.find((n) => n.key === watch("network")); return ( <> diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx index 7a352ce0f..7bb1984c8 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx @@ -5,6 +5,8 @@ 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 @@ -13,7 +15,6 @@ import { SidebarNavItem } from "@/components/sidebar"; import { useUserPermissions } from "@/hooks/store"; // plane web constants import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project"; -import { EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; export const ProjectSettingsSidebar = observer(() => { const { workspaceSlug, projectId } = useParams(); @@ -21,6 +22,8 @@ export const ProjectSettingsSidebar = observer(() => { // mobx store const { allowPermissions, projectUserInfo } = useUserPermissions(); + const { t } = useTranslation(); + // derived values const currentProjectRole = projectUserInfo?.[workspaceSlug?.toString()]?.[projectId?.toString()]?.role; @@ -58,7 +61,7 @@ export const ProjectSettingsSidebar = observer(() => { isActive={link.highlight(pathname, `/${workspaceSlug}/projects/${projectId}`)} className="text-sm font-medium px-4 py-2" > - {link.label} + {t(link.i18n_label)} ) diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/states/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/states/page.tsx index af9bf5618..54fca1c08 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/states/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/states/page.tsx @@ -2,13 +2,14 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; // components import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectStateRoot } from "@/components/project-states"; // hook import { useProject, useUserPermissions } from "@/hooks/store"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const StatesSettingsPage = observer(() => { const { workspaceSlug, projectId } = useParams(); @@ -16,6 +17,8 @@ const StatesSettingsPage = observer(() => { const { currentProjectDetails } = useProject(); const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { t } = useTranslation(); + // derived values const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - States` : undefined; // derived values @@ -32,7 +35,7 @@ const StatesSettingsPage = observer(() => { <>
    -

    States

    +

    {t("common.states")}

    {workspaceSlug && projectId && ( diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx index 6fd44adfc..cc9a8fff6 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx @@ -1,10 +1,20 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { Layers, Lock } from "lucide-react"; +// plane constants +import { + EIssueLayoutTypes, + EIssueFilterType, + EIssuesStoreType, + ISSUE_DISPLAY_FILTERS_BY_PAGE, + EViewAccess, + EUserPermissions, + EUserPermissionsLevel, +} from "@plane/constants"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui @@ -13,16 +23,9 @@ import { Breadcrumbs, Button, CustomMenu, Tooltip, Header } from "@plane/ui"; import { BreadcrumbLink, Logo } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; // constants -import { - EIssuesStoreType, - EIssueFilterType, - EIssueLayoutTypes, - ISSUE_DISPLAY_FILTERS_BY_LAYOUT, -} from "@/constants/issue"; -import { EViewAccess } from "@/constants/views"; +import { ViewQuickActions } from "@/components/views"; // helpers import { isIssueFilterActive } from "@/helpers/filter.helper"; -import { getPublishViewLink } from "@/helpers/project-views.helpers"; import { truncateText } from "@/helpers/string.helper"; // hooks import { @@ -36,9 +39,12 @@ import { useProjectView, useUserPermissions, } from "@/hooks/store"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; +// plane web +import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; export const ProjectViewIssuesHeader: React.FC = observer(() => { + // refs + const parentRef = useRef(null); // router const { workspaceSlug, projectId, viewId } = useParams(); // store hooks @@ -134,27 +140,14 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { [EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT ); - const publishLink = getPublishViewLink(viewDetails?.anchor); + + if (!viewDetails) return; return (
    - - - - - ) - } - /> - } - /> + + { <> )} - {viewDetails?.anchor && publishLink ? ( - - - Live - - ) : ( - <> - )} +
    + +
    {!viewDetails?.is_locked ? ( @@ -259,8 +247,9 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { displayFilters={issueFilters?.displayFilters ?? {}} handleDisplayFiltersUpdate={handleDisplayFilters} layoutDisplayFiltersOptions={ - activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined + activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined } + projectId={projectId.toString()} labels={projectLabels} memberIds={projectMemberIds ?? undefined} states={projectStates} @@ -271,7 +260,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { { }} size="sm" > - Add issue + Add work item ) : ( <> diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx index 3cb73a33e..ea9a6ac8a 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx @@ -1,43 +1,28 @@ "use client"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; import { Layers } from "lucide-react"; // ui import { Breadcrumbs, Button, Header } from "@plane/ui"; // components -import { BreadcrumbLink, Logo } from "@/components/common"; +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"; export const ProjectViewsHeader = observer(() => { - // router - const { workspaceSlug } = useParams(); // store hooks const { toggleCreateViewModal } = useCommandPalette(); - const { currentProjectDetails, loader } = useProject(); + const { loader } = useProject(); return ( <>
    - - - - - ) - } - /> - } - /> + + } />} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx index 387f6e5b4..d4a3051ec 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx @@ -4,29 +4,37 @@ import { useCallback } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // components +import { EUserPermissionsLevel, EUserProjectRoles, EViewAccess } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { TViewFilterProps } from "@plane/types"; import { Header, EHeaderVariant } from "@plane/ui"; import { PageHead } from "@/components/core"; -import { EmptyState } from "@/components/empty-state"; +import { DetailedEmptyState } from "@/components/empty-state"; import { ProjectViewsList } from "@/components/views"; import { ViewAppliedFiltersList } from "@/components/views/applied-filters"; -import { EmptyStateType } from "@/constants/empty-state"; // constants -import { EViewAccess } from "@/constants/views"; // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks -import { useProject, useProjectView } from "@/hooks/store"; +import { useProject, useProjectView, useUserPermissions } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; const ProjectViewsPage = observer(() => { // router + const router = useAppRouter(); const { workspaceSlug, projectId } = useParams(); + // plane hooks + const { t } = useTranslation(); // store const { getProjectById, currentProjectDetails } = useProject(); const { filters, updateFilters, clearAllFilters } = useProjectView(); + const { allowPermissions } = useUserPermissions(); // derived values const project = projectId ? getProjectById(projectId.toString()) : undefined; const pageTitle = project?.name ? `${project?.name} - Views` : undefined; + const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/views" }); const handleRemoveFilter = useCallback( (key: keyof TViewFilterProps, value: string | EViewAccess | null) => { @@ -53,9 +61,17 @@ const ProjectViewsPage = observer(() => { if (currentProjectDetails?.issue_views_view === false) return (
    - { + router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`); + }, + disabled: !canPerformEmptyStateActions, + }} />
    ); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx index fc2ec0075..8e1d19435 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx @@ -1,11 +1,18 @@ "use client"; import { ReactNode } from "react"; -// layouts -import { ProjectAuthWrapper } from "@/layouts/auth-layout"; +import { useParams } from "next/navigation"; +// plane web layouts +import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper"; -const ProjectDetailLayout = ({ children }: { children: ReactNode }) => ( - {children} -); +const ProjectDetailLayout = ({ children }: { children: ReactNode }) => { + // router + const { workspaceSlug, projectId } = useParams(); + return ( + + {children} + + ); +}; export default ProjectDetailLayout; diff --git a/web/app/[workspaceSlug]/(projects)/settings/api-tokens/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/api-tokens/page.tsx index fd2ed9669..21334ff23 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/api-tokens/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/api-tokens/page.tsx @@ -4,20 +4,20 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; -// ui +// 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 { EmptyState } from "@/components/empty-state"; +import { DetailedEmptyState } from "@/components/empty-state"; import { APITokenSettingsLoader } from "@/components/ui"; -// constants -import { EmptyStateType } from "@/constants/empty-state"; import { API_TOKENS_LIST } from "@/constants/fetch-keys"; // store hooks import { useUserPermissions, useWorkspace } from "@/hooks/store"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; // services import { APITokenService } from "@/services/api_token.service"; @@ -28,11 +28,14 @@ const ApiTokensPage = observer(() => { 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, @@ -40,7 +43,9 @@ const ApiTokensPage = observer(() => { workspaceSlug && canPerformWorkspaceAdminActions ? apiTokenService.getApiTokens(workspaceSlug.toString()) : null ); - const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - API Tokens` : undefined; + const pageTitle = currentWorkspace?.name + ? `${currentWorkspace.name} - ${t("workspace_settings.settings.api_tokens.title")}` + : undefined; if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { return ; @@ -58,9 +63,9 @@ const ApiTokensPage = observer(() => { {tokens.length > 0 ? ( <>
    -

    API tokens

    +

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

    @@ -72,13 +77,17 @@ const ApiTokensPage = observer(() => { ) : (
    -

    API tokens

    +

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

    - +
    )} diff --git a/web/app/[workspaceSlug]/(projects)/settings/billing/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/billing/page.tsx index 03f0cce5e..801b04b37 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/billing/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/billing/page.tsx @@ -2,13 +2,13 @@ import { observer } from "mobx-react"; // component +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; // hooks import { useUserPermissions, useWorkspace } from "@/hooks/store"; // plane web components import { BillingRoot } from "@/plane-web/components/workspace"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const BillingSettingsPage = observer(() => { // store hooks diff --git a/web/app/[workspaceSlug]/(projects)/settings/exports/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/exports/page.tsx index dc3f3aafc..1b63406de 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/exports/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/exports/page.tsx @@ -2,6 +2,8 @@ import { observer } from "mobx-react"; // components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import ExportGuide from "@/components/exporter/guide"; @@ -9,19 +11,21 @@ import ExportGuide from "@/components/exporter/guide"; import { cn } from "@/helpers/common.helper"; // hooks import { useUserPermissions, useWorkspace } from "@/hooks/store"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const ExportsPage = observer(() => { // store hooks const { workspaceUserInfo, allowPermissions } = useUserPermissions(); const { currentWorkspace } = useWorkspace(); + const { t } = useTranslation(); // derived values const canPerformWorkspaceMemberActions = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.WORKSPACE ); - const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Exports` : undefined; + const pageTitle = currentWorkspace?.name + ? `${currentWorkspace.name} - ${t("workspace_settings.settings.exports.title")}` + : undefined; // if user is not authorized to view this page if (workspaceUserInfo && !canPerformWorkspaceMemberActions) { @@ -37,7 +41,7 @@ const ExportsPage = observer(() => { })} >
    -

    Exports

    +

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

    diff --git a/web/app/[workspaceSlug]/(projects)/settings/header.tsx b/web/app/[workspaceSlug]/(projects)/settings/header.tsx index d98db1a65..003e72743 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/header.tsx @@ -4,6 +4,7 @@ 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"; @@ -12,6 +13,7 @@ import { useWorkspace } from "@/hooks/store"; export const WorkspaceSettingHeader: FC = observer(() => { const { currentWorkspace, loader } = useWorkspace(); + const { t } = useTranslation(); return (
    @@ -27,7 +29,7 @@ export const WorkspaceSettingHeader: FC = observer(() => { /> } /> - } /> + } />
    diff --git a/web/app/[workspaceSlug]/(projects)/settings/imports/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/imports/page.tsx index dc0815751..718742804 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/imports/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/imports/page.tsx @@ -2,11 +2,11 @@ import { observer } from "mobx-react"; // components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { PageHead } from "@/components/core"; import IntegrationGuide from "@/components/integration/guide"; // hooks import { useUserPermissions, useWorkspace } from "@/hooks/store"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const ImportsPage = observer(() => { // store hooks diff --git a/web/app/[workspaceSlug]/(projects)/settings/integrations/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/integrations/page.tsx index 290eb24ca..ef31bd82f 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/integrations/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/integrations/page.tsx @@ -3,6 +3,7 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; // components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { PageHead } from "@/components/core"; import { SingleIntegrationCard } from "@/components/integration"; import { IntegrationAndImportExportBanner, IntegrationsSettingsLoader } from "@/components/ui"; @@ -10,7 +11,6 @@ import { IntegrationAndImportExportBanner, IntegrationsSettingsLoader } from "@/ import { APP_INTEGRATIONS } from "@/constants/fetch-keys"; // hooks import { useUserPermissions, useWorkspace } from "@/hooks/store"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; // services import { IntegrationService } from "@/services/integrations"; diff --git a/web/app/[workspaceSlug]/(projects)/settings/layout.tsx b/web/app/[workspaceSlug]/(projects)/settings/layout.tsx index 9bd133cc1..788ca02ae 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/layout.tsx @@ -3,12 +3,12 @@ import { FC, ReactNode } from "react"; import { observer } from "mobx-react"; // components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { NotAuthorizedView } from "@/components/auth-screens"; import { AppHeader } from "@/components/core"; // hooks import { useUserPermissions } from "@/hooks/store"; // plane web constants -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; // local components import { WorkspaceSettingHeader } from "./header"; import { MobileWorkspaceSettingsTabs } from "./mobile-header-tabs"; diff --git a/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx index ab23261d9..8be7a9d22 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx @@ -5,21 +5,24 @@ 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 { useTranslation } from "@plane/i18n"; import { IWorkspaceBulkInviteFormData } from "@plane/types"; // ui import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // components import { NotAuthorizedView } from "@/components/auth-screens"; +import { CountChip } from "@/components/common"; import { PageHead } from "@/components/core"; -import { SendWorkspaceInvitationModal, WorkspaceMembersList } from "@/components/workspace"; -// constants -import { MEMBER_INVITED } from "@/constants/event-tracker"; +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"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; +// plane web components +import { BillingActionsButton } from "@/plane-web/components/workspace/billing"; +import { SendWorkspaceInvitationModal } from "@/plane-web/components/workspace/members"; const WorkspaceMembersSettingsPage = observer(() => { // states @@ -31,9 +34,10 @@ const WorkspaceMembersSettingsPage = observer(() => { const { workspaceUserInfo, allowPermissions } = useUserPermissions(); const { captureEvent } = useEventTracker(); const { - workspace: { inviteMembersToWorkspace }, + workspace: { workspaceMemberIds, inviteMembersToWorkspace }, } = useMember(); const { currentWorkspace } = useWorkspace(); + const { t } = useTranslation(); // derived values const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); @@ -62,7 +66,7 @@ const WorkspaceMembersSettingsPage = observer(() => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Success!", - message: "Invitations sent successfully.", + message: t("workspace_settings.settings.members.invitations_sent_successfully"), }); }) .catch((err) => { @@ -80,8 +84,9 @@ const WorkspaceMembersSettingsPage = observer(() => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: `${err.error ?? "Something went wrong. Please try again."}`, + message: `${err.error ?? t("something_went_wrong_please_try_again")}`, }); + throw err; }); }; @@ -102,17 +107,22 @@ const WorkspaceMembersSettingsPage = observer(() => { onSubmit={handleWorkspaceInvite} />
    -
    -

    Members

    +
    +

    + {t("workspace_settings.settings.members.title")} + {workspaceMemberIds && workspaceMemberIds.length > 0 && ( + + )} +

    setSearchQuery(e.target.value)} @@ -120,9 +130,10 @@ const WorkspaceMembersSettingsPage = observer(() => {
    {canPerformWorkspaceAdminActions && ( )} +
    diff --git a/web/app/[workspaceSlug]/(projects)/settings/mobile-header-tabs.tsx b/web/app/[workspaceSlug]/(projects)/settings/mobile-header-tabs.tsx index ee3035220..b699a1ff2 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/mobile-header-tabs.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/mobile-header-tabs.tsx @@ -1,11 +1,10 @@ import { observer } from "mobx-react"; import { useParams, usePathname } from "next/navigation"; +import { WORKSPACE_SETTINGS_LINKS, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; // hooks import { useUserPermissions } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; -// plane web constants -import { EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; -import { WORKSPACE_SETTINGS_LINKS } from "@/plane-web/constants/workspace"; // plane web helpers import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; @@ -13,6 +12,7 @@ export const MobileWorkspaceSettingsTabs = observer(() => { const router = useAppRouter(); const { workspaceSlug } = useParams(); const pathname = usePathname(); + const { t } = useTranslation(); // mobx store const { allowPermissions } = useUserPermissions(); @@ -31,7 +31,7 @@ export const MobileWorkspaceSettingsTabs = observer(() => { key={index} onClick={() => router.push(`/${workspaceSlug}${item.href}`)} > - {item.label} + {t(item.i18n_label)}
    ) )} diff --git a/web/app/[workspaceSlug]/(projects)/settings/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/page.tsx index c628e313c..6088cf0a5 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/page.tsx @@ -2,6 +2,7 @@ import { observer } from "mobx-react"; // components +import { useTranslation } from "@plane/i18n"; import { PageHead } from "@/components/core"; import { WorkspaceDetails } from "@/components/workspace"; // hooks @@ -10,8 +11,11 @@ import { useWorkspace } from "@/hooks/store"; const WorkspaceSettingsPage = observer(() => { // store hooks const { currentWorkspace } = useWorkspace(); + const { t } = useTranslation(); // derived values - const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - General Settings` : undefined; + const pageTitle = currentWorkspace?.name + ? t("workspace_settings.page_label", { workspace: currentWorkspace.name }) + : undefined; return ( <> diff --git a/web/app/[workspaceSlug]/(projects)/settings/sidebar.tsx b/web/app/[workspaceSlug]/(projects)/settings/sidebar.tsx index 6bce5daa3..a12065536 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/sidebar.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/sidebar.tsx @@ -4,13 +4,12 @@ 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 constants -import { EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; -import { WORKSPACE_SETTINGS_LINKS } from "@/plane-web/constants/workspace"; // plane web helpers import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; @@ -19,12 +18,13 @@ export const WorkspaceSettingsSidebar = observer(() => { const { workspaceSlug } = useParams(); const pathname = usePathname(); // mobx store + const { t } = useTranslation(); const { allowPermissions } = useUserPermissions(); return (
    - SETTINGS + {t("settings")}
    {WORKSPACE_SETTINGS_LINKS.map( (link) => @@ -36,7 +36,7 @@ export const WorkspaceSettingsSidebar = observer(() => { isActive={link.highlight(pathname, `/${workspaceSlug}`)} className="text-sm font-medium px-4 py-2" > - {link.label} + {t(link.i18n_label)} ) diff --git a/web/app/[workspaceSlug]/(projects)/settings/webhooks/[webhookId]/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/webhooks/[webhookId]/page.tsx index 4669f4064..5edc914e9 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/webhooks/[webhookId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/webhooks/[webhookId]/page.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { IWebhook } from "@plane/types"; // ui import { TOAST_TYPE, setToast } from "@plane/ui"; @@ -13,7 +14,6 @@ import { PageHead } from "@/components/core"; import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "@/components/web-hooks"; // hooks import { useUserPermissions, useWebhook, useWorkspace } from "@/hooks/store"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const WebhookDetailsPage = observer(() => { // states diff --git a/web/app/[workspaceSlug]/(projects)/settings/webhooks/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/webhooks/page.tsx index 86c922f07..2623660da 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/webhooks/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/webhooks/page.tsx @@ -4,39 +4,43 @@ import React, { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; -// ui +// 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 { EmptyState } from "@/components/empty-state"; +import { DetailedEmptyState } from "@/components/empty-state"; import { WebhookSettingsLoader } from "@/components/ui"; import { WebhooksList, CreateWebhookModal } from "@/components/web-hooks"; -// constants -import { EmptyStateType } from "@/constants/empty-state"; // hooks import { useUserPermissions, useWebhook, useWorkspace } from "@/hooks/store"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; const WebhooksListPage = observer(() => { // states const [showCreateWebhookModal, setShowCreateWebhookModal] = useState(false); // router const { workspaceSlug } = useParams(); + // plane hooks + const { t } = useTranslation(); // mobx store const { workspaceUserInfo, allowPermissions } = useUserPermissions(); - const { fetchWebhooks, webhooks, clearSecretKey, webhookSecretKey, createWebhook } = useWebhook(); const { currentWorkspace } = useWorkspace(); - + // derived values const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/webhooks" }); useSWR( workspaceSlug && canPerformWorkspaceAdminActions ? `WEBHOOKS_LIST_${workspaceSlug}` : null, workspaceSlug && canPerformWorkspaceAdminActions ? () => fetchWebhooks(workspaceSlug.toString()) : null ); - const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Webhooks` : undefined; + const pageTitle = currentWorkspace?.name + ? `${currentWorkspace.name} - ${t("workspace_settings.settings.webhooks.title")}` + : undefined; // clear secret key when modal is closed. useEffect(() => { @@ -65,9 +69,9 @@ const WebhooksListPage = observer(() => { {Object.keys(webhooks).length > 0 ? (
    -
    Webhooks
    +
    {t("workspace_settings.settings.webhooks.title")}
    @@ -75,13 +79,17 @@ const WebhooksListPage = observer(() => { ) : (
    -
    Webhooks
    +
    {t("workspace_settings.settings.webhooks.title")}
    - +
    )} diff --git a/web/app/[workspaceSlug]/(projects)/sidebar.tsx b/web/app/[workspaceSlug]/(projects)/sidebar.tsx index ac55fdec8..27a73137e 100644 --- a/web/app/[workspaceSlug]/(projects)/sidebar.tsx +++ b/web/app/[workspaceSlug]/(projects)/sidebar.tsx @@ -2,18 +2,13 @@ import { FC, useEffect, useRef } from "react"; import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react"; // plane helpers +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useOutsideClickDetector } from "@plane/hooks"; // components -import { - SidebarDropdown, - SidebarHelpSection, - SidebarProjectsList, - SidebarQuickActions, - SidebarUserMenu, - SidebarWorkspaceMenu, -} from "@/components/workspace"; -// helpers +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"; @@ -21,7 +16,9 @@ import { useFavorite } from "@/hooks/store/use-favorite"; import useSize from "@/hooks/use-window-size"; // plane web components import { SidebarAppSwitcher } from "@/plane-web/components/sidebar"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; +import { SidebarTeamsList } from "@/plane-web/components/workspace/sidebar/teams-sidebar-list"; +import { ExtendedProjectSidebar } from "./extended-project-sidebar"; +import { ExtendedAppSidebar } from "./extended-sidebar"; export const AppSidebar: FC = observer(() => { // store hooks @@ -47,61 +44,68 @@ export const AppSidebar: FC = observer(() => { }); useEffect(() => { - if (windowSize[0] < 768) !sidebarCollapsed && toggleSidebar(); + if (windowSize[0] < 768 && !sidebarCollapsed) toggleSidebar(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [windowSize]); const isFavoriteEmpty = isEmpty(groupedFavorites); return ( -
    + <>
    - -
    - - -
    -
    -
    - - - +
    + {/* Workspace switcher and settings */} + +
    + {/* App switcher */} + {canPerformWorkspaceMemberActions && } + {/* Quick actions */} + +

    - {canPerformWorkspaceMemberActions && !isFavoriteEmpty && } - - +
    + + {sidebarCollapsed && ( +
    + )} + {/* Favorites Menu */} + {canPerformWorkspaceMemberActions && !isFavoriteEmpty && } + {/* Teams List */} + + {/* Projects List */} + +
    + {/* Help Section */} +
    -
    -
    + + + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/stickies/header.tsx b/web/app/[workspaceSlug]/(projects)/stickies/header.tsx new file mode 100644 index 000000000..9e3f1a45d --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/stickies/header.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { observer } from "mobx-react"; +// ui +import { useParams } from "next/navigation"; +import { Breadcrumbs, Button, Header, RecentStickyIcon } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common"; + +// hooks +import { StickySearch } from "@/components/stickies/modal/search"; +import { useStickyOperations } from "@/components/stickies/sticky/use-operations"; +// plane-web +import { useSticky } from "@/hooks/use-stickies"; + +export const WorkspaceStickyHeader = observer(() => { + const { workspaceSlug } = useParams(); + // hooks + const { creatingSticky, toggleShowNewSticky } = useSticky(); + const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() }); + + return ( + <> +
    + +
    + + } + /> + } + /> + +
    +
    + + + + + +
    + + ); +}); diff --git a/web/app/[workspaceSlug]/(projects)/stickies/layout.tsx b/web/app/[workspaceSlug]/(projects)/stickies/layout.tsx new file mode 100644 index 000000000..b1d7e6b92 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/stickies/layout.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { AppHeader, ContentWrapper } from "@/components/core"; +import { WorkspaceStickyHeader } from "./header"; + +export default function WorkspaceStickiesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/app/[workspaceSlug]/(projects)/stickies/page.tsx b/web/app/[workspaceSlug]/(projects)/stickies/page.tsx new file mode 100644 index 000000000..48c2cc374 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/stickies/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +// components +import { PageHead } from "@/components/core"; +import { StickiesInfinite } from "@/components/stickies"; + +export default function WorkspaceStickiesPage() { + return ( + <> + +
    + +
    + + ); +} diff --git a/web/app/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx b/web/app/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx index 3eec4b027..bf7d78a6d 100644 --- a/web/app/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx @@ -1,13 +1,15 @@ "use client"; +import { useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; +// plane imports +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 -import { DEFAULT_GLOBAL_VIEWS_LIST } from "@/constants/workspace"; // hooks import { useWorkspace } from "@/hooks/store"; @@ -16,19 +18,25 @@ const GlobalViewIssuesPage = observer(() => { const { globalViewId } = useParams(); // store hooks const { currentWorkspace } = useWorkspace(); + // states + const [isLoading, setIsLoading] = useState(false); // derived values const defaultView = DEFAULT_GLOBAL_VIEWS_LIST.find((view) => view.key === globalViewId); const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - All Views` : undefined; + // handlers + const toggleLoading = (value: boolean) => setIsLoading(value); return ( <>
    - {globalViewId && } - + {globalViewId && ( + + )} +
    diff --git a/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx b/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx index 500d23ed7..5a5ee0a6e 100644 --- a/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx @@ -3,8 +3,11 @@ import { useCallback, useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -// types import { Layers } from "lucide-react"; +// plane constants +import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { Breadcrumbs, Button, Header } from "@plane/ui"; @@ -12,8 +15,6 @@ import { Breadcrumbs, Button, Header } from "@plane/ui"; import { BreadcrumbLink } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection } from "@/components/issues"; import { CreateUpdateWorkspaceViewModal } from "@/components/workspace"; -// constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; // helpers import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks @@ -33,6 +34,7 @@ export const GlobalIssuesHeader = observer(() => { const { workspace: { workspaceMemberIds }, } = useMember(); + const { t } = useTranslation(); const issueFilters = globalViewId ? filters[globalViewId.toString()] : undefined; @@ -103,7 +105,7 @@ export const GlobalIssuesHeader = observer(() => { } />} + link={} />} /> @@ -112,12 +114,12 @@ export const GlobalIssuesHeader = observer(() => { {!isLocked ? ( <> { memberIds={workspaceMemberIds ?? undefined} /> - + { )}
    diff --git a/web/app/[workspaceSlug]/(projects)/workspace-views/page.tsx b/web/app/[workspaceSlug]/(projects)/workspace-views/page.tsx index 7de442584..87d0cfc58 100644 --- a/web/app/[workspaceSlug]/(projects)/workspace-views/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/workspace-views/page.tsx @@ -4,13 +4,15 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; // icons import { Search } from "lucide-react"; +// plane imports +import { DEFAULT_GLOBAL_VIEWS_LIST } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; // ui import { Input } from "@plane/ui"; // components import { PageHead } from "@/components/core"; import { GlobalDefaultViewListItem, GlobalViewsList } from "@/components/workspace"; // constants -import { DEFAULT_GLOBAL_VIEWS_LIST } from "@/constants/workspace"; // hooks import { useWorkspace } from "@/hooks/store"; @@ -18,6 +20,7 @@ const WorkspaceViewsPage = observer(() => { const [query, setQuery] = useState(""); // store const { currentWorkspace } = useWorkspace(); + const { t } = useTranslation(); // derived values const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - All Views` : undefined; @@ -36,7 +39,7 @@ const WorkspaceViewsPage = observer(() => { />
    - {DEFAULT_GLOBAL_VIEWS_LIST.filter((v) => v.label.toLowerCase().includes(query.toLowerCase())).map( + {DEFAULT_GLOBAL_VIEWS_LIST.filter((v) => t(v.i18n_label).toLowerCase().includes(query.toLowerCase())).map( (option) => ( ) diff --git a/web/app/accounts/forgot-password/page.tsx b/web/app/accounts/forgot-password/page.tsx index 91516c5b9..22a241db6 100644 --- a/web/app/accounts/forgot-password/page.tsx +++ b/web/app/accounts/forgot-password/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { observer } from "mobx-react"; import Image from "next/image"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; @@ -7,10 +8,10 @@ import { useTheme } from "next-themes"; import { Controller, useForm } from "react-hook-form"; // icons import { CircleCheck } from "lucide-react"; -// ui +// plane imports +import { FORGOT_PASS_LINK, NAVIGATE_TO_SIGNUP } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { Button, Input, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; -// constants -import { FORGOT_PASS_LINK, NAVIGATE_TO_SIGNUP } from "@/constants/event-tracker"; // helpers import { EPageTypes } from "@/helpers/authentication.helper"; import { cn } from "@/helpers/common.helper"; @@ -20,12 +21,12 @@ import { useEventTracker } from "@/hooks/store"; import useTimer from "@/hooks/use-timer"; // wrappers import { AuthenticationWrapper } from "@/lib/wrappers"; -// services // images import PlaneBackgroundPatternDark from "@/public/auth/background-pattern-dark.svg"; import PlaneBackgroundPattern from "@/public/auth/background-pattern.svg"; import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; +// services import { AuthService } from "@/services/auth.service"; type TForgotPasswordFormValues = { @@ -39,10 +40,12 @@ const defaultValues: TForgotPasswordFormValues = { // services const authService = new AuthService(); -export default function ForgotPasswordPage() { +const ForgotPasswordPage = observer(() => { // search params const searchParams = useSearchParams(); const email = searchParams.get("email"); + // plane hooks + const { t } = useTranslation(); // store hooks const { captureEvent } = useEventTracker(); // hooks @@ -73,9 +76,8 @@ export default function ForgotPasswordPage() { }); setToast({ type: TOAST_TYPE.SUCCESS, - title: "Email sent", - message: - "Check your inbox for a link to reset your password. If it doesn't appear within a few minutes, check your spam folder.", + title: t("auth.forgot_password.toast.success.title"), + message: t("auth.forgot_password.toast.success.message"), }); setResendCodeTimer(30); }) @@ -85,8 +87,8 @@ export default function ForgotPasswordPage() { }); setToast({ type: TOAST_TYPE.ERROR, - title: "Error!", - message: err?.error ?? "Something went wrong. Please try again.", + title: t("auth.forgot_password.toast.error.title"), + message: err?.error ?? t("auth.forgot_password.toast.error.message"), }); }); }; @@ -111,13 +113,13 @@ export default function ForgotPasswordPage() {
    - New to Plane?{" "} + {t("auth.common.new_to_plane")} captureEvent(NAVIGATE_TO_SIGNUP, {})} className="font-semibold text-custom-primary-100 hover:underline" > - Create an account + {t("auth.common.create_account")}
    @@ -125,23 +127,21 @@ export default function ForgotPasswordPage() {

    - Reset your password + {t("auth.forgot_password.title")}

    -

    - Enter your user account{"'"}s verified email address and we will send you a password reset link. -

    +

    {t("auth.forgot_password.description")}

    checkEmailValidity(value) || "Email is invalid", + required: t("auth.common.email.errors.required"), + validate: (value) => checkEmailValidity(value) || t("auth.common.email.errors.invalid"), }} render={({ field: { value, onChange, ref } }) => ( 0} @@ -162,7 +162,7 @@ export default function ForgotPasswordPage() { {resendTimerCode > 0 && (

    - We sent the reset link to your email address + {t("auth.forgot_password.email_sent")}

    )}
    @@ -174,10 +174,12 @@ export default function ForgotPasswordPage() { disabled={!isValid} loading={isSubmitting || resendTimerCode > 0} > - {resendTimerCode > 0 ? `Resend in ${resendTimerCode} seconds` : "Send reset link"} + {resendTimerCode > 0 + ? t("auth.common.resend_in", { seconds: resendTimerCode }) + : t("auth.forgot_password.send_reset_link")} - Back to sign in + {t("auth.common.back_to_sign_in")}
    @@ -186,4 +188,6 @@ export default function ForgotPasswordPage() {
    ); -} +}); + +export default ForgotPasswordPage; diff --git a/web/app/accounts/reset-password/page.tsx b/web/app/accounts/reset-password/page.tsx index 04d6e3115..e0230f205 100644 --- a/web/app/accounts/reset-password/page.tsx +++ b/web/app/accounts/reset-password/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; +import { observer } from "mobx-react"; import Image from "next/image"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; @@ -8,6 +9,7 @@ import { useSearchParams } from "next/navigation"; import { useTheme } from "next-themes"; import { Eye, EyeOff } from "lucide-react"; // ui +import { useTranslation } from "@plane/i18n"; import { Button, Input } from "@plane/ui"; // components import { AuthBanner, PasswordStrengthMeter } from "@/components/account"; @@ -45,7 +47,7 @@ const defaultValues: TResetPasswordFormValues = { // services const authService = new AuthService(); -export default function ResetPasswordPage() { +const ResetPasswordPage = observer(() => { // search params const searchParams = useSearchParams(); const uidb64 = searchParams.get("uidb64"); @@ -65,7 +67,8 @@ export default function ResetPasswordPage() { const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); const [errorInfo, setErrorInfo] = useState(undefined); - + // plane hooks + const { t } = useTranslation(); // hooks const { resolvedTheme } = useTheme(); @@ -127,9 +130,9 @@ export default function ResetPasswordPage() {

    - Set new password + {t("auth.reset_password.title")}

    -

    Secure your account with a strong password

    +

    {t("auth.reset_password.description")}

    {errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && ( setErrorInfo(value)} /> @@ -142,7 +145,7 @@ export default function ResetPasswordPage() {
    handleFormChange("password", e.target.value)} //hasError={Boolean(errors.password)} - placeholder="Enter password" + placeholder={t("auth.common.password.placeholder")} className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" minLength={8} onFocus={() => setIsPasswordInputFocused(true)} @@ -193,7 +196,7 @@ export default function ResetPasswordPage() {
    handleFormChange("confirm_password", e.target.value)} - placeholder="Confirm password" + placeholder={t("auth.common.password.confirm_password.placeholder")} className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" onFocus={() => setIsRetryPasswordInputFocused(true)} onBlur={() => setIsRetryPasswordInputFocused(false)} @@ -220,10 +223,12 @@ export default function ResetPasswordPage() {
    {!!resetFormData.confirm_password && resetFormData.password !== resetFormData.confirm_password && - renderPasswordMatchError && Passwords don{"'"}t match} + renderPasswordMatchError && ( + {t("auth.common.password.errors.match")} + )}
    @@ -232,4 +237,6 @@ export default function ResetPasswordPage() {
    ); -} +}); + +export default ResetPasswordPage; diff --git a/web/app/accounts/set-password/page.tsx b/web/app/accounts/set-password/page.tsx index f3ac35b76..5bfa7c08f 100644 --- a/web/app/accounts/set-password/page.tsx +++ b/web/app/accounts/set-password/page.tsx @@ -8,7 +8,8 @@ import { useSearchParams } from "next/navigation"; // icons import { useTheme } from "next-themes"; import { Eye, EyeOff } from "lucide-react"; -// ui +// plane imports +import { useTranslation } from "@plane/i18n"; import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; // components import { PasswordStrengthMeter } from "@/components/account"; @@ -60,9 +61,10 @@ const SetPasswordPage = observer(() => { const [csrfToken, setCsrfToken] = useState(undefined); const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); + // plane hooks + const { t } = useTranslation(); // hooks const { resolvedTheme } = useTheme(); - // hooks const { data: user, handleSetPassword } = useUser(); useEffect(() => { @@ -95,8 +97,8 @@ const SetPasswordPage = observer(() => { } catch (err: any) { setToast({ type: TOAST_TYPE.ERROR, - title: "Error!", - message: err?.error ?? "Something went wrong. Please try again.", + title: t("common.errors.default.title"), + message: err?.error ?? t("common.errors.default.message"), }); } }; @@ -108,7 +110,8 @@ const SetPasswordPage = observer(() => { const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; return ( - + // TODO: change to EPageTypes.SET_PASSWORD +
    {

    - Secure your account + {t("auth.set_password.title")}

    -

    Setting password helps you login securely

    +

    {t("auth.set_password.description")}

    handleSubmit(e)}>
    { type="email" value={user?.email} //hasError={Boolean(errors.email)} - placeholder="name@company.com" + placeholder={t("auth.common.email.placeholder")} className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400 cursor-not-allowed" autoComplete="on" disabled @@ -154,7 +157,7 @@ const SetPasswordPage = observer(() => {
    { value={passwordFormData.password} onChange={(e) => handleFormChange("password", e.target.value)} //hasError={Boolean(errors.password)} - placeholder="Enter password" + placeholder={t("auth.common.password.placeholder")} className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" minLength={8} onFocus={() => setIsPasswordInputFocused(true)} @@ -187,7 +190,7 @@ const SetPasswordPage = observer(() => {
    { name="confirm_password" value={passwordFormData.confirm_password} onChange={(e) => handleFormChange("confirm_password", e.target.value)} - placeholder="Confirm password" + placeholder={t("auth.common.password.confirm_password.placeholder")} className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" onFocus={() => setIsRetryPasswordInputFocused(true)} onBlur={() => setIsRetryPasswordInputFocused(false)} @@ -214,10 +217,12 @@ const SetPasswordPage = observer(() => {
    {!!passwordFormData.confirm_password && passwordFormData.password !== passwordFormData.confirm_password && - renderPasswordMatchError && Passwords don{"'"}t match} + renderPasswordMatchError && ( + {t("auth.common.password.errors.match")} + )}
    diff --git a/web/app/create-workspace/page.tsx b/web/app/create-workspace/page.tsx index 36bc8978a..842532034 100644 --- a/web/app/create-workspace/page.tsx +++ b/web/app/create-workspace/page.tsx @@ -5,6 +5,7 @@ import { observer } from "mobx-react"; import Image from "next/image"; import Link from "next/link"; import { useTheme } from "next-themes"; +import { useTranslation } from "@plane/i18n"; import { IWorkspace } from "@plane/types"; // components import { Button, getButtonStyling } from "@plane/ui"; @@ -22,6 +23,7 @@ import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue import WorkspaceCreationDisabled from "@/public/workspace/workspace-creation-disabled.png"; const CreateWorkspacePage = observer(() => { + const { t } = useTranslation(); // router const router = useAppRouter(); // store hooks @@ -38,6 +40,18 @@ const CreateWorkspacePage = observer(() => { // derived values const isWorkspaceCreationDisabled = getIsWorkspaceCreationDisabled(); + // methods + const getMailtoHref = () => { + const subject = t("workspace_creation.request_email.subject"); + const body = t("workspace_creation.request_email.body", { + firstName: currentUser?.first_name || "", + lastName: currentUser?.last_name || "", + email: currentUser?.email || "", + }); + + return `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; + }; + const onSubmit = async (workspace: IWorkspace) => { await updateUserProfile({ last_workspace_id: workspace.id }).then(() => router.push(`/${workspace.slug}`)); }; @@ -65,26 +79,24 @@ const CreateWorkspacePage = observer(() => { {isWorkspaceCreationDisabled ? (
    Workspace creation disabled -
    Only your instance admin can create workspaces
    -

    - If you know your instance admin's email address,
    click the button below to get in touch with - them. +

    + {t("workspace_creation.errors.creation_disabled.title")} +
    +

    + {t("workspace_creation.errors.creation_disabled.description")}

    ) : (
    -

    Create your workspace

    +

    {t("workspace_creation.heading")}

    { - // Sentry.captureException(error); - // }, [error]); - return ( diff --git a/web/app/invitations/page.tsx b/web/app/invitations/page.tsx index 73ef15b63..df6befa68 100644 --- a/web/app/invitations/page.tsx +++ b/web/app/invitations/page.tsx @@ -7,18 +7,18 @@ import Link from "next/link"; import { useTheme } from "next-themes"; import useSWR, { mutate } from "swr"; -// icons import { CheckCircle2 } from "lucide-react"; +// plane imports +import { ROLE, MEMBER_ACCEPTED, EUserPermissions } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; // types import type { IWorkspaceMemberInvitation } from "@plane/types"; // ui import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // components import { EmptyState } from "@/components/common"; -// constants -import { MEMBER_ACCEPTED } from "@/constants/event-tracker"; +import { WorkspaceLogo } from "@/components/workspace/logo"; import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys"; -import { ROLE } from "@/constants/workspace"; // helpers import { truncateText } from "@/helpers/string.helper"; import { getUserRole } from "@/helpers/user.helper"; @@ -27,8 +27,6 @@ import { useEventTracker, useUser, useUserProfile, useWorkspace } from "@/hooks/ import { useAppRouter } from "@/hooks/use-app-router"; // services import { AuthenticationWrapper } from "@/lib/wrappers"; -// plane web constants -import { EUserPermissions } from "@/plane-web/constants/user-permissions"; // plane web services import { WorkspaceService } from "@/plane-web/services"; // images @@ -45,6 +43,7 @@ const UserInvitationsPage = observer(() => { // router const router = useAppRouter(); // store hooks + const { t } = useTranslation(); const { captureEvent, joinWorkspaceMetricGroup } = useEventTracker(); const { data: currentUser } = useUser(); const { updateUserProfile } = useUserProfile(); @@ -72,8 +71,8 @@ const UserInvitationsPage = observer(() => { if (invitationsRespond.length === 0) { setToast({ type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Please select at least one invitation.", + title: t("error"), + message: t("please_select_at_least_one_invitation"), }); return; } @@ -107,8 +106,8 @@ const UserInvitationsPage = observer(() => { .catch(() => { setToast({ type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Something went wrong, Please try again.", + title: t("error"), + message: t("something_went_wrong_please_try_again"), }); setIsJoiningWorkspaces(false); }); @@ -122,8 +121,8 @@ const UserInvitationsPage = observer(() => { }); setToast({ type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Something went wrong, Please try again.", + title: t("error"), + message: t("something_went_wrong_please_try_again"), }); setIsJoiningWorkspaces(false); }); @@ -152,8 +151,8 @@ const UserInvitationsPage = observer(() => { invitations.length > 0 ? (
    -
    We see that someone has invited you to
    -

    Join a workspace

    +
    {t("we_see_that_someone_has_invited_you_to_join_a_workspace")}
    +

    {t("join_a_workspace")}

    {invitations.map((invitation) => { const isSelected = invitationsRespond.includes(invitation.id); @@ -169,21 +168,11 @@ const UserInvitationsPage = observer(() => { onClick={() => handleInvitation(invitation, isSelected ? "withdraw" : "accepted")} >
    -
    - {invitation.workspace.logo && invitation.workspace.logo.trim() !== "" ? ( - {invitation.workspace.name} - ) : ( - - {invitation.workspace.name[0]} - - )} -
    +
    {truncateText(invitation.workspace.name, 30)}
    @@ -207,12 +196,12 @@ const UserInvitationsPage = observer(() => { disabled={isJoiningWorkspaces || invitationsRespond.length === 0} loading={isJoiningWorkspaces} > - Accept & Join + {t("accept_and_join")} @@ -222,11 +211,11 @@ const UserInvitationsPage = observer(() => { ) : (
    router.push("/"), }} /> diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 648f70357..6024753df 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -6,7 +6,8 @@ import "@/styles/command-pallette.css"; import "@/styles/emoji.css"; import "@/styles/react-day-picker.css"; // meta data info -import { SITE_NAME, SITE_DESCRIPTION } from "@/constants/meta"; + +import { SITE_DESCRIPTION, SITE_NAME } from "@plane/constants"; // helpers import { API_BASE_URL, cn } from "@/helpers/common.helper"; // local @@ -17,12 +18,11 @@ export const metadata: Metadata = { description: SITE_DESCRIPTION, openGraph: { title: "Plane | Simple, extensible, open-source project management tool.", - description: - "Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.", + description: "Open-source project management tool to manage work items, cycles, and product roadmaps easily", url: "https://app.plane.so/", }, keywords: - "software development, plan, ship, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration", + "software development, plan, ship, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration", twitter: { site: "@planepowers", }, @@ -67,7 +67,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) diff --git a/web/app/onboarding/page.tsx b/web/app/onboarding/page.tsx index 6d915dfcb..a26bef3a6 100644 --- a/web/app/onboarding/page.tsx +++ b/web/app/onboarding/page.tsx @@ -4,6 +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 { TOnboardingSteps, TUserProfile } from "@plane/types"; // ui import { TOAST_TYPE, setToast } from "@plane/ui"; @@ -11,7 +12,6 @@ import { TOAST_TYPE, setToast } from "@plane/ui"; import { LogoSpinner } from "@/components/common"; import { InviteMembers, CreateOrJoinWorkspaces, ProfileSetup } from "@/components/onboarding"; // constants -import { USER_ONBOARDING_COMPLETED } from "@/constants/event-tracker"; import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys"; // helpers import { EPageTypes } from "@/helpers/authentication.helper"; diff --git a/web/app/page.tsx b/web/app/page.tsx index 63ef855ca..f63845fcb 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -7,10 +7,11 @@ import Link from "next/link"; // ui import { useTheme } from "next-themes"; // components +import { NAVIGATE_TO_SIGNUP } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { AuthRoot } from "@/components/account"; import { PageHead } from "@/components/core"; // constants -import { NAVIGATE_TO_SIGNUP } from "@/constants/event-tracker"; // helpers import { EAuthModes, EPageTypes } from "@/helpers/authentication.helper"; // hooks @@ -27,6 +28,8 @@ import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue const HomePage = observer(() => { const { resolvedTheme } = useTheme(); + // plane hooks + const { t } = useTranslation(); // hooks const { captureEvent } = useEventTracker(); @@ -37,7 +40,7 @@ const HomePage = observer(() => { <>
    - +
    {
    - New to Plane?{" "} + {t("auth.common.new_to_plane")} captureEvent(NAVIGATE_TO_SIGNUP, {})} className="font-semibold text-custom-primary-100 hover:underline" > - Create an account + {t("auth.common.create_account")}
    diff --git a/web/app/profile/activity/page.tsx b/web/app/profile/activity/page.tsx index afc9b29bf..ca6c9511d 100644 --- a/web/app/profile/activity/page.tsx +++ b/web/app/profile/activity/page.tsx @@ -2,18 +2,19 @@ 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 { EmptyState } from "@/components/empty-state"; +import { DetailedEmptyState } from "@/components/empty-state"; import { ProfileActivityListPage, ProfileSettingContentHeader, ProfileSettingContentWrapper, } from "@/components/profile"; -// constants -import { EmptyStateType } from "@/constants/empty-state"; +// hooks +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; const PER_PAGE = 100; @@ -23,6 +24,10 @@ const ProfileActivityPage = observer(() => { 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); @@ -48,19 +53,25 @@ const ProfileActivityPage = observer(() => { const isLoadMoreVisible = pageCount < totalPages && resultsCount !== 0; if (isEmpty) { - return ; + return ( + + ); } return ( <> - + {activityPages} {isLoadMoreVisible && (
    )} diff --git a/web/app/profile/appearance/page.tsx b/web/app/profile/appearance/page.tsx index 775ff637b..c89bcdf3c 100644 --- a/web/app/profile/appearance/page.tsx +++ b/web/app/profile/appearance/page.tsx @@ -3,6 +3,9 @@ import { useEffect, useState } 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"; // components @@ -10,13 +13,12 @@ import { LogoSpinner } from "@/components/common"; import { CustomThemeSelector, ThemeSwitch, PageHead } from "@/components/core"; import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile"; // constants -import { I_THEME_OPTION, THEME_OPTIONS } from "@/constants/themes"; // helpers import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper"; // hooks import { useUserProfile } from "@/hooks/store"; - const ProfileAppearancePage = observer(() => { + const { t } = useTranslation(); const { setTheme } = useTheme(); // states const [currentTheme, setCurrentTheme] = useState(null); @@ -62,11 +64,11 @@ const ProfileAppearancePage = observer(() => { {userProfile ? ( - +
    -

    Theme

    -

    Select or customize your interface color scheme.

    +

    {t("theme")}

    +

    {t("select_or_customize_your_interface_color_scheme")}

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

    - First name* -

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

    Last name

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

    - Display name* -

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

    - Email* -

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

    - Role* -

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

    - Timezone* -

    - ( - t.value === value)) ?? value) - : "Select a timezone" - } - options={timeZoneOptions} - onChange={onChange} - buttonClassName={errors.user_timezone ? "border-red-500" : ""} - className="rounded-md border-[0.5px] !border-custom-border-200" - optionsClassName="w-72" - input - /> - )} - /> - {errors.user_timezone && {errors.user_timezone.message}} -
    - -
    -

    Language

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

    Current password

    +

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

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

    New password

    +

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

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

    Confirm password

    +

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

    ( { )}
    {!!confirmPassword && password !== confirmPassword && renderPasswordMatchError && ( - Passwords don{"'"}t match + {t("auth.common.password.errors.match")} )}
    diff --git a/web/app/profile/sidebar.tsx b/web/app/profile/sidebar.tsx index 479ef21f5..07fd746c6 100644 --- a/web/app/profile/sidebar.tsx +++ b/web/app/profile/sidebar.tsx @@ -5,15 +5,26 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; // icons -import { ChevronLeft, LogOut, MoveLeft, Plus, UserPlus } from "lucide-react"; -// plane helpers +import { + ChevronLeft, + LogOut, + MoveLeft, + Plus, + UserPlus, + Activity, + Bell, + CircleUser, + KeyRound, + Settings2, +} from "lucide-react"; +// plane imports +import { PROFILE_ACTION_LINKS } from "@plane/constants"; import { useOutsideClickDetector } from "@plane/hooks"; -// ui +import { useTranslation } from "@plane/i18n"; import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; // components import { SidebarNavItem } from "@/components/sidebar"; // constants -import { PROFILE_ACTION_LINKS } from "@/constants/profile"; // helpers import { cn } from "@/helpers/common.helper"; import { getFileURL } from "@/helpers/file.helper"; @@ -23,7 +34,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os"; const WORKSPACE_ACTION_LINKS = [ { - key: "create-workspace", + key: "create_workspace", Icon: Plus, label: "Create workspace", href: "/create-workspace", @@ -36,6 +47,19 @@ const WORKSPACE_ACTION_LINKS = [ }, ]; +export const ProjectActionIcons = ({ type, size, className }: { type: string; size?: number; className?: string }) => { + const icons = { + profile: CircleUser, + security: KeyRound, + activity: Activity, + appearance: Settings2, + notifications: Bell, + }; + + if (type === undefined) return null; + const Icon = icons[type as keyof typeof icons]; + return ; +}; export const ProfileLayoutSidebar = observer(() => { // states const [isSigningOut, setIsSigningOut] = useState(false); @@ -47,6 +71,7 @@ export const ProfileLayoutSidebar = observer(() => { const { data: currentUserSettings } = useUserSettings(); const { workspaces } = useWorkspace(); const { isMobile } = usePlatformOS(); + const { t } = useTranslation(); const workspacesList = Object.values(workspaces ?? {}); @@ -91,8 +116,8 @@ export const ProfileLayoutSidebar = observer(() => { .catch(() => setToast({ type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Failed to sign out. Please try again.", + title: t("sign_out.toast.error.title"), + message: t("sign_out.toast.error.message"), }) ) .finally(() => setIsSigningOut(false)); @@ -117,13 +142,13 @@ export const ProfileLayoutSidebar = observer(() => { {!sidebarCollapsed && ( -

    Profile settings

    +

    {t("profile_settings")}

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

    {link.label}

    } + + + {!sidebarCollapsed &&

    {t(link.i18n_label)}

    }
    @@ -156,7 +182,7 @@ export const ProfileLayoutSidebar = observer(() => {
    {!sidebarCollapsed && ( -
    Workspaces
    +
    {t("workspaces")}
    )} {workspacesList && workspacesList.length > 0 && (
    { {WORKSPACE_ACTION_LINKS.map((link) => ( { }`} > {} - {!sidebarCollapsed && link.label} + {!sidebarCollapsed && t(link.key)}
    @@ -238,7 +264,7 @@ export const ProfileLayoutSidebar = observer(() => { disabled={isSigningOut} > - {!sidebarCollapsed && {isSigningOut ? "Signing out..." : "Sign out"}} + {!sidebarCollapsed && {isSigningOut ? `${t("signing_out")}...` : t("sign_out")}}
    - Already have an account?{" "} + {t("auth.common.already_have_an_account")} captureEvent(NAVIGATE_TO_SIGNIN, {})} className="font-semibold text-custom-primary-100 hover:underline" > - Log in + {t("auth.common.login")}
    diff --git a/web/app/workspace-invitations/page.tsx b/web/app/workspace-invitations/page.tsx index a68290198..8d981fff4 100644 --- a/web/app/workspace-invitations/page.tsx +++ b/web/app/workspace-invitations/page.tsx @@ -82,7 +82,7 @@ const WorkspaceInvitationPage = observer(() => { ) : ( @@ -92,14 +92,14 @@ const WorkspaceInvitationPage = observer(() => { invitationDetail?.accepted ? ( ) : ( {!currentUser ? ( 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 f19ffa809..e8a13f036 100644 --- a/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx +++ b/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx @@ -3,19 +3,63 @@ import React from "react"; import { observer } from "mobx-react"; import Image from "next/image"; -// ui +import { AlertOctagon, BarChart4, CircleDashed, Folder, Microscope, Search } from "lucide-react"; +// plane imports +import { MARKETING_PRICING_PAGE_LINK } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { ContentWrapper, getButtonStyling } from "@plane/ui"; // components import { ProIcon } from "@/components/common"; -// constants -import { MARKETING_PRICING_PAGE_LINK } from "@/constants/common"; -import { WORKSPACE_ACTIVE_CYCLES_DETAILS } from "@/constants/cycle"; // helper import { cn } from "@/helpers/common.helper"; // hooks import { useUser } from "@/hooks/store"; +export const WORKSPACE_ACTIVE_CYCLES_DETAILS = [ + { + key: "10000_feet_view", + title: "10,000-feet view of all active cycles.", + description: + "Zoom out to see running cycles across all your projects at once instead of going from Cycle to Cycle in each project.", + icon: Folder, + }, + { + key: "get_snapshot_of_each_active_cycle", + title: "Get a snapshot of each active cycle.", + description: + "Track high-level metrics for all active cycles, see their state of progress, and get a sense of scope against deadlines.", + icon: CircleDashed, + }, + { + key: "compare_burndowns", + title: "Compare burndowns.", + description: "Monitor how each of your teams are performing with a peek into each cycle’s burndown report.", + icon: BarChart4, + }, + { + key: "quickly_see_make_or_break_issues", + title: "Quickly see make-or-break work items. ", + description: + "Preview high-priority work items for each cycle against due dates. See all of them per cycle in one click.", + icon: AlertOctagon, + }, + { + key: "zoom_into_cycles_that_need_attention", + title: "Zoom into cycles that need attention. ", + description: "Investigate the state of any cycle that doesn’t conform to expectations in one click.", + icon: Search, + }, + { + key: "stay_ahead_of_blockers", + title: "Stay ahead of blockers.", + description: + "Spot challenges from one project to another and see inter-cycle dependencies that aren’t obvious from any other view.", + icon: Microscope, + }, +]; + export const WorkspaceActiveCyclesUpgrade = observer(() => { + const { t } = useTranslation(); // store hooks const { userProfile: { data: userProfile }, @@ -33,10 +77,8 @@ export const WorkspaceActiveCyclesUpgrade = observer(() => { >
    -

    On-demand snapshots of all your cycles

    -

    - Monitor cycles across projects, track high-priority issues, and zoom in cycles that need attention. -

    +

    {t("on_demand_snapshots_of_all_your_cycles")}

    +

    {t("active_cycles_description")}

    @@ -81,11 +123,11 @@ export const WorkspaceActiveCyclesUpgrade = observer(() => {
    {WORKSPACE_ACTIVE_CYCLES_DETAILS.map((item) => (
    -
    -

    {item.title}

    - +
    +

    {t(item.key)}

    +
    - {item.description} + {t(`${item.key}_description`)}
    ))}
    diff --git a/web/ce/components/breadcrumbs/index.ts b/web/ce/components/breadcrumbs/index.ts new file mode 100644 index 000000000..9ff8c7dff --- /dev/null +++ b/web/ce/components/breadcrumbs/index.ts @@ -0,0 +1 @@ +export * from "./project"; diff --git a/web/ce/components/breadcrumbs/project.tsx b/web/ce/components/breadcrumbs/project.tsx new file mode 100644 index 000000000..3b49bb211 --- /dev/null +++ b/web/ce/components/breadcrumbs/project.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { observer } from "mobx-react"; +import { Briefcase } from "lucide-react"; +// ui +import { Breadcrumbs, Logo } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common"; +// hooks +import { useProject } from "@/hooks/store"; + +export const ProjectBreadcrumb = observer(() => { + // store hooks + const { currentProjectDetails } = useProject(); + + return ( + + + + ) + ) : ( + + + + ) + } + /> + } + /> + ); +}); diff --git a/web/ce/components/command-palette/actions/index.ts b/web/ce/components/command-palette/actions/index.ts new file mode 100644 index 000000000..c7f1e122d --- /dev/null +++ b/web/ce/components/command-palette/actions/index.ts @@ -0,0 +1 @@ +export * from "./work-item-actions"; 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 new file mode 100644 index 000000000..9f59d226b --- /dev/null +++ b/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx @@ -0,0 +1,43 @@ +import { Command } from "cmdk"; +import { observer } from "mobx-react"; +import { Check } from "lucide-react"; +// plane imports +import { Spinner, StateGroupIcon } from "@plane/ui"; +// store hooks +import { useProjectState } from "@/hooks/store"; + +export type TChangeWorkItemStateListProps = { + projectId: string | null; + currentStateId: string | null; + handleStateChange: (stateId: string) => void; +}; + +export const ChangeWorkItemStateList = observer((props: TChangeWorkItemStateListProps) => { + const { projectId, currentStateId, handleStateChange } = props; + // store hooks + const { getProjectStates } = useProjectState(); + // derived values + const projectStates = getProjectStates(projectId); + + return ( + <> + {projectStates ? ( + projectStates.length > 0 ? ( + projectStates.map((state) => ( + handleStateChange(state.id)} className="focus:outline-none"> +
    + +

    {state.name}

    +
    +
    {state.id === currentStateId && }
    +
    + )) + ) : ( +
    No states found
    + ) + ) : ( + + )} + + ); +}); diff --git a/web/ce/components/command-palette/actions/work-item-actions/index.ts b/web/ce/components/command-palette/actions/work-item-actions/index.ts new file mode 100644 index 000000000..ac7f8aa86 --- /dev/null +++ b/web/ce/components/command-palette/actions/work-item-actions/index.ts @@ -0,0 +1 @@ +export * from "./change-state-list"; diff --git a/web/ce/components/command-palette/modals/index.ts b/web/ce/components/command-palette/modals/index.ts new file mode 100644 index 000000000..a4fac4b91 --- /dev/null +++ b/web/ce/components/command-palette/modals/index.ts @@ -0,0 +1,3 @@ +export * from "./workspace-level"; +export * from "./project-level"; +export * from "./issue-level"; diff --git a/web/ce/components/command-palette/modals/issue-level.tsx b/web/ce/components/command-palette/modals/issue-level.tsx new file mode 100644 index 000000000..b1d1bdb1c --- /dev/null +++ b/web/ce/components/command-palette/modals/issue-level.tsx @@ -0,0 +1,70 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +// components +import { BulkDeleteIssuesModal } from "@/components/core"; +import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; +// constants +// hooks +import { useCommandPalette, useIssueDetail, useUser } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useIssuesStore } from "@/hooks/use-issue-layout-store"; + +export type TIssueLevelModalsProps = { + projectId: string | undefined; + issueId: string | undefined; +}; + +export const IssueLevelModals: FC = observer((props) => { + const { projectId, issueId } = props; + // router + const pathname = usePathname(); + const { workspaceSlug, cycleId, moduleId } = useParams(); + const router = useAppRouter(); + // store hooks + const { data: currentUser } = useUser(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { + issues: { removeIssue }, + } = useIssuesStore(); + const { + isCreateIssueModalOpen, + toggleCreateIssueModal, + isDeleteIssueModalOpen, + toggleDeleteIssueModal, + isBulkDeleteIssueModalOpen, + toggleBulkDeleteIssueModal, + } = useCommandPalette(); + // derived values + const issueDetails = issueId ? getIssueById(issueId) : undefined; + const isDraftIssue = pathname?.includes("draft-issues") || false; + + return ( + <> + toggleCreateIssueModal(false)} + data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_ids: [moduleId.toString()] } : undefined} + isDraft={isDraftIssue} + /> + {workspaceSlug && projectId && issueId && issueDetails && ( + toggleDeleteIssueModal(false)} + isOpen={isDeleteIssueModalOpen} + data={issueDetails} + onSubmit={async () => { + await removeIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString()); + router.push(`/${workspaceSlug}/projects/${projectId}/issues`); + }} + /> + )} + toggleBulkDeleteIssueModal(false)} + user={currentUser} + /> + + ); +}); diff --git a/web/ce/components/command-palette/modals/project-level.tsx b/web/ce/components/command-palette/modals/project-level.tsx new file mode 100644 index 000000000..e0ff03a48 --- /dev/null +++ b/web/ce/components/command-palette/modals/project-level.tsx @@ -0,0 +1,62 @@ +import { observer } from "mobx-react"; +// components +import { CycleCreateUpdateModal } from "@/components/cycles"; +import { CreateUpdateModuleModal } from "@/components/modules"; +import { CreatePageModal } from "@/components/pages"; +import { CreateUpdateProjectViewModal } from "@/components/views"; +// hooks +import { useCommandPalette } from "@/hooks/store"; +// plane web hooks +import { EPageStoreType } from "@/plane-web/hooks/store"; + +export type TProjectLevelModalsProps = { + workspaceSlug: string; + projectId: string; +}; + +export const ProjectLevelModals = observer((props: TProjectLevelModalsProps) => { + const { workspaceSlug, projectId } = props; + // store hooks + const { + isCreateCycleModalOpen, + toggleCreateCycleModal, + isCreateModuleModalOpen, + toggleCreateModuleModal, + isCreateViewModalOpen, + toggleCreateViewModal, + createPageModal, + toggleCreatePageModal, + } = useCommandPalette(); + + return ( + <> + toggleCreateCycleModal(false)} + workspaceSlug={workspaceSlug.toString()} + projectId={projectId.toString()} + /> + toggleCreateModuleModal(false)} + workspaceSlug={workspaceSlug.toString()} + projectId={projectId.toString()} + /> + toggleCreateViewModal(false)} + workspaceSlug={workspaceSlug.toString()} + projectId={projectId.toString()} + /> + toggleCreatePageModal({ isOpen: false })} + redirectionEnabled + storeType={EPageStoreType.PROJECT} + /> + + ); +}); diff --git a/web/ce/components/command-palette/modals/workspace-level.tsx b/web/ce/components/command-palette/modals/workspace-level.tsx new file mode 100644 index 000000000..64d22493e --- /dev/null +++ b/web/ce/components/command-palette/modals/workspace-level.tsx @@ -0,0 +1,25 @@ +import { observer } from "mobx-react"; +// components +import { CreateProjectModal } from "@/components/project"; +// hooks +import { useCommandPalette } from "@/hooks/store"; + +export type TWorkspaceLevelModalsProps = { + workspaceSlug: string; +}; + +export const WorkspaceLevelModals = observer((props: TWorkspaceLevelModalsProps) => { + const { workspaceSlug } = props; + // store hooks + const { isCreateProjectModalOpen, toggleCreateProjectModal } = useCommandPalette(); + + return ( + <> + toggleCreateProjectModal(false)} + workspaceSlug={workspaceSlug.toString()} + /> + + ); +}); diff --git a/web/ce/components/common/index.ts b/web/ce/components/common/index.ts new file mode 100644 index 000000000..75c2cf410 --- /dev/null +++ b/web/ce/components/common/index.ts @@ -0,0 +1 @@ +export * from "./subscription"; diff --git a/web/ce/components/common/subscription/index.ts b/web/ce/components/common/subscription/index.ts new file mode 100644 index 000000000..beb26e1d9 --- /dev/null +++ b/web/ce/components/common/subscription/index.ts @@ -0,0 +1 @@ +export * from "./subscription-pill"; \ No newline at end of file diff --git a/web/ce/components/common/subscription/subscription-pill.tsx b/web/ce/components/common/subscription/subscription-pill.tsx new file mode 100644 index 000000000..c557ce204 --- /dev/null +++ b/web/ce/components/common/subscription/subscription-pill.tsx @@ -0,0 +1,7 @@ +import { IWorkspace } from "@plane/types"; + +type TProps = { + workspace: IWorkspace; +}; + +export const SubscriptionPill = (props: TProps) => <>; diff --git a/web/ce/components/cycles/active-cycle/root.tsx b/web/ce/components/cycles/active-cycle/root.tsx index a173cfda0..6b2ecb928 100644 --- a/web/ce/components/cycles/active-cycle/root.tsx +++ b/web/ce/components/cycles/active-cycle/root.tsx @@ -1,8 +1,10 @@ "use client"; +import { useMemo } from "react"; import { observer } from "mobx-react"; import { Disclosure } from "@headlessui/react"; -// ui +// plane imports +import { useTranslation } from "@plane/i18n"; import { Row } from "@plane/ui"; // components import { @@ -13,77 +15,97 @@ import { CyclesListItem, } from "@/components/cycles"; import useCyclesDetails from "@/components/cycles/active-cycle/use-cycles-details"; -import { EmptyState } from "@/components/empty-state"; -// constants -import { EmptyStateType } from "@/constants/empty-state"; +import { DetailedEmptyState } from "@/components/empty-state"; +// hooks import { useCycle } from "@/hooks/store"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; import { ActiveCycleIssueDetails } from "@/store/issue/cycle"; interface IActiveCycleDetails { workspaceSlug: string; projectId: string; + cycleId?: string; + showHeader?: boolean; } export const ActiveCycleRoot: React.FC = observer((props) => { - const { workspaceSlug, projectId } = props; - const { currentProjectActiveCycle, currentProjectActiveCycleId } = useCycle(); + const { workspaceSlug, projectId, cycleId: propsCycleId, showHeader = true } = props; + // plane hooks + const { t } = useTranslation(); + // store hooks + const { currentProjectActiveCycleId } = useCycle(); + // derived values + const cycleId = propsCycleId ?? currentProjectActiveCycleId; + const activeCycleResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/cycle/active" }); + // fetch cycle details const { handleFiltersUpdate, cycle: activeCycle, cycleIssueDetails, - } = useCyclesDetails({ workspaceSlug, projectId, cycleId: currentProjectActiveCycleId }); + } = useCyclesDetails({ workspaceSlug, projectId, cycleId }); + + const ActiveCyclesComponent = useMemo( + () => ( + <> + {!cycleId || !activeCycle ? ( + + ) : ( +
    + {cycleId && ( + + )} + +
    + + + +
    +
    +
    + )} + + ), + [cycleId, activeCycle, workspaceSlug, projectId, handleFiltersUpdate, cycleIssueDetails] + ); return ( <> - - {({ open }) => ( - <> - - - - - {!currentProjectActiveCycle ? ( - - ) : ( -
    - {currentProjectActiveCycleId && ( - - )} - -
    - - - -
    -
    -
    - )} -
    - - )} -
    + {showHeader ? ( + + {({ open }) => ( + <> + + + + {ActiveCyclesComponent} + + )} + + ) : ( + <>{ActiveCyclesComponent} + )} ); }); diff --git a/web/ce/components/cycles/additional-actions.tsx b/web/ce/components/cycles/additional-actions.tsx new file mode 100644 index 000000000..1fcb7146f --- /dev/null +++ b/web/ce/components/cycles/additional-actions.tsx @@ -0,0 +1,7 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +type Props = { + cycleId: string; + projectId: string; +}; +export const CycleAdditionalActions: FC = observer(() => <>); diff --git a/web/ce/components/cycles/analytics-sidebar/base.tsx b/web/ce/components/cycles/analytics-sidebar/base.tsx index 87f07e387..c259a4a81 100644 --- a/web/ce/components/cycles/analytics-sidebar/base.tsx +++ b/web/ce/components/cycles/analytics-sidebar/base.tsx @@ -2,6 +2,7 @@ import { FC, Fragment } from "react"; import { observer } from "mobx-react"; // plane ui +import { useTranslation } from "@plane/i18n"; import { TCycleEstimateType } from "@plane/types"; import { Loader } from "@plane/ui"; // components @@ -23,6 +24,7 @@ export const SidebarChart: FC = observer((props) => { // hooks const { getEstimateTypeByCycleId, getCycleById, fetchCycleDetails, fetchArchivedCycleDetails, setEstimateType } = useCycle(); + const { t } = useTranslation(); // derived data const cycleDetails = validateCycleSnapshot(getCycleById(cycleId)); @@ -66,11 +68,11 @@ export const SidebarChart: FC = observer((props) => {
    - Ideal + {t("ideal")}
    - Current + {t("current")}
    {cycleStartDate && cycleEndDate && completionChartDistributionData ? ( @@ -80,7 +82,7 @@ export const SidebarChart: FC = observer((props) => { startDate={cycleStartDate} endDate={cycleEndDate} totalIssues={estimateType === "points" ? totalEstimatePoints : totalIssues} - plotTitle={estimateType === "points" ? "points" : "issues"} + plotTitle={estimateType === "points" ? t("points") : t("work_items")} /> ) : ( diff --git a/web/ce/components/cycles/end-cycle/index.ts b/web/ce/components/cycles/end-cycle/index.ts new file mode 100644 index 000000000..2e60c4561 --- /dev/null +++ b/web/ce/components/cycles/end-cycle/index.ts @@ -0,0 +1,2 @@ +export * from "./modal"; +export * from "./use-end-cycle"; diff --git a/web/ce/components/cycles/end-cycle/modal.tsx b/web/ce/components/cycles/end-cycle/modal.tsx new file mode 100644 index 000000000..754c84f9f --- /dev/null +++ b/web/ce/components/cycles/end-cycle/modal.tsx @@ -0,0 +1,13 @@ +import React from "react"; + +interface Props { + isOpen: boolean; + handleClose: () => void; + cycleId: string; + projectId: string; + workspaceSlug: string; + transferrableIssuesCount: number; + cycleName: string; +} + +export const EndCycleModal: React.FC = () => <>; diff --git a/web/ce/components/cycles/end-cycle/use-end-cycle.tsx b/web/ce/components/cycles/end-cycle/use-end-cycle.tsx new file mode 100644 index 000000000..c1bf62618 --- /dev/null +++ b/web/ce/components/cycles/end-cycle/use-end-cycle.tsx @@ -0,0 +1,7 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const useEndCycle = (isCurrentCycle: boolean) => ({ + isEndCycleModalOpen: false, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + setEndCycleModalOpen: (value: boolean) => {}, + endCycleContextMenu: undefined, +}); diff --git a/web/ce/components/cycles/index.ts b/web/ce/components/cycles/index.ts index 899346875..1da115025 100644 --- a/web/ce/components/cycles/index.ts +++ b/web/ce/components/cycles/index.ts @@ -1,2 +1,4 @@ export * from "./active-cycle"; export * from "./analytics-sidebar"; +export * from "./additional-actions"; +export * from "./end-cycle"; diff --git a/web/ce/components/editor/embeds/index.ts b/web/ce/components/editor/embeds/index.ts new file mode 100644 index 000000000..8146e94d9 --- /dev/null +++ b/web/ce/components/editor/embeds/index.ts @@ -0,0 +1 @@ +export * from "./mentions"; diff --git a/web/ce/components/editor/embeds/mentions/index.ts b/web/ce/components/editor/embeds/mentions/index.ts new file mode 100644 index 000000000..1efe34c51 --- /dev/null +++ b/web/ce/components/editor/embeds/mentions/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ce/components/editor/embeds/mentions/root.tsx b/web/ce/components/editor/embeds/mentions/root.tsx new file mode 100644 index 000000000..16e21f848 --- /dev/null +++ b/web/ce/components/editor/embeds/mentions/root.tsx @@ -0,0 +1,4 @@ +// plane editor +import { TMentionComponentProps } from "@plane/editor"; + +export const EditorAdditionalMentionsRoot: React.FC = () => null; diff --git a/web/ce/components/editor/index.ts b/web/ce/components/editor/index.ts new file mode 100644 index 000000000..cf8352ae4 --- /dev/null +++ b/web/ce/components/editor/index.ts @@ -0,0 +1 @@ +export * from "./embeds"; diff --git a/web/ce/components/epics/epic-modal/index.ts b/web/ce/components/epics/epic-modal/index.ts new file mode 100644 index 000000000..031608e25 --- /dev/null +++ b/web/ce/components/epics/epic-modal/index.ts @@ -0,0 +1 @@ +export * from "./modal"; diff --git a/web/ce/components/epics/epic-modal/modal.tsx b/web/ce/components/epics/epic-modal/modal.tsx new file mode 100644 index 000000000..9c76b7bda --- /dev/null +++ b/web/ce/components/epics/epic-modal/modal.tsx @@ -0,0 +1,19 @@ +"use client"; +import React, { FC } from "react"; +import { TIssue } from "@plane/types"; + +export interface EpicModalProps { + data?: Partial; + isOpen: boolean; + onClose: () => void; + beforeFormSubmit?: () => Promise; + onSubmit?: (res: TIssue) => Promise; + fetchIssueDetails?: boolean; + primaryButtonText?: { + default: string; + loading: string; + }; + isProjectSelectionDisabled?: boolean; +} + +export const CreateUpdateEpicModal: FC = (props) => <>; diff --git a/web/ce/components/epics/index.ts b/web/ce/components/epics/index.ts new file mode 100644 index 000000000..29da0cc8a --- /dev/null +++ b/web/ce/components/epics/index.ts @@ -0,0 +1 @@ +export * from "./epic-modal"; diff --git a/web/ce/components/gantt-chart/dependency/dependency-paths.tsx b/web/ce/components/gantt-chart/dependency/dependency-paths.tsx index f049875f1..6feb208a8 100644 --- a/web/ce/components/gantt-chart/dependency/dependency-paths.tsx +++ b/web/ce/components/gantt-chart/dependency/dependency-paths.tsx @@ -1 +1,9 @@ -export const TimelineDependencyPaths = () => <>; +import { FC } from "react"; + +type Props = { + isEpic?: boolean; +}; +export const TimelineDependencyPaths: FC = (props) => { + const { isEpic = false } = props; + return <>; +}; diff --git a/web/ce/components/global/product-updates-header.tsx b/web/ce/components/global/product-updates-header.tsx index a5965bb2d..5274ab5c1 100644 --- a/web/ce/components/global/product-updates-header.tsx +++ b/web/ce/components/global/product-updates-header.tsx @@ -1,5 +1,6 @@ import { observer } from "mobx-react"; import Image from "next/image"; +import { useTranslation } from "@plane/i18n"; // helpers import { cn } from "@/helpers/common.helper"; // assets @@ -7,20 +8,23 @@ import PlaneLogo from "@/public/plane-logos/blue-without-text.png"; // package.json import packageJson from "package.json"; -export const ProductUpdatesHeader = observer(() => ( -
    -
    -
    What's new
    +export const ProductUpdatesHeader = observer(() => { + const { t } = useTranslation(); + return ( +
    +
    +
    {t("whats_new")}
    - Version: v{packageJson.version} + className={cn( + "px-2 mx-2 py-0.5 text-center text-xs font-medium rounded-full bg-custom-primary-100/20 text-custom-primary-100" + )} + > + {t("version")}: v{packageJson.version} +
    +
    +
    + Plane
    -
    - Plane -
    -
    -)); + ); +}); diff --git a/web/ce/components/global/version-number.tsx b/web/ce/components/global/version-number.tsx index 47ff380d3..6c5c29656 100644 --- a/web/ce/components/global/version-number.tsx +++ b/web/ce/components/global/version-number.tsx @@ -1,4 +1,8 @@ // assets +import { useTranslation } from "@plane/i18n"; import packageJson from "package.json"; -export const PlaneVersionNumber: React.FC = () => Version: v{packageJson.version}; +export const PlaneVersionNumber: React.FC = () => { + const { t } = useTranslation(); + return {t("version")}: v{packageJson.version}; +}; diff --git a/web/ce/components/home/header.tsx b/web/ce/components/home/header.tsx new file mode 100644 index 000000000..c95736c9e --- /dev/null +++ b/web/ce/components/home/header.tsx @@ -0,0 +1 @@ +export const HomePageHeader = () => <>; diff --git a/web/ce/components/issue-types/values/update.tsx b/web/ce/components/issue-types/values/update.tsx index cff391d9e..2fd629042 100644 --- a/web/ce/components/issue-types/values/update.tsx +++ b/web/ce/components/issue-types/values/update.tsx @@ -1,9 +1,12 @@ +import { TIssueServiceType } from "@plane/types"; + export type TIssueAdditionalPropertyValuesUpdateProps = { issueId: string; issueTypeId: string; projectId: string; workspaceSlug: string; isDisabled: boolean; + issueServiceType?: TIssueServiceType; }; export const IssueAdditionalPropertyValuesUpdate: React.FC = () => <>; diff --git a/web/ce/components/issues/filters/index.ts b/web/ce/components/issues/filters/index.ts index 2cd80e3a7..f0f36b6c9 100644 --- a/web/ce/components/issues/filters/index.ts +++ b/web/ce/components/issues/filters/index.ts @@ -1,2 +1,3 @@ export * from "./applied-filters"; export * from "./issue-types"; +export * from "./team-project"; diff --git a/web/ce/components/issues/filters/team-project.tsx b/web/ce/components/issues/filters/team-project.tsx new file mode 100644 index 000000000..4f4787fef --- /dev/null +++ b/web/ce/components/issues/filters/team-project.tsx @@ -0,0 +1,12 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterTeamProjects: React.FC = observer(() => null); diff --git a/web/ce/components/issues/header.tsx b/web/ce/components/issues/header.tsx new file mode 100644 index 000000000..abe44d506 --- /dev/null +++ b/web/ce/components/issues/header.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// icons +import { Circle, ExternalLink } from "lucide-react"; +// plane constants +import { EIssuesStoreType, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// ui +import { Breadcrumbs, Button, LayersIcon, Tooltip, Header } from "@plane/ui"; +// components +import { BreadcrumbLink, 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"; + +export const IssuesHeader = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; + // store hooks + const { + issues: { getGroupIssueCount }, + } = useIssues(EIssuesStoreType.PROJECT); + // i18n + const { t } = useTranslation(); + + const { currentProjectDetails, loader } = useProject(); + + const { toggleCreateIssueModal } = useCommandPalette(); + const { setTrackElement } = useEventTracker(); + const { allowPermissions } = useUserPermissions(); + const { isMobile } = usePlatformOS(); + + const SPACE_APP_URL = (SPACE_BASE_URL.trim() === "" ? window.location.origin : SPACE_BASE_URL) + SPACE_BASE_PATH; + const publishedURL = `${SPACE_APP_URL}/issues/${currentProjectDetails?.anchor}`; + + const issuesCount = getGroupIssueCount(undefined, undefined, false); + const canUserCreateIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + return ( +
    + +
    + router.back()} isLoading={loader === "init-loader"}> + + + } + /> + } + /> + + {issuesCount && issuesCount > 0 ? ( + 1 ? "work items" : "work item"} in this project`} + position="bottom" + > + + + ) : null} +
    + {currentProjectDetails?.anchor ? ( + + + {t("workspace_projects.network.public.title")} + + + ) : ( + <> + )} +
    + +
    + +
    + {canUserCreateIssue ? ( + + ) : ( + <> + )} +
    +
    + ); +}); diff --git a/web/ce/components/issues/index.ts b/web/ce/components/issues/index.ts index 97b57af4b..01fc1d9ac 100644 --- a/web/ce/components/issues/index.ts +++ b/web/ce/components/issues/index.ts @@ -4,3 +4,4 @@ export * from "./issue-modal"; export * from "./issue-details"; export * from "./quick-add"; export * from "./filters"; +export * from "./header"; diff --git a/web/ce/components/issues/issue-details/index.ts b/web/ce/components/issues/issue-details/index.ts index 3979faf94..e23fbe531 100644 --- a/web/ce/components/issues/issue-details/index.ts +++ b/web/ce/components/issues/issue-details/index.ts @@ -2,3 +2,4 @@ export * from "./issue-identifier"; export * from "./issue-properties-activity"; export * from "./issue-type-switcher"; export * from "./issue-type-activity"; +export * from "./parent-select-root"; diff --git a/web/ce/components/issues/issue-details/issue-identifier.tsx b/web/ce/components/issues/issue-details/issue-identifier.tsx index fbd943984..1e87d387e 100644 --- a/web/ce/components/issues/issue-details/issue-identifier.tsx +++ b/web/ce/components/issues/issue-details/issue-identifier.tsx @@ -50,7 +50,7 @@ export const IdentifierText: React.FC = (props) => { navigator.clipboard.writeText(identifier).then(() => { setToast({ type: TOAST_TYPE.SUCCESS, - title: "Issue ID copied to clipboard", + title: "Work item ID copied to clipboard", }); }); } diff --git a/web/ce/components/issues/issue-details/parent-select-root.tsx b/web/ce/components/issues/issue-details/parent-select-root.tsx new file mode 100644 index 000000000..820a578e1 --- /dev/null +++ b/web/ce/components/issues/issue-details/parent-select-root.tsx @@ -0,0 +1,82 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +// ui +import { TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { IssueParentSelect, TIssueOperations } from "@/components/issues"; +// hooks +import { useIssueDetail } from "@/hooks/store"; + +type TIssueParentSelect = { + className?: string; + disabled?: boolean; + issueId: string; + issueOperations: TIssueOperations; + projectId: string; + workspaceSlug: string; +}; + +export const IssueParentSelectRoot: React.FC = observer((props) => { + const { issueId, issueOperations, projectId, workspaceSlug } = props; + const { t } = useTranslation(); + // store hooks + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { + toggleParentIssueModal, + removeSubIssue, + subIssues: { setSubIssueHelpers, fetchSubIssues }, + } = useIssueDetail(); + + // derived values + const issue = getIssueById(issueId); + const parentIssue = issue?.parent_id ? getIssueById(issue.parent_id) : undefined; + + const handleParentIssue = async (_issueId: string | null = null) => { + try { + await issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: _issueId }); + await issueOperations.fetch(workspaceSlug, projectId, issueId, false); + if (_issueId) await fetchSubIssues(workspaceSlug, projectId, _issueId); + toggleParentIssueModal(null); + } catch (error) { + console.error("something went wrong while fetching the issue"); + } + }; + + const handleRemoveSubIssue = async ( + workspaceSlug: string, + projectId: string, + parentIssueId: string, + issueId: string + ) => { + try { + setSubIssueHelpers(parentIssueId, "issue_loader", issueId); + await removeSubIssue(workspaceSlug, projectId, parentIssueId, issueId); + await fetchSubIssues(workspaceSlug, projectId, parentIssueId); + setSubIssueHelpers(parentIssueId, "issue_loader", issueId); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("common.error.label"), + message: t("common.something_went_wrong"), + }); + } + }; + + const workItemLink = `/${workspaceSlug}/projects/${parentIssue?.project_id}/issues/${parentIssue?.id}`; + + if (!issue) return <>; + + return ( + + ); +}); diff --git a/web/ce/components/issues/issue-layouts/empty-states/index.ts b/web/ce/components/issues/issue-layouts/empty-states/index.ts new file mode 100644 index 000000000..319b4c684 --- /dev/null +++ b/web/ce/components/issues/issue-layouts/empty-states/index.ts @@ -0,0 +1,2 @@ +export * from "./team-issues"; +export * from "./team-view-issues"; diff --git a/web/ce/components/issues/issue-layouts/empty-states/team-issues.tsx b/web/ce/components/issues/issue-layouts/empty-states/team-issues.tsx new file mode 100644 index 000000000..1e05f40cb --- /dev/null +++ b/web/ce/components/issues/issue-layouts/empty-states/team-issues.tsx @@ -0,0 +1,3 @@ +import { observer } from "mobx-react"; + +export const TeamEmptyState: React.FC = observer(() => <>); diff --git a/web/ce/components/issues/issue-layouts/empty-states/team-view-issues.tsx b/web/ce/components/issues/issue-layouts/empty-states/team-view-issues.tsx new file mode 100644 index 000000000..03b546be5 --- /dev/null +++ b/web/ce/components/issues/issue-layouts/empty-states/team-view-issues.tsx @@ -0,0 +1,3 @@ +import { observer } from "mobx-react"; + +export const TeamViewEmptyState: React.FC = observer(() => <>); diff --git a/web/ce/components/issues/issue-layouts/issue-stats.tsx b/web/ce/components/issues/issue-layouts/issue-stats.tsx new file mode 100644 index 000000000..3e842c92d --- /dev/null +++ b/web/ce/components/issues/issue-layouts/issue-stats.tsx @@ -0,0 +1,12 @@ +"use client"; + +import React, { FC } from "react"; + +type Props = { + issueId: string; +}; + +export const IssueStats: FC = (props) => { + const { issueId } = props; + return <>; +}; diff --git a/web/ce/components/issues/issue-layouts/utils.tsx b/web/ce/components/issues/issue-layouts/utils.tsx new file mode 100644 index 000000000..48dca43bd --- /dev/null +++ b/web/ce/components/issues/issue-layouts/utils.tsx @@ -0,0 +1,4 @@ +// types +import { IGroupByColumn } from "@plane/types"; + +export const getTeamProjectColumns = (): IGroupByColumn[] | undefined => undefined; diff --git a/web/ce/components/issues/quick-add/root.tsx b/web/ce/components/issues/quick-add/root.tsx index 382f790a0..72daa259e 100644 --- a/web/ce/components/issues/quick-add/root.tsx +++ b/web/ce/components/issues/quick-add/root.tsx @@ -1,6 +1,8 @@ import { FC, useEffect, useRef } from "react"; import { observer } from "mobx-react"; import { UseFormRegister, UseFormSetFocus } from "react-hook-form"; +// plane constants +import { EIssueLayoutTypes } from "@plane/constants"; // plane helpers import { useOutsideClickDetector } from "@plane/hooks"; // types @@ -14,8 +16,6 @@ import { SpreadsheetQuickAddIssueForm, TQuickAddIssueForm, } from "@/components/issues/issue-layouts"; -// constants -import { EIssueLayoutTypes } from "@/constants/issue"; // hooks import { useProject } from "@/hooks/store"; import useKeypress from "@/hooks/use-keypress"; @@ -30,10 +30,11 @@ export type TQuickAddIssueFormRoot = { register: UseFormRegister; onSubmit: () => void; onClose: () => void; + isEpic: boolean; }; export const QuickAddIssueFormRoot: FC = observer((props) => { - const { isOpen, layout, projectId, hasError = false, setFocus, register, onSubmit, onClose } = props; + const { isOpen, layout, projectId, hasError = false, setFocus, register, onSubmit, onClose, isEpic } = props; // store hooks const { getProjectById } = useProject(); // derived values @@ -70,6 +71,7 @@ export const QuickAddIssueFormRoot: FC = observer((props hasError={hasError} register={register} onSubmit={onSubmit} + isEpic={isEpic} /> ); }); diff --git a/web/ce/components/issues/worklog/activity/filter-root.tsx b/web/ce/components/issues/worklog/activity/filter-root.tsx index 2d11ae341..a4dc62a6d 100644 --- a/web/ce/components/issues/worklog/activity/filter-root.tsx +++ b/web/ce/components/issues/worklog/activity/filter-root.tsx @@ -2,9 +2,9 @@ import { FC } from "react"; // components +import { TActivityFilters, ACTIVITY_FILTER_TYPE_OPTIONS, TActivityFilterOption } from "@plane/constants"; import { ActivityFilter } from "@/components/issues"; // plane web constants -import { TActivityFilters, ACTIVITY_FILTER_TYPE_OPTIONS, TActivityFilterOption } from "@/plane-web/constants/issues"; export type TActivityFilterRoot = { selectedFilters: TActivityFilters[]; @@ -20,7 +20,7 @@ export const ActivityFilterRoot: FC = (props) => { const filterKey = key as TActivityFilters; return { key: filterKey, - label: value.label, + labelTranslationKey: value.labelTranslationKey, isSelected: selectedFilters.includes(filterKey), onClick: () => toggleFilter(filterKey), }; 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 211155d37..bd49942ef 100644 --- a/web/ce/components/pages/editor/ai/ask-pi-menu.tsx +++ b/web/ce/components/pages/editor/ai/ask-pi-menu.tsx @@ -6,20 +6,25 @@ import { Tooltip } from "@plane/ui"; import { RichTextReadOnlyEditor } from "@/components/editor"; // helpers import { cn } from "@/helpers/common.helper"; +// hooks +import { useWorkspace } from "@/hooks/store"; type Props = { handleInsertText: (insertOnNextLine: boolean) => void; handleRegenerate: () => Promise; isRegenerating: boolean; - projectId: string; response: string | undefined; workspaceSlug: string; }; export const AskPiMenu: React.FC = (props) => { - const { handleInsertText, handleRegenerate, isRegenerating, projectId, response, workspaceSlug } = props; + const { handleInsertText, handleRegenerate, isRegenerating, response, workspaceSlug } = props; // states const [query, setQuery] = useState(""); + // store hooks + const { getWorkspaceBySlug } = useWorkspace(); + // derived values + const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? ""; return ( <> @@ -41,8 +46,8 @@ export const AskPiMenu: React.FC = (props) => { initialValue={response} containerClassName="!p-0 border-none" editorClassName="!pl-0" + workspaceId={workspaceId} workspaceSlug={workspaceSlug} - projectId={projectId} />
    ) : ( diff --git a/web/ce/components/projects/settings/useProjectColumns.tsx b/web/ce/components/projects/settings/useProjectColumns.tsx index 8103e9eef..bd9e589ad 100644 --- a/web/ce/components/projects/settings/useProjectColumns.tsx +++ b/web/ce/components/projects/settings/useProjectColumns.tsx @@ -1,9 +1,9 @@ import { useState } from "react"; import { useParams } from "next/navigation"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { IWorkspaceMember } from "@plane/types"; import { AccountTypeColumn, NameColumn } from "@/components/project/settings/member-columns"; import { useUser, useUserPermissions } from "@/hooks/store"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; export interface RowData { member: IWorkspaceMember; diff --git a/web/ce/components/relations/activity.ts b/web/ce/components/relations/activity.ts index ad0d4f305..30f08cb74 100644 --- a/web/ce/components/relations/activity.ts +++ b/web/ce/components/relations/activity.ts @@ -5,15 +5,15 @@ export const getRelationActivityContent = (activity: TIssueActivity | undefined) switch (activity.field) { case "blocking": - return activity.old_value === "" ? `marked this issue is blocking issue ` : `removed the blocking issue `; + return activity.old_value === "" ? `marked this work item is blocking work item ` : `removed the blocking work item `; case "blocked_by": return activity.old_value === "" - ? `marked this issue is being blocked by ` - : `removed this issue being blocked by issue `; + ? `marked this work item is being blocked by ` + : `removed this work item being blocked by work item `; case "duplicate": - return activity.old_value === "" ? `marked this issue as duplicate of ` : `removed this issue as a duplicate of `; + return activity.old_value === "" ? `marked this work item as duplicate of ` : `removed this work item as a duplicate of `; case "relates_to": - return activity.old_value === "" ? `marked that this issue relates to ` : `removed the relation from `; + return activity.old_value === "" ? `marked that this work item relates to ` : `removed the relation from `; } return; diff --git a/web/ce/components/relations/index.tsx b/web/ce/components/relations/index.tsx index 5517ac063..7f906564a 100644 --- a/web/ce/components/relations/index.tsx +++ b/web/ce/components/relations/index.tsx @@ -8,28 +8,28 @@ export * from "./activity"; export const ISSUE_RELATION_OPTIONS: Record = { relates_to: { key: "relates_to", - label: "Relates to", + i18n_label: "issue.relation.relates_to", className: "bg-custom-background-80 text-custom-text-200", icon: (size) => , - placeholder: "Add related issues", + placeholder: "Add related work items", }, duplicate: { key: "duplicate", - label: "Duplicate of", + i18n_label: "issue.relation.duplicate", className: "bg-custom-background-80 text-custom-text-200", icon: (size) => , placeholder: "None", }, blocked_by: { key: "blocked_by", - label: "Blocked by", + i18n_label: "issue.relation.blocked_by", className: "bg-red-500/20 text-red-700", icon: (size) => , placeholder: "None", }, blocking: { key: "blocking", - label: "Blocking", + i18n_label: "issue.relation.blocking", className: "bg-yellow-500/20 text-yellow-700", icon: (size) => , placeholder: "None", diff --git a/web/ce/components/sidebar/project-navigation-root.tsx b/web/ce/components/sidebar/project-navigation-root.tsx index 25a0dd9d8..6b269a0a0 100644 --- a/web/ce/components/sidebar/project-navigation-root.tsx +++ b/web/ce/components/sidebar/project-navigation-root.tsx @@ -7,9 +7,12 @@ import { ProjectNavigation } from "@/components/workspace"; type TProjectItemsRootProps = { workspaceSlug: string; projectId: string; + isSidebarCollapsed: boolean; }; export const ProjectNavigationRoot: FC = (props) => { - const { workspaceSlug, projectId } = props; - return ; + const { workspaceSlug, projectId, isSidebarCollapsed } = props; + return ( + + ); }; diff --git a/web/ce/components/workflow/add-state-transition.tsx b/web/ce/components/workflow/add-state-transition.tsx deleted file mode 100644 index 48ba6c02a..000000000 --- a/web/ce/components/workflow/add-state-transition.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Plus } from "lucide-react"; -// Plane -import { cn } from "@plane/editor"; - -type Props = { - workspaceSlug: string; - projectId: string; - parentStateId: string; - onTransitionAdd?: () => void; -}; - -export const AddStateTransition = (props: Props) => ( -
    - <> - - Add Transition -
    Pro
    - -
    -); diff --git a/web/ce/components/workflow/index.tsx b/web/ce/components/workflow/index.ts similarity index 67% rename from web/ce/components/workflow/index.tsx rename to web/ce/components/workflow/index.ts index 3cf9d8d3f..07b59ccc3 100644 --- a/web/ce/components/workflow/index.tsx +++ b/web/ce/components/workflow/index.ts @@ -1,6 +1,5 @@ export * from "./state-option"; -export * from "./state-item-child"; -export * from "./state-transition-count"; export * from "./use-workflow-drag-n-drop"; export * from "./workflow-disabled-message"; export * from "./workflow-group-tree"; +export * from "./workflow-disabled-overlay"; diff --git a/web/ce/components/workflow/state-item-child.tsx b/web/ce/components/workflow/state-item-child.tsx deleted file mode 100644 index 2c07fd9ff..000000000 --- a/web/ce/components/workflow/state-item-child.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { SetStateAction } from "react"; -import { observer } from "mobx-react"; -// Plane -import { IState } from "@plane/types"; -// components -import { StateItemTitle } from "@/components/project-states/state-item-title"; -// constants -import { DISPLAY_WORKFLOW_PRO_CTA } from "@/constants/state"; -// -import { AddStateTransition } from "./add-state-transition"; - -export type StateItemChildProps = { - workspaceSlug: string; - projectId: string; - stateCount: number; - disabled: boolean; - state: IState; - setUpdateStateModal: (value: SetStateAction) => void; -}; - -export const StateItemChild = observer((props: StateItemChildProps) => { - const { workspaceSlug, projectId, stateCount, setUpdateStateModal, disabled, state } = props; - - return ( -
    - - {DISPLAY_WORKFLOW_PRO_CTA && ( - - )} -
    - ); -}); diff --git a/web/ce/components/workflow/state-option.tsx b/web/ce/components/workflow/state-option.tsx index aa9665d90..c9bf24d61 100644 --- a/web/ce/components/workflow/state-option.tsx +++ b/web/ce/components/workflow/state-option.tsx @@ -12,6 +12,7 @@ type Props = { filterAvailableStateIds: boolean; selectedValue: string | null | undefined; className?: string; + isForWorkItemCreation?: boolean; }; export const StateOption = observer((props: Props) => { diff --git a/web/ce/components/workflow/state-transition-count.tsx b/web/ce/components/workflow/state-transition-count.tsx deleted file mode 100644 index b9e4a22f9..000000000 --- a/web/ce/components/workflow/state-transition-count.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { IStateWorkFlow } from "@/plane-web/types"; - -type Props = { - currentTransitionMap?: IStateWorkFlow; -}; - -export const StateTransitionCount = (props: Props) => <>; diff --git a/web/ce/components/workflow/use-workflow-drag-n-drop.ts b/web/ce/components/workflow/use-workflow-drag-n-drop.ts index e4044d843..f6117a0d8 100644 --- a/web/ce/components/workflow/use-workflow-drag-n-drop.ts +++ b/web/ce/components/workflow/use-workflow-drag-n-drop.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ import { TIssueGroupByOptions } from "@plane/types"; export const useWorkFlowFDragNDrop = ( @@ -6,6 +7,7 @@ export const useWorkFlowFDragNDrop = ( ) => ({ workflowDisabledSource: undefined, isWorkflowDropDisabled: false, + getIsWorkflowWorkItemCreationDisabled: (groupId: string, subGroupId?: string) => false, handleWorkFlowState: ( sourceGroupId: string, destinationGroupId: string, diff --git a/web/ce/components/workflow/workflow-disabled-message.tsx b/web/ce/components/workflow/workflow-disabled-message.tsx index bc2c2ee53..af3489556 100644 --- a/web/ce/components/workflow/workflow-disabled-message.tsx +++ b/web/ce/components/workflow/workflow-disabled-message.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ type Props = { parentStateId: string; className?: string; diff --git a/web/ce/components/workflow/workflow-disabled-overlay.tsx b/web/ce/components/workflow/workflow-disabled-overlay.tsx new file mode 100644 index 000000000..5ec69f432 --- /dev/null +++ b/web/ce/components/workflow/workflow-disabled-overlay.tsx @@ -0,0 +1,10 @@ +import { observer } from "mobx-react"; + +export type TWorkflowDisabledOverlayProps = { + messageContainerRef: React.RefObject; + workflowDisabledSource: string; + shouldOverlayBeVisible: boolean; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const WorkFlowDisabledOverlay = observer((props: TWorkflowDisabledOverlayProps) => <>); diff --git a/web/ce/components/workflow/workflow-group-tree.tsx b/web/ce/components/workflow/workflow-group-tree.tsx index 934db70f3..5caed4170 100644 --- a/web/ce/components/workflow/workflow-group-tree.tsx +++ b/web/ce/components/workflow/workflow-group-tree.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ import { TIssueGroupByOptions } from "@plane/types"; type Props = { diff --git a/web/ce/components/workspace-notifications/index.ts b/web/ce/components/workspace-notifications/index.ts index c8711b96a..18c4afa96 100644 --- a/web/ce/components/workspace-notifications/index.ts +++ b/web/ce/components/workspace-notifications/index.ts @@ -1 +1 @@ -export * from './root' \ No newline at end of file +export * from "./notification-card/root"; diff --git a/web/core/components/workspace-notifications/sidebar/notification-card/root.tsx b/web/ce/components/workspace-notifications/notification-card/root.tsx similarity index 89% rename from web/core/components/workspace-notifications/sidebar/notification-card/root.tsx rename to web/ce/components/workspace-notifications/notification-card/root.tsx index bfff113ba..2aff9edfe 100644 --- a/web/core/components/workspace-notifications/sidebar/notification-card/root.tsx +++ b/web/ce/components/workspace-notifications/notification-card/root.tsx @@ -2,10 +2,12 @@ import { FC } from "react"; import { observer } from "mobx-react"; +// plane imports +import { ENotificationLoader, ENotificationQueryParamType } from "@plane/constants"; // components +import { useTranslation } from "@plane/i18n"; import { NotificationItem } from "@/components/workspace-notifications"; // constants -import { ENotificationLoader, ENotificationQueryParamType } from "@/constants/notification"; // hooks import { useWorkspaceNotifications } from "@/hooks/store"; @@ -19,6 +21,7 @@ export const NotificationCardListRoot: FC = observer( // hooks const { loader, paginationInfo, getNotifications, notificationIdsByWorkspaceId } = useWorkspaceNotifications(); const notificationIds = notificationIdsByWorkspaceId(workspaceId); + const { t } = useTranslation(); const getNextNotifications = async () => { try { @@ -40,12 +43,12 @@ export const NotificationCardListRoot: FC = observer( <> {loader === ENotificationLoader.PAGINATION_LOADER ? (
    -
    Loading...
    +
    {t("loading")}...
    ) : (
    - Load more + {t("load_more")}
    )} diff --git a/web/ce/components/workspace/billing/billing-actions-button.tsx b/web/ce/components/workspace/billing/billing-actions-button.tsx new file mode 100644 index 000000000..b15d67b91 --- /dev/null +++ b/web/ce/components/workspace/billing/billing-actions-button.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { observer } from "mobx-react"; + +export type TBillingActionsButtonProps = { + canPerformWorkspaceAdminActions: boolean; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const BillingActionsButton = observer((props: TBillingActionsButtonProps) => <>); diff --git a/web/ce/components/workspace/billing/index.ts b/web/ce/components/workspace/billing/index.ts index 1efe34c51..f02c34d3f 100644 --- a/web/ce/components/workspace/billing/index.ts +++ b/web/ce/components/workspace/billing/index.ts @@ -1 +1,2 @@ export * from "./root"; +export * from "./billing-actions-button"; diff --git a/web/ce/components/workspace/billing/root.tsx b/web/ce/components/workspace/billing/root.tsx index 3a6d6c45c..e9c6bd8f7 100644 --- a/web/ce/components/workspace/billing/root.tsx +++ b/web/ce/components/workspace/billing/root.tsx @@ -1,23 +1,27 @@ -// ui +import { MARKETING_PRICING_PAGE_LINK } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/ui"; -// constants -import { MARKETING_PRICING_PAGE_LINK } from "@/constants/common"; -export const BillingRoot = () => ( -
    -
    -
    -

    Billing and Plans

    -
    -
    -
    -
    -); +
    +
    +

    {t("workspace_settings.settings.billing_and_plans.current_plan")}

    +

    + {t("workspace_settings.settings.billing_and_plans.free_plan")} +

    + + + +
    +
    + + ); +}; diff --git a/web/ce/components/workspace/delete-workspace-modal.tsx b/web/ce/components/workspace/delete-workspace-modal.tsx new file mode 100644 index 000000000..cccee781f --- /dev/null +++ b/web/ce/components/workspace/delete-workspace-modal.tsx @@ -0,0 +1,27 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import type { IWorkspace } from "@plane/types"; +// ui +import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; +// constants +// hooks + +import { DeleteWorkspaceForm } from "@/components/workspace/delete-workspace-form"; + +type Props = { + isOpen: boolean; + data: IWorkspace | null; + onClose: () => void; +}; + +export const DeleteWorkspaceModal: React.FC = observer((props) => { + const { isOpen, data, onClose } = props; + + return ( + onClose()} position={EModalPosition.CENTER} width={EModalWidth.XL}> + + + ); +}); diff --git a/web/ce/components/workspace/delete-workspace-section.tsx b/web/ce/components/workspace/delete-workspace-section.tsx index 93836284f..00fb7b878 100644 --- a/web/ce/components/workspace/delete-workspace-section.tsx +++ b/web/ce/components/workspace/delete-workspace-section.tsx @@ -2,11 +2,12 @@ import { FC, useState } from "react"; import { observer } from "mobx-react"; import { ChevronDown, ChevronUp } from "lucide-react"; // types +import { useTranslation } from "@plane/i18n"; import { IWorkspace } from "@plane/types"; // ui import { Button, Collapsible } from "@plane/ui"; +import { DeleteWorkspaceModal } from "./delete-workspace-modal"; // components -import { DeleteWorkspaceModal } from "@/components/workspace"; type TDeleteWorkspace = { workspace: IWorkspace | null; @@ -17,6 +18,7 @@ export const DeleteWorkspaceSection: FC = observer((props) => // states const [isOpen, setIsOpen] = useState(false); const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState(false); + const { t } = useTranslation(); return ( <> @@ -34,19 +36,20 @@ export const DeleteWorkspaceSection: FC = observer((props) => buttonClassName="flex w-full items-center justify-between py-4" title={ <> - Delete workspace + + {t("workspace_settings.settings.general.delete_workspace")} + {isOpen ? : } } >
    - When deleting a workspace, all of the data and resources within that workspace will be permanently - removed and cannot be recovered. + {t("workspace_settings.settings.general.delete_workspace_description")}
    diff --git a/web/ce/components/workspace/edition-badge.tsx b/web/ce/components/workspace/edition-badge.tsx index e1c3d1c1d..effb5501b 100644 --- a/web/ce/components/workspace/edition-badge.tsx +++ b/web/ce/components/workspace/edition-badge.tsx @@ -1,16 +1,18 @@ import { useState } from "react"; import { observer } from "mobx-react"; +import packageJson from "package.json"; +import { useTranslation } from "@plane/i18n"; // ui import { Button, Tooltip } from "@plane/ui"; // hooks import { usePlatformOS } from "@/hooks/use-platform-os"; // assets -import packageJson from "package.json"; // local components import { PaidPlanUpgradeModal } from "./upgrade"; export const WorkspaceEditionBadge = observer(() => { const { isMobile } = usePlatformOS(); + const { t } = useTranslation(); // states const [isPaidPlanPurchaseModalOpen, setIsPaidPlanPurchaseModalOpen] = useState(false); @@ -27,7 +29,7 @@ export const WorkspaceEditionBadge = observer(() => { className="w-fit min-w-24 cursor-pointer rounded-2xl px-2 py-1 text-center text-sm font-medium outline-none" onClick={() => setIsPaidPlanPurchaseModalOpen(true)} > - Upgrade + {t("sidebar.upgrade")} diff --git a/web/ce/components/workspace/index.ts b/web/ce/components/workspace/index.ts index 94a166947..d68c6eca2 100644 --- a/web/ce/components/workspace/index.ts +++ b/web/ce/components/workspace/index.ts @@ -2,3 +2,5 @@ export * from "./edition-badge"; export * from "./upgrade-badge"; export * from "./billing"; export * from "./delete-workspace-section"; +export * from "./sidebar"; +export * from "./members"; diff --git a/web/ce/components/workspace/members/index.ts b/web/ce/components/workspace/members/index.ts new file mode 100644 index 000000000..5e1651e90 --- /dev/null +++ b/web/ce/components/workspace/members/index.ts @@ -0,0 +1 @@ +export * from "./invite-modal"; diff --git a/web/ce/components/workspace/members/invite-modal.tsx b/web/ce/components/workspace/members/invite-modal.tsx new file mode 100644 index 000000000..629370cc1 --- /dev/null +++ b/web/ce/components/workspace/members/invite-modal.tsx @@ -0,0 +1,60 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { IWorkspaceBulkInviteFormData } from "@plane/types"; +// ui +import { EModalWidth, EModalPosition, ModalCore } from "@plane/ui"; +// components +import { InvitationFields, InvitationModalActions } from "@/components/workspace/invite-modal"; +import { InvitationForm } from "@/components/workspace/invite-modal/form"; +// hooks +import { useWorkspaceInvitationActions } from "@/hooks/use-workspace-invitation"; + +export type TSendWorkspaceInvitationModalProps = { + isOpen: boolean; + onClose: () => void; + onSubmit: (data: IWorkspaceBulkInviteFormData) => Promise | undefined; +}; + +export const SendWorkspaceInvitationModal: React.FC = observer((props) => { + const { isOpen, onClose, onSubmit } = props; + // store hooks + const { t } = useTranslation(); + // router + const { workspaceSlug } = useParams(); + // derived values + const { control, fields, formState, remove, onFormSubmit, handleClose, appendField } = useWorkspaceInvitationActions({ + onSubmit, + onClose, + }); + + return ( + + + } + className="p-5" + > + + + + ); +}); diff --git a/web/ce/components/workspace/settings/useMemberColumns.tsx b/web/ce/components/workspace/settings/useMemberColumns.tsx index 64c92cf52..c34051878 100644 --- a/web/ce/components/workspace/settings/useMemberColumns.tsx +++ b/web/ce/components/workspace/settings/useMemberColumns.tsx @@ -1,8 +1,9 @@ import { useState } from "react"; import { useParams } from "next/navigation"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { AccountTypeColumn, NameColumn, RowData } from "@/components/workspace/settings/member-columns"; import { useUser, useUserPermissions } from "@/hooks/store"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; export const useMemberColumns = () => { // states @@ -12,6 +13,7 @@ export const useMemberColumns = () => { const { data: currentUser } = useUser(); const { allowPermissions } = useUserPermissions(); + const { t } = useTranslation(); const getFormattedDate = (dateStr: string) => { const date = new Date(dateStr); @@ -26,7 +28,7 @@ export const useMemberColumns = () => { const columns = [ { key: "Full name", - content: "Full name", + content: t("workspace_settings.settings.members.details.full_name"), thClassName: "text-left", tdRender: (rowData: RowData) => ( { { key: "Display name", - content: "Display name", + content: t("workspace_settings.settings.members.details.display_name"), tdRender: (rowData: RowData) =>
    {rowData.member.display_name}
    , }, { key: "Email address", - content: "Email address", + content: t("workspace_settings.settings.members.details.email_address"), tdRender: (rowData: RowData) =>
    {rowData.member.email}
    , }, { key: "Account type", - content: "Account type", + content: t("workspace_settings.settings.members.details.account_type"), tdRender: (rowData: RowData) => , }, { key: "Authentication", - content: "Authentication", + content: t("workspace_settings.settings.members.details.authentication"), tdRender: (rowData: RowData) => (
    {rowData.member.last_login_medium?.replace("-", " ")}
    ), @@ -67,7 +69,7 @@ export const useMemberColumns = () => { { key: "Joining date", - content: "Joining date", + content: t("workspace_settings.settings.members.details.joining_date"), tdRender: (rowData: RowData) =>
    {getFormattedDate(rowData?.member?.joining_date || "")}
    , }, ]; diff --git a/web/ce/components/workspace/sidebar/app-search.tsx b/web/ce/components/workspace/sidebar/app-search.tsx new file mode 100644 index 000000000..6ab92b996 --- /dev/null +++ b/web/ce/components/workspace/sidebar/app-search.tsx @@ -0,0 +1,26 @@ +import { observer } from "mobx-react"; +import { Search } from "lucide-react"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useAppTheme, useCommandPalette } from "@/hooks/store"; + +export const AppSearch = observer(() => { + // store hooks + const { sidebarCollapsed } = useAppTheme(); + const { toggleCommandPaletteModal } = useCommandPalette(); + + return ( + + ); +}); diff --git a/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx b/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx new file mode 100644 index 000000000..22cfe0842 --- /dev/null +++ b/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx @@ -0,0 +1,220 @@ +import { FC, useEffect, useRef, useState } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useParams, usePathname } from "next/navigation"; +import { Eye, EyeClosed } from "lucide-react"; +// plane imports +import { EUserPermissionsLevel, IWorkspaceSidebarNavigationItem } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { DragHandle, DropIndicator, Tooltip } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { SidebarNavItem } from "@/components/sidebar"; +// hooks +import { useAppTheme, useUser, useUserPermissions, useWorkspace } from "@/hooks/store"; +// plane web imports +// local imports +import { UpgradeBadge } from "../upgrade-badge"; +import { getSidebarNavigationItemIcon } from "./helper"; + +type TExtendedSidebarItemProps = { + item: IWorkspaceSidebarNavigationItem; + handleOnNavigationItemDrop?: ( + sourceId: string | undefined, + destinationId: string | undefined, + shouldDropAtEnd: boolean + ) => void; + disableDrag?: boolean; + disableDrop?: boolean; + isLastChild: boolean; +}; + +export const ExtendedSidebarItem: FC = observer((props) => { + const { item, handleOnNavigationItemDrop, disableDrag = false, disableDrop = false, isLastChild } = props; + const { t } = useTranslation(); + // states + const [isDragging, setIsDragging] = useState(false); + const [instruction, setInstruction] = useState<"DRAG_OVER" | "DRAG_BELOW" | undefined>(undefined); + // refs + const navigationIemRef = useRef(null); + const dragHandleRef = useRef(null); + + // nextjs hooks + const pathname = usePathname(); + const { workspaceSlug } = useParams(); + // store hooks + const { getNavigationPreferences, updateSidebarPreference } = useWorkspace(); + const { toggleExtendedSidebar } = useAppTheme(); + const { data } = useUser(); + const { allowPermissions } = useUserPermissions(); + + // derived values + const sidebarPreference = getNavigationPreferences(workspaceSlug.toString()); + const isPinned = sidebarPreference?.[item.key]?.is_pinned; + + const handleLinkClick = () => toggleExtendedSidebar(); + + if (!allowPermissions(item.access as any, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())) { + return null; + } + + const itemHref = + item.key === "your_work" + ? `/${workspaceSlug.toString()}${item.href}${data?.id}` + : `/${workspaceSlug.toString()}${item.href}`; + const isActive = itemHref === pathname; + + const pinNavigationItem = (workspaceSlug: string, key: string) => { + updateSidebarPreference(workspaceSlug, key, { is_pinned: true }); + }; + + const unPinNavigationItem = (workspaceSlug: string, key: string) => { + updateSidebarPreference(workspaceSlug, key, { is_pinned: false }); + }; + + const icon = getSidebarNavigationItemIcon(item.key); + + useEffect(() => { + const element = navigationIemRef.current; + const dragHandleElement = dragHandleRef.current; + + if (!element) return; + + return combine( + draggable({ + element, + canDrag: () => !disableDrag, + dragHandle: dragHandleElement ?? undefined, + getInitialData: () => ({ id: item.key, dragInstanceId: "NAVIGATION" }), // var1 + onDragStart: () => { + setIsDragging(true); + }, + onDrop: () => { + setIsDragging(false); + }, + }), + dropTargetForElements({ + element, + canDrop: ({ source }) => + !disableDrop && source?.data?.id !== item.key && source?.data?.dragInstanceId === "NAVIGATION", + getData: ({ input, element }) => { + const data = { id: item.key }; + + // attach instruction for last in list + return attachInstruction(data, { + input, + element, + currentLevel: 0, + indentPerLevel: 0, + mode: isLastChild ? "last-in-group" : "standard", + }); + }, + onDrag: ({ self }) => { + const extractedInstruction = extractInstruction(self?.data)?.type; + // check if the highlight is to be shown above or below + setInstruction( + extractedInstruction + ? extractedInstruction === "reorder-below" && isLastChild + ? "DRAG_BELOW" + : "DRAG_OVER" + : undefined + ); + }, + onDragLeave: () => { + setInstruction(undefined); + }, + onDrop: ({ self, source }) => { + setInstruction(undefined); + const extractedInstruction = extractInstruction(self?.data)?.type; + const currentInstruction = extractedInstruction + ? extractedInstruction === "reorder-below" && isLastChild + ? "DRAG_BELOW" + : "DRAG_OVER" + : undefined; + if (!currentInstruction) return; + + const sourceId = source?.data?.id as string | undefined; + const destinationId = self?.data?.id as string | undefined; + + if (handleOnNavigationItemDrop) + handleOnNavigationItemDrop(sourceId, destinationId, currentInstruction === "DRAG_BELOW"); + }, + }) + ); + }, [isLastChild, handleOnNavigationItemDrop, disableDrag, disableDrop, item.key]); + + return ( +
    + +
    + {!disableDrag && ( + + + + )} + + handleLinkClick()} className="group flex-grow"> +
    + {icon} +

    {t(item.labelTranslationKey)}

    +
    + +
    + {item.key === "active_cycles" && ( +
    + +
    + )} + {isPinned ? ( + + unPinNavigationItem(workspaceSlug.toString(), item.key)} + /> + + ) : ( + + pinNavigationItem(workspaceSlug.toString(), item.key)} + /> + + )} +
    +
    +
    + {isLastChild && } +
    + ); +}); diff --git a/web/ce/components/workspace/sidebar/helper.tsx b/web/ce/components/workspace/sidebar/helper.tsx new file mode 100644 index 000000000..d00b0c361 --- /dev/null +++ b/web/ce/components/workspace/sidebar/helper.tsx @@ -0,0 +1,26 @@ +import { BarChart2, Briefcase, Home, Inbox, Layers, PenSquare } from "lucide-react"; +import { ArchiveIcon, ContrastIcon, UserActivityIcon } from "@plane/ui"; +import { cn } from "@plane/utils"; + +export const getSidebarNavigationItemIcon = (key: string, className: string = "") => { + switch (key) { + case "home": + return ; + case "inbox": + return ; + case "projects": + return ; + case "views": + return ; + case "active_cycles": + return ; + case "analytics": + return ; + case "your_work": + return ; + case "drafts": + return ; + case "archives": + return ; + } +}; diff --git a/web/ce/components/workspace/sidebar/index.ts b/web/ce/components/workspace/sidebar/index.ts new file mode 100644 index 000000000..c22f296a2 --- /dev/null +++ b/web/ce/components/workspace/sidebar/index.ts @@ -0,0 +1,4 @@ +export * from "./app-search"; +export * from "./extended-sidebar-item"; +export * from "./helper"; +export * from "./sidebar-item"; diff --git a/web/ce/components/workspace/sidebar/sidebar-item.tsx b/web/ce/components/workspace/sidebar/sidebar-item.tsx new file mode 100644 index 000000000..51a5735de --- /dev/null +++ b/web/ce/components/workspace/sidebar/sidebar-item.tsx @@ -0,0 +1,90 @@ +"use client"; +import { FC } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useParams, usePathname } from "next/navigation"; +// plane imports +import { EUserPermissionsLevel, IWorkspaceSidebarNavigationItem } from "@plane/constants"; +import { usePlatformOS } from "@plane/hooks"; +import { useTranslation } from "@plane/i18n"; +import { Tooltip } from "@plane/ui"; +// components +import { SidebarNavItem } from "@/components/sidebar"; +import { NotificationAppSidebarOption } from "@/components/workspace-notifications"; +// hooks +import { useAppTheme, useUser, useUserPermissions, useWorkspace } from "@/hooks/store"; +// local imports +import { getSidebarNavigationItemIcon } from "./helper"; + +type TSidebarItemProps = { + item: IWorkspaceSidebarNavigationItem; +}; + +export const SidebarItem: FC = observer((props) => { + const { item } = props; + const { t } = useTranslation(); + // nextjs hooks + const pathname = usePathname(); + const { workspaceSlug } = useParams(); + const { allowPermissions } = useUserPermissions(); + const { getNavigationPreferences } = useWorkspace(); + const { data } = useUser(); + + // store hooks + const { toggleSidebar, sidebarCollapsed, extendedSidebarCollapsed, toggleExtendedSidebar } = useAppTheme(); + const { isMobile } = usePlatformOS(); + + const handleLinkClick = () => { + if (window.innerWidth < 768) { + toggleSidebar(); + } + if (extendedSidebarCollapsed) toggleExtendedSidebar(); + }; + + const staticItems = ["home", "inbox", "pi-chat", "projects"]; + + if (!allowPermissions(item.access as any, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())) { + return null; + } + + const itemHref = + item.key === "your_work" + ? `/${workspaceSlug.toString()}${item.href}/${data?.id}` + : `/${workspaceSlug.toString()}${item.href}`; + + const isActive = itemHref === pathname; + + const sidebarPreference = getNavigationPreferences(workspaceSlug.toString()); + const isPinned = sidebarPreference?.[item.key]?.is_pinned; + if (!isPinned && !staticItems.includes(item.key)) return null; + + const icon = getSidebarNavigationItemIcon(item.key); + + return ( + + handleLinkClick()}> + +
    + {icon} + {!sidebarCollapsed &&

    {t(item.labelTranslationKey)}

    } +
    + {item.key === "inbox" && ( + + )} +
    + +
    + ); +}); diff --git a/web/ce/components/workspace/sidebar/teams-sidebar-list.tsx b/web/ce/components/workspace/sidebar/teams-sidebar-list.tsx new file mode 100644 index 000000000..92cbdfc5f --- /dev/null +++ b/web/ce/components/workspace/sidebar/teams-sidebar-list.tsx @@ -0,0 +1 @@ +export const SidebarTeamsList = () => null; diff --git a/web/ce/components/workspace/upgrade-badge.tsx b/web/ce/components/workspace/upgrade-badge.tsx index 3fc3654cf..1abb731e7 100644 --- a/web/ce/components/workspace/upgrade-badge.tsx +++ b/web/ce/components/workspace/upgrade-badge.tsx @@ -1,5 +1,6 @@ import { FC } from "react"; // helpers +import { useTranslation } from "@plane/i18n"; import { cn } from "@/helpers/common.helper"; type TUpgradeBadge = { @@ -10,6 +11,8 @@ type TUpgradeBadge = { export const UpgradeBadge: FC = (props) => { const { className, size = "sm" } = props; + const { t } = useTranslation(); + return (
    = (props) => { className )} > - Pro + {t("sidebar.pro")}
    ); }; diff --git a/web/ce/components/workspace/upgrade/paid-plans-upgrade-modal.tsx b/web/ce/components/workspace/upgrade/paid-plans-upgrade-modal.tsx index 0f6d0681e..88201af63 100644 --- a/web/ce/components/workspace/upgrade/paid-plans-upgrade-modal.tsx +++ b/web/ce/components/workspace/upgrade/paid-plans-upgrade-modal.tsx @@ -22,7 +22,7 @@ const ONE_PLAN_FEATURES = [ "OIDC + SAML for SSO", "Active Cycles", "Real-time collab + public views and page", - "Link pages in issues and vice-versa", + "Link pages in work items and vice-versa", "Time-tracking + limited bulk ops", "Docker, Kubernetes and more", ]; diff --git a/web/ce/constants/dashboard.ts b/web/ce/constants/dashboard.ts deleted file mode 100644 index 8872982fc..000000000 --- a/web/ce/constants/dashboard.ts +++ /dev/null @@ -1,56 +0,0 @@ -"use client"; - -// icons -import { Home, Inbox, PenSquare } from "lucide-react"; -// ui -import { UserActivityIcon } from "@plane/ui"; -import { Props } from "@/components/icons/types"; -import { TLinkOptions } from "@/constants/dashboard"; -import { EUserPermissions } from "@/plane-web/constants/user-permissions"; -// plane web types -import { TSidebarUserMenuItemKeys } from "@/plane-web/types/dashboard"; - -export type TSidebarUserMenuItems = { - key: TSidebarUserMenuItemKeys; - label: string; - href: string; - access: EUserPermissions[]; - highlight: (pathname: string, baseUrl: string, options?: TLinkOptions) => boolean; - Icon: React.FC; -}; - -export const SIDEBAR_USER_MENU_ITEMS: TSidebarUserMenuItems[] = [ - { - key: "home", - label: "Home", - href: ``, - access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/`, - Icon: Home, - }, - { - key: "your-work", - label: "Your work", - href: "/profile", - access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - highlight: (pathname: string, baseUrl: string, options?: TLinkOptions) => - options?.userId ? pathname.includes(`${baseUrl}/profile/${options?.userId}`) : false, - Icon: UserActivityIcon, - }, - { - key: "notifications", - label: "Inbox", - href: `/notifications`, - access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], - highlight: (pathname: string, baseUrl: string) => pathname.includes(`${baseUrl}/notifications/`), - Icon: Inbox, - }, - { - key: "drafts", - label: "Drafts", - href: `/drafts`, - access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - highlight: (pathname: string, baseUrl: string) => pathname.includes(`${baseUrl}/drafts/`), - Icon: PenSquare, - }, -]; diff --git a/web/ce/constants/editor.ts b/web/ce/constants/editor.ts new file mode 100644 index 000000000..b9a6d5d38 --- /dev/null +++ b/web/ce/constants/editor.ts @@ -0,0 +1,4 @@ +// plane types +import { TSearchEntities } from "@plane/types"; + +export const EDITOR_MENTION_TYPES: TSearchEntities[] = ["user_mention"]; diff --git a/web/ce/constants/index.ts b/web/ce/constants/index.ts index 123db122c..895c18c3d 100644 --- a/web/ce/constants/index.ts +++ b/web/ce/constants/index.ts @@ -1,7 +1,4 @@ export * from "./ai"; export * from "./estimates"; export * from "./gantt-chart"; -export * from "./issues"; export * from "./project"; -export * from "./user-permissions"; -export * from "./workspace"; diff --git a/web/ce/constants/issues.ts b/web/ce/constants/issues.ts deleted file mode 100644 index dc6ffbcb8..000000000 --- a/web/ce/constants/issues.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { TIssueActivityComment } from "@plane/types"; - -export enum EActivityFilterType { - ACTIVITY = "ACTIVITY", - COMMENT = "COMMENT", -} - -export type TActivityFilters = EActivityFilterType; - -export const ACTIVITY_FILTER_TYPE_OPTIONS: Record = { - [EActivityFilterType.ACTIVITY]: { - label: "Updates", - }, - [EActivityFilterType.COMMENT]: { - label: "Comments", - }, -}; - -export const defaultActivityFilters: TActivityFilters[] = [EActivityFilterType.ACTIVITY, EActivityFilterType.COMMENT]; - -export type TActivityFilterOption = { - key: EActivityFilterType; - label: string; - isSelected: boolean; - onClick: () => void; -}; - -export const filterActivityOnSelectedFilters = ( - activity: TIssueActivityComment[], - filter: TActivityFilters[] -): TIssueActivityComment[] => - activity.filter((activity) => filter.includes(activity.activity_type as TActivityFilters)); - -export const ENABLE_ISSUE_DEPENDENCIES = false; diff --git a/web/ce/constants/project/settings/features.tsx b/web/ce/constants/project/settings/features.tsx index b3601e27b..99e6101d5 100644 --- a/web/ce/constants/project/settings/features.tsx +++ b/web/ce/constants/project/settings/features.tsx @@ -4,6 +4,7 @@ import { IProject } from "@plane/types"; import { ContrastIcon, DiceIcon, Intake } from "@plane/ui"; export type TProperties = { + key: string; property: string; title: string; description: string; @@ -23,6 +24,7 @@ export type TFeatureList = { export type TProjectFeatures = { [key: string]: { + key: string; title: string; description: string; featureList: TFeatureList; @@ -31,10 +33,12 @@ export type TProjectFeatures = { export const PROJECT_FEATURES_LIST: TProjectFeatures = { project_features: { - title: "Projects and issues", + key: "projects_and_issues", + title: "Projects and work items", description: "Toggle these on or off this project.", featureList: { cycles: { + key: "cycles", property: "cycle_view", title: "Cycles", description: "Timebox work as you see fit per project and change frequency from one period to the next.", @@ -43,6 +47,7 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = { isEnabled: true, }, modules: { + key: "modules", property: "module_view", title: "Modules", description: "Group work into sub-project-like set-ups with their own leads and assignees.", @@ -51,6 +56,7 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = { isEnabled: true, }, views: { + key: "views", property: "issue_views_view", title: "Views", description: "Save sorts, filters, and display options for later or share them.", @@ -59,6 +65,7 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = { isEnabled: true, }, pages: { + key: "pages", property: "page_view", title: "Pages", description: "Write anything like you write anything.", @@ -67,9 +74,10 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = { isEnabled: true, }, inbox: { + key: "intake", property: "inbox_view", title: "Intake", - description: "Consider and discuss issues before you add them to your project.", + description: "Consider and discuss work items before you add them to your project.", icon: , isPro: false, isEnabled: true, @@ -77,10 +85,12 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = { }, }, project_others: { + key: "work_management", title: "Work management", description: "Available only on some plans as indicated by the label next to the feature below.", featureList: { is_time_tracking_enabled: { + key: "time_tracking", property: "is_time_tracking_enabled", title: "Time Tracking", description: "Log time, see timesheets, and download full CSVs for your entire workspace.", diff --git a/web/ce/constants/project/settings/tabs.ts b/web/ce/constants/project/settings/tabs.ts index 4d9207cc6..50bff1115 100644 --- a/web/ce/constants/project/settings/tabs.ts +++ b/web/ce/constants/project/settings/tabs.ts @@ -1,14 +1,14 @@ // icons +import { EUserPermissions } from "@plane/constants"; import { SettingIcon } from "@/components/icons/attachment"; // types import { Props } from "@/components/icons/types"; // constants -import { EUserPermissions } from "../../user-permissions"; export const PROJECT_SETTINGS = { general: { key: "general", - label: "General", + i18n_label: "common.general", href: `/settings`, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`, @@ -16,7 +16,7 @@ export const PROJECT_SETTINGS = { }, members: { key: "members", - label: "Members", + i18n_label: "members", href: `/settings/members`, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`, @@ -24,7 +24,7 @@ export const PROJECT_SETTINGS = { }, features: { key: "features", - label: "Features", + i18n_label: "common.features", href: `/settings/features`, access: [EUserPermissions.ADMIN], highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/features/`, @@ -32,7 +32,7 @@ export const PROJECT_SETTINGS = { }, states: { key: "states", - label: "States", + i18n_label: "common.states", href: `/settings/states`, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/states/`, @@ -40,7 +40,7 @@ export const PROJECT_SETTINGS = { }, labels: { key: "labels", - label: "Labels", + i18n_label: "common.labels", href: `/settings/labels`, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/labels/`, @@ -48,7 +48,7 @@ export const PROJECT_SETTINGS = { }, estimates: { key: "estimates", - label: "Estimates", + i18n_label: "common.estimates", href: `/settings/estimates`, access: [EUserPermissions.ADMIN], highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/estimates/`, @@ -56,7 +56,7 @@ export const PROJECT_SETTINGS = { }, automations: { key: "automations", - label: "Automations", + i18n_label: "project_settings.automations.label", href: `/settings/automations`, access: [EUserPermissions.ADMIN], highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/automations/`, @@ -66,7 +66,7 @@ export const PROJECT_SETTINGS = { export const PROJECT_SETTINGS_LINKS: { key: string; - label: string; + i18n_label: string; href: string; access: EUserPermissions[]; highlight: (pathname: string, baseUrl: string) => boolean; diff --git a/web/ce/constants/workspace.ts b/web/ce/constants/workspace.ts deleted file mode 100644 index b976111b5..000000000 --- a/web/ce/constants/workspace.ts +++ /dev/null @@ -1,72 +0,0 @@ -// icons -import { SettingIcon } from "@/components/icons/attachment"; -import { Props } from "@/components/icons/types"; -import { EUserPermissions } from "./user-permissions"; -// constants - -export const WORKSPACE_SETTINGS = { - general: { - key: "general", - label: "General", - href: `/settings`, - access: [EUserPermissions.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`, - Icon: SettingIcon, - }, - members: { - key: "members", - label: "Members", - href: `/settings/members`, - access: [EUserPermissions.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`, - Icon: SettingIcon, - }, - "billing-and-plans": { - key: "billing-and-plans", - label: "Billing and plans", - href: `/settings/billing`, - access: [EUserPermissions.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/billing/`, - Icon: SettingIcon, - }, - export: { - key: "export", - label: "Exports", - href: `/settings/exports`, - access: [EUserPermissions.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/exports/`, - Icon: SettingIcon, - }, - webhooks: { - key: "webhooks", - label: "Webhooks", - href: `/settings/webhooks`, - access: [EUserPermissions.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`, - Icon: SettingIcon, - }, - "api-tokens": { - key: "api-tokens", - label: "API tokens", - href: `/settings/api-tokens`, - access: [EUserPermissions.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens/`, - Icon: SettingIcon, - }, -}; - -export const WORKSPACE_SETTINGS_LINKS: { - key: string; - label: string; - href: string; - access: EUserPermissions[]; - highlight: (pathname: string, baseUrl: string) => boolean; - Icon: React.FC; -}[] = [ - WORKSPACE_SETTINGS["general"], - WORKSPACE_SETTINGS["members"], - WORKSPACE_SETTINGS["billing-and-plans"], - WORKSPACE_SETTINGS["export"], - WORKSPACE_SETTINGS["webhooks"], - WORKSPACE_SETTINGS["api-tokens"], -]; diff --git a/web/ce/helpers/command-palette.ts b/web/ce/helpers/command-palette.ts new file mode 100644 index 000000000..28576735f --- /dev/null +++ b/web/ce/helpers/command-palette.ts @@ -0,0 +1,95 @@ +// types +import { TCommandPaletteActionList, TCommandPaletteShortcut, TCommandPaletteShortcutList } from "@plane/types"; +// store +import { store } from "@/lib/store-context"; + +export const getGlobalShortcutsList: () => TCommandPaletteActionList = () => { + const { toggleCreateIssueModal } = store.commandPalette; + + return { + c: { + title: "Create a new work item", + description: "Create a new work item in the current project", + action: () => toggleCreateIssueModal(true), + }, + }; +}; + +export const getWorkspaceShortcutsList: () => TCommandPaletteActionList = () => { + const { toggleCreateProjectModal } = store.commandPalette; + + return { + p: { + title: "Create a new project", + description: "Create a new project in the current workspace", + action: () => toggleCreateProjectModal(true), + }, + }; +}; + +export const getProjectShortcutsList: () => TCommandPaletteActionList = () => { + const { + toggleCreatePageModal, + toggleCreateModuleModal, + toggleCreateCycleModal, + toggleCreateViewModal, + toggleBulkDeleteIssueModal, + } = store.commandPalette; + + return { + d: { + title: "Create a new page", + description: "Create a new page in the current project", + action: () => toggleCreatePageModal({ isOpen: true }), + }, + m: { + title: "Create a new module", + description: "Create a new module in the current project", + action: () => toggleCreateModuleModal(true), + }, + q: { + title: "Create a new cycle", + description: "Create a new cycle in the current project", + action: () => toggleCreateCycleModal(true), + }, + v: { + title: "Create a new view", + description: "Create a new view in the current project", + action: () => toggleCreateViewModal(true), + }, + backspace: { + title: "Bulk delete work items", + description: "Bulk delete work items in the current project", + action: () => toggleBulkDeleteIssueModal(true), + }, + delete: { + title: "Bulk delete work items", + description: "Bulk delete work items in the current project", + action: () => toggleBulkDeleteIssueModal(true), + }, + }; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const handleAdditionalKeyDownEvents = (e: KeyboardEvent) => null; + +export const getNavigationShortcutsList = (): TCommandPaletteShortcut[] => [ + { keys: "Ctrl,K", description: "Open command menu" }, +]; + +export const getCommonShortcutsList = (platform: string): TCommandPaletteShortcut[] => [ + { keys: "P", description: "Create project" }, + { keys: "C", description: "Create work item" }, + { keys: "Q", description: "Create cycle" }, + { keys: "M", description: "Create module" }, + { keys: "V", description: "Create view" }, + { keys: "D", description: "Create page" }, + { keys: "Delete", description: "Bulk delete work items" }, + { keys: "Shift,/", description: "Open shortcuts guide" }, + { + keys: platform === "MacOS" ? "Ctrl,control,C" : "Ctrl,Alt,C", + description: "Copy work item URL from the work item details page", + }, +]; + +export const getAdditionalShortcutsList = (): TCommandPaletteShortcutList[] => []; diff --git a/web/ce/helpers/dashboard.helper.ts b/web/ce/helpers/dashboard.helper.ts deleted file mode 100644 index b2fba63ad..000000000 --- a/web/ce/helpers/dashboard.helper.ts +++ /dev/null @@ -1,5 +0,0 @@ -// plane web types -import { TSidebarUserMenuItemKeys } from "@/plane-web/types/dashboard"; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export const isUserFeatureEnabled = (featureKey: TSidebarUserMenuItemKeys) => true; diff --git a/web/ce/helpers/epic-analytics.ts b/web/ce/helpers/epic-analytics.ts new file mode 100644 index 000000000..43e6ffef0 --- /dev/null +++ b/web/ce/helpers/epic-analytics.ts @@ -0,0 +1,15 @@ +import { TEpicAnalyticsGroup } from "@plane/types"; + +export const updateEpicAnalytics = () => { + const updateAnalytics = ( + workspaceSlug: string, + projectId: string, + epicId: string, + data: { + incrementStateGroupCount?: TEpicAnalyticsGroup; + decrementStateGroupCount?: TEpicAnalyticsGroup; + } + ) => {}; + + return { updateAnalytics }; +}; diff --git a/web/ce/helpers/issue-action-helper.ts b/web/ce/helpers/issue-action-helper.ts new file mode 100644 index 000000000..b1644e2aa --- /dev/null +++ b/web/ce/helpers/issue-action-helper.ts @@ -0,0 +1,15 @@ +import { IssueActions } from "@/hooks/use-issues-actions"; + +export const useTeamIssueActions: () => IssueActions = () => ({ + fetchIssues: () => Promise.resolve(undefined), + fetchNextIssues: () => Promise.resolve(undefined), + removeIssue: () => Promise.resolve(undefined), + updateFilters: () => Promise.resolve(undefined), +}); + +export const useTeamViewIssueActions: () => IssueActions = () => ({ + fetchIssues: () => Promise.resolve(undefined), + fetchNextIssues: () => Promise.resolve(undefined), + removeIssue: () => Promise.resolve(undefined), + updateFilters: () => Promise.resolve(undefined), +}); diff --git a/web/ce/hooks/store/index.ts b/web/ce/hooks/store/index.ts new file mode 100644 index 000000000..1962c9b26 --- /dev/null +++ b/web/ce/hooks/store/index.ts @@ -0,0 +1,2 @@ +export * from "./use-page-store"; +export * from "./use-page"; diff --git a/web/ce/hooks/store/use-page-store.ts b/web/ce/hooks/store/use-page-store.ts new file mode 100644 index 000000000..91bf9306b --- /dev/null +++ b/web/ce/hooks/store/use-page-store.ts @@ -0,0 +1,24 @@ +import { useContext } from "react"; +// context +import { StoreContext } from "@/lib/store-context"; +// mobx store +import { IProjectPageStore } from "@/store/pages/project-page.store"; + +export enum EPageStoreType { + PROJECT = "PROJECT_PAGE", +} + +export type TReturnType = { + [EPageStoreType.PROJECT]: IProjectPageStore; +}; + +export const usePageStore = (storeType: T): TReturnType[T] => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("usePageStore must be used within StoreProvider"); + + if (storeType === EPageStoreType.PROJECT) { + return context.projectPages; + } + + throw new Error(`Invalid store type: ${storeType}`); +}; diff --git a/web/ce/hooks/store/use-page.ts b/web/ce/hooks/store/use-page.ts new file mode 100644 index 000000000..c7bd7ceed --- /dev/null +++ b/web/ce/hooks/store/use-page.ts @@ -0,0 +1,23 @@ +import { useContext } from "react"; +// mobx store +import { StoreContext } from "@/lib/store-context"; +// plane web hooks +import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; + +export type TArgs = { + pageId: string; + storeType: EPageStoreType; +}; + +export const usePage = (args: TArgs) => { + const { pageId, storeType } = args; + // context + const context = useContext(StoreContext); + // store hooks + const pageStore = usePageStore(storeType); + + if (context === undefined) throw new Error("usePage must be used within StoreProvider"); + if (!pageId) throw new Error("pageId is required"); + + return pageStore.getPageById(pageId); +}; diff --git a/web/ce/hooks/use-additional-editor-mention.tsx b/web/ce/hooks/use-additional-editor-mention.tsx new file mode 100644 index 000000000..58416379f --- /dev/null +++ b/web/ce/hooks/use-additional-editor-mention.tsx @@ -0,0 +1,41 @@ +import { useCallback } from "react"; +// plane editor +import { TMentionSection } from "@plane/editor"; +// plane types +import { TSearchEntities, TSearchResponse } from "@plane/types"; + +export type TAdditionalEditorMentionHandlerArgs = { + response: TSearchResponse; + sections: TMentionSection[]; +}; + +export type TAdditionalParseEditorContentArgs = { + id: string; + entityType: TSearchEntities; +}; + +export type TAdditionalParseEditorContentReturnType = + | { + redirectionPath: string; + textContent: string; + } + | undefined; + +export const useAdditionalEditorMention = () => { + const updateAdditionalSections = useCallback((args: TAdditionalEditorMentionHandlerArgs) => { + const {} = args; + }, []); + + const parseAdditionalEditorContent = useCallback( + (args: TAdditionalParseEditorContentArgs): TAdditionalParseEditorContentReturnType => { + const {} = args; + return undefined; + }, + [] + ); + + return { + updateAdditionalSections, + parseAdditionalEditorContent, + }; +}; diff --git a/web/ce/hooks/use-debounced-duplicate-issues.tsx b/web/ce/hooks/use-debounced-duplicate-issues.tsx index f0325bc12..8028a6191 100644 --- a/web/ce/hooks/use-debounced-duplicate-issues.tsx +++ b/web/ce/hooks/use-debounced-duplicate-issues.tsx @@ -1,6 +1,7 @@ import { TDeDupeIssue } from "@plane/types"; export const useDebouncedDuplicateIssues = ( + workspaceSlug: string | undefined, workspaceId: string | undefined, projectId: string | undefined, formData: { name: string | undefined; description_html?: string | undefined; issueId?: string | undefined } diff --git a/web/ce/hooks/use-file-size.ts b/web/ce/hooks/use-file-size.ts index c19df40e5..025c8ddfd 100644 --- a/web/ce/hooks/use-file-size.ts +++ b/web/ce/hooks/use-file-size.ts @@ -1,5 +1,5 @@ -// constants -import { MAX_STATIC_FILE_SIZE } from "@/constants/common"; +// plane imports +import { MAX_FILE_SIZE } from "@plane/constants"; // hooks import { useInstance } from "@/hooks/store"; @@ -12,6 +12,6 @@ export const useFileSize = (): TReturnProps => { const { config } = useInstance(); return { - maxFileSize: config?.file_size_limit ?? MAX_STATIC_FILE_SIZE, + maxFileSize: config?.file_size_limit ?? MAX_FILE_SIZE, }; }; diff --git a/web/ce/hooks/use-issue-embed.tsx b/web/ce/hooks/use-issue-embed.tsx index 5d02d978f..fcb6a0f3c 100644 --- a/web/ce/hooks/use-issue-embed.tsx +++ b/web/ce/hooks/use-issue-embed.tsx @@ -1,12 +1,18 @@ // editor import { TEmbedConfig } from "@plane/editor"; -// types -import { TPageEmbedType } from "@plane/types"; +// plane types +import { TSearchEntityRequestPayload, TSearchResponse } from "@plane/types"; // plane web components import { IssueEmbedUpgradeCard } from "@/plane-web/components/pages"; +export type TIssueEmbedHookProps = { + fetchEmbedSuggestions?: (payload: TSearchEntityRequestPayload) => Promise; + projectId?: string; + workspaceSlug?: string; +}; + // eslint-disable-next-line @typescript-eslint/no-unused-vars -export const useIssueEmbed = (workspaceSlug: string, projectId: string, queryType: TPageEmbedType = "issue") => { +export const useIssueEmbed = (props: TIssueEmbedHookProps) => { const widgetCallback = () => ; const issueEmbedProps: TEmbedConfig["issue"] = { diff --git a/web/ce/hooks/use-page-flag.ts b/web/ce/hooks/use-page-flag.ts new file mode 100644 index 000000000..84dc31c0d --- /dev/null +++ b/web/ce/hooks/use-page-flag.ts @@ -0,0 +1,14 @@ +export type TPageFlagHookArgs = { + workspaceSlug: string; +}; + +export type TPageFlagHookReturnType = { + isMovePageEnabled: boolean; +}; + +export const usePageFlag = (args: TPageFlagHookArgs): TPageFlagHookReturnType => { + const {} = args; + return { + isMovePageEnabled: false, + }; +}; diff --git a/web/ce/hooks/use-workspace-issue-properties-extended.tsx b/web/ce/hooks/use-workspace-issue-properties-extended.tsx new file mode 100644 index 000000000..6e9ba79f2 --- /dev/null +++ b/web/ce/hooks/use-workspace-issue-properties-extended.tsx @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const useWorkspaceIssuePropertiesExtended = (workspaceSlug: string | string[] | undefined) => {}; diff --git a/web/ce/layouts/project-wrapper.tsx b/web/ce/layouts/project-wrapper.tsx new file mode 100644 index 000000000..585ed567b --- /dev/null +++ b/web/ce/layouts/project-wrapper.tsx @@ -0,0 +1,21 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +// layouts +import { ProjectAuthWrapper as CoreProjectAuthWrapper } from "@/layouts/auth-layout"; + +export type IProjectAuthWrapper = { + workspaceSlug: string; + projectId: string; + children: React.ReactNode; +}; + +export const ProjectAuthWrapper: FC = observer((props) => { + // props + const { workspaceSlug, projectId, children } = props; + + return ( + + {children} + + ); +}); diff --git a/web/ce/layouts/workspace-wrapper.tsx b/web/ce/layouts/workspace-wrapper.tsx new file mode 100644 index 000000000..fcde83e7f --- /dev/null +++ b/web/ce/layouts/workspace-wrapper.tsx @@ -0,0 +1,15 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +// layouts +import { WorkspaceAuthWrapper as CoreWorkspaceAuthWrapper } from "@/layouts/auth-layout"; + +export type IWorkspaceAuthWrapper = { + children: React.ReactNode; +}; + +export const WorkspaceAuthWrapper: FC = observer((props) => { + // props + const { children } = props; + + return {children}; +}); diff --git a/web/ce/services/project/view.service.ts b/web/ce/services/project/view.service.ts index 6cb76222a..9a6ee12a2 100644 --- a/web/ce/services/project/view.service.ts +++ b/web/ce/services/project/view.service.ts @@ -1,5 +1,5 @@ +import { EViewAccess } from "@plane/constants"; import { TPublishViewSettings } from "@plane/types"; -import { EViewAccess } from "@/constants/views"; import { API_BASE_URL } from "@/helpers/common.helper"; import { ViewService as CoreViewService } from "@/services/view.service"; diff --git a/web/ce/services/workspace.service.ts b/web/ce/services/workspace.service.ts index 59fe39c69..9dcfb7f6c 100644 --- a/web/ce/services/workspace.service.ts +++ b/web/ce/services/workspace.service.ts @@ -1,4 +1,4 @@ -import { EViewAccess } from "@/constants/views"; +import { EViewAccess } from "@plane/constants"; import { API_BASE_URL } from "@/helpers/common.helper"; import { WorkspaceService as CoreWorkspaceService } from "@/services/workspace.service"; diff --git a/web/ce/store/command-palette.store.ts b/web/ce/store/command-palette.store.ts new file mode 100644 index 000000000..1b6fabf18 --- /dev/null +++ b/web/ce/store/command-palette.store.ts @@ -0,0 +1,26 @@ +import { computed, makeObservable } from "mobx"; +// types / constants +import { BaseCommandPaletteStore, IBaseCommandPaletteStore } from "@/store/base-command-palette.store"; + +export interface ICommandPaletteStore extends IBaseCommandPaletteStore { + // computed + isAnyModalOpen: boolean; +} + +export class CommandPaletteStore extends BaseCommandPaletteStore implements ICommandPaletteStore { + constructor() { + super(); + makeObservable(this, { + // computed + isAnyModalOpen: computed, + }); + } + + /** + * Checks whether any modal is open or not in the base command palette. + * @returns boolean + */ + get isAnyModalOpen(): boolean { + return Boolean(super.getCoreModalsState()); + } +} diff --git a/web/ce/store/cycle/index.ts b/web/ce/store/cycle/index.ts new file mode 100644 index 000000000..d9b3080a8 --- /dev/null +++ b/web/ce/store/cycle/index.ts @@ -0,0 +1 @@ +export type { ICycleStore } from "@/store/cycle.store"; diff --git a/web/ce/store/issue/epic/filter.store.ts b/web/ce/store/issue/epic/filter.store.ts new file mode 100644 index 000000000..a4733c60a --- /dev/null +++ b/web/ce/store/issue/epic/filter.store.ts @@ -0,0 +1,15 @@ +import { IProjectIssuesFilter, ProjectIssuesFilter } from "@/store/issue/project"; +import { IIssueRootStore } from "@/store/issue/root.store"; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export type IProjectEpicsFilter = IProjectIssuesFilter; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export class ProjectEpicsFilter extends ProjectIssuesFilter implements IProjectEpicsFilter { + constructor(_rootStore: IIssueRootStore) { + super(_rootStore); + + // root store + this.rootIssueStore = _rootStore; + } +} diff --git a/web/ce/store/issue/epic/index.ts b/web/ce/store/issue/epic/index.ts new file mode 100644 index 000000000..0fe6c946b --- /dev/null +++ b/web/ce/store/issue/epic/index.ts @@ -0,0 +1,2 @@ +export * from "./filter.store"; +export * from "./issue.store"; diff --git a/web/ce/store/issue/epic/issue.store.ts b/web/ce/store/issue/epic/issue.store.ts new file mode 100644 index 000000000..90ccee84d --- /dev/null +++ b/web/ce/store/issue/epic/issue.store.ts @@ -0,0 +1,14 @@ +import { IProjectIssues, ProjectIssues } from "@/store/issue/project"; +import { IIssueRootStore } from "@/store/issue/root.store"; +import { IProjectEpicsFilter } from "./filter.store"; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors + +export type IProjectEpics = IProjectIssues; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export class ProjectEpics extends ProjectIssues implements IProjectEpics { + constructor(_rootStore: IIssueRootStore, issueFilterStore: IProjectEpicsFilter) { + super(_rootStore, issueFilterStore); + } +} diff --git a/web/ce/store/issue/issue-details/activity.store.ts b/web/ce/store/issue/issue-details/activity.store.ts index 6c0029c4f..93b925aba 100644 --- a/web/ce/store/issue/issue-details/activity.store.ts +++ b/web/ce/store/issue/issue-details/activity.store.ts @@ -7,9 +7,16 @@ import uniq from "lodash/uniq"; import update from "lodash/update"; import { action, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; -import { TIssueActivityComment, TIssueActivity, TIssueActivityMap, TIssueActivityIdMap } from "@plane/types"; +// plane package imports +import { EIssueServiceType, E_SORT_ORDER, EActivityFilterType } from "@plane/constants"; +import { + TIssueActivityComment, + TIssueActivity, + TIssueActivityMap, + TIssueActivityIdMap, + TIssueServiceType, +} from "@plane/types"; // plane web constants -import { EActivityFilterType } from "@/plane-web/constants/issues"; // services import { IssueActivityService } from "@/services/issue"; // store @@ -29,40 +36,40 @@ export interface IIssueActivityStoreActions { export interface IIssueActivityStore extends IIssueActivityStoreActions { // observables - sortOrder: 'asc' | 'desc' loader: TActivityLoader; activities: TIssueActivityIdMap; activityMap: TIssueActivityMap; // helper methods getActivitiesByIssueId: (issueId: string) => string[] | undefined; getActivityById: (activityId: string) => TIssueActivity | undefined; - getActivityCommentByIssueId: (issueId: string) => TIssueActivityComment[] | undefined; - toggleSortOrder: ()=>void; + getActivityCommentByIssueId: (issueId: string, sortOrder: E_SORT_ORDER) => TIssueActivityComment[] | undefined; } export class IssueActivityStore implements IIssueActivityStore { // observables - sortOrder: "asc" | "desc" = 'asc'; loader: TActivityLoader = "fetch"; activities: TIssueActivityIdMap = {}; activityMap: TIssueActivityMap = {}; // services + serviceType; issueActivityService; - constructor(protected store: CoreRootStore) { + constructor( + protected store: CoreRootStore, + serviceType: TIssueServiceType = EIssueServiceType.ISSUES + ) { makeObservable(this, { // observables - sortOrder: observable.ref, loader: observable.ref, activities: observable, activityMap: observable, // actions fetchActivities: action, - toggleSortOrder: action }); + this.serviceType = serviceType; // services - this.issueActivityService = new IssueActivityService(); + this.issueActivityService = new IssueActivityService(this.serviceType); } // helper methods @@ -76,13 +83,16 @@ export class IssueActivityStore implements IIssueActivityStore { return this.activityMap[activityId] ?? undefined; }; - getActivityCommentByIssueId = computedFn((issueId: string) => { + getActivityCommentByIssueId = computedFn((issueId: string, sortOrder: E_SORT_ORDER) => { if (!issueId) return undefined; let activityComments: TIssueActivityComment[] = []; + const currentStore = + this.serviceType === EIssueServiceType.EPICS ? this.store.issue.epicDetail : this.store.issue.issueDetail; + const activities = this.getActivitiesByIssueId(issueId) || []; - const comments = this.store.issue.issueDetail.comment.getCommentsByIssueId(issueId) || []; + const comments = currentStore.comment.getCommentsByIssueId(issueId) || []; activities.forEach((activityId) => { const activity = this.getActivityById(activityId); @@ -95,7 +105,7 @@ export class IssueActivityStore implements IIssueActivityStore { }); comments.forEach((commentId) => { - const comment = this.store.issue.issueDetail.comment.getCommentById(commentId); + const comment = currentStore.comment.getCommentById(commentId); if (!comment) return; activityComments.push({ id: comment.id, @@ -104,15 +114,11 @@ export class IssueActivityStore implements IIssueActivityStore { }); }); - activityComments = orderBy(activityComments, (e)=>new Date(e.created_at || 0), this.sortOrder); + activityComments = orderBy(activityComments, (e) => new Date(e.created_at || 0), sortOrder); return activityComments; }); - toggleSortOrder = ()=>{ - this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'; - } - // actions public async fetchActivities( workspaceSlug: string, diff --git a/web/ce/store/issue/team-views/filter.store.ts b/web/ce/store/issue/team-views/filter.store.ts new file mode 100644 index 000000000..9c33f9405 --- /dev/null +++ b/web/ce/store/issue/team-views/filter.store.ts @@ -0,0 +1,12 @@ +import { IProjectViewIssuesFilter, ProjectViewIssuesFilter } from "@/store/issue/project-views"; +import { IIssueRootStore } from "@/store/issue/root.store"; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export type ITeamViewIssuesFilter = IProjectViewIssuesFilter; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export class TeamViewIssuesFilter extends ProjectViewIssuesFilter implements IProjectViewIssuesFilter { + constructor(_rootStore: IIssueRootStore) { + super(_rootStore); + } +} diff --git a/web/ce/store/issue/team-views/index.ts b/web/ce/store/issue/team-views/index.ts new file mode 100644 index 000000000..0fe6c946b --- /dev/null +++ b/web/ce/store/issue/team-views/index.ts @@ -0,0 +1,2 @@ +export * from "./filter.store"; +export * from "./issue.store"; diff --git a/web/ce/store/issue/team-views/issue.store.ts b/web/ce/store/issue/team-views/issue.store.ts new file mode 100644 index 000000000..328370f85 --- /dev/null +++ b/web/ce/store/issue/team-views/issue.store.ts @@ -0,0 +1,13 @@ +import { IProjectViewIssues, ProjectViewIssues } from "@/store/issue/project-views"; +import { IIssueRootStore } from "@/store/issue/root.store"; +import { ITeamViewIssuesFilter } from "./filter.store"; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export type ITeamViewIssues = IProjectViewIssues; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export class TeamViewIssues extends ProjectViewIssues implements IProjectViewIssues { + constructor(_rootStore: IIssueRootStore, teamViewFilterStore: ITeamViewIssuesFilter) { + super(_rootStore, teamViewFilterStore); + } +} diff --git a/web/ce/store/issue/team/filter.store.ts b/web/ce/store/issue/team/filter.store.ts new file mode 100644 index 000000000..42b2d5dd2 --- /dev/null +++ b/web/ce/store/issue/team/filter.store.ts @@ -0,0 +1,12 @@ +import { IProjectIssuesFilter, ProjectIssuesFilter } from "@/store/issue/project"; +import { IIssueRootStore } from "@/store/issue/root.store"; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export type ITeamIssuesFilter = IProjectIssuesFilter; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export class TeamIssuesFilter extends ProjectIssuesFilter implements IProjectIssuesFilter { + constructor(_rootStore: IIssueRootStore) { + super(_rootStore); + } +} diff --git a/web/ce/store/issue/team/index.ts b/web/ce/store/issue/team/index.ts new file mode 100644 index 000000000..0fe6c946b --- /dev/null +++ b/web/ce/store/issue/team/index.ts @@ -0,0 +1,2 @@ +export * from "./filter.store"; +export * from "./issue.store"; diff --git a/web/ce/store/issue/team/issue.store.ts b/web/ce/store/issue/team/issue.store.ts new file mode 100644 index 000000000..2e3979436 --- /dev/null +++ b/web/ce/store/issue/team/issue.store.ts @@ -0,0 +1,13 @@ +import { IProjectIssues, ProjectIssues } from "@/store/issue/project"; +import { IIssueRootStore } from "@/store/issue/root.store"; +import { ITeamIssuesFilter } from "./filter.store"; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export type ITeamIssues = IProjectIssues; + +// @ts-nocheck - This class will never be used, extending similar class to avoid type errors +export class TeamIssues extends ProjectIssues implements IProjectIssues { + constructor(_rootStore: IIssueRootStore, teamIssueFilterStore: ITeamIssuesFilter) { + super(_rootStore, teamIssueFilterStore); + } +} diff --git a/web/ce/store/timeline/base-timeline.store.ts b/web/ce/store/timeline/base-timeline.store.ts index 3e15d124d..81de55fc4 100644 --- a/web/ce/store/timeline/base-timeline.store.ts +++ b/web/ce/store/timeline/base-timeline.store.ts @@ -20,8 +20,8 @@ type BlockData = { id: string; name: string; sort_order: number | null; - start_date: string | undefined | null; - target_date: string | undefined | null; + start_date?: string | undefined | null; + target_date?: string | undefined | null; }; export interface IBaseTimelineStore { diff --git a/web/ce/types/dashboard.ts b/web/ce/types/dashboard.ts deleted file mode 100644 index d615ac4af..000000000 --- a/web/ce/types/dashboard.ts +++ /dev/null @@ -1 +0,0 @@ -export type TSidebarUserMenuItemKeys = "home" | "your-work" | "notifications" | "drafts"; diff --git a/web/ce/types/index.ts b/web/ce/types/index.ts index d18d0137a..105b7e96a 100644 --- a/web/ce/types/index.ts +++ b/web/ce/types/index.ts @@ -1,4 +1,3 @@ export * from "./projects"; export * from "./issue-types"; export * from "./gantt-chart"; -export * from "./state.d"; diff --git a/web/ce/types/projects/index.ts b/web/ce/types/projects/index.ts index 244d8c4df..9fb35777a 100644 --- a/web/ce/types/projects/index.ts +++ b/web/ce/types/projects/index.ts @@ -1 +1,2 @@ export * from "./projects"; +export * from "./project-activity"; diff --git a/web/ce/types/projects/project-activity.ts b/web/ce/types/projects/project-activity.ts new file mode 100644 index 000000000..bd61cf5ef --- /dev/null +++ b/web/ce/types/projects/project-activity.ts @@ -0,0 +1,21 @@ +import { TProjectBaseActivity } from "@plane/types"; + +export type TProjectActivity = TProjectBaseActivity & { + content: string; + userId: string; + projectId: string; + + actor_detail: { + display_name: string; + id: string; + }; + workspace_detail: { + slug: string; + }; + project_detail: { + name: string; + }; + + createdAt: string; + updatedAt: string; +}; diff --git a/web/ce/types/projects/projects.ts b/web/ce/types/projects/projects.ts index 567c9488d..92c32b65d 100644 --- a/web/ce/types/projects/projects.ts +++ b/web/ce/types/projects/projects.ts @@ -1,3 +1,5 @@ -import { IProject } from "@plane/types"; +import { IPartialProject, IProject } from "@plane/types"; -export type TProject = IProject; +export type TPartialProject = IPartialProject; + +export type TProject = TPartialProject & IProject; diff --git a/web/ce/types/state.d.ts b/web/ce/types/state.d.ts deleted file mode 100644 index 22309db81..000000000 --- a/web/ce/types/state.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface IStateTransition { - transition_state_id: string; - actors: string[]; -} - -export interface IStateWorkFlow { - [transitionId: string]: IStateTransition; -} diff --git a/web/core/components/account/auth-forms/auth-header.tsx b/web/core/components/account/auth-forms/auth-header.tsx index 4f0949aa2..b33c694ba 100644 --- a/web/core/components/account/auth-forms/auth-header.tsx +++ b/web/core/components/account/auth-forms/auth-header.tsx @@ -1,5 +1,7 @@ import { FC, ReactNode } from "react"; +import { observer } from "mobx-react"; import useSWR from "swr"; +import { useTranslation } from "@plane/i18n"; import { IWorkspaceMemberInvitation } from "@plane/types"; // components import { LogoSpinner } from "@/components/common"; @@ -21,38 +23,40 @@ type TAuthHeader = { const Titles = { [EAuthModes.SIGN_IN]: { [EAuthSteps.EMAIL]: { - header: "Log in or sign up", + header: "auth.sign_in.header.step.email.header", subHeader: "", }, [EAuthSteps.PASSWORD]: { - header: "Log in or sign up", - subHeader: "Use your email-password combination to log in.", + header: "auth.sign_in.header.step.password.header", + subHeader: "auth.sign_in.header.step.password.sub_header", }, [EAuthSteps.UNIQUE_CODE]: { - header: "Log in or Sign up", - subHeader: "Log in using a unique code sent to the email address above.", + header: "auth.sign_in.header.step.unique_code.header", + subHeader: "auth.sign_in.header.step.unique_code.sub_header", }, }, [EAuthModes.SIGN_UP]: { [EAuthSteps.EMAIL]: { - header: "Sign up", + header: "auth.sign_up.header.step.email.header", subHeader: "", }, [EAuthSteps.PASSWORD]: { - header: "Sign up", - subHeader: "Sign up using an email-password combination.", + header: "auth.sign_up.header.step.password.header", + subHeader: "auth.sign_up.header.step.password.sub_header", }, [EAuthSteps.UNIQUE_CODE]: { - header: "Sign up", - subHeader: "Sign up using a unique code sent to the email address above.", + header: "auth.sign_up.header.step.unique_code.header", + subHeader: "auth.sign_up.header.step.unique_code.sub_header", }, }, }; const workSpaceService = new WorkspaceService(); -export const AuthHeader: FC = (props) => { +export const AuthHeader: FC = observer((props) => { const { workspaceSlug, invitationId, invitationEmail, authMode, currentAuthStep, children } = props; + // plane imports + const { t } = useTranslation(); const { data: invitation, isLoading } = useSWR( workspaceSlug && invitationId ? `WORKSPACE_INVITATION_${workspaceSlug}_${invitationId}` : null, @@ -74,13 +78,12 @@ export const AuthHeader: FC = (props) => { return { header: (
    - Join {" "} + {t("common.join")}{" "} + {" "} {workspace.name}
    ), - subHeader: `${ - mode == EAuthModes.SIGN_UP ? "Create an account" : "Sign in" - } to start managing work with your team.`, + subHeader: mode == EAuthModes.SIGN_UP ? "auth.sign_up.header.label" : "auth.sign_in.header.label", }; } @@ -99,10 +102,12 @@ export const AuthHeader: FC = (props) => { return ( <>
    -

    {header}

    -

    {subHeader}

    +

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

    +

    {t(subHeader)}

    {children} ); -}; +}); diff --git a/web/core/components/account/auth-forms/auth-root.tsx b/web/core/components/account/auth-forms/auth-root.tsx index d3fa45fa7..004e81682 100644 --- a/web/core/components/account/auth-forms/auth-root.tsx +++ b/web/core/components/account/auth-forms/auth-root.tsx @@ -1,6 +1,7 @@ import React, { FC, useEffect, useState } from "react"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; +import { useTranslation } from "@plane/i18n"; import { IEmailCheckData } from "@plane/types"; // components import { @@ -51,6 +52,8 @@ export const AuthRoot: FC = observer((props) => { const [email, setEmail] = useState(emailParam ? emailParam.toString() : ""); const [errorInfo, setErrorInfo] = useState(undefined); const [isExistingEmail, setIsExistingEmail] = useState(false); + // plane hooks + const { t } = useTranslation(); // hooks const { config } = useInstance(); diff --git a/web/core/components/account/auth-forms/email.tsx b/web/core/components/account/auth-forms/email.tsx index f0b88407a..724f52442 100644 --- a/web/core/components/account/auth-forms/email.tsx +++ b/web/core/components/account/auth-forms/email.tsx @@ -4,9 +4,9 @@ import { FC, FormEvent, useMemo, useRef, useState } from "react"; import { observer } from "mobx-react"; // icons import { CircleAlert, XCircle } from "lucide-react"; -// types +// plane imports +import { useTranslation } from "@plane/i18n"; import { IEmailCheckData } from "@plane/types"; -// ui import { Button, Input, Spinner } from "@plane/ui"; // helpers import { cn } from "@/helpers/common.helper"; @@ -22,9 +22,10 @@ export const AuthEmailForm: FC = observer((props) => { // states const [isSubmitting, setIsSubmitting] = useState(false); const [email, setEmail] = useState(defaultEmail); - + // plane hooks + const { t } = useTranslation(); const emailError = useMemo( - () => (email && !checkEmailValidity(email) ? { email: "Email is invalid" } : undefined), + () => (email && !checkEmailValidity(email) ? { email: "auth.common.email.errors.invalid" } : undefined), [email] ); @@ -40,14 +41,14 @@ export const AuthEmailForm: FC = observer((props) => { const isButtonDisabled = email.length === 0 || Boolean(emailError?.email) || isSubmitting; - const [isFocused, setIsFocused] = useState(true) + const [isFocused, setIsFocused] = useState(true); const inputRef = useRef(null); return (
    = observer((props) => { !isFocused && Boolean(emailError?.email) ? `border-red-500` : `border-onboarding-border-100` )} tabIndex={-1} - onFocus={() => {setIsFocused(true)}} - onBlur={() => {setIsFocused(false)}} + onFocus={() => { + setIsFocused(true); + }} + onBlur={() => { + setIsFocused(false); + }} > = observer((props) => { type="email" value={email} onChange={(e) => setEmail(e.target.value)} - placeholder="name@company.com" + placeholder={t("auth.common.email.placeholder")} className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 autofill:bg-red-500 border-0 focus:bg-none active:bg-transparent`} autoComplete="on" autoFocus ref={inputRef} /> - {email.length > 0 && ( + {email.length > 0 && ( { @@ -83,13 +88,13 @@ export const AuthEmailForm: FC = observer((props) => { {emailError?.email && !isFocused && (

    - {emailError.email} + {t(emailError.email)}

    )}
    ); -}); \ No newline at end of file +}); diff --git a/web/core/components/account/auth-forms/forgot-password-popover.tsx b/web/core/components/account/auth-forms/forgot-password-popover.tsx index 31bafce26..4062d9a68 100644 --- a/web/core/components/account/auth-forms/forgot-password-popover.tsx +++ b/web/core/components/account/auth-forms/forgot-password-popover.tsx @@ -2,6 +2,8 @@ import { Fragment, useState } from "react"; import { usePopper } from "react-popper"; import { X } from "lucide-react"; import { Popover } from "@headlessui/react"; +// plane imports +import { useTranslation } from "@plane/i18n"; export const ForgotPasswordPopover = () => { // popper-js refs @@ -19,6 +21,8 @@ export const ForgotPasswordPopover = () => { }, ], }); + // plane hooks + const { t } = useTranslation(); return ( @@ -28,7 +32,7 @@ export const ForgotPasswordPopover = () => { ref={setReferenceElement} className="text-xs font-medium text-custom-primary-100 outline-none" > - Forgot your password? + {t("auth.common.forgot_password")} @@ -40,9 +44,7 @@ export const ForgotPasswordPopover = () => { {...attributes.popper} > 🤥 -

    - We see that your god hasn{"'"}t enabled SMTP, we will not be able to send a password reset link -

    +

    {t("auth.forgot_password.errors.smtp_not_enabled")}

    diff --git a/web/core/components/account/auth-forms/password.tsx b/web/core/components/account/auth-forms/password.tsx index 088dc3194..979899679 100644 --- a/web/core/components/account/auth-forms/password.tsx +++ b/web/core/components/account/auth-forms/password.tsx @@ -5,17 +5,13 @@ import { observer } from "mobx-react"; import Link from "next/link"; // icons import { Eye, EyeOff, Info, X, XCircle } from "lucide-react"; -// ui +// plane imports +import { FORGOT_PASSWORD, SIGN_IN_WITH_CODE, SIGN_IN_WITH_PASSWORD, SIGN_UP_WITH_PASSWORD } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { Button, Input, Spinner } from "@plane/ui"; // components import { ForgotPasswordPopover, PasswordStrengthMeter } from "@/components/account"; // constants -import { - FORGOT_PASSWORD, - SIGN_IN_WITH_CODE, - SIGN_IN_WITH_PASSWORD, - SIGN_UP_WITH_PASSWORD, -} from "@/constants/event-tracker"; // helpers import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper"; import { API_BASE_URL } from "@/helpers/common.helper"; @@ -49,6 +45,8 @@ const authService = new AuthService(); export const AuthPasswordForm: React.FC = observer((props: Props) => { const { email, isSMTPConfigured, handleAuthStep, handleEmailClear, mode, nextPath } = props; + // plane imports + const { t } = useTranslation(); // hooks const { captureEvent } = useEventTracker(); // ref @@ -92,7 +90,7 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { href={`/accounts/forgot-password?email=${encodeURIComponent(email)}`} className="text-xs font-medium text-custom-primary-100" > - Forgot your password? + {t("auth.common.forgot_password")} ) : ( @@ -134,7 +132,7 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => {
    -
    Try setting-up a strong password to proceed
    +
    {t("auth.sign_up.errors.password.strength")}
    setBannerMessage(false)} @@ -158,7 +156,7 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { if (isPasswordValid) { setIsSubmitting(true); captureEvent(mode === EAuthModes.SIGN_IN ? SIGN_IN_WITH_PASSWORD : SIGN_UP_WITH_PASSWORD); - formRef.current && formRef.current.submit(); // Manually submit the form if the condition is met + if (formRef.current) formRef.current.submit(); // Manually submit the form if the condition is met } else { setBannerMessage(true); } @@ -170,7 +168,7 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { {nextPath && }
    = observer((props: Props) => { type="email" value={passwordFormData.email} onChange={(e) => handleFormChange("email", e.target.value)} - placeholder="name@company.com" + placeholder={t("auth.common.email.placeholder")} className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 border-0`} disabled /> @@ -196,7 +194,7 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => {
    = observer((props: Props) => { name="password" value={passwordFormData.password} onChange={(e) => handleFormChange("password", e.target.value)} - placeholder="Enter password" + placeholder={t("auth.common.password.placeholder")} className="disable-autofill-style h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" onFocus={() => setIsPasswordInputFocused(true)} onBlur={() => setIsPasswordInputFocused(false)} @@ -229,7 +227,7 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { {mode === EAuthModes.SIGN_UP && (
    = observer((props: Props) => { name="confirm_password" value={passwordFormData.confirm_password} onChange={(e) => handleFormChange("confirm_password", e.target.value)} - placeholder="Confirm password" + placeholder={t("auth.common.password.confirm_password.placeholder")} className="disable-autofill-style h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" onFocus={() => setIsRetryPasswordInputFocused(true)} onBlur={() => setIsRetryPasswordInputFocused(false)} @@ -256,7 +254,9 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => {
    {!!passwordFormData.confirm_password && passwordFormData.password !== passwordFormData.confirm_password && - renderPasswordMatchError && Passwords don{"'"}t match} + renderPasswordMatchError && ( + {t("auth.common.password.errors.match")} + )}
    )} @@ -267,9 +267,9 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { {isSubmitting ? ( ) : isSMTPConfigured ? ( - "Continue" + t("common.continue") ) : ( - "Go to workspace" + t("common.go_to_workspace") )} {isSMTPConfigured && ( @@ -280,7 +280,7 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { className="w-full" size="lg" > - Sign in with unique code + {t("auth.common.sign_in_with_unique_code")} )} diff --git a/web/core/components/account/auth-forms/unique-code.tsx b/web/core/components/account/auth-forms/unique-code.tsx index 530874eb9..b301f268b 100644 --- a/web/core/components/account/auth-forms/unique-code.tsx +++ b/web/core/components/account/auth-forms/unique-code.tsx @@ -2,9 +2,10 @@ import React, { useEffect, useState } from "react"; import { CircleCheck, XCircle } from "lucide-react"; +import { CODE_VERIFIED } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { Button, Input, Spinner } from "@plane/ui"; // constants -import { CODE_VERIFIED } from "@/constants/event-tracker"; // helpers import { EAuthModes } from "@/helpers/authentication.helper"; import { API_BASE_URL } from "@/helpers/common.helper"; @@ -49,6 +50,8 @@ export const AuthUniqueCodeForm: React.FC = (props) => { const [isSubmitting, setIsSubmitting] = useState(false); // timer const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0); + // plane hooks + const { t } = useTranslation(); const handleFormChange = (key: keyof TUniqueCodeFormValues, value: string) => setUniqueCodeFormData((prev) => ({ ...prev, [key]: value })); @@ -94,7 +97,7 @@ export const AuthUniqueCodeForm: React.FC = (props) => { {nextPath && }
    = (props) => { type="email" value={uniqueCodeFormData.email} onChange={(e) => handleFormChange("email", e.target.value)} - placeholder="name@company.com" + placeholder={t("auth.common.email.placeholder")} className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 border-0`} autoComplete="on" disabled @@ -121,20 +124,20 @@ export const AuthUniqueCodeForm: React.FC = (props) => {
    handleFormChange("code", e.target.value)} - placeholder="gets-sets-flys" + placeholder={t("auth.common.unique_code.placeholder")} className="disable-autofill-style h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" autoFocus />

    - Paste the code sent to your email + {t("auth.common.unique_code.paste_code")}

    diff --git a/web/core/components/account/deactivate-account-modal.tsx b/web/core/components/account/deactivate-account-modal.tsx index aa27af466..1132a8d74 100644 --- a/web/core/components/account/deactivate-account-modal.tsx +++ b/web/core/components/account/deactivate-account-modal.tsx @@ -3,6 +3,7 @@ import React, { useState } from "react"; import { Trash2 } from "lucide-react"; import { Dialog, Transition } from "@headlessui/react"; +import { useTranslation } from "@plane/i18n"; // ui import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // hooks @@ -18,6 +19,7 @@ export const DeactivateAccountModal: React.FC = (props) => { const router = useAppRouter(); const { isOpen, onClose } = props; // hooks + const { t } = useTranslation(); const { deactivateAccount, signOut } = useUser(); // states @@ -90,11 +92,10 @@ export const DeactivateAccountModal: React.FC = (props) => {
    - Deactivate your account? + {t("deactivate_your_account")}

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

    @@ -102,10 +103,10 @@ export const DeactivateAccountModal: React.FC = (props) => {
    diff --git a/web/core/components/account/password-strength-meter.tsx b/web/core/components/account/password-strength-meter.tsx index 342f77efb..1f0abae9c 100644 --- a/web/core/components/account/password-strength-meter.tsx +++ b/web/core/components/account/password-strength-meter.tsx @@ -1,6 +1,7 @@ "use client"; import { FC, useMemo } from "react"; +import { useTranslation } from "@plane/i18n"; // import { CircleCheck } from "lucide-react"; // helpers import { cn } from "@/helpers/common.helper"; @@ -17,6 +18,7 @@ type TPasswordStrengthMeter = { export const PasswordStrengthMeter: FC = (props) => { const { password, isFocused = false } = props; + const { t } = useTranslation(); // derived values const strength = useMemo(() => getPasswordStrength(password), [password]); const strengthBars = useMemo(() => { @@ -24,40 +26,40 @@ export const PasswordStrengthMeter: FC = (props) => { case E_PASSWORD_STRENGTH.EMPTY: { return { bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`], - text: "Please enter your password.", + text: t("auth.common.password.errors.empty"), textColor: "text-custom-text-100", }; } case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID: { return { bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`], - text: "Password length should me more than 8 characters.", + text: t("auth.common.password.errors.length"), textColor: "text-red-500", }; } case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID: { return { bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`], - text: "Password is weak.", + text: t("auth.common.password.errors.strength.weak"), textColor: "text-red-500", }; } case E_PASSWORD_STRENGTH.STRENGTH_VALID: { return { bars: [`bg-green-500`, `bg-green-500`, `bg-green-500`], - text: "Password is strong.", + text: t("auth.common.password.errors.strength.strong"), textColor: "text-green-500", }; } default: { return { bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`], - text: "Please enter your password.", + text: t("auth.common.password.errors.empty"), textColor: "text-custom-text-100", }; } } - }, [strength]); + }, [strength,t]); const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true; diff --git a/web/core/components/analytics/custom-analytics/graph/custom-tooltip.tsx b/web/core/components/analytics/custom-analytics/graph/custom-tooltip.tsx index 9101f1cf2..853c541e7 100644 --- a/web/core/components/analytics/custom-analytics/graph/custom-tooltip.tsx +++ b/web/core/components/analytics/custom-analytics/graph/custom-tooltip.tsx @@ -1,9 +1,10 @@ // nivo import { BarTooltipProps } from "@nivo/bar"; +// plane imports +import { ANALYTICS_DATE_KEYS } from "@plane/constants"; import { IAnalyticsParams, IAnalyticsResponse } from "@plane/types"; -import { DATE_KEYS } from "@/constants/analytics"; +// helpers import { renderMonthAndYear } from "@/helpers/analytics.helper"; -// types type Props = { datum: BarTooltipProps; @@ -23,7 +24,7 @@ export const CustomTooltip: React.FC = ({ datum, analytics, params }) => }; if (params.segment) { - if (DATE_KEYS.includes(params.segment)) tooltipValue = renderMonthAndYear(datum.id); + if (ANALYTICS_DATE_KEYS.includes(params.segment)) tooltipValue = renderMonthAndYear(datum.id); else if (params.segment === "labels__id") { const label = analytics.extras.label_details.find((l) => l.labels__id === datum.id); tooltipValue = label && label.labels__name ? label.labels__name : "None"; @@ -41,8 +42,8 @@ export const CustomTooltip: React.FC = ({ datum, analytics, params }) => : "None"; } else tooltipValue = datum.id; } else { - if (DATE_KEYS.includes(params.x_axis)) tooltipValue = datum.indexValue; - else tooltipValue = datum.id === "count" ? "Issue count" : "Estimate"; + if (ANALYTICS_DATE_KEYS.includes(params.x_axis)) tooltipValue = datum.indexValue; + else tooltipValue = datum.id === "count" ? "Work item count" : "Estimate"; } return ( diff --git a/web/core/components/analytics/custom-analytics/graph/index.tsx b/web/core/components/analytics/custom-analytics/graph/index.tsx index 57531e373..87627bba3 100644 --- a/web/core/components/analytics/custom-analytics/graph/index.tsx +++ b/web/core/components/analytics/custom-analytics/graph/index.tsx @@ -32,7 +32,7 @@ export const AnalyticsGraph: React.FC = ({ analytics, barGraphData, param let data: number[] = []; if (params.segment) - // find the total no of issues in each segment + // find the total no of work items in each segment data = Object.keys(analytics.distribution).map((segment) => { let totalSegmentIssues = 0; diff --git a/web/core/components/analytics/custom-analytics/main-content.tsx b/web/core/components/analytics/custom-analytics/main-content.tsx index 8c1f8451f..b990c53bc 100644 --- a/web/core/components/analytics/custom-analytics/main-content.tsx +++ b/web/core/components/analytics/custom-analytics/main-content.tsx @@ -46,7 +46,7 @@ export const CustomAnalyticsMainContent: React.FC = (props) => { ) : (
    -

    No matching issues found. Try changing the parameters.

    +

    No matching work items found. Try changing the parameters.

    ) diff --git a/web/core/components/analytics/custom-analytics/select-bar.tsx b/web/core/components/analytics/custom-analytics/select-bar.tsx index 8df1e71e1..27cfad236 100644 --- a/web/core/components/analytics/custom-analytics/select-bar.tsx +++ b/web/core/components/analytics/custom-analytics/select-bar.tsx @@ -1,12 +1,11 @@ import { observer } from "mobx-react"; import { Control, Controller, UseFormSetValue } from "react-hook-form"; -// types +// plane imports +import { ANALYTICS_X_AXIS_VALUES } from "@plane/constants"; import { IAnalyticsParams } from "@plane/types"; -// ui import { Row } from "@plane/ui"; // components import { SelectProject, SelectSegment, SelectXAxis, SelectYAxis } from "@/components/analytics"; -import { ANALYTICS_X_AXIS_VALUES } from "@/constants/analytics"; // hooks import { useProject } from "@/hooks/store"; diff --git a/web/core/components/analytics/custom-analytics/select/y-axis.tsx b/web/core/components/analytics/custom-analytics/select/y-axis.tsx index c97521c36..42c9145e8 100644 --- a/web/core/components/analytics/custom-analytics/select/y-axis.tsx +++ b/web/core/components/analytics/custom-analytics/select/y-axis.tsx @@ -2,10 +2,10 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; +// plane imports +import { ANALYTICS_Y_AXIS_VALUES } from "@plane/constants"; import { TYAxisValues } from "@plane/types"; import { CustomSelect } from "@plane/ui"; -// constants -import { ANALYTICS_Y_AXIS_VALUES } from "@/constants/analytics"; // hooks import { useProjectEstimates } from "@/hooks/store"; // plane web constants diff --git a/web/core/components/analytics/custom-analytics/sidebar/projects-list.tsx b/web/core/components/analytics/custom-analytics/sidebar/projects-list.tsx index d70807467..c07867378 100644 --- a/web/core/components/analytics/custom-analytics/sidebar/projects-list.tsx +++ b/web/core/components/analytics/custom-analytics/sidebar/projects-list.tsx @@ -1,6 +1,9 @@ import { observer } from "mobx-react"; // icons -import { Contrast, LayoutGrid, Users } from "lucide-react"; +import { Contrast, LayoutGrid, Users, Loader as Spinner } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { Loader } from "@plane/ui"; // components import { Logo } from "@/components/common"; // helpers @@ -10,19 +13,26 @@ import { useProject } from "@/hooks/store"; type Props = { projectIds: string[]; + isLoading: boolean; + isUpdating: boolean; }; export const CustomAnalyticsSidebarProjectsList: React.FC = observer((props) => { - const { projectIds } = props; - - const { getProjectById } = useProject(); + const { projectIds, isLoading, isUpdating } = props; + // store hooks + const { getProjectById, getProjectAnalyticsCountById } = useProject(); + const { t } = useTranslation(); return (
    -

    Selected Projects

    +
    +

    {t("workspace_analytics.selected_projects")}

    + {isUpdating && } +
    {projectIds.map((projectId) => { const project = getProjectById(projectId); + const projectAnalyticsCount = getProjectAnalyticsCountById(projectId); if (!project) return; @@ -38,27 +48,37 @@ export const CustomAnalyticsSidebarProjectsList: React.FC = observer((pro
    -
    -
    - -
    Total members
    -
    - {project.total_members} -
    -
    -
    - -
    Total cycles
    -
    - {project.total_cycles} -
    -
    -
    - -
    Total modules
    -
    - {project.total_modules} -
    + {isLoading ? ( + + + + + + ) : ( + <> +
    +
    + +
    {t("workspace_analytics.total_members")}
    +
    + {projectAnalyticsCount?.total_members} +
    +
    +
    + +
    {t("workspace_analytics.total_cycles")}
    +
    + {projectAnalyticsCount?.total_cycles} +
    +
    +
    + +
    {t("workspace_analytics.total_modules")}
    +
    + {projectAnalyticsCount?.total_modules} +
    + + )}
    ); diff --git a/web/core/components/analytics/custom-analytics/sidebar/sidebar-header.tsx b/web/core/components/analytics/custom-analytics/sidebar/sidebar-header.tsx index 9873d08a8..707a14922 100644 --- a/web/core/components/analytics/custom-analytics/sidebar/sidebar-header.tsx +++ b/web/core/components/analytics/custom-analytics/sidebar/sidebar-header.tsx @@ -1,9 +1,10 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // components +import { NETWORK_CHOICES } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { Logo } from "@/components/common"; // constants -import { NETWORK_CHOICES } from "@/constants/project"; // helpers import { renderFormattedDate } from "@/helpers/date-time.helper"; // hooks @@ -16,6 +17,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => { const { getCycleById } = useCycle(); const { getModuleById } = useModule(); const { getUserDetails } = useMember(); + const { t } = useTranslation(); const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined; @@ -91,7 +93,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
    Network
    - {NETWORK_CHOICES.find((n) => n.key === projectDetails?.network)?.label ?? ""} + {t(NETWORK_CHOICES.find((n) => n.key === projectDetails?.network)?.i18n_label ?? "")}
    diff --git a/web/core/components/analytics/custom-analytics/sidebar/sidebar.tsx b/web/core/components/analytics/custom-analytics/sidebar/sidebar.tsx index 639a830cd..5df139205 100644 --- a/web/core/components/analytics/custom-analytics/sidebar/sidebar.tsx +++ b/web/core/components/analytics/custom-analytics/sidebar/sidebar.tsx @@ -3,10 +3,11 @@ import { useEffect } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -import { mutate } from "swr"; +import useSWR, { mutate } from "swr"; // icons import { CalendarDays, Download, RefreshCw } from "lucide-react"; // types +import { useTranslation } from "@plane/i18n"; import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "@plane/types"; // ui import { Button, LayersIcon, TOAST_TYPE, setToast } from "@plane/ui"; @@ -30,19 +31,28 @@ type Props = { const analyticsService = new AnalyticsService(); +const PROJECT_ANALYTICS_COUNT_PARAMS = { + fields: "total_members,total_cycles,total_modules", +}; + export const CustomAnalyticsSidebar: React.FC = observer((props) => { const { analytics, params, isProjectLevel = false } = props; // router const { workspaceSlug, projectId, cycleId, moduleId } = useParams(); // store hooks const { data: currentUser } = useUser(); - const { workspaceProjectIds, getProjectById } = useProject(); + const { workspaceProjectIds, getProjectById, fetchProjectAnalyticsCount } = useProject(); const { getWorkspaceById } = useWorkspace(); - + const { t } = useTranslation(); const { fetchCycleDetails, getCycleById } = useCycle(); const { fetchModuleDetails, getModuleById } = useModule(); + // fetch project analytics count + const { isLoading: isProjectAnalyticsLoading, isValidating: isProjectAnalyticsUpdating } = useSWR( + workspaceSlug ? ["projectAnalyticsCount", workspaceSlug] : null, + workspaceSlug ? () => fetchProjectAnalyticsCount(workspaceSlug.toString(), PROJECT_ANALYTICS_COUNT_PARAMS) : null + ); - const projectDetails = projectId ? getProjectById(projectId.toString()) ?? undefined : undefined; + const projectDetails = projectId ? (getProjectById(projectId.toString()) ?? undefined) : undefined; const trackExportAnalytics = () => { if (!currentUser) return; @@ -152,7 +162,7 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => {
    {analytics ? analytics.total : "..."} -
    Issues
    +
    {t("work_items")}
    {isProjectLevel && (
    @@ -171,7 +181,11 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => {
    <> {!isProjectLevel && selectedProjects && selectedProjects.length > 0 && ( - + )} @@ -187,10 +201,10 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => { mutate(ANALYTICS(workspaceSlug.toString(), params)); }} > -
    Refresh
    +
    {t("refresh")}
    diff --git a/web/core/components/analytics/custom-analytics/table.tsx b/web/core/components/analytics/custom-analytics/table.tsx index 49039ed02..fd580d727 100644 --- a/web/core/components/analytics/custom-analytics/table.tsx +++ b/web/core/components/analytics/custom-analytics/table.tsx @@ -1,10 +1,11 @@ "use client"; import { BarDatum } from "@nivo/bar"; +// plane package imports +import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES } from "@plane/constants"; import { IAnalyticsParams, IAnalyticsResponse, TIssuePriorities } from "@plane/types"; import { PriorityIcon, Tooltip } from "@plane/ui"; // helpers -import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES } from "@/constants/analytics"; import { generateBarColor, generateDisplayName, renderChartDynamicLabel } from "@/helpers/analytics.helper"; import { cn } from "@/helpers/common.helper"; diff --git a/web/core/components/analytics/project-modal/main-content.tsx b/web/core/components/analytics/project-modal/main-content.tsx index e91282801..cd3813f80 100644 --- a/web/core/components/analytics/project-modal/main-content.tsx +++ b/web/core/components/analytics/project-modal/main-content.tsx @@ -1,12 +1,12 @@ import React, { Fragment } from "react"; import { observer } from "mobx-react"; import { Tab } from "@headlessui/react"; +// plane package imports +import { ANALYTICS_TABS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { ICycle, IModule, IProject } from "@plane/types"; // components import { CustomAnalytics, ScopeAndDemand } from "@/components/analytics"; -// types -import { ANALYTICS_TABS } from "@/constants/analytics"; -// constants type Props = { fullScreen: boolean; @@ -17,7 +17,7 @@ type Props = { export const ProjectAnalyticsModalMainContent: React.FC = observer((props) => { const { fullScreen, cycleDetails, moduleDetails } = props; - + const { t } = useTranslation(); return ( @@ -29,7 +29,7 @@ export const ProjectAnalyticsModalMainContent: React.FC = observer((props selected ? "text-custom-primary-100 " : "hover:text-custom-text-200" }`} > - {tab.title} + {t(tab.i18n_title)}
    diff --git a/web/core/components/analytics/scope-and-demand/demand.tsx b/web/core/components/analytics/scope-and-demand/demand.tsx index 39a04cf9b..fefdc8fc6 100644 --- a/web/core/components/analytics/scope-and-demand/demand.tsx +++ b/web/core/components/analytics/scope-and-demand/demand.tsx @@ -1,52 +1,58 @@ +// plane imports +import { STATE_GROUPS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; // types import { IDefaultAnalyticsResponse, TStateGroups } from "@plane/types"; // constants import { Card } from "@plane/ui"; -import { STATE_GROUPS } from "@/constants/state"; type Props = { defaultAnalytics: IDefaultAnalyticsResponse; }; -export const AnalyticsDemand: React.FC = ({ defaultAnalytics }) => ( - -
    -

    Total open tasks

    -

    {defaultAnalytics.open_issues}

    -
    -
    - {defaultAnalytics?.open_issues_classified.map((group) => { - const percentage = ((group.state_count / defaultAnalytics.total_issues) * 100).toFixed(0); +export const AnalyticsDemand: React.FC = ({ defaultAnalytics }) => { + const { t } = useTranslation(); - return ( -
    -
    -
    - +
    +

    {t("workspace_analytics.open_tasks")}

    +

    {defaultAnalytics.open_issues}

    +
    +
    + {defaultAnalytics?.open_issues_classified.map((group) => { + const percentage = ((group.state_count / defaultAnalytics.total_issues) * 100).toFixed(0); + + return ( +
    +
    +
    + +
    {group.state_group}
    + + {group.state_count} + +
    +

    {percentage}%

    +
    +
    +
    -
    {group.state_group}
    - - {group.state_count} -
    -

    {percentage}%

    -
    -
    -
    -
    - ); - })} -
    - -); + ); + })} +
    + + ); +}; diff --git a/web/core/components/analytics/scope-and-demand/leaderboard.tsx b/web/core/components/analytics/scope-and-demand/leaderboard.tsx index 69b1c5d67..76f96be4a 100644 --- a/web/core/components/analytics/scope-and-demand/leaderboard.tsx +++ b/web/core/components/analytics/scope-and-demand/leaderboard.tsx @@ -1,4 +1,5 @@ // plane ui +import { useTranslation } from "@plane/i18n"; import { Card } from "@plane/ui"; // components import { ProfileEmptyState } from "@/components/ui"; @@ -21,45 +22,48 @@ type Props = { workspaceSlug: string; }; -export const AnalyticsLeaderBoard: React.FC = ({ users, title, emptyStateMessage, workspaceSlug }) => ( - -
    {title}
    - {users.length > 0 ? ( - - ) : ( -
    - -
    - )} -
    -); +export const AnalyticsLeaderBoard: React.FC = ({ users, title, emptyStateMessage, workspaceSlug }) => { + const { t } = useTranslation(); + return ( + +
    {title}
    + {users.length > 0 ? ( + + ) : ( +
    + +
    + )} +
    + ); +}; diff --git a/web/core/components/analytics/scope-and-demand/scope-and-demand.tsx b/web/core/components/analytics/scope-and-demand/scope-and-demand.tsx index 6aac07c6a..ae51727aa 100644 --- a/web/core/components/analytics/scope-and-demand/scope-and-demand.tsx +++ b/web/core/components/analytics/scope-and-demand/scope-and-demand.tsx @@ -2,6 +2,7 @@ import { useParams } from "next/navigation"; import useSWR from "swr"; // ui +import { useTranslation } from "@plane/i18n"; import { Button, ContentWrapper, Loader } from "@plane/ui"; // components import { AnalyticsDemand, AnalyticsLeaderBoard, AnalyticsScope, AnalyticsYearWiseIssues } from "@/components/analytics"; @@ -21,6 +22,7 @@ export const ScopeAndDemand: React.FC = (props) => { const { fullScreen = true } = props; const { workspaceSlug, projectId, cycleId, moduleId } = useParams(); + const { t } = useTranslation(); const isProjectLevel = projectId ? true : false; @@ -66,8 +68,8 @@ export const ScopeAndDemand: React.FC = (props) => { count: user?.count, id: user?.created_by__id, }))} - title="Most issues created" - emptyStateMessage="Co-workers and the number of issues created by them appears here." + title={t("workspace_analytics.most_work_items_created.title")} + emptyStateMessage={t("workspace_analytics.most_work_items_created.empty_state")} workspaceSlug={workspaceSlug?.toString() ?? ""} /> = (props) => { count: user?.count, id: user?.assignees__id, }))} - title="Most issues closed" - emptyStateMessage="Co-workers and the number of issues closed by them appears here." + title={t("workspace_analytics.most_work_items_closed.title")} + emptyStateMessage={t("workspace_analytics.most_work_items_closed.empty_state")} workspaceSlug={workspaceSlug?.toString() ?? ""} />
    @@ -99,10 +101,10 @@ export const ScopeAndDemand: React.FC = (props) => { ) : (
    -

    There was some error in fetching the data.

    +

    {t("workspace_analytics.error")}

    diff --git a/web/core/components/analytics/scope-and-demand/scope.tsx b/web/core/components/analytics/scope-and-demand/scope.tsx index 671115097..13cd60d56 100644 --- a/web/core/components/analytics/scope-and-demand/scope.tsx +++ b/web/core/components/analytics/scope-and-demand/scope.tsx @@ -1,4 +1,5 @@ // plane types +import { useTranslation } from "@plane/i18n"; import { IDefaultAnalyticsUser } from "@plane/types"; // plane ui import { Card } from "@plane/ui"; @@ -14,82 +15,85 @@ type Props = { pendingAssignedIssues: IDefaultAnalyticsUser[]; }; -export const AnalyticsScope: React.FC = ({ pendingUnAssignedIssuesUser, pendingAssignedIssues }) => ( - -
    -
    -
    -
    Pending issues
    - {pendingUnAssignedIssuesUser && ( -
    - Unassigned: {pendingUnAssignedIssuesUser.count} +export const AnalyticsScope: React.FC = ({ pendingUnAssignedIssuesUser, pendingAssignedIssues }) => { + const { t } = useTranslation(); + return ( + +
    +
    +
    +
    {t("workspace_analytics.pending_work_items.title")}
    + {pendingUnAssignedIssuesUser && ( +
    + {t("unassigned")}: {pendingUnAssignedIssuesUser.count} +
    + )} +
    + + {pendingAssignedIssues && pendingAssignedIssues.length > 0 ? ( + `#f97316`} + customYAxisTickValues={pendingAssignedIssues.map((d) => (d.count > 0 ? d.count : 50))} + tooltip={(datum) => { + const assignee = pendingAssignedIssues.find((a) => a.assignees__id === `${datum.indexValue}`); + + return ( +
    + + {assignee ? assignee.assignees__display_name : "No assignee"}:{" "} + + {datum.value} +
    + ); + }} + axisBottom={{ + renderTick: (datum) => { + const assignee = pendingAssignedIssues[datum.tickIndex] ?? ""; + + if (assignee && assignee?.assignees__avatar_url && assignee?.assignees__avatar_url !== "") + return ( + + + + ); + else + return ( + + + + {datum.value ? `${assignee.assignees__display_name}`.toUpperCase()[0] : "?"} + + + ); + }, + }} + margin={{ top: 20 }} + theme={{ + axis: {}, + }} + /> + ) : ( +
    +
    )}
    - - {pendingAssignedIssues && pendingAssignedIssues.length > 0 ? ( - `#f97316`} - customYAxisTickValues={pendingAssignedIssues.map((d) => (d.count > 0 ? d.count : 50))} - tooltip={(datum) => { - const assignee = pendingAssignedIssues.find((a) => a.assignees__id === `${datum.indexValue}`); - - return ( -
    - - {assignee ? assignee.assignees__display_name : "No assignee"}:{" "} - - {datum.value} -
    - ); - }} - axisBottom={{ - renderTick: (datum) => { - const assignee = pendingAssignedIssues[datum.tickIndex] ?? ""; - - if (assignee && assignee?.assignees__avatar_url && assignee?.assignees__avatar_url !== "") - return ( - - - - ); - else - return ( - - - - {datum.value ? `${assignee.assignees__display_name}`.toUpperCase()[0] : "?"} - - - ); - }, - }} - margin={{ top: 20 }} - theme={{ - axis: {}, - }} - /> - ) : ( -
    - -
    - )}
    -
    - -); + + ); +}; diff --git a/web/core/components/analytics/scope-and-demand/year-wise-issues.tsx b/web/core/components/analytics/scope-and-demand/year-wise-issues.tsx index b2c5805dd..0f469db70 100644 --- a/web/core/components/analytics/scope-and-demand/year-wise-issues.tsx +++ b/web/core/components/analytics/scope-and-demand/year-wise-issues.tsx @@ -1,4 +1,5 @@ // ui +import { useTranslation } from "@plane/i18n"; import { IDefaultAnalyticsResponse } from "@plane/types"; import { Card } from "@plane/ui"; import { LineGraph, ProfileEmptyState } from "@/components/ui"; @@ -12,49 +13,52 @@ type Props = { defaultAnalytics: IDefaultAnalyticsResponse; }; -export const AnalyticsYearWiseIssues: React.FC = ({ defaultAnalytics }) => ( - -

    Issues closed in a year

    - {defaultAnalytics.issue_completed_month_wise.length > 0 ? ( - ({ - x: month.shortTitle, - y: - defaultAnalytics.issue_completed_month_wise.find((data) => data.month === parseInt(index, 10))?.count || - 0, - })), - }, - ]} - customYAxisTickValues={defaultAnalytics.issue_completed_month_wise.map((data) => data.count)} - height="300px" - colors={(datum) => datum.color} - curve="monotoneX" - margin={{ top: 20 }} - enableSlices="x" - sliceTooltip={(datum) => ( -
    - {datum.slice.points[0].data.yFormatted} - issues closed in - {datum.slice.points[0].data.xFormatted} -
    - )} - theme={{ - background: "rgb(var(--color-background-100))", - }} - enableArea - /> - ) : ( -
    - = ({ defaultAnalytics }) => { + const { t } = useTranslation(); + return ( + +

    {t("workspace_analytics.work_items_closed_in_a_year.title")}

    + {defaultAnalytics.issue_completed_month_wise.length > 0 ? ( + ({ + x: t(month.shortTitle), + y: + defaultAnalytics.issue_completed_month_wise.find((data) => data.month === parseInt(index, 10)) + ?.count || 0, + })), + }, + ]} + customYAxisTickValues={defaultAnalytics.issue_completed_month_wise.map((data) => data.count)} + height="300px" + colors={(datum) => datum.color} + curve="monotoneX" + margin={{ top: 20 }} + enableSlices="x" + sliceTooltip={(datum) => ( +
    + {datum.slice.points[0].data.yFormatted} + {t("workspace_analytics.work_items_closed_in")} + {datum.slice.points[0].data.xFormatted} +
    + )} + theme={{ + background: "rgb(var(--color-background-100))", + }} + enableArea /> -
    - )} -
    -); + ) : ( +
    + +
    + )} + + ); +}; diff --git a/web/core/components/api-token/delete-token-modal.tsx b/web/core/components/api-token/delete-token-modal.tsx index 4e7d15fdf..ecc85a558 100644 --- a/web/core/components/api-token/delete-token-modal.tsx +++ b/web/core/components/api-token/delete-token-modal.tsx @@ -4,6 +4,7 @@ import { useState, FC } from "react"; import { useParams } from "next/navigation"; import { mutate } from "swr"; // types +import { useTranslation } from "@plane/i18n"; import { IApiToken } from "@plane/types"; // ui import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui"; @@ -26,6 +27,7 @@ export const DeleteApiTokenModal: FC = (props) => { const [deleteLoading, setDeleteLoading] = useState(false); // router params const { workspaceSlug } = useParams(); + const { t } = useTranslation(); const handleClose = () => { onClose(); @@ -42,8 +44,8 @@ export const DeleteApiTokenModal: FC = (props) => { .then(() => { setToast({ type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Token deleted successfully.", + title: t("workspace_settings.settings.api_tokens.delete.success.title"), + message: t("workspace_settings.settings.api_tokens.delete.success.message"), }); mutate( @@ -57,8 +59,8 @@ export const DeleteApiTokenModal: FC = (props) => { .catch((err) => setToast({ type: TOAST_TYPE.ERROR, - title: "Error!", - message: err?.message ?? "Something went wrong. Please try again.", + title: t("workspace_settings.settings.api_tokens.delete.error.title"), + message: err?.message ?? t("workspace_settings.settings.api_tokens.delete.error.message"), }) ) .finally(() => setDeleteLoading(false)); @@ -70,12 +72,8 @@ export const DeleteApiTokenModal: FC = (props) => { handleSubmit={handleDeletion} isSubmitting={deleteLoading} isOpen={isOpen} - title="Delete API token" - content={ - <> - Any application using this token will no longer have the access to Plane data. This action cannot be undone. - - } + title={t("workspace_settings.settings.api_tokens.delete.title")} + content={<>{t("workspace_settings.settings.api_tokens.delete.description")} } /> ); }; diff --git a/web/core/components/api-token/modal/form.tsx b/web/core/components/api-token/modal/form.tsx index 18ade7f2a..17e14ae56 100644 --- a/web/core/components/api-token/modal/form.tsx +++ b/web/core/components/api-token/modal/form.tsx @@ -5,6 +5,7 @@ import { add } from "date-fns"; import { Controller, useForm } from "react-hook-form"; import { Calendar } from "lucide-react"; // types +import { useTranslation } from "@plane/i18n"; import { IApiToken } from "@plane/types"; // ui import { Button, CustomSelect, Input, TextArea, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui"; @@ -76,6 +77,8 @@ export const CreateApiTokenForm: React.FC = (props) => { reset, watch, } = useForm({ defaultValues }); + // hooks + const { t } = useTranslation(); const handleFormSubmit = async (data: IApiToken) => { // if never expires is toggled off, and the user has not selected a custom date or a predefined date, show an error @@ -115,19 +118,21 @@ export const CreateApiTokenForm: React.FC = (props) => { return (
    -

    Create token

    +

    + {t("workspace_settings.settings.api_tokens.create_token")} +

    val.trim() !== "" || "Title is required", + validate: (val) => val.trim() !== "" || t("title_is_required"), }} render={({ field: { value, onChange } }) => ( = (props) => { value={value} onChange={onChange} hasError={Boolean(errors.label)} - placeholder="Title" + placeholder={t("title")} className="w-full text-base" /> )} @@ -150,7 +155,7 @@ export const CreateApiTokenForm: React.FC = (props) => { value={value} onChange={onChange} hasError={Boolean(errors.description)} - placeholder="Description" + placeholder={t("description")} className="w-full text-base resize-none min-h-24" /> )} @@ -229,14 +234,16 @@ export const CreateApiTokenForm: React.FC = (props) => {
    {}} size="sm" />
    - Never expires + {t("workspace_settings.settings.api_tokens.never_expires")}
    diff --git a/web/core/components/api-token/modal/generated-token-details.tsx b/web/core/components/api-token/modal/generated-token-details.tsx index 8f0a62c3a..da833cefb 100644 --- a/web/core/components/api-token/modal/generated-token-details.tsx +++ b/web/core/components/api-token/modal/generated-token-details.tsx @@ -1,6 +1,7 @@ "use client"; import { Copy } from "lucide-react"; +import { useTranslation } from "@plane/i18n"; import { IApiToken } from "@plane/types"; // ui import { Button, Tooltip, TOAST_TYPE, setToast } from "@plane/ui"; @@ -19,12 +20,13 @@ type Props = { export const GeneratedTokenDetails: React.FC = (props) => { const { handleClose, tokenDetails } = props; const { isMobile } = usePlatformOS(); + const { t } = useTranslation(); const copyApiToken = (token: string) => { copyTextToClipboard(token).then(() => setToast({ type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Token copied to clipboard.", + title: `${t("success")}!`, + message: t("workspace_setting.token_copied"), }) ); }; @@ -32,11 +34,8 @@ export const GeneratedTokenDetails: React.FC = (props) => { return (
    -

    Key created

    -

    - Copy and save this secret key in Plane Pages. You can{"'"}t see this key after you hit Close. A CSV file - containing the key has been downloaded. -

    +

    {t("workspace_settings.key_created")}

    +

    {t("workspace_settings.copy_key")}

    diff --git a/web/core/components/archives/archive-tabs-list.tsx b/web/core/components/archives/archive-tabs-list.tsx index d1daeb77f..5bba6cc20 100644 --- a/web/core/components/archives/archive-tabs-list.tsx +++ b/web/core/components/archives/archive-tabs-list.tsx @@ -2,11 +2,33 @@ import { FC } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; -// constants -import { ARCHIVES_TAB_LIST } from "@/constants/archives"; +// types +import { IProject } from "@plane/types"; // hooks import { useProject } from "@/hooks/store"; +const ARCHIVES_TAB_LIST: { + key: string; + label: string; + shouldRender: (projectDetails: IProject) => boolean; +}[] = [ + { + key: "issues", + label: "Work items", + shouldRender: () => true, + }, + { + key: "cycles", + label: "Cycles", + shouldRender: (projectDetails) => projectDetails.cycle_view, + }, + { + key: "modules", + label: "Modules", + shouldRender: (projectDetails) => projectDetails.module_view, + }, +]; + export const ArchiveTabsList: FC = observer(() => { // router const { workspaceSlug, projectId } = useParams(); diff --git a/web/core/components/auth-screens/project/join-project.tsx b/web/core/components/auth-screens/project/join-project.tsx index 4ec94acab..b815f8738 100644 --- a/web/core/components/auth-screens/project/join-project.tsx +++ b/web/core/components/auth-screens/project/join-project.tsx @@ -16,7 +16,7 @@ export const JoinProject: React.FC = () => { const [isJoiningProject, setIsJoiningProject] = useState(false); // store hooks const { joinProject } = useUserPermissions(); - const { fetchProjects } = useProject(); + const { fetchProjectDetails } = useProject(); const { workspaceSlug, projectId } = useParams(); @@ -26,7 +26,7 @@ export const JoinProject: React.FC = () => { setIsJoiningProject(true); joinProject(workspaceSlug.toString(), projectId.toString()) - .then(() => fetchProjects(workspaceSlug.toString())) + .then(() => fetchProjectDetails(workspaceSlug.toString(), projectId.toString())) .finally(() => setIsJoiningProject(false)); }; diff --git a/web/core/components/automation/auto-archive-automation.tsx b/web/core/components/automation/auto-archive-automation.tsx index 8ef488da5..018f69fd4 100644 --- a/web/core/components/automation/auto-archive-automation.tsx +++ b/web/core/components/automation/auto-archive-automation.tsx @@ -2,18 +2,19 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; import { ArchiveRestore } from "lucide-react"; // types +import { PROJECT_AUTOMATION_MONTHS,EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { IProject } from "@plane/types"; // ui import { CustomSelect, Loader, ToggleSwitch } from "@plane/ui"; // component import { SelectMonthModal } from "@/components/automation"; // constants -import { PROJECT_AUTOMATION_MONTHS } from "@/constants/project"; // hooks import { useProject, useUserPermissions } from "@/hooks/store"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; type Props = { handleChange: (formData: Partial) => Promise; @@ -23,19 +24,22 @@ const initialValues: Partial = { archive_in: 1 }; export const AutoArchiveAutomation: React.FC = observer((props) => { const { handleChange } = props; + // router + const { workspaceSlug } = useParams(); // states const [monthModal, setmonthModal] = useState(false); // store hooks const { allowPermissions } = useUserPermissions(); + const { t } = useTranslation(); const { currentProjectDetails } = useProject(); const isAdmin = allowPermissions( [EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, - currentProjectDetails?.workspace_detail?.slug, + workspaceSlug?.toString(), currentProjectDetails?.id - ); +); return ( <> @@ -53,9 +57,9 @@ export const AutoArchiveAutomation: React.FC = observer((props) => {
    -

    Auto-archive closed issues

    +

    {t("project_settings.automations.auto-archive.title")}

    - Plane will auto archive issues that have been completed or canceled. + {t("project_settings.automations.auto-archive.description")}

    @@ -75,7 +79,9 @@ export const AutoArchiveAutomation: React.FC = observer((props) => { currentProjectDetails.archive_in !== 0 && (
    -
    Auto-archive issues that are closed for
    +
    + {t("project_settings.automations.auto-archive.duration")} +
    = observer((props) => { > <> {PROJECT_AUTOMATION_MONTHS.map((month) => ( - - {month.label} + + {t(month.i18n_label, { month: month.value })} ))} @@ -100,7 +106,7 @@ export const AutoArchiveAutomation: React.FC = observer((props) => { className="flex w-full select-none items-center rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80" onClick={() => setmonthModal(true)} > - Customize time range + {t("customize_time_range")} diff --git a/web/core/components/automation/auto-close-automation.tsx b/web/core/components/automation/auto-close-automation.tsx index dce5764d4..da674644d 100644 --- a/web/core/components/automation/auto-close-automation.tsx +++ b/web/core/components/automation/auto-close-automation.tsx @@ -2,19 +2,20 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; // icons import { ArchiveX } from "lucide-react"; // types +import { PROJECT_AUTOMATION_MONTHS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { IProject } from "@plane/types"; // ui import { CustomSelect, CustomSearchSelect, ToggleSwitch, StateGroupIcon, DoubleCircleIcon, Loader } from "@plane/ui"; // component import { SelectMonthModal } from "@/components/automation"; // constants -import { PROJECT_AUTOMATION_MONTHS } from "@/constants/project"; // hooks import { useProject, useProjectState, useUserPermissions } from "@/hooks/store"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; type Props = { handleChange: (formData: Partial) => Promise; @@ -22,12 +23,15 @@ type Props = { export const AutoCloseAutomation: React.FC = observer((props) => { const { handleChange } = props; + // router + const { workspaceSlug } = useParams(); // states const [monthModal, setmonthModal] = useState(false); // store hooks const { currentProjectDetails } = useProject(); const { projectStates } = useProjectState(); const { allowPermissions } = useUserPermissions(); + const { t } = useTranslation(); // const stateGroups = projectStateStore.groupedProjectStates ?? undefined; @@ -59,7 +63,7 @@ export const AutoCloseAutomation: React.FC = observer((props) => { const isAdmin = allowPermissions( [EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, - currentProjectDetails?.workspace_detail?.slug, + workspaceSlug?.toString(), currentProjectDetails?.id ); @@ -79,9 +83,9 @@ export const AutoCloseAutomation: React.FC = observer((props) => {
    -

    Auto-close issues

    +

    {t("project_settings.automations.auto-close.title")}

    - Plane will automatically close issues that haven{"'"}t been completed or canceled. + {t("project_settings.automations.auto-close.description")}

    @@ -102,7 +106,9 @@ export const AutoCloseAutomation: React.FC = observer((props) => {
    -
    Auto-close issues that are inactive for
    +
    + {t("project_settings.automations.auto-close.duration")} +
    = observer((props) => { > <> {PROJECT_AUTOMATION_MONTHS.map((month) => ( - - {month.label} + + {t(month.i18n_label, { month: month.value })} ))} @@ -134,7 +140,9 @@ export const AutoCloseAutomation: React.FC = observer((props) => {
    -
    Auto-close status
    +
    + {t("project_settings.automations.auto-close.auto_close_status")} +
    = observer((props) => { )} {selectedOption?.name ? selectedOption.name - : (currentDefaultState?.name ?? State)} + : (currentDefaultState?.name ?? {t("state")})}
    } onChange={(val: string) => { diff --git a/web/core/components/automation/select-month-modal.tsx b/web/core/components/automation/select-month-modal.tsx index 9a0ebb05f..b35b9885e 100644 --- a/web/core/components/automation/select-month-modal.tsx +++ b/web/core/components/automation/select-month-modal.tsx @@ -93,7 +93,7 @@ export const SelectMonthModal: React.FC = ({ type, initialValues, isOpen, id="close_in" name="close_in" type="number" - value={value.toString()} + value={value?.toString()} onChange={onChange} ref={ref} hasError={Boolean(errors.close_in)} @@ -127,7 +127,7 @@ export const SelectMonthModal: React.FC = ({ type, initialValues, isOpen, id="archive_in" name="archive_in" type="number" - value={value.toString()} + value={value?.toString()} onChange={onChange} ref={ref} hasError={Boolean(errors.archive_in)} diff --git a/web/core/components/command-palette/actions/issue-actions/actions-list.tsx b/web/core/components/command-palette/actions/issue-actions/actions-list.tsx index 6c1c01aca..6fa28cdce 100644 --- a/web/core/components/command-palette/actions/issue-actions/actions-list.tsx +++ b/web/core/components/command-palette/actions/issue-actions/actions-list.tsx @@ -7,12 +7,10 @@ import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2, Users } from "lucide-r import { TIssue } from "@plane/types"; // hooks import { DoubleCircleIcon, TOAST_TYPE, setToast } from "@plane/ui"; -// constants -import { EIssuesStoreType } from "@/constants/issue"; // helpers import { copyTextToClipboard } from "@/helpers/string.helper"; // hooks -import { useCommandPalette, useIssues, useUser } from "@/hooks/store"; +import { useCommandPalette, useIssueDetail, useUser } from "@/hooks/store"; type Props = { closePalette: () => void; @@ -26,13 +24,14 @@ type Props = { export const CommandPaletteIssueActions: React.FC = observer((props) => { const { closePalette, issueDetails, pages, setPages, setPlaceholder, setSearchTerm } = props; // router - const { workspaceSlug, projectId, issueId } = useParams(); + const { workspaceSlug } = useParams(); // hooks - const { - issues: { updateIssue }, - } = useIssues(EIssuesStoreType.PROJECT); + const { updateIssue } = useIssueDetail(); const { toggleCommandPaletteModal, toggleDeleteIssueModal } = useCommandPalette(); const { data: currentUser } = useUser(); + // derived values + const issueId = issueDetails?.id; + const projectId = issueDetails?.project_id; const handleUpdateIssue = async (formData: Partial) => { if (!workspaceSlug || !projectId || !issueDetails) return; @@ -66,21 +65,15 @@ export const CommandPaletteIssueActions: React.FC = observer((props) => { const url = new URL(window.location.href); copyTextToClipboard(url.href) .then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Copied to clipboard", - }); + setToast({ type: TOAST_TYPE.SUCCESS, title: "Copied to clipboard" }); }) .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Some error occurred", - }); + setToast({ type: TOAST_TYPE.ERROR, title: "Some error occurred" }); }); }; return ( - + { setPlaceholder("Change state..."); @@ -144,7 +137,7 @@ export const CommandPaletteIssueActions: React.FC = observer((props) => {
    - Delete issue + Delete work item
    = observer((props) => { >
    - Copy issue URL + Copy work item URL
    diff --git a/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx b/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx index 319adf8f2..4e43ef887 100644 --- a/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx +++ b/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx @@ -8,56 +8,55 @@ import { Check } from "lucide-react"; import { TIssue } from "@plane/types"; // plane ui import { Avatar } from "@plane/ui"; -// constants -import { EIssuesStoreType } from "@/constants/issue"; // helpers import { getFileURL } from "@/helpers/file.helper"; // hooks -import { useIssues, useMember } from "@/hooks/store"; +import { useIssueDetail, useMember } from "@/hooks/store"; -type Props = { - closePalette: () => void; - issue: TIssue; -}; +type Props = { closePalette: () => void; issue: TIssue }; export const ChangeIssueAssignee: React.FC = observer((props) => { const { closePalette, issue } = props; // router params - const { workspaceSlug, projectId } = useParams(); + const { workspaceSlug } = useParams(); // store + const { updateIssue } = useIssueDetail(); const { - issues: { updateIssue }, - } = useIssues(EIssuesStoreType.PROJECT); - const { - project: { projectMemberIds, getProjectMemberDetails }, + project: { getProjectMemberIds, getProjectMemberDetails }, } = useMember(); + // derived values + const projectId = issue?.project_id ?? ""; + const projectMemberIds = getProjectMemberIds(projectId); const options = - projectMemberIds?.map((userId) => { - const memberDetails = getProjectMemberDetails(userId); + projectMemberIds + ?.map((userId) => { + if (!projectId) return; + const memberDetails = getProjectMemberDetails(userId, projectId.toString()); - return { - value: `${memberDetails?.member?.id}`, - query: `${memberDetails?.member?.display_name}`, - content: ( - <> -
    - - {memberDetails?.member?.display_name} -
    - {issue.assignee_ids.includes(memberDetails?.member?.id ?? "") && ( -
    - + return { + value: `${memberDetails?.member?.id}`, + query: `${memberDetails?.member?.display_name}`, + content: ( + <> +
    + + {memberDetails?.member?.display_name}
    - )} - - ), - }; - }) ?? []; + {issue.assignee_ids.includes(memberDetails?.member?.id ?? "") && ( +
    + +
    + )} + + ), + }; + }) + .filter((o) => o !== undefined) ?? []; const handleUpdateIssue = async (formData: Partial) => { if (!workspaceSlug || !projectId || !issue) return; @@ -80,15 +79,18 @@ export const ChangeIssueAssignee: React.FC = observer((props) => { return ( <> - {options.map((option) => ( - handleIssueAssignees(option.value)} - className="focus:outline-none" - > - {option.content} - - ))} + {options.map( + (option) => + option && ( + handleIssueAssignees(option.value)} + className="focus:outline-none" + > + {option.content} + + ) + )} ); }); diff --git a/web/core/components/command-palette/actions/issue-actions/change-priority.tsx b/web/core/components/command-palette/actions/issue-actions/change-priority.tsx index 5bd8ce850..9e2a874a2 100644 --- a/web/core/components/command-palette/actions/issue-actions/change-priority.tsx +++ b/web/core/components/command-palette/actions/issue-actions/change-priority.tsx @@ -1,30 +1,30 @@ -"use client"; `` +"use client"; import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { Check } from "lucide-react"; +// plane constants +import { ISSUE_PRIORITIES } from "@plane/constants"; +// plane types import { TIssue, TIssuePriorities } from "@plane/types"; // mobx store import { PriorityIcon } from "@plane/ui"; -import { EIssuesStoreType, ISSUE_PRIORITIES } from "@/constants/issue"; -import { useIssues } from "@/hooks/store"; +import { useIssueDetail } from "@/hooks/store"; // ui // types // constants -type Props = { - closePalette: () => void; - issue: TIssue; -}; +type Props = { closePalette: () => void; issue: TIssue }; export const ChangeIssuePriority: React.FC = observer((props) => { const { closePalette, issue } = props; // router params - const { workspaceSlug, projectId } = useParams(); - const { - issues: { updateIssue }, - } = useIssues(EIssuesStoreType.PROJECT); + const { workspaceSlug } = useParams(); + // store hooks + const { updateIssue } = useIssueDetail(); + // derived values + const projectId = issue?.project_id; const submitChanges = async (formData: Partial) => { if (!workspaceSlug || !projectId || !issue) return; diff --git a/web/core/components/command-palette/actions/issue-actions/change-state.tsx b/web/core/components/command-palette/actions/issue-actions/change-state.tsx index 5d512f4ac..154b578b9 100644 --- a/web/core/components/command-palette/actions/issue-actions/change-state.tsx +++ b/web/core/components/command-palette/actions/issue-actions/change-state.tsx @@ -1,32 +1,25 @@ "use client"; -import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -// hooks -import { Check } from "lucide-react"; +// plane imports import { TIssue } from "@plane/types"; -import { Spinner, StateGroupIcon } from "@plane/ui"; -import { EIssuesStoreType } from "@/constants/issue"; -import { useProjectState, useIssues } from "@/hooks/store"; -// ui -// icons -// types +// store hooks +import { useIssueDetail } from "@/hooks/store"; +// plane web imports +import { ChangeWorkItemStateList } from "@/plane-web/components/command-palette/actions/work-item-actions"; -type Props = { - closePalette: () => void; - issue: TIssue; -}; +type Props = { closePalette: () => void; issue: TIssue }; export const ChangeIssueState: React.FC = observer((props) => { const { closePalette, issue } = props; // router params - const { workspaceSlug, projectId } = useParams(); + const { workspaceSlug } = useParams(); // store hooks - const { - issues: { updateIssue }, - } = useIssues(EIssuesStoreType.PROJECT); - const { projectStates } = useProjectState(); + const { updateIssue } = useIssueDetail(); + // derived values + const projectId = issue?.project_id; + const currentStateId = issue?.state_id; const submitChanges = async (formData: Partial) => { if (!workspaceSlug || !projectId || !issue) return; @@ -43,24 +36,10 @@ export const ChangeIssueState: React.FC = observer((props) => { }; return ( - <> - {projectStates ? ( - projectStates.length > 0 ? ( - projectStates.map((state) => ( - handleIssueState(state.id)} className="focus:outline-none"> -
    - -

    {state.name}

    -
    -
    {state.id === issue.state_id && }
    -
    - )) - ) : ( -
    No states found
    - ) - ) : ( - - )} - + ); }); diff --git a/web/core/components/command-palette/actions/search-results.tsx b/web/core/components/command-palette/actions/search-results.tsx index dba61ecc3..8d67635ba 100644 --- a/web/core/components/command-palette/actions/search-results.tsx +++ b/web/core/components/command-palette/actions/search-results.tsx @@ -28,6 +28,7 @@ export const CommandPaletteSearchResults: React.FC = (props) => { const section = (results.results as any)[key]; const currentSection = commandGroups[key]; + if (!currentSection) return null; if (section.length > 0) { return ( diff --git a/web/core/components/command-palette/actions/theme-actions.tsx b/web/core/components/command-palette/actions/theme-actions.tsx index 2ae81d3f3..9df95c00a 100644 --- a/web/core/components/command-palette/actions/theme-actions.tsx +++ b/web/core/components/command-palette/actions/theme-actions.tsx @@ -5,9 +5,10 @@ import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useTheme } from "next-themes"; import { Settings } from "lucide-react"; +// plane imports +import { THEME_OPTIONS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { TOAST_TYPE, setToast } from "@plane/ui"; -// constants -import { THEME_OPTIONS } from "@/constants/themes"; // hooks import { useUserProfile } from "@/hooks/store"; @@ -20,6 +21,7 @@ export const CommandPaletteThemeActions: FC = observer((props) => { const { setTheme } = useTheme(); // hooks const { updateUserTheme } = useUserProfile(); + const { t } = useTranslation(); // states const [mounted, setMounted] = useState(false); @@ -53,7 +55,7 @@ export const CommandPaletteThemeActions: FC = observer((props) => { >
    - {theme.label} + {t(theme.i18n_label)}
    ))} diff --git a/web/core/components/command-palette/actions/workspace-settings-actions.tsx b/web/core/components/command-palette/actions/workspace-settings-actions.tsx index dee51c1b6..670788a33 100644 --- a/web/core/components/command-palette/actions/workspace-settings-actions.tsx +++ b/web/core/components/command-palette/actions/workspace-settings-actions.tsx @@ -4,13 +4,14 @@ import { Command } from "cmdk"; // hooks import Link from "next/link"; import { useParams } from "next/navigation"; - +import { WORKSPACE_SETTINGS_LINKS, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// components +import { SettingIcon } from "@/components/icons"; // hooks import { useUserPermissions } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; // plane wev constants -import { EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; -import { WORKSPACE_SETTINGS_LINKS } from "@/plane-web/constants/workspace"; // plane web helpers import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; @@ -25,6 +26,7 @@ export const CommandPaletteWorkspaceSettingsActions: React.FC = (props) = // router params const { workspaceSlug } = useParams(); // mobx store + const { t } = useTranslation(); const { allowPermissions } = useUserPermissions(); // derived values @@ -46,8 +48,8 @@ export const CommandPaletteWorkspaceSettingsActions: React.FC = (props) = >
    - - {setting.label} + + {t(setting.i18n_label)}
    diff --git a/web/core/components/command-palette/command-modal.tsx b/web/core/components/command-palette/command-modal.tsx index b80b0dfe1..7fdc96f5a 100644 --- a/web/core/components/command-palette/command-modal.tsx +++ b/web/core/components/command-palette/command-modal.tsx @@ -5,12 +5,14 @@ import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; -import { FolderPlus, Search, Settings } from "lucide-react"; +import { CommandIcon, FolderPlus, Search, Settings, X } from "lucide-react"; import { Dialog, Transition } from "@headlessui/react"; -// types +// plane imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { IWorkspaceSearchResults } from "@plane/types"; -// ui -import { LayersIcon, Loader, ToggleSwitch, Tooltip } from "@plane/ui"; +import { LayersIcon, Loader, ToggleSwitch } from "@plane/ui"; +import { cn } from "@plane/utils"; // components import { ChangeIssueAssignee, @@ -23,34 +25,33 @@ import { CommandPaletteThemeActions, CommandPaletteWorkspaceSettingsActions, } from "@/components/command-palette"; -import { EmptyState } from "@/components/empty-state"; -// constants -import { EmptyStateType } from "@/constants/empty-state"; -// fetch-keys -import { ISSUE_DETAILS } from "@/constants/fetch-keys"; +import { SimpleEmptyState } from "@/components/empty-state"; // helpers import { getTabIndex } from "@/helpers/tab-indices.helper"; // hooks -import { useCommandPalette, useEventTracker, useProject, useUser, useUserPermissions } from "@/hooks/store"; +import { + useCommandPalette, + useEventTracker, + useIssueDetail, + useProject, + useUser, + useUserPermissions, +} from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; import useDebounce from "@/hooks/use-debounce"; import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web components +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; import { IssueIdentifier } from "@/plane-web/components/issues"; // plane web services import { WorkspaceService } from "@/plane-web/services"; -// services -import { IssueService } from "@/services/issue"; -import { EUserPermissions, EUserPermissionsLevel } from "ee/constants/user-permissions"; const workspaceService = new WorkspaceService(); -const issueService = new IssueService(); export const CommandModal: React.FC = observer(() => { - // hooks - const { workspaceProjectIds } = useProject(); - const { isMobile } = usePlatformOS(); - const { canPerformAnyCreateAction } = useUser(); + // router + const router = useAppRouter(); + const { workspaceSlug, projectId: routerProjectId, workItem } = useParams(); // states const [placeholder, setPlaceholder] = useState("Type a command or search..."); const [resultsCount, setResultsCount] = useState(0); @@ -58,46 +59,61 @@ export const CommandModal: React.FC = observer(() => { const [isSearching, setIsSearching] = useState(false); const [searchTerm, setSearchTerm] = useState(""); const [results, setResults] = useState({ - results: { - workspace: [], - project: [], - issue: [], - cycle: [], - module: [], - issue_view: [], - page: [], - }, + results: { workspace: [], project: [], issue: [], cycle: [], module: [], issue_view: [], page: [] }, }); const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); const [pages, setPages] = useState([]); + const [searchInIssue, setSearchInIssue] = useState(false); + // plane hooks + const { t } = useTranslation(); + // hooks + const { + issue: { getIssueById }, + fetchIssueWithIdentifier, + } = useIssueDetail(); + const { workspaceProjectIds } = useProject(); + const { platform, isMobile } = usePlatformOS(); + const { canPerformAnyCreateAction } = useUser(); const { isCommandPaletteOpen, toggleCommandPaletteModal, toggleCreateIssueModal, toggleCreateProjectModal } = useCommandPalette(); const { allowPermissions } = useUserPermissions(); const { setTrackElement } = useEventTracker(); + const projectIdentifier = workItem?.toString().split("-")[0]; + const sequence_id = workItem?.toString().split("-")[1]; + // fetch work item details using identifier + const { data: workItemDetailsSWR } = useSWR( + workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null, + workspaceSlug && workItem + ? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id) + : null + ); - // router - const router = useAppRouter(); - // router params - const { workspaceSlug, projectId, issueId } = useParams(); - + // derived values + const issueDetails = workItemDetailsSWR ? getIssueById(workItemDetailsSWR?.id) : null; + const issueId = issueDetails?.id; + const projectId = issueDetails?.project_id ?? routerProjectId; const page = pages[pages.length - 1]; - const debouncedSearchTerm = useDebounce(searchTerm, 500); - const { baseTabIndex } = getTabIndex(undefined, isMobile); - const canPerformWorkspaceActions = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.WORKSPACE ); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" }); - // TODO: update this to mobx store - const { data: issueDetails } = useSWR( - workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId.toString()) : null, - workspaceSlug && projectId && issueId - ? () => issueService.retrieve(workspaceSlug.toString(), projectId.toString(), issueId.toString()) - : null - ); + useEffect(() => { + if (issueDetails && isCommandPaletteOpen) { + setSearchInIssue(true); + } + }, [issueDetails, isCommandPaletteOpen]); + + useEffect(() => { + if (!projectId && !isWorkspaceLevel) { + setIsWorkspaceLevel(true); + } else { + setIsWorkspaceLevel(false); + } + }, [projectId]); const closePalette = () => { toggleCommandPaletteModal(false); @@ -136,15 +152,7 @@ export const CommandModal: React.FC = observer(() => { }); } else { setResults({ - results: { - workspace: [], - project: [], - issue: [], - cycle: [], - module: [], - issue_view: [], - page: [], - }, + results: { workspace: [], project: [], issue: [], cycle: [], module: [], issue_view: [], page: [] }, }); setIsLoading(false); setIsSearching(false); @@ -155,7 +163,16 @@ export const CommandModal: React.FC = observer(() => { return ( setSearchTerm("")} as={React.Fragment}> - closePalette()}> + { + closePalette(); + if (searchInIssue) { + setSearchInIssue(true); + } + }} + > { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
    { if (value.toLowerCase().includes(search.toLowerCase())) return 1; return 0; }} - onKeyDown={(e) => { - // when search term is not empty, esc should clear the search term - if (e.key === "Escape" && searchTerm) setSearchTerm(""); + shouldFilter={searchTerm.length > 0} + onKeyDown={(e: any) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + e.stopPropagation(); + closePalette(); + return; + } - // when user tries to close the modal with esc - if (e.key === "Escape" && !page && !searchTerm) closePalette(); + if (e.key === "Tab") { + e.preventDefault(); + const commandList = document.querySelector("[cmdk-list]"); + const items = commandList?.querySelectorAll("[cmdk-item]") || []; + const selectedItem = commandList?.querySelector('[aria-selected="true"]'); + if (items.length === 0) return; + + const currentIndex = Array.from(items).indexOf(selectedItem as Element); + let nextIndex; + + if (e.shiftKey) { + nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1; + } else { + nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0; + } + + const nextItem = items[nextIndex] as HTMLElement; + if (nextItem) { + nextItem.setAttribute("aria-selected", "true"); + selectedItem?.setAttribute("aria-selected", "false"); + nextItem.focus(); + nextItem.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + } + + if (e.key === "Escape" && searchTerm) { + e.preventDefault(); + setSearchTerm(""); + } + + if (e.key === "Escape" && !page && !searchTerm) { + e.preventDefault(); + closePalette(); + } - // Escape goes to previous page - // Backspace goes to previous page when search is empty if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) { e.preventDefault(); setPages((pages) => pages.slice(0, -1)); @@ -202,49 +254,40 @@ export const CommandModal: React.FC = observer(() => { } }} > -
    - {issueDetails && ( -
    - {issueDetails.project_id && ( - - )} - {issueDetails.name} -
    - )} - {projectId && ( - -
    - - setIsWorkspaceLevel((prevData) => !prevData)} - /> -
    -
    - )} -
    -
    -
    diff --git a/web/core/components/command-palette/command-palette.tsx b/web/core/components/command-palette/command-palette.tsx index c38266c40..9399c52f5 100644 --- a/web/core/components/command-palette/command-palette.tsx +++ b/web/core/components/command-palette/command-palette.tsx @@ -2,100 +2,85 @@ import React, { useCallback, useEffect, FC, useMemo } from "react"; import { observer } from "mobx-react"; -import { useParams, usePathname } from "next/navigation"; +import { useParams } from "next/navigation"; import useSWR from "swr"; // ui +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { TOAST_TYPE, setToast } from "@plane/ui"; // components import { CommandModal, ShortcutsModal } from "@/components/command-palette"; -import { BulkDeleteIssuesModal } from "@/components/core"; -import { CycleCreateUpdateModal } from "@/components/cycles"; -import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; -import { CreateUpdateModuleModal } from "@/components/modules"; -import { CreatePageModal } from "@/components/pages"; -import { CreateProjectModal } from "@/components/project"; -import { CreateUpdateProjectViewModal } from "@/components/views"; -// constants -import { ISSUE_DETAILS } from "@/constants/fetch-keys"; // helpers import { copyTextToClipboard } from "@/helpers/string.helper"; // hooks -import { useEventTracker, useUser, useAppTheme, useCommandPalette, useUserPermissions } from "@/hooks/store"; -import { useAppRouter } from "@/hooks/use-app-router"; -import { useIssuesStore } from "@/hooks/use-issue-layout-store"; +import { + useEventTracker, + useUser, + useAppTheme, + useCommandPalette, + useUserPermissions, + useIssueDetail, +} from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; -// services -import { IssueService } from "@/services/issue"; - -// services -const issueService = new IssueService(); +// plane web components +import { + IssueLevelModals, + ProjectLevelModals, + WorkspaceLevelModals, +} from "@/plane-web/components/command-palette/modals"; +// plane web constants +// plane web helpers +import { + getGlobalShortcutsList, + getProjectShortcutsList, + getWorkspaceShortcutsList, + handleAdditionalKeyDownEvents, +} from "@/plane-web/helpers/command-palette"; export const CommandPalette: FC = observer(() => { - // router - const router = useAppRouter(); // router params - const { workspaceSlug, projectId, issueId, cycleId, moduleId } = useParams(); - // pathname - const pathname = usePathname(); + const { workspaceSlug, projectId: paramsProjectId, workItem } = useParams(); // store hooks + const { fetchIssueWithIdentifier } = useIssueDetail(); const { toggleSidebar } = useAppTheme(); const { setTrackElement } = useEventTracker(); const { platform } = usePlatformOS(); - const { - data: currentUser, - // canPerformProjectMemberActions, - // canPerformWorkspaceMemberActions, - canPerformAnyCreateAction, - // canPerformProjectAdminActions, - } = useUser(); - const { - issues: { removeIssue }, - } = useIssuesStore(); - const { - toggleCommandPaletteModal, - isCreateIssueModalOpen, - toggleCreateIssueModal, - isCreateCycleModalOpen, - toggleCreateCycleModal, - createPageModal, - toggleCreatePageModal, - isCreateProjectModalOpen, - toggleCreateProjectModal, - isCreateModuleModalOpen, - toggleCreateModuleModal, - isCreateViewModalOpen, - toggleCreateViewModal, - isShortcutModalOpen, - toggleShortcutModal, - isBulkDeleteIssueModalOpen, - toggleBulkDeleteIssueModal, - isDeleteIssueModalOpen, - toggleDeleteIssueModal, - isAnyModalOpen, - } = useCommandPalette(); + const { data: currentUser, canPerformAnyCreateAction } = useUser(); + const { toggleCommandPaletteModal, isShortcutModalOpen, toggleShortcutModal, isAnyModalOpen } = useCommandPalette(); const { allowPermissions } = useUserPermissions(); + // derived values + const projectIdentifier = workItem?.toString().split("-")[0]; + const sequence_id = workItem?.toString().split("-")[1]; + const { data: issueDetails } = useSWR( - workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null, - workspaceSlug && projectId && issueId - ? () => issueService.retrieve(workspaceSlug as string, projectId as string, issueId as string) + workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null, + workspaceSlug && workItem + ? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id) : null ); - // derived values + const issueId = issueDetails?.id; + const projectId = paramsProjectId?.toString() ?? issueDetails?.project_id; + const canPerformWorkspaceMemberActions = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.WORKSPACE ); const canPerformProjectMemberActions = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.PROJECT + EUserPermissionsLevel.PROJECT, + workspaceSlug?.toString(), + projectId + ); + const canPerformProjectAdminActions = allowPermissions( + [EUserPermissions.ADMIN], + EUserPermissionsLevel.PROJECT, + workspaceSlug?.toString(), + projectId ); - const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); const copyIssueUrlToClipboard = useCallback(() => { - if (!issueId) return; + if (!workItem) return; const url = new URL(window.location.href); copyTextToClipboard(url.href) @@ -111,7 +96,7 @@ export const CommandPalette: FC = observer(() => { title: "Some error occurred", }); }); - }, [issueId]); + }, [workItem]); // auth const performProjectCreateActions = useCallback( @@ -170,62 +155,11 @@ export const CommandPalette: FC = observer(() => { project: Record void }>; } = useMemo( () => ({ - global: { - c: { - title: "Create a new issue", - description: "Create a new issue in the current project", - action: () => toggleCreateIssueModal(true), - }, - }, - workspace: { - p: { - title: "Create a new project", - description: "Create a new project in the current workspace", - action: () => toggleCreateProjectModal(true), - }, - }, - project: { - d: { - title: "Create a new page", - description: "Create a new page in the current project", - action: () => toggleCreatePageModal({ isOpen: true }), - }, - m: { - title: "Create a new module", - description: "Create a new module in the current project", - action: () => toggleCreateModuleModal(true), - }, - q: { - title: "Create a new cycle", - description: "Create a new cycle in the current project", - action: () => toggleCreateCycleModal(true), - }, - v: { - title: "Create a new view", - description: "Create a new view in the current project", - action: () => toggleCreateViewModal(true), - }, - backspace: { - title: "Bulk delete issues", - description: "Bulk delete issues in the current project", - action: () => toggleBulkDeleteIssueModal(true), - }, - delete: { - title: "Bulk delete issues", - description: "Bulk delete issues in the current project", - action: () => toggleBulkDeleteIssueModal(true), - }, - }, + global: getGlobalShortcutsList(), + workspace: getWorkspaceShortcutsList(), + project: getProjectShortcutsList(), }), - [ - toggleBulkDeleteIssueModal, - toggleCreateCycleModal, - toggleCreateIssueModal, - toggleCreateModuleModal, - toggleCreatePageModal, - toggleCreateProjectModal, - toggleCreateViewModal, - ] + [] ); const handleKeyDown = useCallback( @@ -296,6 +230,8 @@ export const CommandPalette: FC = observer(() => { shortcutsList.project[keyPressed].action(); } } + // Additional keydown events + handleAdditionalKeyDownEvents(e); }, [ copyIssueUrlToClipboard, @@ -320,75 +256,16 @@ export const CommandPalette: FC = observer(() => { return () => document.removeEventListener("keydown", handleKeyDown); }, [handleKeyDown]); - const isDraftIssue = pathname?.includes("draft-issues") || false; - if (!currentUser) return null; return ( <> toggleShortcutModal(false)} /> - {workspaceSlug && ( - toggleCreateProjectModal(false)} - workspaceSlug={workspaceSlug.toString()} - /> - )} + {workspaceSlug && } {workspaceSlug && projectId && ( - <> - toggleCreateCycleModal(false)} - workspaceSlug={workspaceSlug.toString()} - projectId={projectId.toString()} - /> - toggleCreateModuleModal(false)} - workspaceSlug={workspaceSlug.toString()} - projectId={projectId.toString()} - /> - toggleCreateViewModal(false)} - workspaceSlug={workspaceSlug.toString()} - projectId={projectId.toString()} - /> - toggleCreatePageModal({ isOpen: false })} - redirectionEnabled - /> - + )} - - toggleCreateIssueModal(false)} - data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_ids: [moduleId.toString()] } : undefined} - isDraft={isDraftIssue} - /> - - {workspaceSlug && projectId && issueId && issueDetails && ( - toggleDeleteIssueModal(false)} - isOpen={isDeleteIssueModalOpen} - data={issueDetails} - onSubmit={async () => { - await removeIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString()); - router.push(`/${workspaceSlug}/projects/${projectId}/issues`); - }} - /> - )} - - toggleBulkDeleteIssueModal(false)} - user={currentUser} - /> + ); diff --git a/web/core/components/command-palette/helpers.tsx b/web/core/components/command-palette/helpers.tsx index 99c8c310e..05885ebbd 100644 --- a/web/core/components/command-palette/helpers.tsx +++ b/web/core/components/command-palette/helpers.tsx @@ -11,6 +11,8 @@ import { } from "@plane/types"; // ui import { ContrastIcon, DiceIcon } from "@plane/ui"; +// helpers +import { generateWorkItemLink } from "@/helpers/issue.helper"; // plane web components import { IssueIdentifier } from "@/plane-web/components/issues"; @@ -48,8 +50,14 @@ export const commandGroups: {
    ), path: (issue: IWorkspaceIssueSearchResult) => - `/${issue?.workspace__slug}/projects/${issue?.project_id}/issues/${issue?.id}`, - title: "Issues", + generateWorkItemLink({ + workspaceSlug: issue?.workspace__slug, + projectId: issue?.project_id, + issueId: issue?.id, + projectIdentifier: issue.project__identifier, + sequenceId: issue?.sequence_id, + }), + title: "Work items", }, issue_view: { icon: , diff --git a/web/core/components/command-palette/shortcuts-modal/commands-list.tsx b/web/core/components/command-palette/shortcuts-modal/commands-list.tsx index e72e92e0b..570cb02fa 100644 --- a/web/core/components/command-palette/shortcuts-modal/commands-list.tsx +++ b/web/core/components/command-palette/shortcuts-modal/commands-list.tsx @@ -3,6 +3,12 @@ import { Command } from "lucide-react"; import { substringMatch } from "@/helpers/string.helper"; // hooks import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web helpers +import { + getAdditionalShortcutsList, + getCommonShortcutsList, + getNavigationShortcutsList, +} from "@/plane-web/helpers/command-palette"; type Props = { searchQuery: string; @@ -16,26 +22,14 @@ export const ShortcutCommandsList: React.FC = (props) => { { key: "navigation", title: "Navigation", - shortcuts: [{ keys: "Ctrl,K", description: "Open command menu" }], + shortcuts: getNavigationShortcutsList(), }, { key: "common", title: "Common", - shortcuts: [ - { keys: "P", description: "Create project" }, - { keys: "C", description: "Create issue" }, - { keys: "Q", description: "Create cycle" }, - { keys: "M", description: "Create module" }, - { keys: "V", description: "Create view" }, - { keys: "D", description: "Create page" }, - { keys: "Delete", description: "Bulk delete issues" }, - { keys: "Shift,/", description: "Open shortcuts guide" }, - { - keys: platform === "MacOS" ? "Ctrl,control,C" : "Ctrl,Alt,C", - description: "Copy issue URL from the issue details page", - }, - ], + shortcuts: getCommonShortcutsList(platform), }, + ...getAdditionalShortcutsList(), ]; const filteredShortcuts = KEYBOARD_SHORTCUTS.map((category) => { @@ -69,7 +63,11 @@ export const ShortcutCommandsList: React.FC = (props) => {
    {key === "Ctrl" ? (
    - { platform === "MacOS" ? : 'Ctrl'} + {platform === "MacOS" ? ( + + ) : ( + "Ctrl" + )}
    ) : ( diff --git a/web/core/components/common/access-field.tsx b/web/core/components/common/access-field.tsx index dd7606a3a..f2c50934d 100644 --- a/web/core/components/common/access-field.tsx +++ b/web/core/components/common/access-field.tsx @@ -1,44 +1,53 @@ import { LucideIcon } from "lucide-react"; -import { cn } from "@plane/editor"; +// plane ui +import { useTranslation } from "@plane/i18n"; import { Tooltip } from "@plane/ui"; +// plane utils +import { cn } from "@plane/utils"; type Props = { onChange: (value: number) => void; value: number; accessSpecifiers: { key: number; - label: string; + i18n_label?: string; + label?: string; icon: LucideIcon; }[]; isMobile?: boolean; }; +// TODO: Remove label once i18n is done export const AccessField = (props: Props) => { const { onChange, value, accessSpecifiers, isMobile = false } = props; + const { t } = useTranslation(); return (
    - {accessSpecifiers.map((access, index) => ( - - - - ))} + tabIndex={2 + index} + > + + + + ); + })}
    ); }; diff --git a/web/core/components/common/activity/activity-block.tsx b/web/core/components/common/activity/activity-block.tsx new file mode 100644 index 000000000..61c1a0b7b --- /dev/null +++ b/web/core/components/common/activity/activity-block.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { FC, ReactNode } from "react"; +import { Network } from "lucide-react"; +// types +import { TWorkspaceBaseActivity } from "@plane/types"; +// ui +import { Tooltip } from "@plane/ui"; +// helpers +import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "@/helpers/date-time.helper"; +// hooks +import { usePlatformOS } from "@/hooks/use-platform-os"; +// local components +import { User } from "./user"; + +type TActivityBlockComponent = { + icon?: ReactNode; + activity: TWorkspaceBaseActivity; + ends: "top" | "bottom" | undefined; + children: ReactNode; + customUserName?: string; +}; + +export const ActivityBlockComponent: FC = (props) => { + const { icon, activity, ends, children, customUserName } = props; + // hooks + const { isMobile } = usePlatformOS(); + + if (!activity) return <>; + return ( +
    +
    + {icon ? icon : } +
    +
    +
    + {children} +
    +
    + + + {calculateTimeAgo(activity.created_at)} + + +
    +
    +
    + ); +}; diff --git a/web/core/components/common/activity/activity-item.tsx b/web/core/components/common/activity/activity-item.tsx new file mode 100644 index 000000000..643d89738 --- /dev/null +++ b/web/core/components/common/activity/activity-item.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; + +import { TProjectActivity } from "@/plane-web/types"; +import { ActivityBlockComponent } from "./activity-block"; +import { iconsMap, messages } from "./helper"; + +type TActivityItem = { + activity: TProjectActivity; + showProject?: boolean; + ends?: "top" | "bottom" | undefined; +}; + +export const ActivityItem: FC = observer((props) => { + const { activity, showProject = true, ends } = props; + + if (!activity) return null; + + const activityType = activity.field; + if (!activityType) return null; + + const { message, customUserName } = messages(activity); + const icon = iconsMap[activityType] || iconsMap.default; + + return ( + + <>{message} + + ); +}); diff --git a/web/core/components/common/activity/helper.tsx b/web/core/components/common/activity/helper.tsx new file mode 100644 index 000000000..b3d16e7d0 --- /dev/null +++ b/web/core/components/common/activity/helper.tsx @@ -0,0 +1,281 @@ +import { ReactNode } from "react"; +import { + Signal, + RotateCcw, + Network, + Link as LinkIcon, + Calendar, + Tag, + Inbox, + AlignLeft, + Users, + Paperclip, + Type, + Triangle, + FileText, + Globe, + Hash, + Clock, + Bell, + LayoutGrid, + GitBranch, + Timer, + ListTodo, + Layers, +} from "lucide-react"; + +// components +import { ArchiveIcon, DoubleCircleIcon, ContrastIcon, DiceIcon, Intake } from "@plane/ui"; +import { store } from "@/lib/store-context"; +import { TProjectActivity } from "@/plane-web/types"; + +type ActivityIconMap = { + [key: string]: ReactNode; +}; +export const iconsMap: ActivityIconMap = { + priority: , + archived_at: , + restored: , + link: , + start_date: , + target_date: , + label: , + inbox: , + description: , + assignee: , + attachment: , + name: , + state: , + estimate: , + cycle: , + module: , + page: , + network: , + identifier: , + timezone: , + is_project_updates_enabled: , + is_epic_enabled: , + is_workflow_enabled: , + is_time_tracking_enabled: , + is_issue_type_enabled: , + default: , + module_view: , + cycle_view: , + issue_views_view: , + page_view: , + intake_view: , +}; + +export const messages = (activity: TProjectActivity): { message: string | ReactNode; customUserName?: string } => { + const activityType = activity.field; + const newValue = activity.new_value; + const oldValue = activity.old_value; + const verb = activity.verb; + const workspaceDetail = store.workspaceRoot.getWorkspaceById(activity.workspace); + + const getBooleanActionText = (value: string | undefined) => { + if (value === "true") return "enabled"; + if (value === "false") return "disabled"; + return verb; + }; + + switch (activityType) { + case "priority": + return { + message: ( + <> + set the priority to {newValue || "none"} + + ), + }; + case "archived_at": + return { + message: newValue === "restore" ? "restored the project" : "archived the project", + customUserName: newValue === "archive" ? "Plane" : undefined, + }; + case "name": + return { + message: ( + <> + renamed the project to {newValue} + + ), + }; + case "description": + return { + message: newValue ? "updated the project description" : "removed the project description", + }; + case "start_date": + return { + message: ( + <> + {newValue ? ( + <> + set the start date to {newValue} + + ) : ( + "removed the start date" + )} + + ), + }; + case "target_date": + return { + message: ( + <> + {newValue ? ( + <> + set the target date to {newValue} + + ) : ( + "removed the target date" + )} + + ), + }; + case "state": + return { + message: ( + <> + set the state to {newValue || "none"} + + ), + }; + case "estimate": + return { + message: ( + <> + {newValue ? ( + <> + set the estimate point to {newValue} + + ) : ( + <> + removed the estimate point + {oldValue && ( + <> + {" "} + {oldValue} + + )} + + )} + + ), + }; + case "cycles": + return { + message: ( + <> + + {verb} this project {verb === "removed" ? "from" : "to"} the cycle{" "} + + {verb !== "removed" ? ( + + {activity.new_value} + + ) : ( + {activity.old_value || "Unknown cycle"} + )} + + ), + }; + case "modules": + return { + message: ( + <> + + {verb} this project {verb === "removed" ? "from" : "to"} the module{" "} + + + {verb === "removed" ? oldValue : newValue || "Unknown module"} + + + ), + }; + case "labels": + return { + message: ( + <> + {verb} the label{" "} + {newValue || oldValue || "Untitled label"} + + ), + }; + case "inbox": + return { + message: <>{newValue ? "enabled" : "disabled"} inbox, + }; + case "page": + return { + message: ( + <> + {newValue ? "created" : "removed"} the project page{" "} + {newValue || oldValue || "Untitled page"} + + ), + }; + case "network": + return { + message: <>{newValue ? "enabled" : "disabled"} network access, + }; + case "identifier": + return { + message: ( + <> + updated project identifier to {newValue || "none"} + + ), + }; + case "timezone": + return { + message: ( + <> + changed project timezone to{" "} + {newValue || "default"} + + ), + }; + case "module_view": + case "cycle_view": + case "issue_views_view": + case "page_view": + case "intake_view": + return { + message: ( + <> + {getBooleanActionText(newValue)} {activityType.replace(/_view$/, "").replace(/_/g, " ")} view + + ), + }; + case "is_project_updates_enabled": + return { + message: <>{getBooleanActionText(newValue)} project updates, + }; + case "is_epic_enabled": + return { + message: <>{getBooleanActionText(newValue)} epics, + }; + case "is_workflow_enabled": + return { + message: <>{getBooleanActionText(newValue)} custom workflow, + }; + case "is_time_tracking_enabled": + return { + message: <>{getBooleanActionText(newValue)} time tracking, + }; + case "is_issue_type_enabled": + return { + message: <>{getBooleanActionText(newValue)} work item types, + }; + default: + return { + message: `${verb} ${activityType?.replace(/_/g, " ")} `, + }; + } +}; diff --git a/web/core/components/common/activity/index.ts b/web/core/components/common/activity/index.ts new file mode 100644 index 000000000..8ab32d3d9 --- /dev/null +++ b/web/core/components/common/activity/index.ts @@ -0,0 +1 @@ +export * from "./activity-item"; diff --git a/web/core/components/common/activity/user.tsx b/web/core/components/common/activity/user.tsx new file mode 100644 index 000000000..c82ab3616 --- /dev/null +++ b/web/core/components/common/activity/user.tsx @@ -0,0 +1,37 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +// types +import { TWorkspaceBaseActivity } from "@plane/types"; +// store hooks +import { useMember, useWorkspace } from "@/hooks/store"; + +type TUser = { + activity: TWorkspaceBaseActivity; + customUserName?: string; +}; + +export const User: FC = observer((props) => { + const { activity, customUserName } = props; + // store hooks + const { getUserDetails } = useMember(); + const { getWorkspaceById } = useWorkspace(); + // derived values + const actorDetail = getUserDetails(activity.actor); + const workspaceDetail = getWorkspaceById(activity.workspace); + + return ( + <> + {customUserName || actorDetail?.display_name.includes("-intake") ? ( + {customUserName || "Plane"} + ) : ( + + {actorDetail?.display_name} + + )} + + ); +}); diff --git a/web/core/components/common/applied-filters/date.tsx b/web/core/components/common/applied-filters/date.tsx index 0f5680c48..9dcde53a4 100644 --- a/web/core/components/common/applied-filters/date.tsx +++ b/web/core/components/common/applied-filters/date.tsx @@ -1,8 +1,8 @@ import { observer } from "mobx-react"; // icons import { X } from "lucide-react"; -// constants -import { DATE_BEFORE_FILTER_OPTIONS } from "@/constants/filters"; +// plane constants +import { DATE_BEFORE_FILTER_OPTIONS } from "@plane/constants"; // helpers import { renderFormattedDate } from "@/helpers/date-time.helper"; import { capitalizeFirstLetter } from "@/helpers/string.helper"; diff --git a/web/core/components/common/filters/created-at.tsx b/web/core/components/common/filters/created-at.tsx index 9baf391be..b1c23f70a 100644 --- a/web/core/components/common/filters/created-at.tsx +++ b/web/core/components/common/filters/created-at.tsx @@ -1,10 +1,9 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; -// components +// plane constants +import { DATE_BEFORE_FILTER_OPTIONS } from "@plane/constants"; import { DateFilterModal } from "@/components/core"; import { FilterHeader, FilterOption } from "@/components/issues"; -// constants -import { DATE_BEFORE_FILTER_OPTIONS } from "@/constants/filters"; // helpers import { isInDateFormat } from "@/helpers/date-time.helper"; diff --git a/web/core/components/common/index.ts b/web/core/components/common/index.ts index 28b732388..1f9a42440 100644 --- a/web/core/components/common/index.ts +++ b/web/core/components/common/index.ts @@ -5,3 +5,4 @@ export * from "./logo-spinner"; export * from "./logo"; export * from "./pro-icon"; export * from "./count-chip"; +export * from "./activity"; diff --git a/web/core/components/common/latest-feature-block.tsx b/web/core/components/common/latest-feature-block.tsx index a108e05bf..35aacddcf 100644 --- a/web/core/components/common/latest-feature-block.tsx +++ b/web/core/components/common/latest-feature-block.tsx @@ -28,7 +28,7 @@ export const LatestFeatureBlock = () => {
    Plane Issues { const { workspaceSlug } = useParams(); const { isMobile } = usePlatformOS(); + const workItemLink = generateWorkItemLink({ + workspaceSlug: workspaceSlug?.toString() ?? activity.workspace_detail?.slug, + projectId: activity?.project, + issueId: activity?.issue, + projectIdentifier: activity?.project_detail?.identifier, + sequenceId: activity?.issue_detail?.sequence_id, + }); + return ( {activity?.issue_detail ? ( { ) : ( - {" an Issue"}{" "} + {" a work item"}{" "} )} @@ -100,20 +107,20 @@ const LabelPill = observer(({ labelId, workspaceSlug }: { labelId: string; works const inboxActivityMessage = { declined: { - showIssue: "declined issue", - noIssue: "declined this issue from intake.", + showIssue: "declined work item", + noIssue: "declined this work item from intake.", }, snoozed: { - showIssue: "snoozed issue", - noIssue: "snoozed this issue.", + showIssue: "snoozed work item", + noIssue: "snoozed this work item.", }, accepted: { - showIssue: "accepted issue", - noIssue: "accepted this issue from intake.", + showIssue: "accepted work item", + noIssue: "accepted this work item from intake.", }, markedDuplicate: { - showIssue: "declined issue", - noIssue: "declined this issue from intake by marking a duplicate issue.", + showIssue: "declined work item", + noIssue: "declined this work item from intake by marking a duplicate work item.", }, }; @@ -128,7 +135,7 @@ const getInboxUserActivityMessage = (activity: IIssueActivity, showIssue: boolea case "2": return showIssue ? inboxActivityMessage.markedDuplicate.showIssue : inboxActivityMessage.markedDuplicate.noIssue; default: - return "updated intake issue status."; + return "updated intake work item status."; } }; @@ -393,7 +400,7 @@ const activityDetails: { return ( <> - added {showIssue ? : "this issue"}{" "} + added {showIssue ? : "this work item"}{" "} to the cycle{" "} - added {showIssue ? : "this issue"} to the module{" "} + added {showIssue ? : "this work item"} to the module{" "} - marked that {showIssue ? : "this issue"} relates to{" "} + marked that {showIssue ? : "this work item"} relates to{" "} {activity.new_value}. ); @@ -570,14 +577,14 @@ const activityDetails: { if (activity.old_value === "") return ( <> - marked {showIssue ? : "this issue"} is blocking issue{" "} + marked {showIssue ? : "this work item"} is blocking work item{" "} {activity.new_value}. ); else return ( <> - removed the blocking issue{" "} + removed the blocking work item{" "} {activity.old_value}. ); @@ -589,14 +596,14 @@ const activityDetails: { if (activity.old_value === "") return ( <> - marked {showIssue ? : "this issue"} is being blocked by{" "} + marked {showIssue ? : "this work item"} is being blocked by{" "} {activity.new_value}. ); else return ( <> - removed {showIssue ? : "this issue"} being blocked by issue{" "} + removed {showIssue ? : "this work item"} being blocked by work item{" "} {activity.old_value}. ); @@ -608,14 +615,14 @@ const activityDetails: { if (activity.old_value === "") return ( <> - marked {showIssue ? : "this issue"} as duplicate of{" "} + marked {showIssue ? : "this work item"} as duplicate of{" "} {activity.new_value}. ); else return ( <> - removed {showIssue ? : "this issue"} as a duplicate of{" "} + removed {showIssue ? : "this work item"} as a duplicate of{" "} {activity.old_value}. ); @@ -709,7 +716,7 @@ const activityDetails: { )} - {activity.verb === "2" && ` from intake by marking a duplicate issue.`} + {activity.verb === "2" && ` from intake by marking a duplicate work item.`} ), icon:
    = (props) => {
    {selectedIssues.length > 0 && ( )}
    diff --git a/web/core/components/core/modals/gpt-assistant-popover.tsx b/web/core/components/core/modals/gpt-assistant-popover.tsx index 0056977ed..676da4624 100644 --- a/web/core/components/core/modals/gpt-assistant-popover.tsx +++ b/web/core/components/core/modals/gpt-assistant-popover.tsx @@ -6,12 +6,15 @@ import { Controller, useForm } from "react-hook-form"; // services import { usePopper } from "react-popper"; import { AlertCircle } from "lucide-react"; import { Popover, Transition } from "@headlessui/react"; +// plane editor +import { EditorReadOnlyRefApi } from "@plane/editor"; // ui import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; // components import { RichTextReadOnlyEditor } from "@/components/editor/rich-text-editor/rich-text-read-only-editor"; // services import { AIService } from "@/services/ai.service"; +const aiService = new AIService(); type Props = { isOpen: boolean; @@ -22,6 +25,7 @@ type Props = { prompt?: string; button: JSX.Element; className?: string; + workspaceId: string; workspaceSlug: string; projectId: string; }; @@ -31,8 +35,6 @@ type FormData = { task: string; }; -const aiService = new AIService(); - export const GptAssistantPopover: React.FC = (props) => { const { isOpen, @@ -43,6 +45,7 @@ export const GptAssistantPopover: React.FC = (props) => { prompt, button, className = "", + workspaceId, workspaceSlug, projectId, } = props; @@ -51,7 +54,8 @@ export const GptAssistantPopover: React.FC = (props) => { const [invalidResponse, setInvalidResponse] = useState(false); const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); - const editorRef = useRef(null); + // refs + const editorRef = useRef(null); const responseRef = useRef(null); // popper const { styles, attributes } = usePopper(referenceElement, popperElement, { @@ -188,7 +192,7 @@ export const GptAssistantPopover: React.FC = (props) => { return ( - @@ -218,6 +222,7 @@ export const GptAssistantPopover: React.FC = (props) => { initialValue={prompt} containerClassName="-m-3" ref={editorRef} + workspaceId={workspaceId} workspaceSlug={workspaceSlug} projectId={projectId} /> @@ -230,6 +235,7 @@ export const GptAssistantPopover: React.FC = (props) => { id="ai-assistant-response" initialValue={`

    ${response}

    `} ref={responseRef} + workspaceId={workspaceId} workspaceSlug={workspaceSlug} projectId={projectId} /> diff --git a/web/core/components/core/modals/issue-search-modal-empty-state.tsx b/web/core/components/core/modals/issue-search-modal-empty-state.tsx index 578d39a60..20646efb1 100644 --- a/web/core/components/core/modals/issue-search-modal-empty-state.tsx +++ b/web/core/components/core/modals/issue-search-modal-empty-state.tsx @@ -1,10 +1,10 @@ import React from "react"; -// components +// plane imports +import { useTranslation } from "@plane/i18n"; import { ISearchIssueResponse } from "@plane/types"; -import { EmptyState } from "@/components/empty-state"; -// types -import { EmptyStateType } from "@/constants/empty-state"; -// constants +// components +import { SimpleEmptyState } from "@/components/empty-state"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; interface EmptyStateProps { issues: ISearchIssueResponse[]; @@ -19,18 +19,28 @@ export const IssueSearchModalEmptyState: React.FC = ({ debouncedSearchTerm, isSearching, }) => { - const renderEmptyState = (type: EmptyStateType) => ( -
    - -
    + // plane hooks + const { t } = useTranslation(); + // derived values + const searchResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" }); + const issuesResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/issues" }); + + const EmptyStateContainer = ({ children }: { children: React.ReactNode }) => ( +
    {children}
    ); - const emptyState = - issues.length === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && !isSearching - ? renderEmptyState(EmptyStateType.ISSUE_RELATION_SEARCH_EMPTY_STATE) - : issues.length === 0 - ? renderEmptyState(EmptyStateType.ISSUE_RELATION_EMPTY_STATE) - : null; - - return emptyState; + if (issues.length === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && !isSearching) { + return ( + + + + ); + } else if (issues.length === 0) { + return ( + + + + ); + } + return null; }; diff --git a/web/core/components/core/modals/user-image-upload-modal.tsx b/web/core/components/core/modals/user-image-upload-modal.tsx index ad7a4daac..286f36415 100644 --- a/web/core/components/core/modals/user-image-upload-modal.tsx +++ b/web/core/components/core/modals/user-image-upload-modal.tsx @@ -5,12 +5,10 @@ import { observer } from "mobx-react"; import { useDropzone } from "react-dropzone"; import { UserCircle2 } from "lucide-react"; import { Transition, Dialog } from "@headlessui/react"; -// plane types +// plane imports +import { MAX_FILE_SIZE } from "@plane/constants"; import { EFileAssetType } from "@plane/types/src/enums"; -// hooks import { Button, TOAST_TYPE, setToast } from "@plane/ui"; -// constants -import { MAX_STATIC_FILE_SIZE } from "@/constants/common"; // helpers import { getAssetIdFromUrl, getFileURL } from "@/helpers/file.helper"; import { checkURLValidity } from "@/helpers/string.helper"; @@ -40,7 +38,7 @@ export const UserImageUploadModal: React.FC = observer((props) => { accept: { "image/*": [".png", ".jpg", ".jpeg", ".webp"], }, - maxSize: MAX_STATIC_FILE_SIZE, + maxSize: MAX_FILE_SIZE, multiple: false, }); diff --git a/web/core/components/core/modals/workspace-image-upload-modal.tsx b/web/core/components/core/modals/workspace-image-upload-modal.tsx index cc7248a62..df0248c84 100644 --- a/web/core/components/core/modals/workspace-image-upload-modal.tsx +++ b/web/core/components/core/modals/workspace-image-upload-modal.tsx @@ -5,12 +5,10 @@ import { useParams } from "next/navigation"; import { useDropzone } from "react-dropzone"; import { UserCircle2 } from "lucide-react"; import { Transition, Dialog } from "@headlessui/react"; -// plane types +// plane imports +import { MAX_FILE_SIZE } from "@plane/constants"; import { EFileAssetType } from "@plane/types/src/enums"; -// hooks import { Button } from "@plane/ui"; -// constants -import { MAX_STATIC_FILE_SIZE } from "@/constants/common"; // helpers import { getAssetIdFromUrl, getFileURL } from "@/helpers/file.helper"; import { checkURLValidity } from "@/helpers/string.helper"; @@ -48,7 +46,7 @@ export const WorkspaceImageUploadModal: React.FC = observer((props) => { accept: { "image/*": [".png", ".jpg", ".jpeg", ".webp"], }, - maxSize: MAX_STATIC_FILE_SIZE, + maxSize: MAX_FILE_SIZE, multiple: false, }); diff --git a/web/core/components/core/sidebar/progress-chart.tsx b/web/core/components/core/sidebar/progress-chart.tsx index 25dd3fee5..bb4eeb493 100644 --- a/web/core/components/core/sidebar/progress-chart.tsx +++ b/web/core/components/core/sidebar/progress-chart.tsx @@ -48,7 +48,7 @@ const ProgressChart: React.FC = ({ endDate, totalIssues, className = "", - plotTitle = "issues", + plotTitle = "work items", }) => { const chartData = Object.keys(distribution ?? []).map((key) => ({ currentDate: renderFormattedDateWithoutYear(key), diff --git a/web/core/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx b/web/core/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx index fb847ec17..7dc398812 100644 --- a/web/core/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx +++ b/web/core/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx @@ -1,20 +1,20 @@ "use client"; -import { FC } from "react"; import { observer } from "mobx-react"; import { Menu } from "lucide-react"; import { useAppTheme } from "@/hooks/store"; -export const SidebarHamburgerToggle: FC = observer(() => { +export const SidebarHamburgerToggle = observer(() => { // store hooks const { toggleSidebar } = useAppTheme(); return ( -
    toggleSidebar()} > - -
    + + ); }); diff --git a/web/core/components/core/sidebar/single-progress-stats.tsx b/web/core/components/core/sidebar/single-progress-stats.tsx index 25f976d84..047d8bc04 100644 --- a/web/core/components/core/sidebar/single-progress-stats.tsx +++ b/web/core/components/core/sidebar/single-progress-stats.tsx @@ -21,8 +21,8 @@ export const SingleProgressStats: React.FC = ({ } ${selected ? "bg-custom-background-90" : ""}`} onClick={onClick} > -
    {title}
    -
    +
    {title}
    +
    {isNaN(Math.round((completed / total) * 100)) ? "0" : Math.round((completed / total) * 100)}% diff --git a/web/core/components/core/theme/custom-theme-selector.tsx b/web/core/components/core/theme/custom-theme-selector.tsx index da9034393..d66fbe82f 100644 --- a/web/core/components/core/theme/custom-theme-selector.tsx +++ b/web/core/components/core/theme/custom-theme-selector.tsx @@ -1,29 +1,16 @@ "use client"; +import { useMemo } from "react"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; // types +import { useTranslation } from "@plane/i18n"; import { IUserTheme } from "@plane/types"; // ui import { Button, InputColorPicker, setPromiseToast } from "@plane/ui"; // hooks import { useUserProfile } from "@/hooks/store"; -const inputRules = { - minLength: { - value: 7, - message: "Enter a valid hex code of 6 characters", - }, - maxLength: { - value: 7, - message: "Enter a valid hex code of 6 characters", - }, - pattern: { - value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, - message: "Enter a valid hex code of 6 characters", - }, -}; - type TCustomThemeSelector = { applyThemeChange: (theme: Partial) => void; }; @@ -32,7 +19,7 @@ export const CustomThemeSelector: React.FC = observer((pro const { applyThemeChange } = props; // hooks const { data: userProfile, updateUserTheme } = useUserProfile(); - + const { t } = useTranslation(); const { control, formState: { errors, isSubmitting }, @@ -51,6 +38,24 @@ export const CustomThemeSelector: React.FC = observer((pro }, }); + const inputRules = useMemo( + () => ({ + minLength: { + value: 7, + message: t("enter_a_valid_hex_code_of_6_characters"), + }, + maxLength: { + value: 7, + message: t("enter_a_valid_hex_code_of_6_characters"), + }, + pattern: { + value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, + message: t("enter_a_valid_hex_code_of_6_characters"), + }, + }), + [t] // Empty dependency array since these rules never change + ); + const handleUpdateTheme = async (formData: Partial) => { const payload: IUserTheme = { background: formData.background, @@ -66,14 +71,14 @@ export const CustomThemeSelector: React.FC = observer((pro const updateCurrentUserThemePromise = updateUserTheme(payload); setPromiseToast(updateCurrentUserThemePromise, { - loading: "Updating theme...", + loading: t("updating_theme"), success: { - title: "Success!", - message: () => "Theme updated successfully!", + title: t("success"), + message: () => t("theme_updated_successfully"), }, error: { - title: "Error!", - message: () => "Failed to Update the theme", + title: t("error"), + message: () => t("failed_to_update_the_theme"), }, }); @@ -91,16 +96,16 @@ export const CustomThemeSelector: React.FC = observer((pro return (
    -

    Customize your theme

    +

    {t("customize_your_theme")}

    -

    Background color

    +

    {t("background_color")}

    ( = observer((pro
    -

    Text color

    +

    {t("text_color")}

    ( = observer((pro
    -

    Primary(Theme) color

    +

    {t("primary_color")}

    ( = observer((pro
    -

    Sidebar background color

    +

    {t("sidebar_background_color")}

    ( = observer((pro
    -

    Sidebar text color

    +

    {t("sidebar_text_color")}

    ( = observer((pro
    diff --git a/web/core/components/core/theme/theme-switch.tsx b/web/core/components/core/theme/theme-switch.tsx index b79e2104e..1a188fa03 100644 --- a/web/core/components/core/theme/theme-switch.tsx +++ b/web/core/components/core/theme/theme-switch.tsx @@ -1,9 +1,11 @@ "use client"; import { FC } from "react"; +// plane imports +import { I_THEME_OPTION, THEME_OPTIONS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; // constants import { CustomSelect } from "@plane/ui"; -import { THEME_OPTIONS, I_THEME_OPTION } from "@/constants/themes"; // ui type Props = { @@ -13,7 +15,7 @@ type Props = { export const ThemeSwitch: FC = (props) => { const { value, onChange } = props; - + const { t } = useTranslation(); return ( = (props) => { }} />
    - {value.label} + {t(value.key)}
    ) : ( - "Select your theme" + t("select_your_theme") ) } onChange={onChange} @@ -72,7 +74,7 @@ export const ThemeSwitch: FC = (props) => { }} />
    - {themeOption.label} + {t(themeOption.key)}
    ))} diff --git a/web/core/components/cycles/active-cycle/cycle-stats.tsx b/web/core/components/cycles/active-cycle/cycle-stats.tsx index 6fcdaebd5..f932415c3 100644 --- a/web/core/components/cycles/active-cycle/cycle-stats.tsx +++ b/web/core/components/cycles/active-cycle/cycle-stats.tsx @@ -6,17 +6,16 @@ import { observer } from "mobx-react"; import { CalendarCheck } from "lucide-react"; // headless ui import { Tab } from "@headlessui/react"; -// types +// plane imports +import { EIssuesStoreType } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { ICycle, IIssueFilterOptions } from "@plane/types"; // ui import { Tooltip, Loader, PriorityIcon, Avatar } from "@plane/ui"; // components import { SingleProgressStats } from "@/components/core"; import { StateDropdown } from "@/components/dropdowns"; -import { EmptyState } from "@/components/empty-state"; -// constants -import { EmptyStateType } from "@/constants/empty-state"; -import { EIssuesStoreType } from "@/constants/issue"; +import { SimpleEmptyState } from "@/components/empty-state"; // helpers import { cn } from "@/helpers/common.helper"; import { renderFormattedDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.helper"; @@ -26,6 +25,7 @@ import { useIssueDetail, useIssues } from "@/hooks/store"; import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; import useLocalStorage from "@/hooks/use-local-storage"; // plane web components +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; import { IssueIdentifier } from "@/plane-web/components/issues"; // store import { ActiveCycleIssueDetails } from "@/store/issue/cycle"; @@ -41,11 +41,18 @@ export type ActiveCycleStatsProps = { export const ActiveCycleStats: FC = observer((props) => { const { workspaceSlug, projectId, cycle, cycleId, handleFiltersUpdate, cycleIssueDetails } = props; - + // local storage const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees"); - + // refs const issuesContainerRef = useRef(null); + // states const [issuesLoaderElement, setIssueLoaderElement] = useState(null); + // plane hooks + const { t } = useTranslation(); + // derived values + const priorityResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/active-cycle/priority" }); + const assigneesResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/active-cycle/assignee" }); + const labelsResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/active-cycle/label" }); const currentValue = (tab: string | null) => { switch (tab) { @@ -119,7 +126,7 @@ export const ActiveCycleStats: FC = observer((props) => { ) } > - Priority Issues + {t("project_cycles.active_cycle.priority_issue")} @@ -132,7 +139,7 @@ export const ActiveCycleStats: FC = observer((props) => { ) } > - Assignees + {t("project_cycles.active_cycle.assignees")} @@ -145,7 +152,7 @@ export const ActiveCycleStats: FC = observer((props) => { ) } > - Labels + {t("project_cycles.active_cycle.labels")} @@ -231,10 +238,9 @@ export const ActiveCycleStats: FC = observer((props) => { ) : (
    -
    ) @@ -283,7 +289,7 @@ export const ActiveCycleStats: FC = observer((props) => {
    User
    - No assignee + {t("no_assignee")}
    } completed={assignee.completed_issues} @@ -293,10 +299,9 @@ export const ActiveCycleStats: FC = observer((props) => { }) ) : (
    -
    ) @@ -315,14 +320,14 @@ export const ActiveCycleStats: FC = observer((props) => { +
    - {label.label_name ?? "No labels"} + {label.label_name ?? "No labels"}
    } completed={label.completed_issues} @@ -336,7 +341,7 @@ export const ActiveCycleStats: FC = observer((props) => { )) ) : (
    - +
    ) ) : ( diff --git a/web/core/components/cycles/active-cycle/productivity.tsx b/web/core/components/cycles/active-cycle/productivity.tsx index 74957af03..613355c7e 100644 --- a/web/core/components/cycles/active-cycle/productivity.tsx +++ b/web/core/components/cycles/active-cycle/productivity.tsx @@ -1,16 +1,17 @@ import { FC, Fragment } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; +// plane imports +import { useTranslation } from "@plane/i18n"; import { ICycle, TCycleEstimateType } from "@plane/types"; import { Loader } from "@plane/ui"; // components import ProgressChart from "@/components/core/sidebar/progress-chart"; -import { EmptyState } from "@/components/empty-state"; +import { SimpleEmptyState } from "@/components/empty-state"; // constants -import { EmptyStateType } from "@/constants/empty-state"; -import { useCycle, useProjectEstimates } from "@/hooks/store"; +import { useCycle } from "@/hooks/store"; // plane web constants -import { EEstimateSystem } from "@/plane-web/constants/estimates"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; import { EstimateTypeDropdown } from "../dropdowns/estimate-type-dropdown"; export type ActiveCycleProductivityProps = { @@ -21,11 +22,13 @@ export type ActiveCycleProductivityProps = { export const ActiveCycleProductivity: FC = observer((props) => { const { workspaceSlug, projectId, cycle } = props; + // plane hooks + const { t } = useTranslation(); // hooks const { getEstimateTypeByCycleId, setEstimateType } = useCycle(); - // derived values const estimateType: TCycleEstimateType = (cycle && getEstimateTypeByCycleId(cycle.id)) || "issues"; + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/active-cycle/chart" }); const onChange = async (value: TCycleEstimateType) => { if (!workspaceSlug || !projectId || !cycle || !cycle.id) return; @@ -40,7 +43,9 @@ export const ActiveCycleProductivity: FC = observe
    -

    Issue burndown

    +

    + {t("project_cycles.active_cycle.issue_burndown")} +

    @@ -53,17 +58,17 @@ export const ActiveCycleProductivity: FC = observe
    - Ideal + {t("project_cycles.active_cycle.ideal")}
    - Current + {t("project_cycles.active_cycle.current")}
    {estimateType === "points" ? ( {`Pending points - ${cycle.backlog_estimate_points + cycle.unstarted_estimate_points + cycle.started_estimate_points}`} ) : ( - {`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`} + {`Pending work items - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`} )}
    @@ -84,7 +89,7 @@ export const ActiveCycleProductivity: FC = observe startDate={cycle.start_date ?? ""} endDate={cycle.end_date ?? ""} totalIssues={cycle.total_issues || 0} - plotTitle={"issues"} + plotTitle={"work items"} /> )} @@ -95,7 +100,7 @@ export const ActiveCycleProductivity: FC = observe ) : ( <>
    - +
    )} diff --git a/web/core/components/cycles/active-cycle/progress.tsx b/web/core/components/cycles/active-cycle/progress.tsx index f75c51526..c2f0174f6 100644 --- a/web/core/components/cycles/active-cycle/progress.tsx +++ b/web/core/components/cycles/active-cycle/progress.tsx @@ -2,17 +2,16 @@ import { FC } from "react"; import { observer } from "mobx-react"; -// types +// plane package imports +import { PROGRESS_STATE_GROUPS_DETAILS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { ICycle, IIssueFilterOptions } from "@plane/types"; -// ui import { LinearProgressIndicator, Loader } from "@plane/ui"; // components -import { EmptyState } from "@/components/empty-state"; -// constants -import { PROGRESS_STATE_GROUPS_DETAILS } from "@/constants/common"; -import { EmptyStateType } from "@/constants/empty-state"; +import { SimpleEmptyState } from "@/components/empty-state"; // hooks import { useProjectState } from "@/hooks/store"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; export type ActiveCycleProgressProps = { cycle: ICycle | null; @@ -23,9 +22,10 @@ export type ActiveCycleProgressProps = { export const ActiveCycleProgress: FC = observer((props) => { const { handleFiltersUpdate, cycle } = props; + // plane hooks + const { t } = useTranslation(); // store hooks const { groupedProjectStates } = useProjectState(); - // derived values const progressIndicatorData = PROGRESS_STATE_GROUPS_DETAILS.map((group, index) => ({ id: index, @@ -41,16 +41,17 @@ export const ActiveCycleProgress: FC = observer((props backlog: cycle?.backlog_issues, } : {}; + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/active-cycle/progress" }); return cycle && cycle.hasOwnProperty("started_issues") ? (
    -

    Progress

    +

    {t("project_cycles.active_cycle.progress")}

    {cycle.total_issues > 0 && ( {`${cycle.completed_issues + cycle.cancelled_issues}/${cycle.total_issues - cycle.cancelled_issues} ${ - cycle.completed_issues + cycle.cancelled_issues > 1 ? "Issues" : "Issue" + cycle.completed_issues + cycle.cancelled_issues > 1 ? "Work items" : "Work item" } closed`} )} @@ -83,7 +84,7 @@ export const ActiveCycleProgress: FC = observer((props {group}
    {`${groupedIssues[group]} ${ - groupedIssues[group] > 1 ? "Issues" : "Issue" + groupedIssues[group] > 1 ? "Work items" : "Work item" }`}
    @@ -94,7 +95,7 @@ export const ActiveCycleProgress: FC = observer((props {`${cycle.cancelled_issues} cancelled ${ - cycle.cancelled_issues > 1 ? "issues are" : "issue is" + cycle.cancelled_issues > 1 ? "work items are" : "work item is" } excluded from this report.`}{" "} @@ -102,7 +103,7 @@ export const ActiveCycleProgress: FC = observer((props
    ) : (
    - +
    )}
    diff --git a/web/core/components/cycles/active-cycle/use-cycles-details.ts b/web/core/components/cycles/active-cycle/use-cycles-details.ts index cd148705b..2bfe9951e 100644 --- a/web/core/components/cycles/active-cycle/use-cycles-details.ts +++ b/web/core/components/cycles/active-cycle/use-cycles-details.ts @@ -2,9 +2,9 @@ import { useCallback } from "react"; import isEqual from "lodash/isEqual"; import { useRouter } from "next/navigation"; import useSWR from "swr"; +import { EIssueFilterType, EIssuesStoreType } from "@plane/constants"; import { IIssueFilterOptions } from "@plane/types"; import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys"; -import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; import { useCycle, useIssues } from "@/hooks/store"; interface IActiveCycleDetails { @@ -30,21 +30,23 @@ const useCyclesDetails = (props: IActiveCycleDetails) => { // fetch cycle details useSWR( - workspaceSlug && projectId && cycle ? `PROJECT_ACTIVE_CYCLE_${projectId}_PROGRESS` : null, - workspaceSlug && projectId && cycle ? () => fetchActiveCycleProgress(workspaceSlug, projectId, cycle.id) : null, + workspaceSlug && projectId && cycle?.id ? `PROJECT_ACTIVE_CYCLE_${projectId}_PROGRESS` : null, + workspaceSlug && projectId && cycle?.id ? () => fetchActiveCycleProgress(workspaceSlug, projectId, cycle.id) : null, { revalidateIfStale: false, revalidateOnFocus: false } ); useSWR( - workspaceSlug && projectId && cycle && !cycle?.distribution ? `PROJECT_ACTIVE_CYCLE_${projectId}_DURATION` : null, - workspaceSlug && projectId && cycle && !cycle?.distribution + workspaceSlug && projectId && cycle?.id && !cycle?.distribution + ? `PROJECT_ACTIVE_CYCLE_${projectId}_DURATION` + : null, + workspaceSlug && projectId && cycle?.id && !cycle?.distribution ? () => fetchActiveCycleAnalytics(workspaceSlug, projectId, cycle.id, "issues") : null ); useSWR( - workspaceSlug && projectId && cycle && !cycle?.estimate_distribution + workspaceSlug && projectId && cycle?.id && !cycle?.estimate_distribution ? `PROJECT_ACTIVE_CYCLE_${projectId}_ESTIMATE_DURATION` : null, - workspaceSlug && projectId && cycle && !cycle?.estimate_distribution + workspaceSlug && projectId && cycle?.id && !cycle?.estimate_distribution ? () => fetchActiveCycleAnalytics(workspaceSlug, projectId, cycle.id, "points") : null ); diff --git a/web/core/components/cycles/analytics-sidebar/issue-progress.tsx b/web/core/components/cycles/analytics-sidebar/issue-progress.tsx index d9725d1d6..47f9df5e5 100644 --- a/web/core/components/cycles/analytics-sidebar/issue-progress.tsx +++ b/web/core/components/cycles/analytics-sidebar/issue-progress.tsx @@ -7,11 +7,12 @@ import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; import { ChevronUp, ChevronDown } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; +import { EIssueFilterType, EIssuesStoreType } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { ICycle, IIssueFilterOptions, TCyclePlotType, TProgressSnapshot } from "@plane/types"; // components import { CycleProgressStats } from "@/components/cycles"; // constants -import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; // helpers import { getDate } from "@/helpers/date-time.helper"; // hooks @@ -30,7 +31,7 @@ type Options = { }; export const cycleEstimateOptions: Options[] = [ - { value: "issues", label: "Issues" }, + { value: "issues", label: "Work items" }, { value: "points", label: "Points" }, ]; export const cycleChartOptions: Options[] = [ @@ -63,6 +64,7 @@ export const CycleAnalyticsProgress: FC = observer((pro const { issuesFilter: { issueFilters, updateFilters }, } = useIssues(EIssuesStoreType.CYCLE); + const { t } = useTranslation(); // derived values const cycleDetails = validateCycleSnapshot(getCycleById(cycleId)); @@ -138,7 +140,9 @@ export const CycleAnalyticsProgress: FC = observer((pro {isCycleDateValid ? (
    -
    Progress
    +
    + {t("project_cycles.active_cycle.progress")} +
    {open ? ( @@ -150,7 +154,9 @@ export const CycleAnalyticsProgress: FC = observer((pro
    ) : (
    -
    Progress
    +
    + {t("project_cycles.active_cycle.progress")} +
    )} diff --git a/web/core/components/cycles/analytics-sidebar/progress-stats.tsx b/web/core/components/cycles/analytics-sidebar/progress-stats.tsx index 069ccb2ff..4f13b8612 100644 --- a/web/core/components/cycles/analytics-sidebar/progress-stats.tsx +++ b/web/core/components/cycles/analytics-sidebar/progress-stats.tsx @@ -4,6 +4,7 @@ import { FC } from "react"; import { observer } from "mobx-react"; import Image from "next/image"; import { Tab } from "@headlessui/react"; +import { useTranslation } from "@plane/i18n"; import { IIssueFilterOptions, IIssueFilters, @@ -73,6 +74,7 @@ type TStateStatComponent = { export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => { const { distribution, isEditable, filters, handleFiltersUpdate } = props; + const { t } = useTranslation(); return (
    {distribution && distribution.length > 0 ? ( @@ -104,7 +106,7 @@ export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) =>
    User
    - No assignee + {t("no_assignee")}
    } completed={assignee?.completed ?? 0} @@ -117,7 +119,7 @@ export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) =>
    empty members
    -
    No assignees yet
    +
    {t("no_assignee")}
    )}
    @@ -126,6 +128,7 @@ export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => export const LabelStatComponent = observer((props: TLabelStatComponent) => { const { distribution, isEditable, filters, handleFiltersUpdate } = props; + const { t } = useTranslation(); return (
    {distribution && distribution.length > 0 ? ( @@ -135,14 +138,14 @@ export const LabelStatComponent = observer((props: TLabelStatComponent) => { +
    - {label.title ?? "No labels"} + {label.title ?? t("no_labels_yet")}
    } completed={label.completed} @@ -165,7 +168,7 @@ export const LabelStatComponent = observer((props: TLabelStatComponent) => { backgroundColor: label.color ?? "transparent", }} /> - {label.title ?? "No labels"} + {label.title ?? t("no_labels_yet")}
    } completed={label.completed} @@ -179,7 +182,7 @@ export const LabelStatComponent = observer((props: TLabelStatComponent) => {
    empty label
    -
    No labels yet
    +
    {t("no_labels_yet")}
    )}
    @@ -222,15 +225,15 @@ export const StateStatComponent = observer((props: TStateStatComponent) => { const progressStats = [ { key: "stat-states", - title: "States", + i18n_title: "common.states", }, { key: "stat-assignees", - title: "Assignees", + i18n_title: "common.assignees", }, { key: "stat-labels", - title: "Labels", + i18n_title: "common.labels", }, ]; @@ -267,6 +270,7 @@ export const CycleProgressStats: FC = observer((props) => { `cycle-analytics-tab-${cycleId}`, "stat-assignees" ); + const { t } = useTranslation(); // derived values const currentTabIndex = (tab: string): number => progressStats.findIndex((stat) => stat.key === tab); @@ -337,7 +341,7 @@ export const CycleProgressStats: FC = observer((props) => { key={stat.key} onClick={() => setCycleTab(stat.key)} > - {stat.title} + {t(stat.i18n_title)} ))} diff --git a/web/core/components/cycles/analytics-sidebar/root.tsx b/web/core/components/cycles/analytics-sidebar/root.tsx index fd8c984a6..b709c0e63 100644 --- a/web/core/components/cycles/analytics-sidebar/root.tsx +++ b/web/core/components/cycles/analytics-sidebar/root.tsx @@ -2,7 +2,6 @@ import React from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // ui import { Loader } from "@plane/ui"; // components @@ -13,19 +12,19 @@ import useCyclesDetails from "../active-cycle/use-cycles-details"; type Props = { handleClose: () => void; isArchived?: boolean; - cycleId?: string; + cycleId: string; + projectId: string; + workspaceSlug: string; }; export const CycleDetailsSidebar: React.FC = observer((props) => { - const { handleClose, isArchived } = props; - // router - const { workspaceSlug, projectId, cycleId } = useParams(); + const { handleClose, isArchived, projectId, workspaceSlug, cycleId } = props; // store hooks const { cycle: cycleDetails } = useCyclesDetails({ - workspaceSlug: workspaceSlug.toString(), - projectId: projectId.toString(), - cycleId: cycleId?.toString() || props.cycleId, + workspaceSlug, + projectId, + cycleId, }); if (!cycleDetails) @@ -47,21 +46,17 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
    - +
    {workspaceSlug && projectId && cycleDetails?.id && ( - + )}
    ); diff --git a/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx b/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx index c60b5dae9..eaa460f7a 100644 --- a/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx +++ b/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx @@ -4,6 +4,7 @@ import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react"; import { LayersIcon, SquareUser, Users } from "lucide-react"; // plane types +import { useTranslation } from "@plane/i18n"; import { ICycle } from "@plane/types"; // plane ui import { Avatar, AvatarGroup, TextArea } from "@plane/ui"; @@ -24,6 +25,7 @@ export const CycleSidebarDetails: FC = observer((props) => { // hooks const { getUserDetails } = useMember(); const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates(); + const { t } = useTranslation(); const areEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId.toString()); const cycleStatus = cycleDetails?.status?.toLocaleLowerCase(); @@ -32,10 +34,10 @@ export const CycleSidebarDetails: FC = observer((props) => { const issueCount = isCompleted && !isEmpty(cycleDetails?.progress_snapshot) ? cycleDetails?.progress_snapshot?.total_issues === 0 - ? "0 Issue" + ? `0 ${t("common.work_item")}` : `${cycleDetails?.progress_snapshot?.completed_issues}/${cycleDetails?.progress_snapshot?.total_issues}` : cycleDetails?.total_issues === 0 - ? "0 Issue" + ? `0 ${t("common.work_item")}` : `${cycleDetails?.completed_issues}/${cycleDetails?.total_issues}`; const estimateType = areEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId); const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined; @@ -51,10 +53,10 @@ export const CycleSidebarDetails: FC = observer((props) => { const issueEstimatePointCount = isCompleted && !isEmpty(cycleDetails?.progress_snapshot) ? cycleDetails?.progress_snapshot.total_issues === 0 - ? "0 Issue" + ? `0 ${t("common.work_item")}` : `${cycleDetails?.progress_snapshot.completed_estimate_points}/${cycleDetails?.progress_snapshot.total_estimate_points}` : cycleDetails?.total_issues === 0 - ? "0 Issue" + ? `0 ${t("common.work_item")}` : `${cycleDetails?.completed_estimate_points}/${cycleDetails?.total_estimate_points}`; return (
    @@ -70,7 +72,7 @@ export const CycleSidebarDetails: FC = observer((props) => {
    - Lead + {t("lead")}
    @@ -83,7 +85,7 @@ export const CycleSidebarDetails: FC = observer((props) => {
    - Members + {t("members")}
    @@ -104,7 +106,7 @@ export const CycleSidebarDetails: FC = observer((props) => { ) : ( - No assignees + {t("no_assignee")} )}
    @@ -113,7 +115,7 @@ export const CycleSidebarDetails: FC = observer((props) => {
    - Issues + {t("work_items")}
    {issueCount} @@ -127,7 +129,7 @@ export const CycleSidebarDetails: FC = observer((props) => {
    - Points + {t("points")}
    {issueEstimatePointCount} diff --git a/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx b/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx index af645804e..c8d4cc487 100644 --- a/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx +++ b/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx @@ -5,14 +5,13 @@ import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; import { ArchiveIcon, ArchiveRestoreIcon, ChevronRight, EllipsisIcon, LinkIcon, Trash2 } from "lucide-react"; // types +import { CYCLE_STATUS, CYCLE_UPDATED, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { ICycle } from "@plane/types"; // ui import { CustomMenu, setToast, TOAST_TYPE } from "@plane/ui"; // components import { DateRangeDropdown } from "@/components/dropdowns"; -// constants -import { CYCLE_STATUS } from "@/constants/cycle"; -import { CYCLE_UPDATED } from "@/constants/event-tracker"; // helpers import { renderFormattedPayloadDate, getDate } from "@/helpers/date-time.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper"; @@ -20,7 +19,6 @@ import { copyUrlToClipboard } from "@/helpers/string.helper"; import { useCycle, useEventTracker, useUserPermissions } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; // plane web constants -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; // services import { CycleService } from "@/services/cycle.service"; // local components @@ -53,6 +51,7 @@ export const CycleSidebarHeader: FC = observer((props) => { const { allowPermissions } = useUserPermissions(); const { updateCycleDetails, restoreCycle } = useCycle(); const { setTrackElement, captureCycleEvent } = useEventTracker(); + const { t } = useTranslation(); // form info const { control, reset } = useForm({ @@ -71,16 +70,16 @@ export const CycleSidebarHeader: FC = observer((props) => { .then(() => { setToast({ type: TOAST_TYPE.SUCCESS, - title: "Restore success", - message: "Your cycle can be found in project cycles.", + title: t("project_cycles.action.restore.success.title"), + message: t("project_cycles.action.restore.success.description"), }); router.push(`/${workspaceSlug.toString()}/projects/${projectId.toString()}/archives/cycles`); }) .catch(() => setToast({ type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Cycle could not be restored. Please try again.", + title: t("project_cycles.action.restore.failed.title"), + message: t("project_cycles.action.restore.failed.description"), }) ); }; @@ -90,14 +89,14 @@ export const CycleSidebarHeader: FC = observer((props) => { .then(() => { setToast({ type: TOAST_TYPE.SUCCESS, - title: "Link Copied!", - message: "Cycle link copied to clipboard.", + title: t("common.link_copied"), + message: t("common.link_copied_to_clipboard"), }); }) .catch(() => { setToast({ type: TOAST_TYPE.ERROR, - title: "Some error occurred", + title: t("common.errors.default.message"), }); }); }; @@ -167,15 +166,14 @@ export const CycleSidebarHeader: FC = observer((props) => { submitChanges(payload, "date_range"); setToast({ type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Cycle updated successfully.", + title: t("project_cycles.action.update.success.title"), + message: t("project_cycles.action.update.success.description"), }); } else { setToast({ type: TOAST_TYPE.ERROR, - title: "Error!", - message: - "You already have a cycle on the given dates, if you want to create a draft cycle, you can do that by removing both the dates.", + title: t("project_cycles.action.update.failed.title"), + message: t("project_cycles.action.update.error.already_exists"), }); reset({ ...cycleDetails }); } @@ -232,15 +230,15 @@ export const CycleSidebarHeader: FC = observer((props) => { {isCompleted ? (
    - Archive cycle + {t("common.archive")}
    ) : (
    -

    Archive cycle

    +

    {t("common.archive")}

    - Only completed cycles
    can be archived. + {t("project_cycles.only_completed_cycles_can_be_archived")}

    @@ -251,7 +249,7 @@ export const CycleSidebarHeader: FC = observer((props) => { - Restore cycle + {t("project_cycles.action.restore.title")} )} @@ -264,7 +262,7 @@ export const CycleSidebarHeader: FC = observer((props) => { > - Delete cycle + {t("delete")} )} @@ -283,7 +281,7 @@ export const CycleSidebarHeader: FC = observer((props) => { backgroundColor: `${currentCycle.color}20`, }} > - {currentCycle.title} + {t(currentCycle.i18n_title)} )}
    diff --git a/web/core/components/cycles/applied-filters/date.tsx b/web/core/components/cycles/applied-filters/date.tsx index 488eef12c..c2ee47be5 100644 --- a/web/core/components/cycles/applied-filters/date.tsx +++ b/web/core/components/cycles/applied-filters/date.tsx @@ -1,7 +1,7 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; // helpers -import { DATE_AFTER_FILTER_OPTIONS } from "@/constants/filters"; +import { DATE_AFTER_FILTER_OPTIONS } from "@plane/constants"; import { renderFormattedDate } from "@/helpers/date-time.helper"; import { capitalizeFirstLetter } from "@/helpers/string.helper"; // constants diff --git a/web/core/components/cycles/applied-filters/root.tsx b/web/core/components/cycles/applied-filters/root.tsx index 9fa74c393..b53f0ab4d 100644 --- a/web/core/components/cycles/applied-filters/root.tsx +++ b/web/core/components/cycles/applied-filters/root.tsx @@ -1,12 +1,13 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { TCycleFilters } from "@plane/types"; // hooks import { Tag } from "@plane/ui"; import { AppliedDateFilters, AppliedStatusFilters } from "@/components/cycles"; import { replaceUnderscoreIfSnakeCase } from "@/helpers/string.helper"; import { useUserPermissions } from "@/hooks/store"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; // components // helpers @@ -26,6 +27,7 @@ export const CycleAppliedFiltersList: React.FC = observer((props) => { const { appliedFilters, handleClearAllFilters, handleRemoveFilter, alwaysAllowEditing } = props; // store hooks const { allowPermissions } = useUserPermissions(); + const { t } = useTranslation(); if (!appliedFilters) return null; @@ -77,7 +79,7 @@ export const CycleAppliedFiltersList: React.FC = observer((props) => { {isEditingAllowed && ( diff --git a/web/core/components/cycles/applied-filters/status.tsx b/web/core/components/cycles/applied-filters/status.tsx index d46b56503..7bb219251 100644 --- a/web/core/components/cycles/applied-filters/status.tsx +++ b/web/core/components/cycles/applied-filters/status.tsx @@ -1,6 +1,7 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; -import { CYCLE_STATUS } from "@/constants/cycle"; +import { CYCLE_STATUS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { cn } from "@/helpers/common.helper"; type Props = { @@ -11,6 +12,7 @@ type Props = { export const AppliedStatusFilters: React.FC = observer((props) => { const { handleRemove, values, editable } = props; + const { t } = useTranslation(); return ( <> @@ -25,7 +27,7 @@ export const AppliedStatusFilters: React.FC = observer((props) => { statusDetails?.textColor )} > - {statusDetails?.title} + {statusDetails && t(statusDetails?.i18n_title)} {editable && (
    diff --git a/web/core/components/cycles/delete-modal.tsx b/web/core/components/cycles/delete-modal.tsx index f89a1cea7..210b1a13a 100644 --- a/web/core/components/cycles/delete-modal.tsx +++ b/web/core/components/cycles/delete-modal.tsx @@ -4,12 +4,12 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { useParams, useSearchParams } from "next/navigation"; // types +import { PROJECT_ERROR_MESSAGES, CYCLE_DELETED } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { ICycle } from "@plane/types"; // ui import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui"; // constants -import { CYCLE_DELETED } from "@/constants/event-tracker"; -import { PROJECT_ERROR_MESSAGES } from "@/constants/project"; // hooks import { useEventTracker, useCycle } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; @@ -29,6 +29,7 @@ export const CycleDeleteModal: React.FC = observer((props) => { // store hooks const { captureCycleEvent } = useEventTracker(); const { deleteCycle } = useCycle(); + const { t } = useTranslation(); // router const router = useAppRouter(); const { cycleId } = useParams(); @@ -59,9 +60,9 @@ export const CycleDeleteModal: React.FC = observer((props) => { ? PROJECT_ERROR_MESSAGES.permissionError : PROJECT_ERROR_MESSAGES.cycleDeleteError; setToast({ - title: currentError.title, + title: t(currentError.i18n_title), type: TOAST_TYPE.ERROR, - message: currentError.message, + message: currentError.i18n_message && t(currentError.i18n_message), }); captureCycleEvent({ eventName: CYCLE_DELETED, diff --git a/web/core/components/cycles/dropdowns/filters/end-date.tsx b/web/core/components/cycles/dropdowns/filters/end-date.tsx index e5b4a7a86..f9b7d2f37 100644 --- a/web/core/components/cycles/dropdowns/filters/end-date.tsx +++ b/web/core/components/cycles/dropdowns/filters/end-date.tsx @@ -1,11 +1,11 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; - +// constants +import { DATE_AFTER_FILTER_OPTIONS } from "@plane/constants"; // components import { DateFilterModal } from "@/components/core"; import { FilterHeader, FilterOption } from "@/components/issues"; -// constants -import { DATE_AFTER_FILTER_OPTIONS } from "@/constants/filters"; + // helpers import { isInDateFormat } from "@/helpers/date-time.helper"; diff --git a/web/core/components/cycles/dropdowns/filters/start-date.tsx b/web/core/components/cycles/dropdowns/filters/start-date.tsx index 9bfd8f2d8..eb2032edb 100644 --- a/web/core/components/cycles/dropdowns/filters/start-date.tsx +++ b/web/core/components/cycles/dropdowns/filters/start-date.tsx @@ -1,11 +1,11 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; - +// constants +import { DATE_AFTER_FILTER_OPTIONS } from "@plane/constants"; // components import { DateFilterModal } from "@/components/core"; import { FilterHeader, FilterOption } from "@/components/issues"; -// constants -import { DATE_AFTER_FILTER_OPTIONS } from "@/constants/filters"; + // helpers import { isInDateFormat } from "@/helpers/date-time.helper"; @@ -17,7 +17,6 @@ type Props = { export const FilterStartDate: React.FC = observer((props) => { const { appliedFilters, handleUpdate, searchQuery } = props; - const [previewEnabled, setPreviewEnabled] = useState(true); const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false); diff --git a/web/core/components/cycles/dropdowns/filters/status.tsx b/web/core/components/cycles/dropdowns/filters/status.tsx index b2ca62b51..0e24a2a21 100644 --- a/web/core/components/cycles/dropdowns/filters/status.tsx +++ b/web/core/components/cycles/dropdowns/filters/status.tsx @@ -1,10 +1,11 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; +import { CYCLE_STATUS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { TCycleGroups } from "@plane/types"; // components import { FilterHeader, FilterOption } from "@/components/issues"; // types -import { CYCLE_STATUS } from "@/constants/cycle"; // constants type Props = { @@ -17,7 +18,8 @@ export const FilterStatus: React.FC = observer((props) => { const { appliedFilters, handleUpdate, searchQuery } = props; // states const [previewEnabled, setPreviewEnabled] = useState(true); - + //hooks + const { t } = useTranslation(); const appliedFiltersCount = appliedFilters?.length ?? 0; const filteredOptions = CYCLE_STATUS.filter((p) => p.value.includes(searchQuery.toLowerCase())); @@ -36,7 +38,7 @@ export const FilterStatus: React.FC = observer((props) => { key={status.value} isChecked={appliedFilters?.includes(status.value) ? true : false} onClick={() => handleUpdate(status.value)} - title={status.title} + title={t(status.i18n_title)} /> )) ) : ( diff --git a/web/core/components/cycles/form.tsx b/web/core/components/cycles/form.tsx index 660d33cdb..c5c9c9186 100644 --- a/web/core/components/cycles/form.tsx +++ b/web/core/components/cycles/form.tsx @@ -2,14 +2,16 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; +// plane imports +import { ETabIndices } from "@plane/constants"; // types +import { useTranslation } from "@plane/i18n"; import { ICycle } from "@plane/types"; // ui import { Button, Input, TextArea } from "@plane/ui"; // components import { DateRangeDropdown, ProjectDropdown } from "@/components/dropdowns"; // constants -import { ETabIndices } from "@/constants/tab-indices"; // helpers import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; import { shouldRenderProject } from "@/helpers/project.helper"; @@ -34,6 +36,7 @@ const defaultValues: Partial = { export const CycleForm: React.FC = (props) => { const { handleFormSubmit, handleClose, status, projectId, setActiveProject, data, isMobile = false } = props; + const { t } = useTranslation(); // form data const { formState: { errors, isSubmitting, dirtyFields }, @@ -75,6 +78,7 @@ export const CycleForm: React.FC = (props) => { onChange(val); setActiveProject(val); }} + multiple={false} buttonVariant="border-with-text" renderCondition={(project) => shouldRenderProject(project)} tabIndex={getIndex("cover_image")} @@ -83,7 +87,9 @@ export const CycleForm: React.FC = (props) => { )} /> )} -

    {status ? "Update" : "Create"} cycle

    +

    + {status ? t("project_cycles.update_cycle") : t("project_cycles.create_cycle")} +

    @@ -91,17 +97,17 @@ export const CycleForm: React.FC = (props) => { name="name" control={control} rules={{ - required: "Title is required", + required: t("title_is_required"), maxLength: { value: 255, - message: "Title should be less than 255 characters", + message: t("title_should_be_less_than_255_characters"), }, }} render={({ field: { value, onChange } }) => ( = (props) => { render={({ field: { value, onChange } }) => (