diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000000..fa445360f0a --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,20 @@ +### Description + + +### Type of Change + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] Feature (non-breaking change which adds functionality) +- [ ] Improvement (change that would cause existing functionality to not work as expected) +- [ ] Code refactoring +- [ ] Performance improvements +- [ ] Documentation update + +### Screenshots and Media (if applicable) + + +### Test Scenarios + + +### References + \ No newline at end of file diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index 1e06c1bd319..627c782f998 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -314,8 +314,8 @@ jobs: buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} attach_assets_to_build: - if: ${{ needs.branch_build_setup.outputs.build_type == 'Build' }} - name: Attach Assets to Build + if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }} + name: Attach Assets to Release runs-on: ubuntu-20.04 needs: [branch_build_setup] steps: diff --git a/README.md b/README.md index 38ead5f9970..9c4ea9da614 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,7 @@ Plane Logo

- -

Plane

-

Open-source project management that unlocks customer value

+

Plane

@@ -44,79 +42,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 + +Getting started with Plane is simple. Choose the setup that works best for you: -The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. +- **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. -If you would like to self-host Plane, please see our [deployment guide](https://docs.plane.so/docker-compose). +- **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 manage and customize settings using [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). - -Thats it! +That’s it! You’re all set to begin coding. Remember to refresh your browser if changes don’t auto-reload. Happy contributing! 🎉 -## ❤️ 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 +169,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. + +## ❤️ Community + +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. + +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! + +## 🛡️ Security -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. +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. -Email squawk@plane.so to disclose any security vulnerabilities. +To disclose any security issues, please email us at security@plane.so. -## ❤️ Contribute +## 🤝 Contributing -There are many ways to contribute to Plane, including: +There are many ways you can contribute to Plane: -- 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. +- 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/app/authentication/github/form.tsx b/admin/app/authentication/github/form.tsx index 23ba9ccd155..4842675cd59 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{" "} { }; 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/workspace/create/form.tsx b/admin/app/workspace/create/form.tsx index 958f53153a2..2a7eda207ef 100644 --- a/admin/app/workspace/create/form.tsx +++ b/admin/app/workspace/create/form.tsx @@ -3,13 +3,11 @@ 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"; +import { WEB_BASE_URL, ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants"; // types 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 diff --git a/admin/app/workspace/page.tsx b/admin/app/workspace/page.tsx index ef8a3c42d28..3ca34b69e39 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 84cde94d44e..3c7ec111a33 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 c2b264baeb9..208225e0cec 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 10d5cbd0dad..d776477497b 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/root.tsx b/admin/core/components/admin-sidebar/root.tsx index 9ef6b92bd0f..05dde0d8ab1 100644 --- a/admin/core/components/admin-sidebar/root.tsx +++ b/admin/core/components/admin-sidebar/root.tsx @@ -3,7 +3,7 @@ import { FC, useEffect, useRef } from "react"; import { observer } from "mobx-react"; // plane helpers -import { useOutsideClickDetector } from "@plane/helpers"; +import { useOutsideClickDetector } from "@plane/hooks"; // components import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar"; // hooks diff --git a/admin/core/components/admin-sidebar/sidebar-dropdown.tsx b/admin/core/components/admin-sidebar/sidebar-dropdown.tsx index e0741f7c4a4..f34372413fe 100644 --- a/admin/core/components/admin-sidebar/sidebar-dropdown.tsx +++ b/admin/core/components/admin-sidebar/sidebar-dropdown.tsx @@ -5,11 +5,10 @@ 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 { 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 diff --git a/admin/core/components/admin-sidebar/sidebar-menu.tsx b/admin/core/components/admin-sidebar/sidebar-menu.tsx index a985842e7f3..618551ae65c 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 81365d7f0cf..5edcb611818 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 191d7a0a772..7c1e5ea292b 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 50895a45920..897deb7c481 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 07c566d684e..57035580f66 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 735201025bc..4181338d21e 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 12d11a2f89b..0f3cc98e386 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 55f8b4afb45..88ad78a1e16 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 4d0eade08ed..ca8f301620c 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 8e1570781c4..0b974b38c5d 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 e0a96a6ef68..ae693eb728c 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 aafd5f7a3ed..00000000000 --- 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/lib/auth-helpers.tsx b/admin/core/lib/auth-helpers.tsx new file mode 100644 index 00000000000..582b56e298c --- /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/auth.service.ts b/admin/core/services/auth.service.ts index 2cea01beec6..a47fd839628 100644 --- a/admin/core/services/auth.service.ts +++ b/admin/core/services/auth.service.ts @@ -1,5 +1,4 @@ -// helpers -import { API_BASE_URL } from "@/helpers/common.helper"; +import { API_BASE_URL } from "@plane/constants"; // services import { APIService } from "@/services/api.service"; diff --git a/admin/core/services/instance.service.ts b/admin/core/services/instance.service.ts index feb94ceea45..510a780d792 100644 --- a/admin/core/services/instance.service.ts +++ b/admin/core/services/instance.service.ts @@ -1,4 +1,5 @@ -// types +// plane internal packages +import { API_BASE_URL } from "@plane/constants"; import type { IFormattedInstanceConfiguration, IInstance, @@ -7,7 +8,6 @@ import type { IInstanceInfo, } from "@plane/types"; // helpers -import { API_BASE_URL } from "@/helpers/common.helper"; import { APIService } from "@/services/api.service"; export class InstanceService extends APIService { diff --git a/admin/core/services/user.service.ts b/admin/core/services/user.service.ts index 42eb6eb224b..74ef2a81bfa 100644 --- a/admin/core/services/user.service.ts +++ b/admin/core/services/user.service.ts @@ -1,7 +1,6 @@ -// types +// plane internal packages +import { API_BASE_URL } from "@plane/constants"; import type { IUser } from "@plane/types"; -// helpers -import { API_BASE_URL } from "@/helpers/common.helper"; // services import { APIService } from "@/services/api.service"; diff --git a/admin/core/services/workspace.service.ts b/admin/core/services/workspace.service.ts index 81ba36a6f76..787ad426983 100644 --- a/admin/core/services/workspace.service.ts +++ b/admin/core/services/workspace.service.ts @@ -1,7 +1,6 @@ -// types +// plane internal packages +import { API_BASE_URL } from "@plane/constants"; import type { IWorkspace, TWorkspacePaginationInfo } from "@plane/types"; -// helpers -import { API_BASE_URL } from "@/helpers/common.helper"; // services import { APIService } from "@/services/api.service"; diff --git a/admin/core/store/instance.store.ts b/admin/core/store/instance.store.ts index 01ab552846f..0daadb1fcda 100644 --- a/admin/core/store/instance.store.ts +++ b/admin/core/store/instance.store.ts @@ -1,5 +1,7 @@ import set from "lodash/set"; import { observable, action, computed, makeObservable, runInAction } from "mobx"; +// plane internal packages +import { EInstanceStatus, TInstanceStatus } from "@plane/constants"; import { IInstance, IInstanceAdmin, @@ -8,8 +10,6 @@ import { IInstanceInfo, IInstanceConfig, } from "@plane/types"; -// helpers -import { EInstanceStatus, TInstanceStatus } from "@/helpers/instance.helper"; // services import { InstanceService } from "@/services/instance.service"; // root store diff --git a/admin/core/store/user.store.ts b/admin/core/store/user.store.ts index df17c9b0046..7f56c0523ef 100644 --- a/admin/core/store/user.store.ts +++ b/admin/core/store/user.store.ts @@ -1,7 +1,7 @@ import { action, observable, runInAction, makeObservable } from "mobx"; +// plane internal packages +import { EUserStatus, TUserStatus } from "@plane/constants"; 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"; diff --git a/admin/helpers/authentication.helper.tsx b/admin/helpers/authentication.helper.tsx deleted file mode 100644 index 627ff182cad..00000000000 --- 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 e282e57925f..00000000000 --- 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 6e1f546360c..00000000000 --- 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 ae6aab829c3..00000000000 --- 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 dfe9a5c65fb..00000000000 --- 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 a48508118e8..00000000000 --- 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/package.json b/admin/package.json index e4026da02e5..e2fe4cf331d 100644 --- a/admin/package.json +++ b/admin/package.json @@ -1,6 +1,6 @@ { "name": "admin", - "version": "0.24.0", + "version": "0.24.1", "private": true, "scripts": { "dev": "turbo run develop", @@ -14,9 +14,10 @@ "dependencies": { "@headlessui/react": "^1.7.19", "@plane/constants": "*", - "@plane/helpers": "*", + "@plane/hooks": "*", "@plane/types": "*", "@plane/ui": "*", + "@plane/utils": "*", "@sentry/nextjs": "^8.32.0", "@tailwindcss/typography": "^0.5.9", "@types/lodash": "^4.17.0", @@ -26,7 +27,7 @@ "lucide-react": "^0.356.0", "mobx": "^6.12.0", "mobx-react": "^9.1.1", - "next": "^14.2.12", + "next": "^14.2.20", "next-themes": "^0.2.1", "postcss": "^8.4.38", "react": "^18.3.1", diff --git a/admin/tsconfig.json b/admin/tsconfig.json index 1748435814d..f9bb7cf10b7 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/Dockerfile.api b/apiserver/Dockerfile.api index 97a2b2d4105..b0fa447885a 100644 --- a/apiserver/Dockerfile.api +++ b/apiserver/Dockerfile.api @@ -4,7 +4,7 @@ FROM python:3.12.5-alpine AS backend ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONUNBUFFERED 1 ENV PIP_DISABLE_PIP_VERSION_CHECK=1 -ENV INSTANCE_CHANGELOG_URL https://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/ +ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/ WORKDIR /code diff --git a/apiserver/Dockerfile.dev b/apiserver/Dockerfile.dev index c81966de4f2..3ec8c6340ac 100644 --- a/apiserver/Dockerfile.dev +++ b/apiserver/Dockerfile.dev @@ -4,7 +4,7 @@ FROM python:3.12.5-alpine AS backend ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONUNBUFFERED 1 ENV PIP_DISABLE_PIP_VERSION_CHECK=1 -ENV INSTANCE_CHANGELOG_URL https://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/ +ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/ RUN apk --no-cache add \ "bash~=5.2" \ diff --git a/apiserver/package.json b/apiserver/package.json index 4b44b3898cb..6d350d83c9a 100644 --- a/apiserver/package.json +++ b/apiserver/package.json @@ -1,4 +1,4 @@ { "name": "plane-api", - "version": "0.24.0" + "version": "0.24.1" } diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index f4f06c324aa..d394dc9bd6d 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -4,7 +4,7 @@ # 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): total_issues = serializers.IntegerField(read_only=True) @@ -24,6 +24,18 @@ def validate(self, data): 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 + data["start_date"] = convert_to_utc( + str(data.get("start_date").date()), project_id, is_start_date=True + ) + data["end_date"] = convert_to_utc( + str(data.get("end_date", None).date()), project_id + ) return data class Meta: diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 72918b2683d..3293b79dd90 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -237,17 +237,37 @@ def to_representation(self, instance): 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/views/intake.py b/apiserver/plane/api/views/intake.py index c2d0733ba91..faefc3761cb 100644 --- a/apiserver/plane/api/views/intake.py +++ b/apiserver/plane/api/views/intake.py @@ -109,16 +109,6 @@ def post(self, request, slug, project_id): {"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 @@ def post(self, request, slug, project_id): ), 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/project.py b/apiserver/plane/api/views/project.py index fa645a63cc5..527f43edb54 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -258,7 +258,9 @@ def patch(self, request, slug, pk): 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( diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py index bf08de4fef9..28ec62134cf 100644 --- a/apiserver/plane/app/serializers/cycle.py +++ b/apiserver/plane/app/serializers/cycle.py @@ -5,6 +5,7 @@ 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,17 @@ def validate(self, data): 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 + data["start_date"] = convert_to_utc( + str(data.get("start_date").date()), project_id, is_start_date=True + ) + data["end_date"] = convert_to_utc( + str(data.get("end_date", None).date()), project_id + ) return data class Meta: diff --git a/apiserver/plane/app/serializers/webhook.py b/apiserver/plane/app/serializers/webhook.py index fa4019f7ae0..1036b700c69 100644 --- a/apiserver/plane/app/serializers/webhook.py +++ b/apiserver/plane/app/serializers/webhook.py @@ -116,7 +116,7 @@ def update(self, instance, validated_data): class Meta: model = Webhook fields = "__all__" - read_only_fields = ["workspace", "secret_key"] + read_only_fields = ["workspace", "secret_key", "deleted_at"] class WebhookLogSerializer(DynamicBaseSerializer): diff --git a/apiserver/plane/app/urls/__init__.py b/apiserver/plane/app/urls/__init__.py index 8798e80440e..3be75536b2f 100644 --- a/apiserver/plane/app/urls/__init__.py +++ b/apiserver/plane/app/urls/__init__.py @@ -17,6 +17,7 @@ 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 @@ *workspace_urls, *api_urls, *webhook_urls, + *timezone_urls, ] diff --git a/apiserver/plane/app/urls/search.py b/apiserver/plane/app/urls/search.py index bbea8093ddf..0bbbd9cf7f4 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 @@ 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 00000000000..ff14d029f2e --- /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 4f0e80959b5..a0e0bcd38f7 100644 --- a/apiserver/plane/app/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -68,9 +68,7 @@ # user workspace invitations path( "users/me/workspaces/invitations/", - UserWorkspaceInvitationsViewSet.as_view( - {"get": "list", "post": "create"} - ), + UserWorkspaceInvitationsViewSet.as_view({"get": "list", "post": "create"}), name="user-workspace-invitations", ), path( diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 581a1065d96..56ea78b4130 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -158,7 +158,7 @@ ) from .page.version import PageVersionEndpoint -from .search.base import GlobalSearchEndpoint +from .search.base import GlobalSearchEndpoint, SearchEndpoint from .search.issue import IssueSearchEndpoint @@ -204,3 +204,5 @@ from .notification.base import MarkAllReadNotificationViewSet from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint + +from .timezone.base import TimezoneEndpoint diff --git a/apiserver/plane/app/views/asset/v2.py b/apiserver/plane/app/views/asset/v2.py index 827c9590840..3c1442022fc 100644 --- a/apiserver/plane/app/views/asset/v2.py +++ b/apiserver/plane/app/views/asset/v2.py @@ -126,7 +126,13 @@ def post(self, request): ) # 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( { diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 61ea9eed461..9bf498886b3 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 @@ -52,6 +54,11 @@ # Module imports from .. import BaseAPIView, BaseViewSet from plane.bgtasks.webhook_task import model_activity +from plane.utils.timezone_converter import ( + convert_utc_to_project_timezone, + convert_to_utc, + user_timezone_converter, +) class CycleViewSet(BaseViewSet): @@ -67,6 +74,19 @@ def get_queryset(self): 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() @@ -119,12 +139,15 @@ def get_queryset(self): .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 +183,22 @@ def list(self, request, slug, project_id): # 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( @@ -191,6 +226,8 @@ def list(self, request, slug, project_id): "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) @@ -221,6 +258,8 @@ def list(self, request, slug, project_id): "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]) @@ -417,6 +456,8 @@ def retrieve(self, request, slug, project_id, pk): ) queryset = queryset.first() + datetime_fields = ["start_date", "end_date"] + data = user_timezone_converter(data, datetime_fields, request.user.user_timezone) recent_visited_task.delay( slug=slug, @@ -492,6 +533,9 @@ def post(self, request, slug, project_id): status=status.HTTP_400_BAD_REQUEST, ) + start_date = convert_to_utc(str(start_date), project_id, is_start_date=True) + end_date = convert_to_utc(str(end_date), project_id) + # Check if any cycle intersects in the given interval cycles = Cycle.objects.filter( Q(workspace__slug=slug) diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index 6e87e0b8f5b..593239d013e 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -17,8 +17,6 @@ UUIDField, Value, Subquery, - Case, - When, ) from django.db.models.functions import Coalesce from django.utils import timezone @@ -60,10 +58,11 @@ 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): @@ -434,6 +433,13 @@ def create(self, request, slug, project_id): 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) @@ -449,12 +455,10 @@ def retrieve(self, request, slug, project_id, pk=None): .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") .annotate( - cycle_id=Case( - When( - issue_cycle__cycle__deleted_at__isnull=True, - then=F("issue_cycle__cycle_id"), - ), - default=None, + cycle_id=Subquery( + CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[ + :1 + ] ) ) .annotate( @@ -657,6 +661,12 @@ def partial_update(self, request, slug, project_id, pk=None): 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) diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index e461917fb38..19e2522d2c1 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.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/module/archive.py b/apiserver/plane/app/views/module/archive.py index 82c1d47eb4e..d5c632f966d 100644 --- a/apiserver/plane/app/views/module/archive.py +++ b/apiserver/plane/app/views/module/archive.py @@ -28,7 +28,7 @@ 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 8f9839b71f1..3e3a4c2db72 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -56,7 +56,7 @@ 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 from plane.bgtasks.webhook_task import model_activity from .. import BaseAPIView, BaseViewSet from plane.bgtasks.recent_visited_task import recent_visited_task diff --git a/apiserver/plane/app/views/project/member.py b/apiserver/plane/app/views/project/member.py index c274d87c5f8..55d2d4a58b6 100644 --- a/apiserver/plane/app/views/project/member.py +++ b/apiserver/plane/app/views/project/member.py @@ -16,12 +16,7 @@ WorkspaceUserPermission, ) -from plane.db.models import ( - Project, - ProjectMember, - IssueUserProperty, - WorkspaceMember, -) +from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember from plane.bgtasks.project_add_user_email_task import project_add_user_email from plane.utils.host import base_host from plane.app.permissions.base import allow_permission, ROLE @@ -83,10 +78,7 @@ def create(self, request, slug, project_id): workspace_member_role = WorkspaceMember.objects.get( workspace__slug=slug, member=member, is_active=True ).role - if workspace_member_role in [20] and member_roles.get(member) in [ - 5, - 15, - ]: + if workspace_member_role in [20] and member_roles.get(member) in [5, 15]: return Response( { "error": "You cannot add a user with role lower than the workspace role" @@ -94,10 +86,7 @@ def create(self, request, slug, project_id): status=status.HTTP_400_BAD_REQUEST, ) - if workspace_member_role in [5] and member_roles.get(member) in [ - 15, - 20, - ]: + if workspace_member_role in [5] and member_roles.get(member) in [15, 20]: return Response( { "error": "You cannot add a user with role higher than the workspace role" @@ -135,8 +124,7 @@ def create(self, request, slug, project_id): sort_order = [ project_member.get("sort_order") for project_member in project_members - if str(project_member.get("member_id")) - == str(member.get("member_id")) + if str(project_member.get("member_id")) == str(member.get("member_id")) ] # Create a new project member bulk_project_members.append( @@ -145,9 +133,7 @@ def create(self, request, slug, project_id): role=member.get("role", 5), project_id=project_id, workspace_id=project.workspace_id, - sort_order=( - sort_order[0] - 10000 if len(sort_order) else 65535 - ), + sort_order=(sort_order[0] - 10000 if len(sort_order) else 65535), ) ) # Create a new issue property @@ -238,9 +224,7 @@ def partial_update(self, request, slug, project_id, pk): > requested_project_member.role ): return Response( - { - "error": "You cannot update a role that is higher than your own role" - }, + {"error": "You cannot update a role that is higher than your own role"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -280,9 +264,7 @@ def destroy(self, request, slug, project_id, pk): # User cannot deactivate higher role if requesting_project_member.role < project_member.role: return Response( - { - "error": "You cannot remove a user having role higher than you" - }, + {"error": "You cannot remove a user having role higher than you"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -303,10 +285,7 @@ def leave(self, request, slug, project_id): if ( project_member.role == 20 and not ProjectMember.objects.filter( - workspace__slug=slug, - project_id=project_id, - role=20, - is_active=True, + workspace__slug=slug, project_id=project_id, role=20, is_active=True ).count() > 1 ): @@ -344,7 +323,6 @@ def get(self, request, slug): ).values("project_id", "role") project_members = { - str(member["project_id"]): member["role"] - for member in project_members + str(member["project_id"]): member["role"] for member in project_members } return Response(project_members, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/search/base.py b/apiserver/plane/app/views/search/base.py index 5161103f54c..1f6754a9e7c 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 @@ Module, Page, IssueView, + ProjectMember, ProjectPage, + WorkspaceMember, ) @@ -237,3 +250,459 @@ def get(self, request, slug): 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}) + + base_filters = Q( + q, + is_active=True, + workspace__slug=slug, + member__is_bot=False, + project_id=project_id, + role__gt=10, + ) + if issue_id: + issue_created_by = ( + Issue.objects.filter(id=issue_id) + .values_list("created_by_id", flat=True) + .first() + ) + # Add condition to include `issue_created_by` in the query + filters = Q(member_id=issue_created_by) | base_filters + else: + filters = base_filters + + # Query to fetch users + users = ( + ProjectMember.objects.filter(filters) + .annotate( + member__avatar_url=Case( + When( + 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") + .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, + 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/timezone/base.py b/apiserver/plane/app/views/timezone/base.py new file mode 100644 index 00000000000..77c87704736 --- /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/workspace/base.py b/apiserver/plane/app/views/workspace/base.py index 515a3479bbd..058f7702abd 100644 --- a/apiserver/plane/app/views/workspace/base.py +++ b/apiserver/plane/app/views/workspace/base.py @@ -41,6 +41,7 @@ from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS from plane.license.utils.instance_value import get_configuration_value + class WorkSpaceViewSet(BaseViewSet): model = Workspace serializer_class = WorkSpaceSerializer @@ -81,12 +82,12 @@ def get_queryset(self): def create(self, request): try: - DISABLE_WORKSPACE_CREATION, = get_configuration_value( + (DISABLE_WORKSPACE_CREATION,) = get_configuration_value( [ { "key": "DISABLE_WORKSPACE_CREATION", "default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"), - }, + } ] ) diff --git a/apiserver/plane/app/views/workspace/cycle.py b/apiserver/plane/app/views/workspace/cycle.py index ec08f47c959..a9398a91dbf 100644 --- a/apiserver/plane/app/views/workspace/cycle.py +++ b/apiserver/plane/app/views/workspace/cycle.py @@ -10,7 +10,7 @@ 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/member.py b/apiserver/plane/app/views/workspace/member.py index 91a89ad0721..9541f99803d 100644 --- a/apiserver/plane/app/views/workspace/member.py +++ b/apiserver/plane/app/views/workspace/member.py @@ -1,22 +1,12 @@ # Django imports -from django.db.models import ( - Count, - Q, - OuterRef, - Subquery, - IntegerField, -) +from django.db.models import Count, Q, OuterRef, Subquery, IntegerField from django.db.models.functions import Coalesce # Third party modules from rest_framework import status from rest_framework.response import Response -from plane.app.permissions import ( - WorkspaceEntityPermission, - allow_permission, - ROLE, -) +from plane.app.permissions import WorkspaceEntityPermission, allow_permission, ROLE # Module imports from plane.app.serializers import ( @@ -26,12 +16,7 @@ WorkSpaceMemberSerializer, ) from plane.app.views.base import BaseAPIView -from plane.db.models import ( - Project, - ProjectMember, - WorkspaceMember, - DraftIssue, -) +from plane.db.models import Project, ProjectMember, WorkspaceMember, DraftIssue from plane.utils.cache import invalidate_cache from .. import BaseViewSet @@ -119,9 +104,7 @@ def destroy(self, request, slug, pk): if requesting_workspace_member.role < workspace_member.role: return Response( - { - "error": "You cannot remove a user having role higher than you" - }, + {"error": "You cannot remove a user having role higher than you"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -148,9 +131,7 @@ def destroy(self, request, slug, pk): # Deactivate the users from the projects where the user is part of _ = ProjectMember.objects.filter( - workspace__slug=slug, - member_id=workspace_member.member_id, - is_active=True, + workspace__slug=slug, member_id=workspace_member.member_id, is_active=True ).update(is_active=False) workspace_member.is_active = False @@ -164,9 +145,7 @@ def destroy(self, request, slug, pk): multiple=True, ) @invalidate_cache(path="/api/users/me/settings/") - @invalidate_cache( - path="api/users/me/workspaces/", user=False, multiple=True - ) + @invalidate_cache(path="api/users/me/workspaces/", user=False, multiple=True) @allow_permission( allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" ) @@ -213,9 +192,7 @@ def leave(self, request, slug): # # Deactivate the users from the projects where the user is part of _ = ProjectMember.objects.filter( - workspace__slug=slug, - member_id=workspace_member.member_id, - is_active=True, + workspace__slug=slug, member_id=workspace_member.member_id, is_active=True ).update(is_active=False) # # Deactivate the user @@ -279,9 +256,7 @@ def get(self, request, slug): project_members = ProjectMember.objects.filter( workspace__slug=slug, project_id__in=project_ids, is_active=True ).select_related("project", "member", "workspace") - project_members = ProjectMemberRoleSerializer( - project_members, many=True - ).data + project_members = ProjectMemberRoleSerializer(project_members, many=True).data project_members_dict = dict() diff --git a/apiserver/plane/authentication/views/app/check.py b/apiserver/plane/authentication/views/app/check.py index c7e4b8a5e2f..0ad1db61f38 100644 --- a/apiserver/plane/authentication/views/app/check.py +++ b/apiserver/plane/authentication/views/app/check.py @@ -60,6 +60,9 @@ def post(self, request): ) return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) + # Lower the email + email = str(email).lower().strip() + # Validate email try: validate_email(email) diff --git a/apiserver/plane/authentication/views/space/check.py b/apiserver/plane/authentication/views/space/check.py index 9b4d8aa56d2..c8a4539b712 100644 --- a/apiserver/plane/authentication/views/space/check.py +++ b/apiserver/plane/authentication/views/space/check.py @@ -60,6 +60,7 @@ def post(self, request): ) return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) + email = str(email).lower().strip() # Validate email try: validate_email(email) diff --git a/apiserver/plane/bgtasks/deletion_task.py b/apiserver/plane/bgtasks/deletion_task.py index 0752272e3c7..30ff7e8bd3d 100644 --- a/apiserver/plane/bgtasks/deletion_task.py +++ b/apiserver/plane/bgtasks/deletion_task.py @@ -3,7 +3,8 @@ from django.apps import apps from django.conf import settings from django.db import models -from django.core.exceptions import ObjectDoesNotExist +from django.db.models.fields.related import OneToOneRel + # Third party imports from celery import shared_task @@ -11,31 +12,98 @@ @shared_task def soft_delete_related_objects(app_label, model_name, instance_pk, using=None): + """ + Soft delete related objects for a given model instance + """ + # Get the model class using app registry model_class = apps.get_model(app_label, model_name) - instance = model_class.all_objects.get(pk=instance_pk) - related_fields = instance._meta.get_fields() - for field in related_fields: - if field.one_to_many or field.one_to_one: + + # Get the instance using all_objects to ensure we can get even if it's already soft deleted + try: + instance = model_class.all_objects.get(pk=instance_pk) + except model_class.DoesNotExist: + return + + # Get all related fields that are reverse relationships + all_related = [ + f + for f in instance._meta.get_fields() + if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete + ] + + # Handle each related field + for relation in all_related: + related_name = relation.get_accessor_name() + + # Skip if the relation doesn't exist + if not hasattr(instance, related_name): + continue + + # Get the on_delete behavior name + on_delete_name = ( + relation.on_delete.__name__ + if hasattr(relation.on_delete, "__name__") + else "" + ) + + if on_delete_name == "DO_NOTHING": + continue + + elif on_delete_name == "SET_NULL": + # Handle SET_NULL relationships + if isinstance(relation, OneToOneRel): + # For OneToOne relationships + related_obj = getattr(instance, related_name, None) + if related_obj and isinstance(related_obj, models.Model): + setattr(related_obj, relation.remote_field.name, None) + related_obj.save(update_fields=[relation.remote_field.name]) + else: + # For other relationships + related_queryset = getattr(instance, related_name).all() + related_queryset.update(**{relation.remote_field.name: None}) + + else: + # Handle CASCADE and other delete behaviors try: - # Check if the field has CASCADE on delete - if ( - not hasattr(field.remote_field, "on_delete") - or field.remote_field.on_delete == models.CASCADE - ): - if field.one_to_many: - related_objects = getattr(instance, field.name).all() - elif field.one_to_one: - related_object = getattr(instance, field.name) - related_objects = ( - [related_object] if related_object is not None else [] - ) - - for obj in related_objects: - if obj: - obj.deleted_at = timezone.now() - obj.save(using=using) - except ObjectDoesNotExist: - pass + if relation.one_to_one: + # Handle OneToOne relationships + related_obj = getattr(instance, related_name, None) + if related_obj: + if hasattr(related_obj, "deleted_at"): + if not related_obj.deleted_at: + related_obj.deleted_at = timezone.now() + related_obj.save() + # Recursively handle related objects + soft_delete_related_objects( + related_obj._meta.app_label, + related_obj._meta.model_name, + related_obj.pk, + using, + ) + else: + # Handle other relationships + related_queryset = getattr(instance, related_name).all() + for related_obj in related_queryset: + if hasattr(related_obj, "deleted_at"): + if not related_obj.deleted_at: + related_obj.deleted_at = timezone.now() + related_obj.save() + # Recursively handle related objects + soft_delete_related_objects( + related_obj._meta.app_label, + related_obj._meta.model_name, + related_obj.pk, + using, + ) + except Exception as e: + # Log the error or handle as needed + print(f"Error handling relation {related_name}: {str(e)}") + continue + + # Finally, soft delete the instance itself if it hasn't been deleted yet + if hasattr(instance, "deleted_at") and not instance.deleted_at: + instance.deleted_at = timezone.now() + instance.save() # @shared_task diff --git a/apiserver/plane/bgtasks/export_task.py b/apiserver/plane/bgtasks/export_task.py index f7b19f00abf..33e382f4415 100644 --- a/apiserver/plane/bgtasks/export_task.py +++ b/apiserver/plane/bgtasks/export_task.py @@ -162,8 +162,7 @@ def generate_table_row(issue): issue["priority"], ( f"{issue['created_by__first_name']} {issue['created_by__last_name']}" - if issue["created_by__first_name"] - and issue["created_by__last_name"] + if issue["created_by__first_name"] and issue["created_by__last_name"] else "" ), ( @@ -197,8 +196,7 @@ def generate_json_row(issue): "Priority": issue["priority"], "Created By": ( f"{issue['created_by__first_name']} {issue['created_by__last_name']}" - if issue["created_by__first_name"] - and issue["created_by__last_name"] + if issue["created_by__first_name"] and issue["created_by__last_name"] else "" ), "Assignee": ( @@ -208,17 +206,11 @@ def generate_json_row(issue): ), "Labels": issue["labels__name"] if issue["labels__name"] else "", "Cycle Name": issue["issue_cycle__cycle__name"], - "Cycle Start Date": dateConverter( - issue["issue_cycle__cycle__start_date"] - ), + "Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]), "Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]), "Module Name": issue["issue_module__module__name"], - "Module Start Date": dateConverter( - issue["issue_module__module__start_date"] - ), - "Module Target Date": dateConverter( - issue["issue_module__module__target_date"] - ), + "Module Start Date": dateConverter(issue["issue_module__module__start_date"]), + "Module Target Date": dateConverter(issue["issue_module__module__target_date"]), "Created At": dateTimeConverter(issue["created_at"]), "Updated At": dateTimeConverter(issue["updated_at"]), "Completed At": dateTimeConverter(issue["completed_at"]), 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 00000000000..14956cb50cf --- /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 00000000000..a29fb6c5727 --- /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 00000000000..698cedf1553 --- /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 ade24790965..e58344bbf2e 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -32,7 +32,6 @@ 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,10 +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/db/management/commands/create_project_member.py b/apiserver/plane/db/management/commands/create_project_member.py index a2a5c669e47..927f97e9d59 100644 --- a/apiserver/plane/db/management/commands/create_project_member.py +++ b/apiserver/plane/db/management/commands/create_project_member.py @@ -13,28 +13,14 @@ class Command(BaseCommand): - help = "Add a member to a project. If present in the workspace" def add_arguments(self, parser): # Positional argument + parser.add_argument("--project_id", type=str, nargs="?", help="Project ID") + parser.add_argument("--user_email", type=str, nargs="?", help="User Email") parser.add_argument( - "--project_id", - type=str, - nargs="?", - help="Project ID", - ) - parser.add_argument( - "--user_email", - type=str, - nargs="?", - help="User Email", - ) - parser.add_argument( - "--role", - type=int, - nargs="?", - help="Role of the user in the project", + "--role", type=int, nargs="?", help="Role of the user in the project" ) def handle(self, *args: Any, **options: Any): @@ -67,9 +53,7 @@ def handle(self, *args: Any, **options: Any): # Get the smallest sort order smallest_sort_order = ( - ProjectMember.objects.filter( - workspace_id=project.workspace_id, - ) + ProjectMember.objects.filter(workspace_id=project.workspace_id) .order_by("sort_order") .first() ) @@ -79,22 +63,15 @@ def handle(self, *args: Any, **options: Any): else: sort_order = 65535 - if ProjectMember.objects.filter( - project=project, - member=user, - ).exists(): + if ProjectMember.objects.filter(project=project, member=user).exists(): # Update the project member - ProjectMember.objects.filter( - project=project, - member=user, - ).update(is_active=True, sort_order=sort_order, role=role) + ProjectMember.objects.filter(project=project, member=user).update( + is_active=True, sort_order=sort_order, role=role + ) else: # Create the project member ProjectMember.objects.create( - project=project, - member=user, - role=role, - sort_order=sort_order, + project=project, member=user, role=role, sort_order=sort_order ) # Issue Property @@ -102,9 +79,7 @@ def handle(self, *args: Any, **options: Any): # Success message self.stdout.write( - self.style.SUCCESS( - f"User {user_email} added to project {project_id}" - ) + self.style.SUCCESS(f"User {user_email} added to project {project_id}") ) return except CommandError as 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 00000000000..7ff2fc39147 --- /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 00000000000..2b6632f2618 --- /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 00000000000..086f52316e7 --- /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 00000000000..1b312215778 --- /dev/null +++ b/apiserver/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py @@ -0,0 +1,124 @@ +# Generated by Django 4.2.15 on 2024-12-24 14:57 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0087_remove_issueversion_description_and_more'), + ] + + operations = [ + migrations.AddField( + model_name="sticky", + name="sort_order", + field=models.FloatField(default=65535), + ), + migrations.CreateModel( + name="WorkspaceUserLink", + fields=[ + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("title", models.CharField(blank=True, max_length=255, null=True)), + ("url", models.TextField()), + ("metadata", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="owner_workspace_user_link", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Workspace User Link", + "verbose_name_plural": "Workspace User Links", + "db_table": "workspace_user_links", + "ordering": ("-created_at",), + }, + ), + migrations.AlterField( + model_name="pagelog", + name="entity_name", + field=models.CharField(max_length=30, verbose_name="Transaction Type"), + ), + migrations.AlterUniqueTogether( + name="webhook", + unique_together={("workspace", "url", "deleted_at")}, + ), + migrations.AddConstraint( + model_name="webhook", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("workspace", "url"), + name="webhook_url_unique_url_when_deleted_at_null", + ), + ), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 36810956c27..216e445e6b2 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -41,6 +41,8 @@ IssueSequence, IssueSubscriber, IssueVote, + IssueVersion, + IssueDescriptionVersion, ) from .module import Module, ModuleIssue, ModuleLink, ModuleMember, ModuleUserProperties from .notification import EmailNotificationLog, Notification, UserNotificationPreference @@ -53,7 +55,6 @@ ProjectMemberInvite, ProjectPublicMember, ) -from .deploy_board import DeployBoard from .session import Session from .social_connection import SocialLoginConnection from .state import State @@ -67,26 +68,9 @@ WorkspaceMemberInvite, WorkspaceTheme, WorkspaceUserProperties, + WorkspaceUserLink, ) -from .importer import Importer - -from .page import Page, PageLog, PageLabel - -from .estimate import Estimate, EstimatePoint - -from .intake import Intake, IntakeIssue - -from .analytic import AnalyticView - -from .notification import Notification, UserNotificationPreference, EmailNotificationLog - -from .exporter import ExporterHistory - -from .webhook import Webhook, WebhookLog - -from .dashboard import Dashboard, DashboardWidget, Widget - from .favorite import UserFavorite from .issue_type import IssueType @@ -96,3 +80,5 @@ from .label import Label from .device import Device, DeviceSession + +from .sticky import Sticky diff --git a/apiserver/plane/db/models/asset.py b/apiserver/plane/db/models/asset.py index 9f99a8144aa..9973d122f52 100644 --- a/apiserver/plane/db/models/asset.py +++ b/apiserver/plane/db/models/asset.py @@ -44,45 +44,25 @@ class EntityTypeContext(models.TextChoices): "db.User", on_delete=models.CASCADE, null=True, related_name="assets" ) workspace = models.ForeignKey( - "db.Workspace", - on_delete=models.CASCADE, - null=True, - related_name="assets", + "db.Workspace", on_delete=models.CASCADE, null=True, related_name="assets" ) draft_issue = models.ForeignKey( - "db.DraftIssue", - on_delete=models.CASCADE, - null=True, - related_name="assets", + "db.DraftIssue", on_delete=models.CASCADE, null=True, related_name="assets" ) project = models.ForeignKey( - "db.Project", - on_delete=models.CASCADE, - null=True, - related_name="assets", + "db.Project", on_delete=models.CASCADE, null=True, related_name="assets" ) issue = models.ForeignKey( "db.Issue", on_delete=models.CASCADE, null=True, related_name="assets" ) comment = models.ForeignKey( - "db.IssueComment", - on_delete=models.CASCADE, - null=True, - related_name="assets", + "db.IssueComment", on_delete=models.CASCADE, null=True, related_name="assets" ) page = models.ForeignKey( "db.Page", on_delete=models.CASCADE, null=True, related_name="assets" ) - entity_type = models.CharField( - max_length=255, - null=True, - blank=True, - ) - entity_identifier = models.CharField( - max_length=255, - null=True, - blank=True, - ) + entity_type = models.CharField(max_length=255, null=True, blank=True) + entity_identifier = models.CharField(max_length=255, null=True, blank=True) is_deleted = models.BooleanField(default=False) is_archived = models.BooleanField(default=False) external_id = models.CharField(max_length=255, null=True, blank=True) diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index e50dbe7ce82..ca7347ad792 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -15,6 +15,7 @@ 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 @@ -660,11 +661,6 @@ def __str__(self): class IssueVersion(ProjectBaseModel): - issue = models.ForeignKey( - "db.Issue", - on_delete=models.CASCADE, - related_name="versions", - ) PRIORITY_CHOICES = ( ("urgent", "Urgent"), ("high", "High"), @@ -672,14 +668,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, @@ -688,9 +681,9 @@ class IssueVersion(ProjectBaseModel): ) start_date = models.DateField(null=True, blank=True) target_date = models.DateField(null=True, blank=True) - sequence_id = models.IntegerField( - default=1, verbose_name="Issue Sequence ID" - ) + 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) @@ -698,29 +691,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) + cycle = models.UUIDField(null=True, blank=True) + modules = ArrayField(models.UUIDField(), blank=True, default=list) + properties = models.JSONField(default=dict) # issue properties + meta = models.JSONField(default=dict) # issue meta 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, + + issue = models.ForeignKey( + "db.Issue", on_delete=models.CASCADE, related_name="versions" ) - cycle = models.UUIDField( + activity = models.ForeignKey( + "db.IssueActivity", + on_delete=models.SET_NULL, null=True, - blank=True, + related_name="versions", ) - modules = ArrayField( - models.UUIDField(), - blank=True, - default=list, + owned_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_versions", ) - properties = models.JSONField(default=dict) - meta = models.JSONField(default=dict) class Meta: verbose_name = "Issue Version" @@ -740,43 +730,93 @@ def log_issue_version(cls, issue, user): 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() + 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 81e2b15a0fc..7ebf5ff6003 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -90,7 +90,7 @@ class PageLog(BaseModel): page = models.ForeignKey(Page, related_name="page_log", on_delete=models.CASCADE) entity_identifier = models.UUIDField(null=True) entity_name = models.CharField( - max_length=30, choices=TYPE_CHOICES, verbose_name="Transaction Type" + max_length=30, verbose_name="Transaction Type" ) workspace = models.ForeignKey( "db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log" diff --git a/apiserver/plane/db/models/sticky.py b/apiserver/plane/db/models/sticky.py new file mode 100644 index 00000000000..96060d9e2e0 --- /dev/null +++ b/apiserver/plane/db/models/sticky.py @@ -0,0 +1,48 @@ +# Django imports +from django.conf import settings +from django.db import models + +# Module imports +from .base import BaseModel + + +class Sticky(BaseModel): + 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(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): + 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 34a86a2519e..8a34e4d7903 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 @@ -178,6 +186,12 @@ class Profile(TimeAuditModel): billing_address = models.JSONField(null=True) has_billing_address = models.BooleanField(default=False) company_name = models.CharField(max_length=255, blank=True) + # 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" diff --git a/apiserver/plane/db/models/webhook.py b/apiserver/plane/db/models/webhook.py index 92d45a05892..dc04e041998 100644 --- a/apiserver/plane/db/models/webhook.py +++ b/apiserver/plane/db/models/webhook.py @@ -29,9 +29,7 @@ def validate_domain(value): class Webhook(BaseModel): workspace = models.ForeignKey( - "db.Workspace", - on_delete=models.CASCADE, - related_name="workspace_webhooks", + "db.Workspace", on_delete=models.CASCADE, related_name="workspace_webhooks" ) url = models.URLField( validators=[validate_schema, validate_domain], max_length=1024 @@ -49,11 +47,18 @@ def __str__(self): return f"{self.workspace.slug} {self.url}" class Meta: - unique_together = ["workspace", "url"] + unique_together = ["workspace", "url", "deleted_at"] verbose_name = "Webhook" verbose_name_plural = "Webhooks" db_table = "webhooks" ordering = ("-created_at",) + constraints = [ + models.UniqueConstraint( + fields=["workspace", "url"], + condition=models.Q(deleted_at__isnull=True), + name="webhook_url_unique_url_when_deleted_at_null", + ) + ] class WebhookLog(BaseModel): diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index df1f26d3f5c..4a879aacc12 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -102,12 +102,7 @@ def get_default_display_properties(): def get_issue_props(): - return { - "subscribed": True, - "assigned": True, - "created": True, - "all_issues": True, - } + return {"subscribed": True, "assigned": True, "created": True, "all_issues": True} def slug_validator(value): @@ -136,9 +131,7 @@ class Workspace(BaseModel): max_length=48, db_index=True, unique=True, validators=[slug_validator] ) organization_size = models.CharField(max_length=20, blank=True, null=True) - timezone = models.CharField( - max_length=255, default="UTC", choices=TIMEZONE_CHOICES - ) + timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES) def __str__(self): """Return name of the Workspace""" @@ -167,10 +160,7 @@ class WorkspaceBaseModel(BaseModel): "db.Workspace", models.CASCADE, related_name="workspace_%(class)s" ) project = models.ForeignKey( - "db.Project", - models.CASCADE, - related_name="project_%(class)s", - null=True, + "db.Project", models.CASCADE, related_name="project_%(class)s", null=True ) class Meta: @@ -184,9 +174,7 @@ def save(self, *args, **kwargs): class WorkspaceMember(BaseModel): workspace = models.ForeignKey( - "db.Workspace", - on_delete=models.CASCADE, - related_name="workspace_member", + "db.Workspace", on_delete=models.CASCADE, related_name="workspace_member" ) member = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -221,9 +209,7 @@ def __str__(self): class WorkspaceMemberInvite(BaseModel): workspace = models.ForeignKey( - "db.Workspace", - on_delete=models.CASCADE, - related_name="workspace_member_invite", + "db.Workspace", on_delete=models.CASCADE, related_name="workspace_member_invite" ) email = models.CharField(max_length=255) accepted = models.BooleanField(default=False) @@ -283,9 +269,7 @@ class WorkspaceTheme(BaseModel): ) name = models.CharField(max_length=300) actor = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="themes", + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="themes" ) colors = models.JSONField(default=dict) @@ -320,9 +304,7 @@ class WorkspaceUserProperties(BaseModel): ) filters = models.JSONField(default=get_default_filters) display_filters = models.JSONField(default=get_default_display_filters) - display_properties = models.JSONField( - default=get_default_display_properties - ) + display_properties = models.JSONField(default=get_default_display_properties) class Meta: unique_together = ["workspace", "user", "deleted_at"] @@ -340,3 +322,23 @@ class Meta: def __str__(self): return f"{self.workspace.name} {self.user.email}" + + +class WorkspaceUserLink(WorkspaceBaseModel): + title = models.CharField(max_length=255, null=True, blank=True) + url = models.TextField() + metadata = models.JSONField(default=dict) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="owner_workspace_user_link", + ) + + class Meta: + verbose_name = "Workspace User Link" + verbose_name_plural = "Workspace User Links" + db_table = "workspace_user_links" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.workspace.id} {self.url}" \ No newline at end of file diff --git a/apiserver/plane/license/api/serializers/__init__.py b/apiserver/plane/license/api/serializers/__init__.py index 48ecd4536a9..6e0a5941c40 100644 --- a/apiserver/plane/license/api/serializers/__init__.py +++ b/apiserver/plane/license/api/serializers/__init__.py @@ -2,4 +2,4 @@ from .configuration import InstanceConfigurationSerializer from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer -from .workspace import WorkspaceSerializer \ No newline at end of file +from .workspace import WorkspaceSerializer diff --git a/apiserver/plane/license/api/serializers/user.py b/apiserver/plane/license/api/serializers/user.py index 8935a882f2b..c53b4a48489 100644 --- a/apiserver/plane/license/api/serializers/user.py +++ b/apiserver/plane/license/api/serializers/user.py @@ -1,6 +1,8 @@ from .base import BaseSerializer from plane.db.models import User + + class UserLiteSerializer(BaseSerializer): class Meta: model = User - fields = ["id", "email", "first_name", "last_name",] + fields = ["id", "email", "first_name", "last_name"] diff --git a/apiserver/plane/license/api/views/__init__.py b/apiserver/plane/license/api/views/__init__.py index d57ebf52c09..a2ef90facb6 100644 --- a/apiserver/plane/license/api/views/__init__.py +++ b/apiserver/plane/license/api/views/__init__.py @@ -13,6 +13,8 @@ InstanceAdminUserSessionEndpoint, ) -from .changelog import ChangeLogEndpoint -from .workspace import InstanceWorkSpaceAvailabilityCheckEndpoint, InstanceWorkSpaceEndpoint +from .workspace import ( + InstanceWorkSpaceAvailabilityCheckEndpoint, + InstanceWorkSpaceEndpoint, +) diff --git a/apiserver/plane/license/api/views/changelog.py b/apiserver/plane/license/api/views/changelog.py deleted file mode 100644 index 52583a35fc5..00000000000 --- a/apiserver/plane/license/api/views/changelog.py +++ /dev/null @@ -1,33 +0,0 @@ -# Python imports -import requests - -# Django imports -from django.conf import settings - -# Third party imports -from rest_framework.response import Response -from rest_framework import status -from rest_framework.permissions import AllowAny - -# plane imports -from .base import BaseAPIView - - -class ChangeLogEndpoint(BaseAPIView): - permission_classes = [AllowAny] - - def fetch_change_logs(self): - response = requests.get(settings.INSTANCE_CHANGELOG_URL) - response.raise_for_status() - return response.json() - - def get(self, request): - # Fetch the changelog - if settings.INSTANCE_CHANGELOG_URL: - data = self.fetch_change_logs() - return Response(data, status=status.HTTP_200_OK) - else: - return Response( - {"error": "could not fetch changelog please try again later"}, - status=status.HTTP_400_BAD_REQUEST, - ) diff --git a/apiserver/plane/license/api/views/workspace.py b/apiserver/plane/license/api/views/workspace.py index 14118d85bc8..607016cc3a1 100644 --- a/apiserver/plane/license/api/views/workspace.py +++ b/apiserver/plane/license/api/views/workspace.py @@ -43,19 +43,19 @@ def get(self, request): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - + member_count = ( WorkspaceMember.objects.filter( workspace=OuterRef("id"), member__is_bot=False, is_active=True - ).select_related("owner") + ) + .select_related("owner") .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) workspaces = Workspace.objects.annotate( - total_projects=project_count, - total_members=member_count, + total_projects=project_count, total_members=member_count ) # Add search functionality @@ -66,16 +66,14 @@ def get(self, request): return self.paginate( request=request, queryset=workspaces, - on_results=lambda results: WorkspaceSerializer( - results, many=True, - ).data, + on_results=lambda results: WorkspaceSerializer(results, many=True).data, max_per_page=10, default_per_page=10, ) def post(self, request): try: - serializer = WorkspaceSerializer (data=request.data) + serializer = WorkspaceSerializer(data=request.data) slug = request.data.get("slug", False) name = request.data.get("name", False) diff --git a/apiserver/plane/license/urls.py b/apiserver/plane/license/urls.py index 842af09595f..9c3adbf98ab 100644 --- a/apiserver/plane/license/urls.py +++ b/apiserver/plane/license/urls.py @@ -11,14 +11,12 @@ InstanceAdminUserMeEndpoint, InstanceAdminSignOutEndpoint, InstanceAdminUserSessionEndpoint, - ChangeLogEndpoint, InstanceWorkSpaceAvailabilityCheckEndpoint, InstanceWorkSpaceEndpoint, ) urlpatterns = [ path("", InstanceEndpoint.as_view(), name="instance"), - path("changelog/", ChangeLogEndpoint.as_view(), name="instance-changelog"), path("admins/", InstanceAdminEndpoint.as_view(), name="instance-admins"), path("admins/me/", InstanceAdminUserMeEndpoint.as_view(), name="instance-admins"), path( @@ -62,9 +60,5 @@ InstanceWorkSpaceAvailabilityCheckEndpoint.as_view(), name="instance-workspace-availability", ), - path( - "workspaces/", - InstanceWorkSpaceEndpoint.as_view(), - name="instance-workspace", - ), + path("workspaces/", InstanceWorkSpaceEndpoint.as_view(), name="instance-workspace"), ] diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 15f082f88c8..d535dab817c 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -262,6 +262,9 @@ "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 diff --git a/apiserver/plane/space/urls/project.py b/apiserver/plane/space/urls/project.py index 7676c959924..068b8c5c17f 100644 --- a/apiserver/plane/space/urls/project.py +++ b/apiserver/plane/space/urls/project.py @@ -10,9 +10,15 @@ ProjectStatesEndpoint, ProjectLabelsEndpoint, ProjectMembersEndpoint, + ProjectMetaDataEndpoint, ) urlpatterns = [ + path( + "anchor//meta/", + ProjectMetaDataEndpoint.as_view(), + name="project-meta", + ), path( "anchor//settings/", ProjectDeployBoardPublicSettingsEndpoint.as_view(), diff --git a/apiserver/plane/space/utils/grouper.py b/apiserver/plane/space/utils/grouper.py index 250b54e8915..2740588422e 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/__init__.py b/apiserver/plane/space/views/__init__.py index afdc1d33741..22acfd15bd2 100644 --- a/apiserver/plane/space/views/__init__.py +++ b/apiserver/plane/space/views/__init__.py @@ -25,3 +25,5 @@ from .label import ProjectLabelsEndpoint from .asset import EntityAssetEndpoint, AssetRestoreEndpoint, EntityBulkAssetEndpoint + +from .meta import ProjectMetaDataEndpoint diff --git a/apiserver/plane/space/views/asset.py b/apiserver/plane/space/views/asset.py index 2c672203843..3e1d4d6f782 100644 --- a/apiserver/plane/space/views/asset.py +++ b/apiserver/plane/space/views/asset.py @@ -86,7 +86,13 @@ def post(self, request, anchor): ) # 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/issue.py b/apiserver/plane/space/views/issue.py index a1ab332f91b..699253ae524 100644 --- a/apiserver/plane/space/views/issue.py +++ b/apiserver/plane/space/views/issue.py @@ -701,6 +701,7 @@ def get(self, request, anchor, issue_id): Case( When( votes__isnull=False, + votes__deleted_at__isnull=True, then=JSONObject( vote=F("votes__vote"), actor_details=JSONObject( @@ -732,7 +733,11 @@ def get(self, request, anchor, issue_id): 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 @@ def get(self, request, anchor, issue_id): 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 @@ def get(self, request, anchor, issue_id): 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 new file mode 100644 index 00000000000..fa441359964 --- /dev/null +++ b/apiserver/plane/space/views/meta.py @@ -0,0 +1,34 @@ +# third party +from rest_framework.permissions import AllowAny +from rest_framework import status +from rest_framework.response import Response + +from plane.db.models import DeployBoard, Project + +from .base import BaseAPIView +from plane.space.serializer.project import ProjectLiteSerializer + + +class ProjectMetaDataEndpoint(BaseAPIView): + permission_classes = [AllowAny] + + def get(self, request, anchor): + try: + deploy_board = DeployBoard.objects.filter( + anchor=anchor, entity_name="project" + ).first() + except DeployBoard.DoesNotExist: + return Response( + {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND + ) + + try: + project_id = deploy_board.entity_identifier + project = Project.objects.get(id=project_id) + except Project.DoesNotExist: + return Response( + {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND + ) + + serializer = ProjectLiteSerializer(project) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/utils/timezone_converter.py b/apiserver/plane/utils/timezone_converter.py new file mode 100644 index 00000000000..46a864b62dd --- /dev/null +++ b/apiserver/plane/utils/timezone_converter.py @@ -0,0 +1,100 @@ +import pytz +from plane.db.models import Project +from datetime import datetime, time +from datetime import timedelta + +def user_timezone_converter(queryset, datetime_fields, user_timezone): + # Create a timezone object for the user's timezone + user_tz = pytz.timezone(user_timezone) + + # 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): + """ + 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=1) + + # 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 550abfe997d..00000000000 --- 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/requirements/base.txt b/apiserver/requirements/base.txt index 854ab95f900..40e90aedfc4 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -1,7 +1,7 @@ # base requirements # django -Django==4.2.16 +Django==4.2.17 # rest framework djangorestframework==3.15.2 # postgres diff --git a/app.json b/app.json index bc5789078e2..600b524d2fa 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 d93d85ca14d..ccd8bf328ce 100644 --- a/deploy/selfhost/README.md +++ b/deploy/selfhost/README.md @@ -62,7 +62,7 @@ mkdir plane-selfhost cd plane-selfhost -curl -fsSL -o setup.sh https://raw.githubusercontent.com/makeplane/plane/master/deploy/selfhost/install.sh +curl -fsSL -o setup.sh https://github.com/makeplane/plane/releases/latest/download/setup.sh chmod +x setup.sh ``` diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index fe47e625f65..13cfafa2237 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -1,54 +1,63 @@ +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} + +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 - 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/} - - # 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} + 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} + ADMIN_BASE_URL: ${ADMIN_BASE_URL} + SPACE_BASE_URL: ${SPACE_BASE_URL} + APP_BASE_URL: ${APP_BASE_URL} + 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 @@ -61,7 +70,6 @@ services: - worker space: - <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable} platform: ${DOCKER_PLATFORM:-} pull_policy: if_not_present @@ -75,7 +83,6 @@ services: - web admin: - <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-admin:${APP_RELEASE:-stable} platform: ${DOCKER_PLATFORM:-} pull_policy: if_not_present @@ -88,12 +95,13 @@ services: - 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} depends_on: @@ -101,7 +109,6 @@ services: - web api: - <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} platform: ${DOCKER_PLATFORM:-} pull_policy: if_not_present @@ -111,14 +118,14 @@ services: replicas: ${API_REPLICAS:-1} 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 @@ -126,6 +133,8 @@ services: command: ./bin/docker-entrypoint-worker.sh 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,7 +142,6 @@ services: - plane-mq beat-worker: - <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} platform: ${DOCKER_PLATFORM:-} pull_policy: if_not_present @@ -141,6 +149,8 @@ services: command: ./bin/docker-entrypoint-beat.sh 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,7 +158,6 @@ services: - plane-mq migrator: - <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} platform: ${DOCKER_PLATFORM:-} pull_policy: if_not_present @@ -156,21 +165,23 @@ services: command: ./bin/docker-entrypoint-migrator.sh 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 plane-db: - <<: *app-env image: postgres:15.7-alpine pull_policy: if_not_present restart: unless-stopped command: postgres -c 'max_connections=1000' + 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 @@ -178,30 +189,33 @@ services: - redisdata:/data plane-mq: - <<: *app-env image: rabbitmq:3.13.6-management-alpine restart: always + environment: + <<: *mq-env volumes: - rabbitmq_data:/var/lib/rabbitmq plane-minio: - <<: *app-env image: minio/minio:latest pull_policy: if_not_present restart: unless-stopped command: server /export --console-address ":9090" + 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 + environment: + <<: *proxy-env depends_on: - web - api diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh index 08cd4d9169e..1c2208cab7c 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 diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env index b5221c71a1c..78914c3afd7 100644 --- a/deploy/selfhost/variables.env +++ b/deploy/selfhost/variables.env @@ -47,9 +47,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/package.json b/live/package.json index bdebf125ee5..fc085620007 100644 --- a/live/package.json +++ b/live/package.json @@ -1,16 +1,16 @@ { "name": "live", - "version": "0.24.0", + "version": "0.24.1", "description": "", "main": "./src/server.ts", "private": true, "type": "module", "scripts": { + "dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"", "build": "babel src --out-dir dist --extensions \".ts,.js\"", "start": "node dist/server.js", - "lint": "eslint . --ext .ts,.tsx", - "dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"", - "lint:errors": "eslint . --ext .ts,.tsx --quiet" + "lint": "eslint src --ext .ts,.tsx", + "lint:errors": "eslint src --ext .ts,.tsx --quiet" }, "keywords": [], "author": "", @@ -20,6 +20,7 @@ "@hocuspocus/extension-logger": "^2.11.3", "@hocuspocus/extension-redis": "^2.13.5", "@hocuspocus/server": "^2.11.3", + "@plane/constants": "*", "@plane/editor": "*", "@plane/types": "*", "@sentry/node": "^8.28.0", @@ -30,7 +31,7 @@ "compression": "^1.7.4", "cors": "^2.8.5", "dotenv": "^16.4.5", - "express": "^4.20.0", + "express": "^4.21.2", "express-ws": "^5.0.2", "helmet": "^7.1.0", "ioredis": "^5.4.1", diff --git a/package.json b/package.json index 0700636092a..f14aa4ac793 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "repository": "https://github.com/makeplane/plane.git", - "version": "0.24.0", + "version": "0.24.1", "license": "AGPL-3.0", "private": true, "workspaces": [ diff --git a/packages/constants/package.json b/packages/constants/package.json index ce02a4946ba..c1fe71a306a 100644 --- a/packages/constants/package.json +++ b/packages/constants/package.json @@ -1,6 +1,6 @@ { "name": "@plane/constants", - "version": "0.24.0", + "version": "0.24.1", "private": true, "main": "./src/index.ts" } diff --git a/packages/constants/src/ai.ts b/packages/constants/src/ai.ts new file mode 100644 index 00000000000..8125302440e --- /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/auth.ts b/packages/constants/src/auth.ts index 59f08a37f06..bcdda31b4d4 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/endpoints.ts b/packages/constants/src/endpoints.ts index 751ee20dd3d..b17f7c1a06d 100644 --- a/packages/constants/src/endpoints.ts +++ b/packages/constants/src/endpoints.ts @@ -1,15 +1,25 @@ 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"; 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/packages/constants/src/index.ts b/packages/constants/src/index.ts index 4189086225d..95a4f978435 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -1,4 +1,11 @@ +export * from "./ai"; export * from "./auth"; export * from "./endpoints"; +export * from "./file"; +export * from "./instance"; export * from "./issue"; +export * from "./metadata"; +export * from "./state"; +export * from "./swr"; +export * from "./user"; export * from "./workspace"; 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 index 5db398c7634..19cfe60f3a6 100644 --- a/packages/constants/src/issue.ts +++ b/packages/constants/src/issue.ts @@ -1,5 +1,25 @@ +import { List, Kanban } from "lucide-react"; + export const ALL_ISSUES = "All Issues"; +export type TIssuePriorities = "urgent" | "high" | "medium" | "low" | "none"; + +export type TIssueFilterKeys = "priority" | "state" | "labels"; + +export type TIssueLayout = + | "list" + | "kanban" + | "calendar" + | "spreadsheet" + | "gantt"; + +export type TIssueFilterPriorityObject = { + key: TIssuePriorities; + title: string; + className: string; + icon: string; +}; + export enum EIssueGroupByToServerOptions { "state" = "state_id", "priority" = "priority", @@ -11,6 +31,7 @@ export enum EIssueGroupByToServerOptions { "target_date" = "target_date", "project" = "project_id", "created_by" = "created_by", + "team_project" = "project_id", } export enum EIssueGroupBYServerToProperty { @@ -38,3 +59,127 @@ export enum EServerGroupByToFilterOptions { "project_id" = "project", "created_by" = "created_by", } + +export enum EIssueServiceType { + ISSUES = "issues", + EPICS = "epics", +} + +export enum EIssueLayoutTypes { + LIST = "list", + KANBAN = "kanban", + CALENDAR = "calendar", + GANTT = "gantt_chart", + SPREADSHEET = "spreadsheet", +} + +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 EIssueFilterType { + FILTERS = "filters", + DISPLAY_FILTERS = "display_filters", + DISPLAY_PROPERTIES = "display_properties", + KANBAN_FILTERS = "kanban_filters", +} + +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_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_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 ISSUE_PRIORITY_FILTERS: 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 SITES_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 }, +]; diff --git a/packages/constants/src/metadata.ts b/packages/constants/src/metadata.ts new file mode 100644 index 00000000000..b3563882678 --- /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 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."; + +// 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, issue tracking, agile, scrum, kanban, collaboration"; +export const SPACE_SITE_URL = "https://app.plane.so/"; +export const SPACE_TWITTER_USER_NAME = "planepowers"; diff --git a/space/core/constants/state.ts b/packages/constants/src/state.ts similarity index 72% rename from space/core/constants/state.ts rename to packages/constants/src/state.ts index b0fd622be06..c51728bf9ae 100644 --- a/space/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 const STATE_GROUPS: { [key in TStateGroups]: { @@ -34,4 +39,7 @@ 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, +]; diff --git a/admin/core/constants/swr-config.ts b/packages/constants/src/swr.ts similarity index 81% rename from admin/core/constants/swr-config.ts rename to packages/constants/src/swr.ts index 38478fcea3a..d3eef1cdfe8 100644 --- a/admin/core/constants/swr-config.ts +++ b/packages/constants/src/swr.ts @@ -1,4 +1,4 @@ -export const SWR_CONFIG = { +export const DEFAULT_SWR_CONFIG = { refreshWhenHidden: false, revalidateIfStale: false, revalidateOnFocus: false, diff --git a/admin/helpers/user.helper.ts b/packages/constants/src/user.ts similarity index 100% rename from admin/helpers/user.helper.ts rename to packages/constants/src/user.ts diff --git a/packages/editor/package.json b/packages/editor/package.json index 8471513c97e..e9ef145a0c4 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor", - "version": "0.24.0", + "version": "0.24.1", "description": "Core Editor that powers Plane", "private": true, "main": "./dist/index.mjs", @@ -27,6 +27,7 @@ "dev": "tsup --watch", "check-types": "tsc --noEmit", "lint": "eslint src --ext .ts,.tsx", + "lint:errors": "eslint src --ext .ts,.tsx --quiet", "format": "prettier --write \"**/*.{ts,tsx,md}\"" }, "peerDependencies": { @@ -36,8 +37,9 @@ "dependencies": { "@floating-ui/react": "^0.26.4", "@hocuspocus/provider": "^2.13.5", - "@plane/helpers": "*", + "@plane/types": "*", "@plane/ui": "*", + "@plane/utils": "*", "@tiptap/core": "^2.1.13", "@tiptap/extension-blockquote": "^2.1.13", "@tiptap/extension-character-count": "^2.6.5", @@ -56,7 +58,6 @@ "@tiptap/starter-kit": "^2.1.13", "@tiptap/suggestion": "^2.0.13", "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", @@ -65,7 +66,6 @@ "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", "uuid": "^10.0.0", 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 6a9b9db532c..44c18c2f680 100644 --- a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx +++ b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx @@ -8,7 +8,7 @@ import { IssueWidget } from "@/extensions"; // helpers import { getEditorClassNames } from "@/helpers/common"; // hooks -import { useCollaborativeDocumentEditor } from "@/hooks/use-collaborative-document-editor"; +import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor"; // types import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types"; @@ -19,6 +19,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { containerClassName, disabledExtensions, displayConfig = DEFAULT_DISPLAY_CONFIG, + editable, editorClassName = "", embedHandler, fileHandler, @@ -43,9 +44,9 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { } // use document editor - const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeDocumentEditor({ - onTransaction, + const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({ disabledExtensions, + editable, editorClassName, embedHandler, extensions, @@ -54,6 +55,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { handleEditorReady, id, mentionHandler, + onTransaction, placeholder, realtimeConfig, serverHandler, diff --git a/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx b/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx deleted file mode 100644 index 788ed09619b..00000000000 --- a/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { forwardRef, MutableRefObject } from "react"; -// components -import { DocumentContentLoader, PageRenderer } from "@/components/editors"; -// constants -import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config"; -// extensions -import { IssueWidget } from "@/extensions"; -// helpers -import { getEditorClassNames } from "@/helpers/common"; -// hooks -import { useCollaborativeDocumentReadOnlyEditor } from "@/hooks/use-collaborative-document-read-only-editor"; -// types -import { EditorReadOnlyRefApi, ICollaborativeDocumentReadOnlyEditor } from "@/types"; - -const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOnlyEditor) => { - 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 } = useCollaborativeDocumentReadOnlyEditor({ - 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 514b620e3a2..571cb7e9a1f 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 d1ff3b3d014..f291c8b3a3f 100644 --- a/packages/editor/src/core/components/editors/document/page-renderer.tsx +++ b/packages/editor/src/core/components/editors/document/page-renderer.tsx @@ -140,10 +140,10 @@ export const PageRenderer = (props: IPageRenderer) => { > {editor.isEditable && ( - <> +
- +
)}
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 b36fb44a7a9..0e8ab63f8a8 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 @@ -10,7 +10,7 @@ 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, TFileHandler, TReadOnlyMentionHandler } from "@/types"; interface IDocumentReadOnlyEditor { disabledExtensions: TExtensions[]; @@ -23,9 +23,7 @@ interface IDocumentReadOnlyEditor { fileHandler: Pick; tabIndex?: number; handleEditorReady?: (value: boolean) => void; - mentionHandler: { - highlights: () => Promise; - }; + mentionHandler: TReadOnlyMentionHandler; forwardedRef?: React.MutableRefObject; } diff --git a/packages/editor/src/core/components/editors/editor-container.tsx b/packages/editor/src/core/components/editors/editor-container.tsx index e070d7e45e5..d6563f7b085 100644 --- a/packages/editor/src/core/components/editors/editor-container.tsx +++ b/packages/editor/src/core/components/editors/editor-container.tsx @@ -1,9 +1,9 @@ import { FC, ReactNode } from "react"; import { Editor } from "@tiptap/react"; +// plane utils +import { cn } from "@plane/utils"; // constants import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config"; -// helpers -import { cn } from "@/helpers/common"; // types import { TDisplayConfig } from "@/types"; diff --git a/packages/editor/src/core/components/editors/editor-wrapper.tsx b/packages/editor/src/core/components/editors/editor-wrapper.tsx index 5eadef2b04e..9d1297e239e 100644 --- a/packages/editor/src/core/components/editors/editor-wrapper.tsx +++ b/packages/editor/src/core/components/editors/editor-wrapper.tsx @@ -1,4 +1,4 @@ -import { AnyExtension, Editor } from "@tiptap/core"; +import { Editor, Extensions } from "@tiptap/core"; // components import { EditorContainer } from "@/components/editors"; // constants @@ -12,7 +12,7 @@ import { EditorContentWrapper } from "./editor-content"; type Props = IEditorProps & { children?: (editor: Editor) => React.ReactNode; - extensions: AnyExtension[]; + 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 43793fae35d..42b45509f72 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"; 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 e3ccc6cf62b..24dfeceafdf 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,9 +1,9 @@ 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"; 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 bc7f5a56f12..ba9278b675f 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,10 +1,11 @@ import { Dispatch, FC, SetStateAction } from "react"; import { Editor } from "@tiptap/react"; import { ALargeSmall, Ban } from "lucide-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"; type 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 eaa20ed26bb..fe651fa4e15 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"; +// 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 = { 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 a121c48ef12..7d1378800c9 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 18079f089e1..4ed3709f408 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/root.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/root.tsx @@ -1,5 +1,7 @@ import { FC, useEffect, useState } from "react"; import { BubbleMenu, BubbleMenuProps, Editor, isNodeSelection } from "@tiptap/react"; +// plane utils +import { cn } from "@plane/utils"; // components import { BoldItem, @@ -13,8 +15,6 @@ import { } from "@/components/menus"; // extensions import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection"; -// helpers -import { cn } from "@/helpers/common"; // local components import { TextAlignmentSelector } from "./alignment-selector"; diff --git a/packages/editor/src/core/constants/common.ts b/packages/editor/src/core/constants/common.ts index 7f4f7f66f37..8961bcd915b 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" | "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")), + }, +}; + 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 5a9577044c0..bd4712de99a 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/extensions/callout/color-selector.tsx b/packages/editor/src/core/extensions/callout/color-selector.tsx index 489b051666e..ddc3b879cf5 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 4e9f966aff8..8ea47d50d0a 100644 --- a/packages/editor/src/core/extensions/callout/logo-selector.tsx +++ b/packages/editor/src/core/extensions/callout/logo-selector.tsx @@ -1,9 +1,9 @@ // plane helpers -import { convertHexEmojiToDecimal } from "@plane/helpers"; +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/callout/utils.ts b/packages/editor/src/core/extensions/callout/utils.ts index c450cbdd2d7..6568a40e3ea 100644 --- a/packages/editor/src/core/extensions/callout/utils.ts +++ b/packages/editor/src/core/extensions/callout/utils.ts @@ -1,5 +1,5 @@ // plane helpers -import { sanitizeHTML } from "@plane/helpers"; +import { sanitizeHTML } from "@plane/utils"; // plane ui import { TEmojiLogoProps } from "@plane/ui"; // types 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 8dbdb044f11..a06d839908a 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 cb1b0a00244..8864f49f703 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"; @@ -43,6 +43,16 @@ export const CoreEditorExtensionsWithoutProps = [ codeBlock: false, horizontalRule: false, blockquote: false, + paragraph: { + HTMLAttributes: { + class: "editor-paragraph-block", + }, + }, + heading: { + HTMLAttributes: { + class: "editor-heading-block", + }, + }, dropcursor: false, }), CustomQuoteExtension, @@ -87,7 +97,7 @@ export const CoreEditorExtensionsWithoutProps = [ TableHeader, TableCell, TableRow, - CustomMentionWithoutProps(), + CustomMentionExtensionConfig, CustomTextAlignExtension, CustomCalloutExtensionConfig, CustomColorExtension, 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 b5b27e27185..89194aae0c3 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,9 @@ import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react"; import { NodeSelection } from "@tiptap/pm/state"; +// plane utils +import { cn } from "@plane/utils"; // extensions import { CustoBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image"; -// helpers -import { cn } from "@/helpers/common"; const MIN_SIZE = 100; 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 58b60b306d6..2bd84fcb310 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 8ad99bc4439..eaea423878d 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,7 +1,9 @@ import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react"; import { ImageIcon } from "lucide-react"; -// helpers -import { cn } from "@/helpers/common"; +// plane utils +import { cn } from "@plane/utils"; +// constants +import { ACCEPTED_FILE_EXTENSIONS } from "@/constants/config"; // hooks import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload"; // extensions @@ -127,7 +129,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { return "Uploading..."; } - if (draggedInside) { + if (draggedInside && editor.isEditable) { return "Drop image here"; } @@ -137,14 +139,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 38ea23c9925..560d95cfc4c 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 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); + + // Reset image position + node.style.left = "0px"; + node.style.top = "0px"; + }, + [isFullScreenEnabled, widthInNumber, aspectRatio] + ); + const handleClose = useCallback(() => { + if (isDragging) return; toggleFullScreenMode(false); - setTimeout(() => { - 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 + 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(); + + 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"), + }; + } + }; + + 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; } }, - [handleClose] + [isFullScreenEnabled, magnification] ); - // register keydown listener + + // Event listeners useEffect(() => { - if (isFullScreenEnabled) { - document.addEventListener("keydown", handleKeyDown); + if (!isFullScreenEnabled) return; - return () => { - document.removeEventListener("keydown", handleKeyDown); - }; - } - }, [handleKeyDown, isFullScreenEnabled]); + 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 279567a203a..00000000000 --- 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 8fa8ef695ad..00000000000 --- 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 00000000000..5252aa8b89a --- /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 00000000000..e8e7ed4b7a9 --- /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 9ca99495e55..38c7f996632 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, @@ -29,20 +28,18 @@ import { // helpers import { isValidHttpUrl } from "@/helpers/common"; // types -import { IMentionHighlight, TExtensions, TFileHandler } from "@/types"; +import { TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types"; // plane editor extensions import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions"; type Props = { disabledExtensions: TExtensions[]; fileHandler: Pick; - mentionConfig: { - mentionHighlights?: () => Promise; - }; + mentionHandler: TReadOnlyMentionHandler; }; export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { - const { disabledExtensions, fileHandler, mentionConfig } = props; + const { disabledExtensions, fileHandler, mentionHandler } = props; return [ StarterKit.configure({ @@ -65,6 +62,16 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { codeBlock: false, horizontalRule: false, blockquote: false, + paragraph: { + HTMLAttributes: { + class: "editor-paragraph-block", + }, + }, + heading: { + HTMLAttributes: { + class: "editor-heading-block", + }, + }, dropcursor: false, gapcursor: false, }), @@ -123,13 +130,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 5ab6fbdf5b3..eac71301200 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 }, }), ]; }, 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 3a03c3b6a70..bd8ce2aecdb 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 93b0ce2ea82..4ecd3f8fa2c 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 62c353f9275..5e12c997f0f 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,16 +57,12 @@ 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, }); @@ -91,10 +89,8 @@ const renderItems = () => { onKeyDown: (props: { event: KeyboardEvent }) => { if (props.event.key === "Escape") { popup?.[0].hide(); - return true; } - if (component?.ref?.onKeyDown(props)) { return true; } diff --git a/packages/editor/src/core/helpers/common.ts b/packages/editor/src/core/helpers/common.ts index 0fb32310d6a..8638d2c151d 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/tippy.ts b/packages/editor/src/core/helpers/tippy.ts new file mode 100644 index 00000000000..c254bd450f5 --- /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/hooks/use-collaborative-document-read-only-editor.ts b/packages/editor/src/core/hooks/use-collaborative-document-read-only-editor.ts deleted file mode 100644 index ab696b186bb..00000000000 --- a/packages/editor/src/core/hooks/use-collaborative-document-read-only-editor.ts +++ /dev/null @@ -1,93 +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 { TCollaborativeDocumentReadOnlyEditorHookProps } from "@/types"; - -export const useCollaborativeDocumentReadOnlyEditor = (props: TCollaborativeDocumentReadOnlyEditorHookProps) => { - 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, - providerDocument: provider.document, - }); - - return { - editor, - hasServerConnectionFailed, - hasServerSynced, - }; -}; diff --git a/packages/editor/src/core/hooks/use-collaborative-document-editor.ts b/packages/editor/src/core/hooks/use-collaborative-editor.ts similarity index 91% rename from packages/editor/src/core/hooks/use-collaborative-document-editor.ts rename to packages/editor/src/core/hooks/use-collaborative-editor.ts index c45eed9339e..4abf7d6d1ff 100644 --- a/packages/editor/src/core/hooks/use-collaborative-document-editor.ts +++ b/packages/editor/src/core/hooks/use-collaborative-editor.ts @@ -1,4 +1,4 @@ -import { useEffect, useImperativeHandle, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { HocuspocusProvider } from "@hocuspocus/provider"; import Collaboration from "@tiptap/extension-collaboration"; import { IndexeddbPersistence } from "y-indexeddb"; @@ -9,12 +9,13 @@ import { useEditor } from "@/hooks/use-editor"; // plane editor extensions import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions"; // types -import { TCollaborativeDocumentEditorHookProps } from "@/types"; +import { TCollaborativeEditorProps } from "@/types"; -export const useCollaborativeDocumentEditor = (props: TCollaborativeDocumentEditorHookProps) => { +export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { const { onTransaction, disabledExtensions, + editable, editorClassName, editorProps = {}, embedHandler, @@ -75,7 +76,7 @@ export const useCollaborativeDocumentEditor = (props: TCollaborativeDocumentEdit const editor = useEditor({ disabledExtensions, id, - onTransaction, + editable, editorProps, editorClassName, enableHistory: false, @@ -97,12 +98,12 @@ export const useCollaborativeDocumentEditor = (props: TCollaborativeDocumentEdit }), ], fileHandler, - handleEditorReady, forwardedRef, + handleEditorReady, mentionHandler, + onTransaction, placeholder, provider, - providerDocument: provider.document, tabIndex, }); diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index 4954b00151a..873fb2cdb3e 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -3,7 +3,7 @@ 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, Editor, Extensions } from "@tiptap/react"; import * as Y from "yjs"; // components import { EditorMenuItem, getEditorMenuItems } from "@/components/menus"; @@ -19,28 +19,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; @@ -56,6 +53,7 @@ export interface CustomEditorProps { export const useEditor = (props: CustomEditorProps) => { const { disabledExtensions, + editable = true, editorClassName, editorProps = {}, enableHistory, @@ -76,42 +74,43 @@ export const useEditor = (props: CustomEditorProps) => { 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, - }, - 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?.(); + const editor = useTiptapEditor( + { + editable, + 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: ({ editor }) => { + setSavedSelection(editor.state.selection); + onTransaction?.(); + }, + onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()), + onDestroy: () => handleEditorReady?.(false), }, - onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()), - onDestroy: () => handleEditorReady?.(false), - }); + [editable] + ); // Update the ref whenever savedSelection changes useEffect(() => { diff --git a/packages/editor/src/core/hooks/use-file-upload.ts b/packages/editor/src/core/hooks/use-file-upload.ts index f5f930f2900..65daa2f8e49 100644 --- a/packages/editor/src/core/hooks/use-file-upload.ts +++ b/packages/editor/src/core/hooks/use-file-upload.ts @@ -105,7 +105,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-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index 434176f6c8b..60109faca8e 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 useCustomEditor, Editor, Extensions } from "@tiptap/react"; import * as Y from "yjs"; // extensions import { CoreReadOnlyEditorExtensions } from "@/extensions"; @@ -13,24 +13,22 @@ import { CoreReadOnlyEditorProps } from "@/props"; // types import type { EditorReadOnlyRefApi, - IMentionHighlight, TExtensions, TDocumentEventsServer, TFileHandler, + TReadOnlyMentionHandler, } from "@/types"; interface CustomReadOnlyEditorProps { disabledExtensions: TExtensions[]; editorClassName: string; editorProps?: EditorProps; - extensions?: any; + extensions?: Extensions; forwardedRef?: MutableRefObject; initialValue?: string; fileHandler: Pick; handleEditorReady?: (value: boolean) => void; - mentionHandler: { - highlights: () => Promise; - }; + mentionHandler: TReadOnlyMentionHandler; provider?: HocuspocusProvider; providerDocument?: Y.Doc; } @@ -65,9 +63,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { extensions: [ ...CoreReadOnlyEditorExtensions({ disabledExtensions, - mentionConfig: { - mentionHighlights: mentionHandler.highlights, - }, + mentionHandler, fileHandler, }), ...extensions, diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index 1c015dcb0f7..fabb38f527d 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,21 +53,39 @@ 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) { if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) { @@ -85,140 +126,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; + let isDragging = false; + let lastClientY = 0; + let scrollAnimationFrame = null; + let isDraggedOutsideWindow: "top" | "bottom" | boolean = false; + let isMouseInsideWhileDragging = false; + let currentScrollSpeed = 0; - 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 handleClick = (event: MouseEvent, view: EditorView) => { + handleNodeSelection(event, view, false, options); + }; - const docSize = view.state.doc.content.size; - nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize)); + const handleDragStart = (event: DragEvent, view: EditorView) => { + const { listType: listTypeFromDragStart } = handleNodeSelection(event, view, true, options); + listType = listTypeFromDragStart; + isDragging = true; + lastClientY = event.clientY; + scroll(); + }; - if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) { - // TODO FIX ERROR - const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes); - view.dispatch(view.state.tr.setSelection(nodeSelection)); - } + const handleDragEnd = (event: TEvent, view?: EditorView) => { + event.preventDefault(); + isDragging = false; + isMouseInsideWhileDragging = false; + if (scrollAnimationFrame) { + cancelAnimationFrame(scrollAnimationFrame); + scrollAnimationFrame = null; } - 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 }; + view?.dom.classList.remove("dragging"); }; - const handleClick = (event: MouseEvent, view: EditorView) => { - view.focus(); - - const node = nodeDOMAtCoords({ - x: event.clientX + 50 + options.dragHandleWidth, - y: event.clientY, - }); + function scroll() { + if (!isDragging) { + currentScrollSpeed = 0; + return; + } - if (!(node instanceof Element)) return; + const scrollableParent = getScrollParent(dragHandleElement); + if (!scrollableParent) return; - if (node.matches("blockquote")) { - let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view); - if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return; + const scrollRegionUp = options.scrollThreshold.up; + const scrollRegionDown = window.innerHeight - options.scrollThreshold.down; - const docSize = view.state.doc.content.size; - nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize)); + let targetScrollAmount = 0; - if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) { - // TODO FIX ERROR - const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes); - view.dispatch(view.state.tr.setSelection(nodeSelection)); - } - return; + 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; } - let nodePos = nodePosAtDOM(node, view, options); - - if (nodePos === null || nodePos === undefined) return; + currentScrollSpeed += (targetScrollAmount - currentScrollSpeed) * acceleration; - // Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied - nodePos = calcNodePos(nodePos, view, node); - - // TODO FIX ERROR - // Use NodeSelection to select the node at the calculated position - const nodeSelection = NodeSelection.create(view.state.doc, nodePos); + if (Math.abs(currentScrollSpeed) > 0.1) { + scrollableParent.scrollBy({ top: currentScrollSpeed }); + } - // Dispatch the transaction to update the selection - view.dispatch(view.state.tr.setSelection(nodeSelection)); - }; + scrollAnimationFrame = requestAnimationFrame(scroll); + } let dragHandleElement: HTMLElement | null = null; // drag handle view actions @@ -231,51 +205,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; - } - currentParent = currentParent.parentElement; + const mouseMoveHandler = (e: MouseEvent) => { + if (isMouseInsideWhileDragging) { + handleDragEnd(e, view); } - return document.scrollingElement || document.documentElement; }; - const maxScrollSpeed = 100; + const dragLeaveHandler = (e: DragEvent) => { + if (e.clientY <= 0 || e.clientX <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) { + isMouseInsideWhileDragging = true; - dragHandleElement.addEventListener("drag", (e) => { - hideDragHandle(); - const scrollableParent = getScrollParent(dragHandleElement); - if (!scrollableParent) return; - const scrollThreshold = options.scrollThreshold; - - 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 }); + const windowMiddleY = window.innerHeight / 2; + + if (lastClientY < windowMiddleY) { + isDraggedOutsideWindow = "top"; + } else { + isDraggedOutsideWindow = "bottom"; + } } - }); + }; + + const dragEnterHandler = () => { + isDraggedOutsideWindow = false; + }; + + window.addEventListener("dragleave", dragLeaveHandler); + window.addEventListener("dragenter", dragEnterHandler); + + document.addEventListener("dragover", dragOverHandler); + document.addEventListener("mousemove", mouseMoveHandler); hideDragHandle(); @@ -285,6 +254,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 +291,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; + + // 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); + } - const newList = view.state.schema.nodes.orderedList?.createAndFill(null, listItem); - const slice = new Slice(Fragment.from(newList), 0, 0); + // 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 +334,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 db88f3f73c8..703bb2bf0a9 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 4bda3e51a2d..ee0b9e50007 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 aaa635a508f..ea5bf09f3a7 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-hook.ts b/packages/editor/src/core/types/collaboration-hook.ts index 578c4472788..2e8c9a0e723 100644 --- a/packages/editor/src/core/types/collaboration-hook.ts +++ b/packages/editor/src/core/types/collaboration-hook.ts @@ -6,10 +6,10 @@ import { TEmbedConfig } from "@/plane-editor/types"; import { EditorReadOnlyRefApi, EditorRefApi, - IMentionHighlight, - IMentionSuggestion, TExtensions, TFileHandler, + TMentionHandler, + TReadOnlyMentionHandler, TRealtimeConfig, TUserDetails, } from "@/types"; @@ -21,15 +21,15 @@ export type TServerHandler = { type TCollaborativeEditorHookCommonProps = { 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; }; type TCollaborativeEditorHookProps = TCollaborativeEditorHookCommonProps & { @@ -37,6 +37,7 @@ type TCollaborativeEditorHookProps = TCollaborativeEditorHookCommonProps & { embedHandler?: TEmbedConfig; fileHandler: TFileHandler; forwardedRef?: React.MutableRefObject; + mentionHandler: TMentionHandler; placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; }; @@ -44,6 +45,7 @@ type TCollaborativeEditorHookProps = TCollaborativeEditorHookCommonProps & { type TCollaborativeReadOnlyEditorHookProps = TCollaborativeEditorHookCommonProps & { fileHandler: Pick; forwardedRef?: React.MutableRefObject; + mentionHandler: TReadOnlyMentionHandler; }; export type TCollaborativeRichTextEditorHookProps = TCollaborativeEditorHookProps & { diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index f3ae31b20c0..669e4008430 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -1,11 +1,9 @@ -import { JSONContent } from "@tiptap/core"; +import { Extensions, JSONContent } from "@tiptap/core"; import { Selection } from "@tiptap/pm/state"; // helpers import { IMarking } from "@/helpers/scroll-to-node"; // types import { - IMentionHighlight, - IMentionSuggestion, TAIHandler, TDisplayConfig, TDocumentEventEmitter, @@ -13,6 +11,8 @@ import { TEmbedConfig, TExtensions, TFileHandler, + TMentionHandler, + TReadOnlyMentionHandler, TServerHandler, } from "@/types"; import { TTextAlign } from "@/extensions"; @@ -114,10 +114,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 +125,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; } @@ -144,6 +141,7 @@ export interface ICollaborativeRichTextEditor extends Omit { + editable: boolean; aiHandler?: TAIHandler; embedHandler: TEmbedConfig; handleEditorReady?: (value: boolean) => void; @@ -163,9 +161,7 @@ export interface IReadOnlyEditorProps { forwardedRef?: React.MutableRefObject; id: string; initialValue: string; - mentionHandler: { - highlights: () => Promise; - }; + mentionHandler: TReadOnlyMentionHandler; } export type ILiteTextReadOnlyEditor = IReadOnlyEditorProps; diff --git a/packages/editor/src/core/types/index.ts b/packages/editor/src/core/types/index.ts index fa383222dc3..30562e00152 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 a51bed70420..00000000000 --- 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 00000000000..20f1ec0dcc6 --- /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 eb59deade40..2ddb0568848 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, CollaborativeRichTextEditorWithRef, CollaborativeRichTextReadOnlyEditorWithRef, DocumentReadOnlyEditorWithRef, diff --git a/packages/editor/src/styles/editor.css b/packages/editor/src/styles/editor.css index 8c2210600b3..e234f87cf86 100644 --- a/packages/editor/src/styles/editor.css +++ b/packages/editor/src/styles/editor.css @@ -111,8 +111,12 @@ ul[data-type="taskList"] li > label input[type="checkbox"] { 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-background-80)); +} + +.ProseMirror[contenteditable="false"] input[type="checkbox"] { + pointer-events: none; } ul[data-type="taskList"] li > label input[type="checkbox"][checked] { @@ -133,7 +137,7 @@ ul[data-type="taskList"] li > label input[type="checkbox"][checked]:hover { } /* the p tag just after the ul tag */ -ul[data-type="taskList"] + p { +ul[data-type="taskList"] + p.editor-paragraph-block { margin-top: 0.4rem !important; } @@ -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)); } @@ -180,7 +180,7 @@ ul[data-type="taskList"] li > label input[type="checkbox"] { } ul[data-type="taskList"] li > div { - & > p { + & > p.editor-paragraph-block { margin-top: 10px; transition: color 0.2s ease; } @@ -191,7 +191,7 @@ ul[data-type="taskList"] li > div { } ul[data-type="taskList"] li[data-checked="true"] { - & > div > p { + & > div > p.editor-paragraph-block { color: rgb(var(--color-text-400)); } @@ -322,18 +322,18 @@ ul[data-type="taskList"] ul[data-type="taskList"] { } /* end numbered, bulleted and to-do lists spacing */ -h1, -h2, -h3, -h4, -h5, -h6, -p { +h1.editor-heading-block, +h2.editor-heading-block, +h3.editor-heading-block, +h4.editor-heading-block, +h5.editor-heading-block, +h6.editor-heading-block, +p.editor-paragraph-block { margin: 0 !important; } /* tailwind typography */ -.prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) { +.prose :where(h1.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) { &:not(:first-child) { padding-top: 28px; } @@ -344,7 +344,7 @@ p { font-weight: 600; } -.prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) { +.prose :where(h2.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) { &:not(:first-child) { padding-top: 28px; } @@ -355,7 +355,7 @@ p { font-weight: 600; } -.prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) { +.prose :where(h3.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) { &:not(:first-child) { padding-top: 28px; } @@ -366,7 +366,7 @@ p { font-weight: 600; } -.prose :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) { +.prose :where(h4.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) { &:not(:first-child) { padding-top: 28px; } @@ -377,7 +377,7 @@ p { font-weight: 600; } -.prose :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) { +.prose :where(h5.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) { &:not(:first-child) { padding-top: 20px; } @@ -388,7 +388,7 @@ p { font-weight: 600; } -.prose :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) { +.prose :where(h6.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) { &:not(:first-child) { padding-top: 20px; } @@ -399,7 +399,7 @@ p { font-weight: 600; } -.prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) { +.prose :where(p.editor-paragraph-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) { &:first-child { padding-top: 0; } @@ -420,12 +420,12 @@ p { line-height: var(--line-height-regular); } -p + p { +p.editor-paragraph-block + p.editor-paragraph-block { padding-top: 8px !important; } -.prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p, -.prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p { +.prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p.editor-paragraph-block, +.prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p.editor-paragraph-block { font-size: var(--font-size-list); line-height: var(--line-height-list); } diff --git a/packages/eslint-config/library.js b/packages/eslint-config/library.js index 283590693eb..790364230fb 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/eslint-config/package.json b/packages/eslint-config/package.json index e8de3524ce4..66557c74ed3 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -1,7 +1,7 @@ { "name": "@plane/eslint-config", "private": true, - "version": "0.24.0", + "version": "0.24.1", "files": [ "library.js", "next.js", @@ -10,7 +10,7 @@ "devDependencies": { "@typescript-eslint/eslint-plugin": "^8.6.0", "@typescript-eslint/parser": "^8.6.0", - "eslint": "8", + "eslint": "8.57.1", "eslint-config-next": "^14.1.0", "eslint-config-prettier": "^9.1.0", "eslint-config-turbo": "^1.12.4", diff --git a/packages/helpers/helpers/emoji.helper.ts b/packages/helpers/helpers/emoji.helper.ts deleted file mode 100644 index e0d5a1969c6..00000000000 --- a/packages/helpers/helpers/emoji.helper.ts +++ /dev/null @@ -1,22 +0,0 @@ -export const convertHexEmojiToDecimal = (emojiUnified: string): string => { - if (!emojiUnified) return ""; - - return emojiUnified - .toString() - .split("-") - .map((e) => parseInt(e, 16)) - .join("-"); -}; - -export const emojiCodeToUnicode = (emoji: string) => { - if (!emoji) return ""; - - // convert emoji code to unicode - const uniCodeEmoji = emoji - .toString() - .split("-") - .map((emoji) => parseInt(emoji, 10).toString(16)) - .join("-"); - - return uniCodeEmoji; -}; diff --git a/packages/helpers/helpers/index.ts b/packages/helpers/helpers/index.ts deleted file mode 100644 index e800e98fdcb..00000000000 --- a/packages/helpers/helpers/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./emoji.helper" -export * from "./string.helper" \ No newline at end of file diff --git a/packages/helpers/helpers/string.helper.ts b/packages/helpers/helpers/string.helper.ts deleted file mode 100644 index aad72726290..00000000000 --- a/packages/helpers/helpers/string.helper.ts +++ /dev/null @@ -1,15 +0,0 @@ -import DOMPurify from "isomorphic-dompurify"; - -/** - * @description: This function will remove all the HTML tags from the string - * @param {string} html - * @return {string} - * @example: - * const html = "

    Some text

    "; - * const text = stripHTML(html); - * console.log(text); // Some text - */ -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 -}; \ No newline at end of file diff --git a/packages/helpers/index.ts b/packages/helpers/index.ts deleted file mode 100644 index f1216272dde..00000000000 --- a/packages/helpers/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./helpers"; -export * from "./hooks"; diff --git a/packages/helpers/package.json b/packages/helpers/package.json deleted file mode 100644 index 6e39a19d5e9..00000000000 --- a/packages/helpers/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@plane/helpers", - "version": "0.24.0", - "description": "Helper functions shared across multiple apps internally", - "private": true, - "main": "./dist/index.js", - "module": "./dist/index.mjs", - "types": "./dist/index.d.ts", - "files": [ - "dist/**" - ], - "scripts": { - "build": "tsup ./index.ts --format esm,cjs --dts --external react --minify" - }, - "devDependencies": { - "@types/node": "^22.5.4", - "@types/react": "^18.3.11", - "tsup": "^7.2.0", - "typescript": "^5.6.2" - }, - "dependencies": { - "isomorphic-dompurify": "^2.16.0", - "react": "^18.3.1" - } -} diff --git a/packages/hooks/.eslintignore b/packages/hooks/.eslintignore new file mode 100644 index 00000000000..6019047c3e5 --- /dev/null +++ b/packages/hooks/.eslintignore @@ -0,0 +1,3 @@ +build/* +dist/* +out/* \ No newline at end of file diff --git a/packages/hooks/.eslintrc.js b/packages/hooks/.eslintrc.js new file mode 100644 index 00000000000..558b8f76ed4 --- /dev/null +++ b/packages/hooks/.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/hooks/.prettierignore b/packages/hooks/.prettierignore new file mode 100644 index 00000000000..d5be669c5e0 --- /dev/null +++ b/packages/hooks/.prettierignore @@ -0,0 +1,4 @@ +.turbo +out/ +dist/ +build/ \ No newline at end of file diff --git a/packages/hooks/.prettierrc b/packages/hooks/.prettierrc new file mode 100644 index 00000000000..87d988f1b26 --- /dev/null +++ b/packages/hooks/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/packages/hooks/package.json b/packages/hooks/package.json new file mode 100644 index 00000000000..b45723305cf --- /dev/null +++ b/packages/hooks/package.json @@ -0,0 +1,27 @@ +{ + "name": "@plane/hooks", + "version": "0.24.1", + "description": "React hooks that are shared across multiple apps internally", + "private": true, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist/**" + ], + "scripts": { + "build": "tsup ./src/index.ts --format esm,cjs --dts --external react --minify", + "lint": "eslint src --ext .ts,.tsx", + "lint:errors": "eslint src --ext .ts,.tsx --quiet" + }, + "dependencies": { + "react": "^18.3.1" + }, + "devDependencies": { + "@plane/eslint-config": "*", + "@types/node": "^22.5.4", + "@types/react": "^18.3.11", + "tsup": "^7.2.0", + "typescript": "^5.3.3" + } +} diff --git a/packages/helpers/hooks/index.ts b/packages/hooks/src/index.ts similarity index 100% rename from packages/helpers/hooks/index.ts rename to packages/hooks/src/index.ts diff --git a/packages/helpers/hooks/use-local-storage.tsx b/packages/hooks/src/use-local-storage.tsx similarity index 100% rename from packages/helpers/hooks/use-local-storage.tsx rename to packages/hooks/src/use-local-storage.tsx diff --git a/packages/helpers/hooks/use-outside-click-detector.tsx b/packages/hooks/src/use-outside-click-detector.tsx similarity index 100% rename from packages/helpers/hooks/use-outside-click-detector.tsx rename to packages/hooks/src/use-outside-click-detector.tsx diff --git a/packages/helpers/tsconfig.json b/packages/hooks/tsconfig.json similarity index 88% rename from packages/helpers/tsconfig.json rename to packages/hooks/tsconfig.json index f9715d3d8b1..e8af9092ad7 100644 --- a/packages/helpers/tsconfig.json +++ b/packages/hooks/tsconfig.json @@ -4,6 +4,6 @@ "jsx": "react", "lib": ["esnext", "dom"] }, - "include": ["."], + "include": ["./src"], "exclude": ["dist", "build", "node_modules"] } diff --git a/packages/logger/.eslintignore b/packages/logger/.eslintignore new file mode 100644 index 00000000000..6019047c3e5 --- /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 00000000000..558b8f76ed4 --- /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 00000000000..87d988f1b26 --- /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 00000000000..6b83eb52bb7 --- /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 00000000000..7a374004f5d --- /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 00000000000..fd918e59eff --- /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 00000000000..715301ad305 --- /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 00000000000..e251a5837d6 --- /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 00000000000..2ba1c9799b1 --- /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/services/.eslintignore b/packages/services/.eslintignore new file mode 100644 index 00000000000..6019047c3e5 --- /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 00000000000..558b8f76ed4 --- /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 00000000000..87d988f1b26 --- /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 00000000000..e3473942f73 --- /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.4.0" + } +} diff --git a/packages/services/src/ai/ai.service.ts b/packages/services/src/ai/ai.service.ts new file mode 100644 index 00000000000..6a3b3c637c6 --- /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 00000000000..bce346aa66b --- /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 00000000000..c012fd26f47 --- /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 00000000000..7655bd44242 --- /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 00000000000..3574424dafd --- /dev/null +++ b/packages/services/src/api.service.ts @@ -0,0 +1,110 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import axios, { AxiosInstance, AxiosRequestConfig } from "axios"; + +/** + * 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; + window.location.replace(`/${currentPath ? `?next_path=${currentPath}` : ``}`); + } + 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 00000000000..87e75e536f6 --- /dev/null +++ b/packages/services/src/auth/auth.service.ts @@ -0,0 +1,124 @@ +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 default 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 + */ + async requestCSRFToken(): Promise { + return this.get("/auth/get-csrf-token/") + .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 00000000000..2ab33e86ab7 --- /dev/null +++ b/packages/services/src/auth/index.ts @@ -0,0 +1 @@ +export * from "./auth.service"; 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 00000000000..4897926a925 --- /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 00000000000..8c40f0a298a --- /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 00000000000..3e6f32cd9a4 --- /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 00000000000..c697c2da4a7 --- /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 00000000000..5956023cbd1 --- /dev/null +++ b/packages/services/src/cycle/index.ts @@ -0,0 +1,4 @@ +export * from "./cycle-analytics.service"; +export * from "./cycle-archive.service"; +export * from "./cycle-operations.service"; +export * from "./cycle.service"; diff --git a/packages/services/src/dashboard/dashboard.service.ts b/packages/services/src/dashboard/dashboard.service.ts new file mode 100644 index 00000000000..6a4a6bcca7f --- /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 00000000000..79e3f040095 --- /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 00000000000..92ee523ea6e --- /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 00000000000..a78a7b0929e --- /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 00000000000..e48da3430b5 --- /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/packages/services/src/index.ts b/packages/services/src/index.ts new file mode 100644 index 00000000000..677aa28e7a2 --- /dev/null +++ b/packages/services/src/index.ts @@ -0,0 +1,12 @@ +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"; diff --git a/packages/services/src/instance/index.ts b/packages/services/src/instance/index.ts new file mode 100644 index 00000000000..dd3d4a663c7 --- /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 00000000000..0ffe451fbc3 --- /dev/null +++ b/packages/services/src/instance/instance.service.ts @@ -0,0 +1,44 @@ +import { API_BASE_URL } from "@plane/constants"; +import type { IInstanceInfo, TPage } from "@plane/types"; +import { APIService } from "@/api.service"; + +/** + * Service class for managing instance-related operations + * Handles retrieval of instance information and changelog + * @extends {APIService} + */ +export default class InstanceService extends APIService { + /** + * 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 + */ + async info(): Promise { + return this.get("/api/instances/") + .then((response) => response.data) + .catch((error) => { + throw error; + }); + } + + /** + * 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; + }); + } +} diff --git a/packages/services/src/intake/index.ts b/packages/services/src/intake/index.ts new file mode 100644 index 00000000000..cde9ddbd340 --- /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 00000000000..1f7f7229561 --- /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 00000000000..37e1f81dca2 --- /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/live.service.ts b/packages/services/src/live.service.ts new file mode 100644 index 00000000000..ae4b80864c5 --- /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 00000000000..8f08f92b831 --- /dev/null +++ b/packages/services/src/module/index.ts @@ -0,0 +1,3 @@ +export * from "./link.service"; +export * from "./module.service"; +export * from "./operations.service"; diff --git a/packages/services/src/module/link.service.ts b/packages/services/src/module/link.service.ts new file mode 100644 index 00000000000..0caee9e1983 --- /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 00000000000..1d1732aa914 --- /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 00000000000..b8fddb37d51 --- /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/project/index.ts b/packages/services/src/project/index.ts new file mode 100644 index 00000000000..6ec55d7f7a9 --- /dev/null +++ b/packages/services/src/project/index.ts @@ -0,0 +1 @@ +export * from "./view.service"; diff --git a/packages/services/src/project/view.service.ts b/packages/services/src/project/view.service.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/services/src/user/favorite.service.ts b/packages/services/src/user/favorite.service.ts new file mode 100644 index 00000000000..7e838a3c6c8 --- /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 00000000000..41df23a178d --- /dev/null +++ b/packages/services/src/user/index.ts @@ -0,0 +1 @@ +export * from "./favorite.service"; diff --git a/packages/services/src/workspace/index.ts b/packages/services/src/workspace/index.ts new file mode 100644 index 00000000000..4076a51f524 --- /dev/null +++ b/packages/services/src/workspace/index.ts @@ -0,0 +1,5 @@ +export * from "./invitation.service"; +export * from "./member.service"; +export * from "./notification.service"; +export * from "./view.service"; +export * from "./workspace.service"; diff --git a/packages/services/src/workspace/invitation.service.ts b/packages/services/src/workspace/invitation.service.ts new file mode 100644 index 00000000000..fa344602025 --- /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 00000000000..e92225af386 --- /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 00000000000..a21f6322228 --- /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 00000000000..ca782d43358 --- /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 00000000000..72ef9fcefd8 --- /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 00000000000..0c2f64d1a8c --- /dev/null +++ b/packages/services/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-custom/package.json index 5c31544b3d2..a7fac6401b9 100644 --- a/packages/tailwind-config-custom/package.json +++ b/packages/tailwind-config-custom/package.json @@ -1,6 +1,6 @@ { "name": "tailwind-config-custom", - "version": "0.24.0", + "version": "0.24.1", "description": "common tailwind configuration across monorepo", "main": "index.js", "private": true, diff --git a/packages/types/.prettierrc b/packages/types/.prettierrc new file mode 100644 index 00000000000..87d988f1b26 --- /dev/null +++ b/packages/types/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/packages/types/package.json b/packages/types/package.json index 9ce0fd077dc..6fc823e902c 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@plane/types", - "version": "0.24.0", + "version": "0.24.1", "private": true, "types": "./src/index.d.ts", "main": "./src/index.d.ts" diff --git a/packages/types/src/activity.d.ts b/packages/types/src/activity.d.ts new file mode 100644 index 00000000000..98d54dabc44 --- /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/command-palette.d.ts b/packages/types/src/command-palette.d.ts new file mode 100644 index 00000000000..6e072ab8fb8 --- /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 5fe31ad0006..7e755fcc28e 100644 --- a/packages/types/src/common.d.ts +++ b/packages/types/src/common.d.ts @@ -22,3 +22,5 @@ export type TLogoProps = { background_color?: string; }; }; + +export type TNameDescriptionLoader = "submitting" | "submitted" | "saved"; diff --git a/packages/types/src/enums.ts b/packages/types/src/enums.ts index df6a462b02e..854c0c61405 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 00000000000..1ba50e2f2f3 --- /dev/null +++ b/packages/types/src/epics.d.ts @@ -0,0 +1,16 @@ +export type TEpicAnalyticsGroup = + | "backlog_issues" + | "unstarted_issues" + | "started_issues" + | "completed_issues" + | "cancelled_issues" + | "overdue_issues"; + +export type TEpicAnalytics = { + backlog_issues: number; + unstarted_issues: number; + started_issues: number; + completed_issues: number; + cancelled_issues: number; + overdue_issues: number; +}; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 10e51970062..af1e3ff485d 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -28,7 +28,12 @@ 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"; diff --git a/packages/types/src/instance/base.d.ts b/packages/types/src/instance/base.d.ts index 41198b27c9c..33d0734ad34 100644 --- a/packages/types/src/instance/base.d.ts +++ b/packages/types/src/instance/base.d.ts @@ -57,6 +57,7 @@ export interface IInstanceConfig { // intercom is_intercom_enabled: boolean; intercom_app_id: string | undefined; + instance_changelog_url?: string; } export interface IInstanceAdmin { diff --git a/packages/types/src/issues.d.ts b/packages/types/src/issues.d.ts index eff81f857b2..f77408fd6c0 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/issue.d.ts b/packages/types/src/issues/issue.d.ts index d592ecbb78a..1dd68a9d994 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -1,3 +1,4 @@ +import { EIssueServiceType } from "@plane/constants"; import { TIssuePriorities } from "../issues"; import { TIssueAttachment } from "./issue_attachment"; import { TIssueLink } from "./issue_link"; @@ -39,6 +40,7 @@ export type TBaseIssue = { updated_by: string; is_draft: boolean; + is_epic?: boolean; }; export type IssueRelation = { @@ -122,3 +124,7 @@ export type TIssueDetailWidget = | "relations" | "links" | "attachments"; + +export type TIssueServiceType = + | EIssueServiceType.ISSUES + | EIssueServiceType.EPICS; diff --git a/packages/types/src/project/index.ts b/packages/types/src/project/index.ts index ef7308bf7da..f5478051eb5 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 00000000000..45b9dfc6ac0 --- /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 d48342cebb9..d992bc7105b 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -15,6 +15,7 @@ export interface IProject { archived_at: string | null; archived_issues: number; archived_sub_issues: number; + completed_issues: number; close_in: number; created_at: Date; created_by: string; @@ -136,6 +137,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 00000000000..6eb1475129c --- /dev/null +++ b/packages/types/src/search.d.ts @@ -0,0 +1,83 @@ +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_mention" + | "project_mention" + | "cycle_mention" + | "module_mention" + | "page_mention"; + +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_mention?: TCycleSearchResponse[]; + issue_mention?: TIssueSearchResponse[]; + module_mention?: TModuleSearchResponse[]; + page_mention?: TPageSearchResponse[]; + project_mention?: TProjectSearchResponse[]; + user_mention?: TUserSearchResponse[]; +}; + +export type TSearchEntityRequestPayload = { + count: number; + project_id?: string; + query_type: TSearchEntities[]; + query: string; + team_id?: string; +}; diff --git a/packages/types/src/state.d.ts b/packages/types/src/state.d.ts index 7df658a88b8..120b216da25 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/timezone.d.ts b/packages/types/src/timezone.d.ts new file mode 100644 index 00000000000..b4df123a306 --- /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/view-props.d.ts b/packages/types/src/view-props.d.ts index 57baa4cfdd1..dca1a652c62 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/workspace-notifications.d.ts b/packages/types/src/workspace-notifications.d.ts index 7d960015b9b..0e0e15af178 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/typescript-config/package.json b/packages/typescript-config/package.json index 24387736619..3237a184b49 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@plane/typescript-config", - "version": "0.24.0", + "version": "0.24.1", "private": true, "files": [ "base.json", diff --git a/packages/ui/package.json b/packages/ui/package.json index 27189f606d8..2cfcc3643c1 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@plane/ui", "description": "UI components shared across multiple apps internally", "private": true, - "version": "0.24.0", + "version": "0.24.1", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", @@ -18,7 +18,8 @@ "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "postcss": "postcss styles/globals.css -o styles/output.css --watch", - "lint": "eslint src --ext .ts,.tsx" + "lint": "eslint src --ext .ts,.tsx", + "lint:errors": "eslint src --ext .ts,.tsx --quiet" }, "peerDependencies": { "react": "^18.3.1", @@ -30,8 +31,9 @@ "@blueprintjs/core": "^4.16.3", "@blueprintjs/popover2": "^1.13.3", "@headlessui/react": "^1.7.3", + "@plane/hooks": "*", + "@plane/utils": "*", "@popperjs/core": "^2.11.8", - "@plane/helpers": "*", "clsx": "^2.0.0", "emoji-picker-react": "^4.5.16", "lodash": "^4.17.21", @@ -44,6 +46,8 @@ }, "devDependencies": { "@chromatic-com/storybook": "^1.4.0", + "@plane/eslint-config": "*", + "@plane/typescript-config": "*", "@storybook/addon-essentials": "^8.1.1", "@storybook/addon-interactions": "^8.1.1", "@storybook/addon-links": "^8.1.1", @@ -61,14 +65,15 @@ "@types/react-dom": "^18.2.18", "autoprefixer": "^10.4.19", "classnames": "^2.3.2", - "@plane/eslint-config": "*", "postcss-cli": "^11.0.0", "postcss-nested": "^6.0.1", "storybook": "^8.1.1", "tailwind-config-custom": "*", "tailwindcss": "^3.4.3", - "@plane/typescript-config": "*", "tsup": "^7.2.0", "typescript": "5.3.3" + }, + "resolutions": { + "@types/react": "^18.0.0" } } diff --git a/packages/ui/src/breadcrumbs/index.ts b/packages/ui/src/breadcrumbs/index.ts index 669f5575772..05a8bdbf1b6 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 00000000000..a716ca65e19 --- /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/dropdown/multi-select.tsx b/packages/ui/src/dropdown/multi-select.tsx index 6b50183702c..25f22c6be4a 100644 --- a/packages/ui/src/dropdown/multi-select.tsx +++ b/packages/ui/src/dropdown/multi-select.tsx @@ -5,7 +5,7 @@ import { Combobox } from "@headlessui/react"; // popper-js import { usePopper } from "react-popper"; // plane helpers -import { useOutsideClickDetector } from "@plane/helpers"; +import { useOutsideClickDetector } from "@plane/hooks"; // components import { DropdownButton } from "./common"; import { DropdownOptions } from "./common/options"; diff --git a/packages/ui/src/dropdown/single-select.tsx b/packages/ui/src/dropdown/single-select.tsx index 1c3b05f5b6a..bcdff40c140 100644 --- a/packages/ui/src/dropdown/single-select.tsx +++ b/packages/ui/src/dropdown/single-select.tsx @@ -5,7 +5,7 @@ import { Combobox } from "@headlessui/react"; // popper-js import { usePopper } from "react-popper"; // plane helpers -import { useOutsideClickDetector } from "@plane/helpers"; +import { useOutsideClickDetector } from "@plane/hooks"; // components import { DropdownButton } from "./common"; import { DropdownOptions } from "./common/options"; @@ -110,7 +110,7 @@ export const Dropdown: FC = (props) => { // hooks const handleKeyDown = useDropdownKeyPressed(toggleDropdown, handleClose); - useOutsideClickDetector(dropdownRef, handleClose); + useOutsideClickDetector(dropdownRef, handleClose, true); return ( { useOutsideClickDetector(dropdownRef, closeDropdown, useCaptureForOutsideClick); let menuItems = ( - +
{
+
); }); diff --git a/space/core/components/common/index.ts b/space/core/components/common/index.ts index 1949c069bef..0a63ca1ac8b 100644 --- a/space/core/components/common/index.ts +++ b/space/core/components/common/index.ts @@ -1,2 +1,3 @@ export * from "./project-logo"; export * from "./logo-spinner"; +export * from "./powered-by"; diff --git a/space/core/components/common/powered-by.tsx b/space/core/components/common/powered-by.tsx new file mode 100644 index 00000000000..654089c55c3 --- /dev/null +++ b/space/core/components/common/powered-by.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { FC } from "react"; +import Image from "next/image"; +import { WEBSITE_URL } from "@plane/constants"; +// assets +import planeLogo from "@/public/plane-logo.svg"; + +type TPoweredBy = { + disabled?: boolean; +}; + +export const PoweredBy: FC = (props) => { + // props + const { disabled = false } = props; + + if (disabled || !WEBSITE_URL) return null; + + return ( + +
+ Plane logo +
+
+ Powered by Plane Publish +
+
+ ); +}; diff --git a/space/core/components/editor/embeds/index.ts b/space/core/components/editor/embeds/index.ts new file mode 100644 index 00000000000..8146e94d927 --- /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 00000000000..1efe34c51ec --- /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 00000000000..9ea5ef6fb20 --- /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 00000000000..5a178396b83 --- /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 4ec0141e201..894daf22411 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 5d50271355a..ac0a0633a45 100644 --- a/space/core/components/editor/lite-text-editor.tsx +++ b/space/core/components/editor/lite-text-editor.tsx @@ -2,13 +2,11 @@ import React from "react"; // editor import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } 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 { @@ -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 014f4010c04..5f936baec5a 100644 --- a/space/core/components/editor/lite-text-read-only-editor.tsx +++ b/space/core/components/editor/lite-text-read-only-editor.tsx @@ -1,11 +1,11 @@ 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, @@ -15,25 +15,21 @@ type LiteTextReadOnlyEditorWrapperProps = Omit< }; export const LiteTextReadOnlyEditor = React.forwardRef( - ({ anchor, ...props }, ref) => { - const { mentionHighlights } = useMention(); - - return ( - - ); - } + ({ anchor, ...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 cfe2e1b7f0b..96f4900548c 100644 --- a/space/core/components/editor/rich-text-editor.tsx +++ b/space/core/components/editor/rich-text-editor.tsx @@ -1,6 +1,8 @@ import React, { forwardRef } from "react"; // editor -import { EditorRefApi, IMentionHighlight, IRichTextEditor, RichTextEditorWithRef } from "@plane/editor"; +import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef } from "@plane/editor"; +// components +import { EditorMentionsRoot } from "@/components/editor"; // helpers import { getEditorFileHandlers } from "@/helpers/editor.helper"; @@ -11,19 +13,11 @@ interface RichTextEditorWrapperProps export const RichTextEditor = forwardRef((props, ref) => { const { containerClassName, uploadFile, ...rest } = props; - // store hooks - - // use-mention - - // file size return ( { - throw new Error("Function not implemented."); - }, - suggestions: undefined, + renderComponent: (props) => , }} ref={ref} disabledExtensions={[]} 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 fca1776e254..76075f29656 100644 --- a/space/core/components/editor/rich-text-read-only-editor.tsx +++ b/space/core/components/editor/rich-text-read-only-editor.tsx @@ -1,11 +1,11 @@ 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, @@ -15,23 +15,21 @@ type RichTextReadOnlyEditorWrapperProps = Omit< }; export const RichTextReadOnlyEditor = React.forwardRef( - ({ anchor, ...props }, ref) => { - const { mentionHighlights } = useMention(); - - return ( - - ); - } + ({ anchor, ...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 4593aaf6539..0d6931af553 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/issues/filters/priority.tsx b/space/core/components/issues/filters/priority.tsx index 51c1a751990..4f16f89ba83 100644 --- a/space/core/components/issues/filters/priority.tsx +++ b/space/core/components/issues/filters/priority.tsx @@ -3,9 +3,9 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; // ui +import { ISSUE_PRIORITY_FILTERS } from "@plane/constants"; import { PriorityIcon } from "@plane/ui"; // components -import { issuePriorityFilters } from "@/constants/issue"; import { FilterHeader, FilterOption } from "./helpers"; // constants @@ -22,7 +22,7 @@ export const FilterPriority: React.FC = observer((props) => { 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 ( <> diff --git a/space/core/components/issues/filters/root.tsx b/space/core/components/issues/filters/root.tsx index 641cf007c0e..ff0a13b8909 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/kanban/block-reactions.tsx b/space/core/components/issues/issue-layouts/kanban/block-reactions.tsx index 241a087e781..a7ffd38ae85 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 7c246cc339f..1975963cc1a 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/list/block.tsx b/space/core/components/issues/issue-layouts/list/block.tsx index 39a298448ac..6d4fb0983f5 100644 --- a/space/core/components/issues/issue-layouts/list/block.tsx +++ b/space/core/components/issues/issue-layouts/list/block.tsx @@ -4,10 +4,12 @@ import { useRef } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams, useSearchParams } from "next/navigation"; -// types -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"; // helpers import { queryParamGenerator } from "@/helpers/query-param-generator"; // hooks diff --git a/space/core/components/issues/issue-layouts/list/list-group.tsx b/space/core/components/issues/issue-layouts/list/list-group.tsx index 742cfeef156..75d280d0d07 100644 --- a/space/core/components/issues/issue-layouts/list/list-group.tsx +++ b/space/core/components/issues/issue-layouts/list/list-group.tsx @@ -2,9 +2,10 @@ import { Fragment, MutableRefObject, forwardRef, useRef, useState } from "react"; import { observer } from "mobx-react"; -import { cn } from "@plane/editor"; -// plane +// plane types import { IGroupByColumn, TIssueGroupByOptions, IIssueDisplayProperties, TPaginationData, TLoader } from "@plane/types"; +// plane utils +import { cn } from "@plane/utils"; // hooks import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; // 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 3c596cb53c9..66e9ab2960c 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, diff --git a/space/core/components/issues/issue-layouts/properties/cycle.tsx b/space/core/components/issues/issue-layouts/properties/cycle.tsx index 52c10578978..1774781cd39 100644 --- a/space/core/components/issues/issue-layouts/properties/cycle.tsx +++ b/space/core/components/issues/issue-layouts/properties/cycle.tsx @@ -1,9 +1,10 @@ "use client"; import { observer } from "mobx-react"; -// ui -import { cn } from "@plane/editor"; +// plane ui import { ContrastIcon, Tooltip } from "@plane/ui"; +// plane utils +import { cn } from "@plane/utils"; //hooks import { useCycle } from "@/hooks/store/use-cycle"; diff --git a/space/core/components/issues/issue-layouts/properties/member.tsx b/space/core/components/issues/issue-layouts/properties/member.tsx index bac44d52322..9ae3314f180 100644 --- a/space/core/components/issues/issue-layouts/properties/member.tsx +++ b/space/core/components/issues/issue-layouts/properties/member.tsx @@ -3,9 +3,10 @@ import { observer } from "mobx-react"; // icons import { LucideIcon, Users } from "lucide-react"; -// ui -import { cn } from "@plane/editor"; +// plane ui import { Avatar, AvatarGroup } from "@plane/ui"; +// plane utils +import { cn } from "@plane/utils"; // hooks import { useMember } from "@/hooks/store/use-member"; // diff --git a/space/core/components/issues/issue-layouts/properties/modules.tsx b/space/core/components/issues/issue-layouts/properties/modules.tsx index eaa30d9908b..465f469c0b4 100644 --- a/space/core/components/issues/issue-layouts/properties/modules.tsx +++ b/space/core/components/issues/issue-layouts/properties/modules.tsx @@ -1,9 +1,10 @@ "use client"; import { observer } from "mobx-react"; -// planes -import { cn } from "@plane/editor"; +// plane ui import { DiceIcon, Tooltip } from "@plane/ui"; +// plane utils +import { cn } from "@plane/utils"; // hooks import { useModule } from "@/hooks/store/use-module"; diff --git a/space/core/components/issues/issue-layouts/properties/priority.tsx b/space/core/components/issues/issue-layouts/properties/priority.tsx index efaa8ea36ac..7b4bbda7714 100644 --- a/space/core/components/issues/issue-layouts/properties/priority.tsx +++ b/space/core/components/issues/issue-layouts/properties/priority.tsx @@ -4,7 +4,7 @@ import { TIssuePriorities } from "@plane/types"; import { Tooltip } from "@plane/ui"; // constants -import { issuePriorityFilter } from "@/constants/issue"; +import { getIssuePriorityFilters } from "@plane/utils"; export const IssueBlockPriority = ({ priority, @@ -13,7 +13,7 @@ export const IssueBlockPriority = ({ priority: TIssuePriorities | null; shouldShowName?: boolean; }) => { - const priority_detail = priority != null ? issuePriorityFilter(priority) : null; + const priority_detail = priority != null ? getIssuePriorityFilters(priority) : null; if (priority_detail === null) return <>; diff --git a/space/core/components/issues/issue-layouts/properties/state.tsx b/space/core/components/issues/issue-layouts/properties/state.tsx index 56a09bcd99f..1d2d1c6fe87 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 992f6367c93..4bd85433ee8 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"; diff --git a/space/core/components/issues/navbar/layout-selection.tsx b/space/core/components/issues/navbar/layout-selection.tsx index 2d1465c78cb..1b8344d0242 100644 --- a/space/core/components/issues/navbar/layout-selection.tsx +++ b/space/core/components/issues/navbar/layout-selection.tsx @@ -4,9 +4,8 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { useRouter, useSearchParams } from "next/navigation"; // ui +import { SITES_ISSUE_LAYOUTS } from "@plane/constants"; import { Tooltip } from "@plane/ui"; -// constants -import { ISSUE_LAYOUTS } from "@/constants/issue"; // helpers import { queryParamGenerator } from "@/helpers/query-param-generator"; // hooks @@ -42,7 +41,7 @@ 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 40339bb5c04..4c41677fc7e 100644 --- a/space/core/components/issues/navbar/user-avatar.tsx +++ b/space/core/components/issues/navbar/user-avatar.tsx @@ -7,9 +7,9 @@ import { usePathname, useSearchParams } from "next/navigation"; import { usePopper } from "react-popper"; import { LogOut } from "lucide-react"; import { Popover, Transition } from "@headlessui/react"; +import { API_BASE_URL } from "@plane/constants"; import { Avatar, Button } from "@plane/ui"; // helpers -import { API_BASE_URL } from "@/helpers/common.helper"; import { getFileURL } from "@/helpers/file.helper"; import { queryParamGenerator } from "@/helpers/query-param-generator"; // hooks diff --git a/space/core/components/issues/peek-overview/issue-properties.tsx b/space/core/components/issues/peek-overview/issue-properties.tsx index 0749f8519b4..38dd7ef6f9f 100644 --- a/space/core/components/issues/peek-overview/issue-properties.tsx +++ b/space/core/components/issues/peek-overview/issue-properties.tsx @@ -5,10 +5,9 @@ import { useParams } from "next/navigation"; import { CalendarCheck2, Signal } from "lucide-react"; // 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"; @@ -32,7 +31,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; diff --git a/space/core/components/views/auth.tsx b/space/core/components/views/auth.tsx index 2c5a8a2f4de..39d830a92a2 100644 --- a/space/core/components/views/auth.tsx +++ b/space/core/components/views/auth.tsx @@ -4,15 +4,15 @@ import { observer } from "mobx-react"; import Image from "next/image"; import Link from "next/link"; import { useTheme } from "next-themes"; +import { SPACE_BASE_PATH } from "@plane/constants"; // components import { AuthRoot } from "@/components/account"; -// helpers -import { SPACE_BASE_PATH } from "@/helpers/common.helper"; +import { PoweredBy } from "@/components/common"; // 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"; +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; export const AuthView = observer(() => { // hooks @@ -41,6 +41,7 @@ export const AuthView = observer(() => {
+
); }); diff --git a/space/core/constants/editor.ts b/space/core/constants/editor.ts deleted file mode 100644 index 6089c56046c..00000000000 --- 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 1d9ebbb19c6..00000000000 --- 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 f681ab8b25d..00000000000 --- 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/lib/instance-provider.tsx b/space/core/lib/instance-provider.tsx index 4f28dbcf9b5..06056364f37 100644 --- a/space/core/lib/instance-provider.tsx +++ b/space/core/lib/instance-provider.tsx @@ -6,18 +6,17 @@ import Image from "next/image"; import Link from "next/link"; import { useTheme } from "next-themes"; import useSWR from "swr"; +import { SPACE_BASE_PATH } from "@plane/constants"; // components import { LogoSpinner } from "@/components/common"; import { InstanceFailureView } from "@/components/instance"; -// helpers -import { SPACE_BASE_PATH } from "@/helpers/common.helper"; // hooks import { useInstance, useUser } from "@/hooks/store"; // assets -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"; +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"; export const InstanceProvider = observer(({ children }: { children: ReactNode }) => { const { fetchInstanceInfo, instance, error } = useInstance(); @@ -69,5 +68,5 @@ export const InstanceProvider = observer(({ children }: { children: ReactNode }) ); } - return <>{children}; + return children; }); diff --git a/space/core/lib/store-provider.tsx b/space/core/lib/store-provider.tsx index c1256ddc29b..b810c10562f 100644 --- a/space/core/lib/store-provider.tsx +++ b/space/core/lib/store-provider.tsx @@ -9,13 +9,8 @@ let rootStore = new RootStore(); export const StoreContext = createContext(rootStore); -function initializeStore(initialData = {}) { +function initializeStore() { const singletonRootStore = rootStore ?? new RootStore(); - // If your page has Next.js data fetching methods that use a Mobx store, it will - // get hydrated here, check `pages/ssg.js` and `pages/ssr.js` for more details - if (initialData) { - singletonRootStore.hydrate(initialData); - } // For SSG and SSR always create a new store if (typeof window === "undefined") return singletonRootStore; // Create the store once in the client @@ -29,8 +24,14 @@ export type StoreProviderProps = { initialState?: any; }; -export const StoreProvider = ({ children, initialState = {} }: StoreProviderProps) => { - const store = initializeStore(initialState); +export const StoreProvider = ({ children, initialState = undefined }: StoreProviderProps) => { + const store = initializeStore(); + // If your page has Next.js data fetching methods that use a Mobx store, it will + // get hydrated here, check `pages/ssg.js` and `pages/ssr.js` for more details + if (initialState) { + store.hydrate(initialState); + } + return ( {children} diff --git a/space/core/services/auth.service.ts b/space/core/services/auth.service.ts index 060da53f4a0..3bbfd149e62 100644 --- a/space/core/services/auth.service.ts +++ b/space/core/services/auth.service.ts @@ -1,5 +1,4 @@ -// helpers -import { API_BASE_URL } from "@/helpers/common.helper"; +import { API_BASE_URL } from "@plane/constants"; // services import { APIService } from "@/services/api.service"; // types diff --git a/space/core/services/cycle.service.ts b/space/core/services/cycle.service.ts index 6df75ebde12..7d4ff9a10fd 100644 --- a/space/core/services/cycle.service.ts +++ b/space/core/services/cycle.service.ts @@ -1,5 +1,7 @@ -import { API_BASE_URL } from "@/helpers/common.helper"; +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 { diff --git a/space/core/services/file.service.ts b/space/core/services/file.service.ts index 168738804e2..0b4807affb5 100644 --- a/space/core/services/file.service.ts +++ b/space/core/services/file.service.ts @@ -1,7 +1,6 @@ -// plane types +import { API_BASE_URL } from "@plane/constants"; import { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types"; // helpers -import { API_BASE_URL } from "@/helpers/common.helper"; import { generateFileUploadPayload, getAssetIdFromUrl, getFileMetaDataForUpload } from "@/helpers/file.helper"; // services import { APIService } from "@/services/api.service"; diff --git a/space/core/services/instance.service.ts b/space/core/services/instance.service.ts index a11599b0c73..100929955e0 100644 --- a/space/core/services/instance.service.ts +++ b/space/core/services/instance.service.ts @@ -1,7 +1,5 @@ -// types +import { API_BASE_URL } from "@plane/constants"; import type { IInstanceInfo } from "@plane/types"; -// helpers -import { API_BASE_URL } from "@/helpers/common.helper"; // services import { APIService } from "@/services/api.service"; diff --git a/space/core/services/issue.service.ts b/space/core/services/issue.service.ts index b5ecb807782..8ec67ee45f9 100644 --- a/space/core/services/issue.service.ts +++ b/space/core/services/issue.service.ts @@ -1,4 +1,4 @@ -import { API_BASE_URL } from "@/helpers/common.helper"; +import { API_BASE_URL } from "@plane/constants"; // services import { APIService } from "@/services/api.service"; // types diff --git a/space/core/services/label.service.ts b/space/core/services/label.service.ts index 2a2ee5ad979..3b5585578bc 100644 --- a/space/core/services/label.service.ts +++ b/space/core/services/label.service.ts @@ -1,5 +1,6 @@ +import { API_BASE_URL } from "@plane/constants"; import { IIssueLabel } from "@plane/types"; -import { API_BASE_URL } from "@/helpers/common.helper"; +// services import { APIService } from "./api.service"; export class LabelService extends APIService { diff --git a/space/core/services/member.service.ts b/space/core/services/member.service.ts index 02cd1f77620..9de19455b16 100644 --- a/space/core/services/member.service.ts +++ b/space/core/services/member.service.ts @@ -1,5 +1,7 @@ -import { API_BASE_URL } from "@/helpers/common.helper"; +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 { diff --git a/space/core/services/module.service.ts b/space/core/services/module.service.ts index f89202b6b6c..30d6ebecf17 100644 --- a/space/core/services/module.service.ts +++ b/space/core/services/module.service.ts @@ -1,5 +1,7 @@ -import { API_BASE_URL } from "@/helpers/common.helper"; +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 { diff --git a/space/core/services/project-member.service.ts b/space/core/services/project-member.service.ts index 722380efafe..bac52e75136 100644 --- a/space/core/services/project-member.service.ts +++ b/space/core/services/project-member.service.ts @@ -1,6 +1,5 @@ -// types +import { API_BASE_URL } from "@plane/constants"; import type { IProjectMember, IProjectMembership } from "@plane/types"; -import { API_BASE_URL } from "@/helpers/common.helper"; // services import { APIService } from "@/services/api.service"; diff --git a/space/core/services/publish.service.ts b/space/core/services/publish.service.ts index 896f36ee991..3da72f59a94 100644 --- a/space/core/services/publish.service.ts +++ b/space/core/services/publish.service.ts @@ -1,7 +1,5 @@ -// types +import { API_BASE_URL } from "@plane/constants"; import { TProjectPublishSettings } from "@plane/types"; -// helpers -import { API_BASE_URL } from "@/helpers/common.helper"; // services import { APIService } from "@/services/api.service"; diff --git a/space/core/services/state.service.ts b/space/core/services/state.service.ts index 153f965280d..b877ac530c3 100644 --- a/space/core/services/state.service.ts +++ b/space/core/services/state.service.ts @@ -1,5 +1,6 @@ +import { API_BASE_URL } from "@plane/constants"; import { IState } from "@plane/types"; -import { API_BASE_URL } from "@/helpers/common.helper"; +// services import { APIService } from "./api.service"; export class StateService extends APIService { diff --git a/space/core/services/user.service.ts b/space/core/services/user.service.ts index 1aeb134668a..a00b1a3507f 100644 --- a/space/core/services/user.service.ts +++ b/space/core/services/user.service.ts @@ -1,7 +1,5 @@ -// types +import { API_BASE_URL } from "@plane/constants"; import { IUser, TUserProfile } from "@plane/types"; -// helpers -import { API_BASE_URL } from "@/helpers/common.helper"; // services import { APIService } from "@/services/api.service"; diff --git a/space/core/store/helpers/base-issues.store.ts b/space/core/store/helpers/base-issues.store.ts index 004aa06c630..7abfa324a8d 100644 --- a/space/core/store/helpers/base-issues.store.ts +++ b/space/core/store/helpers/base-issues.store.ts @@ -26,7 +26,7 @@ import { CoreRootStore } from "../root.store"; // constants // helpers -export type TIssueDisplayFilterOptions = Exclude | "target_date"; +export type TIssueDisplayFilterOptions = Exclude | "target_date"; export enum EIssueGroupedAction { ADD = "ADD", diff --git a/space/core/store/issue-filters.store.ts b/space/core/store/issue-filters.store.ts index 0c589dc4d3b..be9ca438b2c 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/mentions.store.ts b/space/core/store/mentions.store.ts deleted file mode 100644 index 977df422138..00000000000 --- 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/root.store.ts b/space/core/store/root.store.ts index de43001d2c9..db9e26566b2 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/ee/components/editor/index.ts b/space/ee/components/editor/index.ts new file mode 100644 index 00000000000..f8506c1d6d1 --- /dev/null +++ b/space/ee/components/editor/index.ts @@ -0,0 +1 @@ +export * from "ce/components/editor"; diff --git a/space/helpers/common.helper.ts b/space/helpers/common.helper.ts index 6db73a4a100..3ffc59573c5 100644 --- a/space/helpers/common.helper.ts +++ b/space/helpers/common.helper.ts @@ -1,21 +1,8 @@ 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_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || ""; -export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || ""; - -export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || ""; - export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || ""; -export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || ""; - -export const GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${ADMIN_BASE_PATH}`); - -export const ASSET_PREFIX = SPACE_BASE_PATH; - export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); export const resolveGeneralTheme = (resolvedTheme: string | undefined) => diff --git a/space/helpers/editor.helper.ts b/space/helpers/editor.helper.ts index b037055afeb..15891cfb1b8 100644 --- a/space/helpers/editor.helper.ts +++ b/space/helpers/editor.helper.ts @@ -1,7 +1,7 @@ -// plane editor +// plane internal +import { MAX_FILE_SIZE } from "@plane/constants"; import { TFileHandler } from "@plane/editor"; -// constants -import { MAX_FILE_SIZE } from "@/constants/common"; + // helpers import { getFileURL } from "@/helpers/file.helper"; // services diff --git a/space/helpers/file.helper.ts b/space/helpers/file.helper.ts index b149ebc7cf0..a94ed7efe9e 100644 --- a/space/helpers/file.helper.ts +++ b/space/helpers/file.helper.ts @@ -1,7 +1,5 @@ -// plane types +import { API_BASE_URL } from "@plane/constants"; import { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types"; -// helpers -import { API_BASE_URL } from "@/helpers/common.helper"; /** * @description from the provided signed URL response, generate a payload to be used to upload the file diff --git a/space/helpers/issue.helper.ts b/space/helpers/issue.helper.ts index a5159edef27..a7129ca495e 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 81bffdef960..8d97c39f617 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/next.config.js b/space/next.config.js index d18ce805f4d..58b6cfa0be8 100644 --- a/space/next.config.js +++ b/space/next.config.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires /* eslint-disable @typescript-eslint/no-var-requires */ /** @type {import('next').NextConfig} */ require("dotenv").config({ path: ".env" }); @@ -28,7 +29,6 @@ const nextConfig = { }, }; - const sentryConfig = { // For all available options, see: // https://github.com/getsentry/sentry-webpack-plugin#options @@ -62,12 +62,10 @@ const sentryConfig = { // https://docs.sentry.io/product/crons/ // https://vercel.com/docs/cron-jobs automaticVercelMonitors: true, -} - +}; if (parseInt(process.env.SENTRY_MONITORING_ENABLED || "0", 10)) { module.exports = withSentryConfig(nextConfig, sentryConfig); } else { module.exports = nextConfig; } - diff --git a/space/package.json b/space/package.json index 7d5f375dd51..2468e2e21ab 100644 --- a/space/package.json +++ b/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "0.24.0", + "version": "0.24.1", "private": true, "scripts": { "dev": "turbo run develop", @@ -25,7 +25,7 @@ "@sentry/nextjs": "^8.32.0", "axios": "^1.7.4", "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", @@ -34,7 +34,7 @@ "mobx": "^6.10.0", "mobx-react": "^9.1.1", "mobx-utils": "^6.0.8", - "next": "^14.2.12", + "next": "^14.2.20", "next-themes": "^0.2.1", "nprogress": "^0.2.0", "react": "^18.3.1", diff --git a/web/.prettierignore b/web/.prettierignore index 43e8a7b8ffb..e841c6b328b 100644 --- a/web/.prettierignore +++ b/web/.prettierignore @@ -1,6 +1,5 @@ .next -.vercel -.tubro +.turbo out/ -dis/ +dist/ build/ \ No newline at end of file diff --git a/web/app/[workspaceSlug]/(projects)/drafts/header.tsx b/web/app/[workspaceSlug]/(projects)/drafts/header.tsx index f77e61c3199..68597b509a0 100644 --- a/web/app/[workspaceSlug]/(projects)/drafts/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/drafts/header.tsx @@ -3,13 +3,13 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { PenSquare } from "lucide-react"; +import { EIssuesStoreType } from "@plane/constants"; // 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 diff --git a/web/app/[workspaceSlug]/(projects)/layout.tsx b/web/app/[workspaceSlug]/(projects)/layout.tsx index f8fe0f8f9e0..340ec57d0d0 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 b69da040a07..e3d73036361 100644 --- a/web/app/[workspaceSlug]/(projects)/notifications/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/notifications/layout.tsx @@ -1,12 +1,12 @@ "use client"; // components -import { NotificationsSidebar } from "@/plane-web/components/workspace-notifications"; +import { NotificationsSidebarRoot } from "@/components/workspace-notifications"; export default function ProjectInboxIssuesLayout({ children }: { children: React.ReactNode }) { return (
- +
{children}
); diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx index b963ca147c9..21d230347c9 100644 --- a/web/app/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx @@ -5,6 +5,8 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // icons import { ChevronDown } from "lucide-react"; +// plane constants +import { EIssueLayoutTypes, EIssueFilterType, EIssuesStoreType } from "@plane/constants"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; // ui @@ -12,13 +14,7 @@ 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 { ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; // helpers import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks 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 f95611fbc45..17df19b2201 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx @@ -3,13 +3,13 @@ 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"; // components import { BreadcrumbLink, Logo } from "@/components/common"; // constants import { PROJECT_ARCHIVES_BREADCRUMB_LIST } from "@/constants/archives"; -import { EIssuesStoreType } from "@/constants/issue"; // hooks import { useIssues, useProject } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; 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 a1f7071a449..37bfae3b1b4 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 @@ -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 678c50c2a8f..dc079bc949d 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,6 +6,8 @@ import Link from "next/link"; import { useParams } from "next/navigation"; // icons import { ArrowRight, PanelRight } from "lucide-react"; +// plane constants +import { EIssueLayoutTypes, EIssueFilterType, EIssuesStoreType } from "@plane/constants"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui @@ -15,12 +17,7 @@ import { ProjectAnalyticsModal } from "@/components/analytics"; import { BreadcrumbLink, Logo } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; // constants -import { - EIssueFilterType, - EIssueLayoutTypes, - EIssuesStoreType, - ISSUE_DISPLAY_FILTERS_BY_LAYOUT, -} from "@/constants/issue"; +import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; // helpers import { cn } from "@/helpers/common.helper"; import { isIssueFilterActive } from "@/helpers/filter.helper"; 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 aa81ae5816a..1f85665fa86 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,6 +4,8 @@ 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 } from "@plane/constants"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui @@ -12,13 +14,7 @@ import { CustomMenu } from "@plane/ui"; 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 { ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; // helpers import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks 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 c1e866d0348..59c5e995bf5 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,6 +3,8 @@ import { FC, useCallback } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; +// plane constants +import { EIssueLayoutTypes, EIssueFilterType, EIssuesStoreType } from "@plane/constants"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui @@ -11,12 +13,7 @@ import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui"; import { BreadcrumbLink, Logo } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; // constants -import { - EIssueFilterType, - EIssuesStoreType, - EIssueLayoutTypes, - ISSUE_DISPLAY_FILTERS_BY_LAYOUT, -} from "@/constants/issue"; +import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; // helpers import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks 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 1b1cffcc64a..c3aacaebb0a 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 025c9fab04f..29f2637b76e 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,6 +5,8 @@ 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 } from "@plane/constants"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui @@ -13,13 +15,7 @@ import { CustomMenu } from "@plane/ui"; 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"; +import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; // helpers import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks 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 38c2f4453fd..e84cef04fdc 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,6 +6,8 @@ import Link from "next/link"; import { useParams } from "next/navigation"; // icons import { ArrowRight, PanelRight } from "lucide-react"; +// plane constants +import { EIssueLayoutTypes, EIssuesStoreType, EIssueFilterType } from "@plane/constants"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui @@ -15,12 +17,7 @@ import { ProjectAnalyticsModal } from "@/components/analytics"; 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 { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; // helpers import { cn } from "@/helpers/common.helper"; import { isIssueFilterActive } from "@/helpers/filter.helper"; 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 0edfa2b5a2b..63cf32ef6ef 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,17 @@ 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 } from "@plane/constants"; // 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"; +import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues/issue-layouts"; // constants -import { - EIssueFilterType, - EIssueLayoutTypes, - EIssuesStoreType, - ISSUE_DISPLAY_FILTERS_BY_LAYOUT, - ISSUE_LAYOUTS, -} from "@/constants/issue"; +import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; // helpers import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks 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 6fd44adfcd9..aca4b5b4a5e 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 @@ -5,6 +5,8 @@ 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 } from "@plane/constants"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui @@ -13,12 +15,7 @@ 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 { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; import { EViewAccess } from "@/constants/views"; // helpers import { isIssueFilterActive } from "@/helpers/filter.helper"; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx index fc2ec0075f3..cdd4f708077 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx @@ -1,8 +1,8 @@ "use client"; import { ReactNode } from "react"; -// layouts -import { ProjectAuthWrapper } from "@/layouts/auth-layout"; +// plane web layouts +import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper"; const ProjectDetailLayout = ({ children }: { children: ReactNode }) => ( {children} diff --git a/web/app/[workspaceSlug]/(projects)/sidebar.tsx b/web/app/[workspaceSlug]/(projects)/sidebar.tsx index 18451d4bbd7..11d23e35f05 100644 --- a/web/app/[workspaceSlug]/(projects)/sidebar.tsx +++ b/web/app/[workspaceSlug]/(projects)/sidebar.tsx @@ -2,7 +2,7 @@ import { FC, useEffect, useRef } from "react"; import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react"; // plane helpers -import { useOutsideClickDetector } from "@plane/helpers"; +import { useOutsideClickDetector } from "@plane/hooks"; // components import { SidebarDropdown, @@ -21,6 +21,7 @@ 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 { SidebarTeamsList } from "@/plane-web/components/workspace/sidebar/teams-sidebar-list"; import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; export const AppSidebar: FC = observer(() => { @@ -47,7 +48,7 @@ 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]); @@ -73,9 +74,12 @@ export const AppSidebar: FC = observer(() => { "px-4": !sidebarCollapsed, })} > + {/* Workspace switcher and settings */}
- + {/* App switcher */} + {canPerformWorkspaceMemberActions && } + {/* Quick actions */}

{ "vertical-scrollbar px-4": !sidebarCollapsed, })} > + {/* User Menu */} - + {/* Workspace Menu */}
+ {/* Favorites Menu */} {canPerformWorkspaceMemberActions && !isFavoriteEmpty && } - + {/* Teams List */} + + {/* Projects List */} + {/* Help Section */} diff --git a/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx b/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx index 500d23ed701..e1ec683ec3a 100644 --- a/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx @@ -3,8 +3,10 @@ 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 } from "@plane/constants"; +// types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { Breadcrumbs, Button, Header } from "@plane/ui"; @@ -13,7 +15,7 @@ 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"; +import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; // helpers import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks diff --git a/web/app/profile/page.tsx b/web/app/profile/page.tsx index 1dd9702a36b..ea02ab56bc4 100644 --- a/web/app/profile/page.tsx +++ b/web/app/profile/page.tsx @@ -5,7 +5,7 @@ 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 type { IUser, TUserProfile } from "@plane/types"; import { Button, CustomSelect, @@ -20,14 +20,14 @@ import { import { DeactivateAccountModal } from "@/components/account"; import { LogoSpinner } from "@/components/common"; import { ImagePickerPopover, UserImageUploadModal, PageHead } from "@/components/core"; +import { TimezoneSelect } from "@/components/global"; import { ProfileSettingContentWrapper } from "@/components/profile"; // constants -import { TIME_ZONES, TTimezone } from "@/constants/timezones"; import { USER_ROLES } from "@/constants/workspace"; // helpers import { getFileURL } from "@/helpers/file.helper"; // hooks -import { useUser } from "@/hooks/store"; +import { useUser, useUserProfile } from "@/hooks/store"; const defaultValues: Partial = { avatar_url: "", @@ -59,29 +59,34 @@ const ProfileSettingsPage = observer(() => { const userCover = watch("cover_image_url"); // store hooks const { data: currentUser, updateCurrentUser } = useUser(); + const { updateUserProfile, data: currentUserProfile } = useUserProfile(); useEffect(() => { - reset({ ...defaultValues, ...currentUser }); - }, [currentUser, reset]); + reset({ ...defaultValues, ...currentUser, ...currentUserProfile }); + }, [currentUser, currentUserProfile, reset]); const onSubmit = async (formData: IUser) => { setIsLoading(true); - const payload: Partial = { + const userPayload: 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, }; + const userProfilePayload: Partial = { + role: formData.role ?? undefined, + }; // 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; + userPayload.cover_image = formData.cover_image_url; + userPayload.cover_image_asset = null; } - const updateCurrentUserDetail = updateCurrentUser(payload).finally(() => setIsLoading(false)); - setPromiseToast(updateCurrentUserDetail, { + const updateUser = Promise.all([updateCurrentUser(userPayload), updateUserProfile(userProfilePayload)]).finally( + () => setIsLoading(false) + ); + setPromiseToast(updateUser, { loading: "Updating...", success: { title: "Success!", @@ -120,22 +125,6 @@ const ProfileSettingsPage = observer(() => { }); }; - 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), - })); - if (!currentUser) return (
@@ -379,19 +368,12 @@ const ProfileSettingsPage = observer(() => { control={control} rules={{ required: "Please select a timezone" }} render={({ field: { value, onChange } }) => ( - 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 + onChange={(value: string) => { + onChange(value); + }} + error={Boolean(errors.user_timezone)} /> )} /> diff --git a/web/app/profile/sidebar.tsx b/web/app/profile/sidebar.tsx index aec3e24dcaa..479ef21f515 100644 --- a/web/app/profile/sidebar.tsx +++ b/web/app/profile/sidebar.tsx @@ -7,7 +7,7 @@ import { usePathname } from "next/navigation"; // icons import { ChevronLeft, LogOut, MoveLeft, Plus, UserPlus } from "lucide-react"; // plane helpers -import { useOutsideClickDetector } from "@plane/helpers"; +import { useOutsideClickDetector } from "@plane/hooks"; // ui import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; // components 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 00000000000..a4fac4b91ef --- /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 00000000000..84a7dddc643 --- /dev/null +++ b/web/ce/components/command-palette/modals/issue-level.tsx @@ -0,0 +1,73 @@ +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +import useSWR from "swr"; +// components +import { BulkDeleteIssuesModal } from "@/components/core"; +import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; +// constants +import { ISSUE_DETAILS } from "@/constants/fetch-keys"; +// hooks +import { useCommandPalette, useUser } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useIssuesStore } from "@/hooks/use-issue-layout-store"; +// services +import { IssueService } from "@/services/issue"; + +// services +const issueService = new IssueService(); + +export const IssueLevelModals = observer(() => { + // router + const pathname = usePathname(); + const { workspaceSlug, projectId, issueId, cycleId, moduleId } = useParams(); + const router = useAppRouter(); + // store hooks + const { data: currentUser } = useUser(); + const { + issues: { removeIssue }, + } = useIssuesStore(); + const { + isCreateIssueModalOpen, + toggleCreateIssueModal, + isDeleteIssueModalOpen, + toggleDeleteIssueModal, + isBulkDeleteIssueModalOpen, + toggleBulkDeleteIssueModal, + } = useCommandPalette(); + // derived values + const isDraftIssue = pathname?.includes("draft-issues") || false; + + 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) + : null + ); + + 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 00000000000..324af8d4834 --- /dev/null +++ b/web/ce/components/command-palette/modals/project-level.tsx @@ -0,0 +1,59 @@ +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"; + +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 + /> + + ); +}); 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 00000000000..64d22493e1d --- /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/cycles/active-cycle/root.tsx b/web/ce/components/cycles/active-cycle/root.tsx index a173cfda03a..5ebddc63f23 100644 --- a/web/ce/components/cycles/active-cycle/root.tsx +++ b/web/ce/components/cycles/active-cycle/root.tsx @@ -1,5 +1,6 @@ "use client"; +import { useMemo } from "react"; import { observer } from "mobx-react"; import { Disclosure } from "@headlessui/react"; // ui @@ -22,68 +23,80 @@ 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; + const { currentProjectActiveCycleId } = useCycle(); + // derived values + const cycleId = propsCycleId ?? currentProjectActiveCycleId; + // 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/editor/embeds/index.ts b/web/ce/components/editor/embeds/index.ts new file mode 100644 index 00000000000..8146e94d927 --- /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 00000000000..1efe34c51ec --- /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 00000000000..16e21f848de --- /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 00000000000..cf8352ae4a8 --- /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 00000000000..031608e25ff --- /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 00000000000..9c76b7bdab8 --- /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 00000000000..29da0cc8acc --- /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 f049875f113..6feb208a8b7 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/issue-types/values/update.tsx b/web/ce/components/issue-types/values/update.tsx index cff391d9ea9..2fd62904266 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 2cd80e3a7e5..f0f36b6c97e 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 00000000000..4f4787fef8b --- /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 00000000000..51dbc3b4174 --- /dev/null +++ b/web/ce/components/issues/header.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// icons +import { Briefcase, Circle, ExternalLink } from "lucide-react"; +// plane constants +import { EIssuesStoreType } from "@plane/constants"; +// 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"; +// 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 IssuesHeader = 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 ? ( + + ) : ( + <> + )} +
+
+ ); +}); diff --git a/web/ce/components/issues/index.ts b/web/ce/components/issues/index.ts index 97b57af4b0c..01fc1d9acfa 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-layouts/empty-states/index.ts b/web/ce/components/issues/issue-layouts/empty-states/index.ts new file mode 100644 index 00000000000..319b4c68440 --- /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 00000000000..1e05f40cbd9 --- /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 00000000000..03b546be53b --- /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/utils.tsx b/web/ce/components/issues/issue-layouts/utils.tsx new file mode 100644 index 00000000000..48dca43bd92 --- /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 51880a6ba1b..72daa259e17 100644 --- a/web/ce/components/issues/quick-add/root.tsx +++ b/web/ce/components/issues/quick-add/root.tsx @@ -1,8 +1,10 @@ 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/helpers"; +import { useOutsideClickDetector } from "@plane/hooks"; // types import { TIssue } from "@plane/types"; // components @@ -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/sidebar/index.ts b/web/ce/components/sidebar/index.ts index 5cda1afb5fe..129f4202072 100644 --- a/web/ce/components/sidebar/index.ts +++ b/web/ce/components/sidebar/index.ts @@ -1 +1,2 @@ export * from "./app-switcher"; +export * from "./project-navigation-root"; diff --git a/web/ce/components/sidebar/project-navigation-root.tsx b/web/ce/components/sidebar/project-navigation-root.tsx new file mode 100644 index 00000000000..25a0dd9d8ca --- /dev/null +++ b/web/ce/components/sidebar/project-navigation-root.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { FC } from "react"; +// components +import { ProjectNavigation } from "@/components/workspace"; + +type TProjectItemsRootProps = { + workspaceSlug: string; + projectId: string; +}; + +export const ProjectNavigationRoot: FC = (props) => { + const { workspaceSlug, projectId } = props; + return ; +}; diff --git a/web/ce/components/workflow/add-state-transition.tsx b/web/ce/components/workflow/add-state-transition.tsx new file mode 100644 index 00000000000..a22769ba254 --- /dev/null +++ b/web/ce/components/workflow/add-state-transition.tsx @@ -0,0 +1,20 @@ +import { Plus } from "lucide-react"; +// plane utils +import { cn } from "@plane/utils"; + +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.tsx new file mode 100644 index 00000000000..3cf9d8d3f46 --- /dev/null +++ b/web/ce/components/workflow/index.tsx @@ -0,0 +1,6 @@ +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"; diff --git a/web/ce/components/workflow/state-item-child.tsx b/web/ce/components/workflow/state-item-child.tsx new file mode 100644 index 00000000000..2c07fd9ff00 --- /dev/null +++ b/web/ce/components/workflow/state-item-child.tsx @@ -0,0 +1,39 @@ +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 new file mode 100644 index 00000000000..aa9665d90b9 --- /dev/null +++ b/web/ce/components/workflow/state-option.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +import { Check } from "lucide-react"; +import { Combobox } from "@headlessui/react"; + +type Props = { + projectId: string | null | undefined; + option: { + value: string | undefined; + query: string; + content: JSX.Element; + }; + filterAvailableStateIds: boolean; + selectedValue: string | null | undefined; + className?: string; +}; + +export const StateOption = observer((props: Props) => { + const { option, className = "" } = props; + + return ( + + `${className} ${active ? "bg-custom-background-80" : ""} ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + ); +}); diff --git a/web/ce/components/workflow/state-transition-count.tsx b/web/ce/components/workflow/state-transition-count.tsx new file mode 100644 index 00000000000..b9e4a22f9db --- /dev/null +++ b/web/ce/components/workflow/state-transition-count.tsx @@ -0,0 +1,7 @@ +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 new file mode 100644 index 00000000000..e4044d843b8 --- /dev/null +++ b/web/ce/components/workflow/use-workflow-drag-n-drop.ts @@ -0,0 +1,15 @@ +import { TIssueGroupByOptions } from "@plane/types"; + +export const useWorkFlowFDragNDrop = ( + groupBy: TIssueGroupByOptions | undefined, + subGroupBy?: TIssueGroupByOptions +) => ({ + workflowDisabledSource: undefined, + isWorkflowDropDisabled: false, + handleWorkFlowState: ( + sourceGroupId: string, + destinationGroupId: string, + sourceSubGroupId?: string, + destinationSubGroupId?: string + ) => {}, +}); diff --git a/web/ce/components/workflow/workflow-disabled-message.tsx b/web/ce/components/workflow/workflow-disabled-message.tsx new file mode 100644 index 00000000000..bc2c2ee535f --- /dev/null +++ b/web/ce/components/workflow/workflow-disabled-message.tsx @@ -0,0 +1,6 @@ +type Props = { + parentStateId: string; + className?: string; +}; + +export const WorkFlowDisabledMessage = (props: Props) => <>; diff --git a/web/ce/components/workflow/workflow-group-tree.tsx b/web/ce/components/workflow/workflow-group-tree.tsx new file mode 100644 index 00000000000..934db70f3ca --- /dev/null +++ b/web/ce/components/workflow/workflow-group-tree.tsx @@ -0,0 +1,8 @@ +import { TIssueGroupByOptions } from "@plane/types"; + +type Props = { + groupBy?: TIssueGroupByOptions; + groupId: string | undefined; +}; + +export const WorkFlowGroupTree = (props: Props) => <>; diff --git a/web/ce/components/workspace-notifications/index.ts b/web/ce/components/workspace-notifications/index.ts index c8711b96a4c..18c4afa968e 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 100% rename from web/core/components/workspace-notifications/sidebar/notification-card/root.tsx rename to web/ce/components/workspace-notifications/notification-card/root.tsx 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 00000000000..92cbdfc5f5f --- /dev/null +++ b/web/ce/components/workspace/sidebar/teams-sidebar-list.tsx @@ -0,0 +1 @@ +export const SidebarTeamsList = () => null; diff --git a/web/ce/constants/dashboard.ts b/web/ce/constants/dashboard.ts index 8872982fc5b..0df2719a772 100644 --- a/web/ce/constants/dashboard.ts +++ b/web/ce/constants/dashboard.ts @@ -1,17 +1,19 @@ "use client"; // icons -import { Home, Inbox, PenSquare } from "lucide-react"; +import { Briefcase, Home, Inbox, Layers, PenSquare, BarChart2 } from "lucide-react"; // ui -import { UserActivityIcon } from "@plane/ui"; +import { UserActivityIcon, ContrastIcon } from "@plane/ui"; import { Props } from "@/components/icons/types"; +// constants import { TLinkOptions } from "@/constants/dashboard"; +// plane web constants import { EUserPermissions } from "@/plane-web/constants/user-permissions"; // plane web types -import { TSidebarUserMenuItemKeys } from "@/plane-web/types/dashboard"; +import { TSidebarUserMenuItemKeys, TSidebarWorkspaceMenuItemKeys } from "@/plane-web/types/dashboard"; -export type TSidebarUserMenuItems = { - key: TSidebarUserMenuItemKeys; +export type TSidebarMenuItems = { + key: T; label: string; href: string; access: EUserPermissions[]; @@ -19,6 +21,8 @@ export type TSidebarUserMenuItems = { Icon: React.FC; }; +export type TSidebarUserMenuItems = TSidebarMenuItems; + export const SIDEBAR_USER_MENU_ITEMS: TSidebarUserMenuItems[] = [ { key: "home", @@ -54,3 +58,47 @@ export const SIDEBAR_USER_MENU_ITEMS: TSidebarUserMenuItems[] = [ Icon: PenSquare, }, ]; + +export type TSidebarWorkspaceMenuItems = TSidebarMenuItems; + +export const SIDEBAR_WORKSPACE_MENU: Partial> = { + projects: { + key: "projects", + label: "Projects", + href: `/projects`, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/projects/`, + Icon: Briefcase, + }, + "all-issues": { + key: "all-issues", + label: "Views", + href: `/workspace-views/all-issues`, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + highlight: (pathname: string, baseUrl: string) => pathname.includes(`${baseUrl}/workspace-views/`), + Icon: Layers, + }, + "active-cycles": { + key: "active-cycles", + label: "Cycles", + href: `/active-cycles`, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/active-cycles/`, + Icon: ContrastIcon, + }, + analytics: { + key: "analytics", + label: "Analytics", + href: `/analytics`, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + highlight: (pathname: string, baseUrl: string) => pathname.includes(`${baseUrl}/analytics/`), + Icon: BarChart2, + }, +}; + +export const SIDEBAR_WORKSPACE_MENU_ITEMS: TSidebarWorkspaceMenuItems[] = [ + SIDEBAR_WORKSPACE_MENU?.projects, + SIDEBAR_WORKSPACE_MENU?.["all-issues"], + SIDEBAR_WORKSPACE_MENU?.["active-cycles"], + SIDEBAR_WORKSPACE_MENU?.analytics, +].filter((item): item is TSidebarWorkspaceMenuItems => item !== undefined); diff --git a/web/ce/constants/editor.ts b/web/ce/constants/editor.ts new file mode 100644 index 00000000000..b9a6d5d3838 --- /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/issues.ts b/web/ce/constants/issues.ts index dc6ffbcb8c2..70a34577f73 100644 --- a/web/ce/constants/issues.ts +++ b/web/ce/constants/issues.ts @@ -1,4 +1,4 @@ -import { TIssueActivityComment } from "@plane/types"; +import { ILayoutDisplayFiltersOptions, TIssueActivityComment } from "@plane/types"; export enum EActivityFilterType { ACTIVITY = "ACTIVITY", @@ -19,7 +19,7 @@ export const ACTIVITY_FILTER_TYPE_OPTIONS: Record void; @@ -32,3 +32,7 @@ export const filterActivityOnSelectedFilters = ( activity.filter((activity) => filter.includes(activity.activity_type as TActivityFilters)); export const ENABLE_ISSUE_DEPENDENCIES = false; + +export const ADDITIONAL_ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { + [pageType: string]: { [layoutType: string]: ILayoutDisplayFiltersOptions }; +} = {}; diff --git a/web/ce/helpers/command-palette.ts b/web/ce/helpers/command-palette.ts new file mode 100644 index 00000000000..fccfcbaa4dd --- /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 issue", + description: "Create a new issue 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 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), + }, + }; +}; + +// 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 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", + }, +]; + +export const getAdditionalShortcutsList = (): TCommandPaletteShortcutList[] => []; diff --git a/web/ce/helpers/dashboard.helper.ts b/web/ce/helpers/dashboard.helper.ts index b2fba63adb0..c96c818a1f0 100644 --- a/web/ce/helpers/dashboard.helper.ts +++ b/web/ce/helpers/dashboard.helper.ts @@ -1,5 +1,8 @@ // plane web types -import { TSidebarUserMenuItemKeys } from "@/plane-web/types/dashboard"; +import { TSidebarUserMenuItemKeys, TSidebarWorkspaceMenuItemKeys } from "@/plane-web/types/dashboard"; // eslint-disable-next-line @typescript-eslint/no-unused-vars export const isUserFeatureEnabled = (featureKey: TSidebarUserMenuItemKeys) => true; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const isWorkspaceFeatureEnabled = (featureKey: TSidebarWorkspaceMenuItemKeys, workspaceSlug: string) => true; diff --git a/web/ce/helpers/epic-analytics.ts b/web/ce/helpers/epic-analytics.ts new file mode 100644 index 00000000000..43e6ffef05d --- /dev/null +++ b/web/ce/helpers/epic-analytics.ts @@ -0,0 +1,15 @@ +import { TEpicAnalyticsGroup } from "@plane/types"; + +export const updateEpicAnalytics = () => { + const updateAnalytics = ( + workspaceSlug: string, + projectId: string, + epicId: string, + data: { + incrementStateGroupCount?: TEpicAnalyticsGroup; + decrementStateGroupCount?: TEpicAnalyticsGroup; + } + ) => {}; + + return { updateAnalytics }; +}; diff --git a/web/ce/helpers/issue-action-helper.ts b/web/ce/helpers/issue-action-helper.ts new file mode 100644 index 00000000000..b1644e2aa26 --- /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/helpers/pi-chat.helper.ts b/web/ce/helpers/pi-chat.helper.ts new file mode 100644 index 00000000000..5676e9c00e6 --- /dev/null +++ b/web/ce/helpers/pi-chat.helper.ts @@ -0,0 +1,3 @@ +export const hideFloatingBot = () => {}; + +export const showFloatingBot = () => {}; 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 00000000000..58416379f1a --- /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 f0325bc1284..8028a619104 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/layouts/project-wrapper.tsx b/web/ce/layouts/project-wrapper.tsx new file mode 100644 index 00000000000..a9223210994 --- /dev/null +++ b/web/ce/layouts/project-wrapper.tsx @@ -0,0 +1,15 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +// layouts +import { ProjectAuthWrapper as CoreProjectAuthWrapper } from "@/layouts/auth-layout"; + +export type IProjectAuthWrapper = { + children: React.ReactNode; +}; + +export const ProjectAuthWrapper: FC = observer((props) => { + // props + const { 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 00000000000..fcde83e7f32 --- /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/index.ts b/web/ce/services/index.ts index 3a7bd700542..d0c05946189 100644 --- a/web/ce/services/index.ts +++ b/web/ce/services/index.ts @@ -1,2 +1,2 @@ export * from "./project"; -export * from "./workspace.service"; \ No newline at end of file +export * from "./workspace.service"; diff --git a/web/ce/services/project/project-state.service.ts b/web/ce/services/project/project-state.service.ts new file mode 100644 index 00000000000..f4a48ae7177 --- /dev/null +++ b/web/ce/services/project/project-state.service.ts @@ -0,0 +1 @@ +export * from "@/services/project/project-state.service"; diff --git a/web/ce/store/command-palette.store.ts b/web/ce/store/command-palette.store.ts new file mode 100644 index 00000000000..1b6fabf1875 --- /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/issue/epic/filter.store.ts b/web/ce/store/issue/epic/filter.store.ts new file mode 100644 index 00000000000..a4733c60a55 --- /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 00000000000..0fe6c946b0c --- /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 00000000000..90ccee84da0 --- /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 6c0029c4f8c..2ec70f3cc18 100644 --- a/web/ce/store/issue/issue-details/activity.store.ts +++ b/web/ce/store/issue/issue-details/activity.store.ts @@ -7,7 +7,14 @@ 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"; +import { EIssueServiceType } from "@plane/constants"; +import { + TIssueActivityComment, + TIssueActivity, + TIssueActivityMap, + TIssueActivityIdMap, + TIssueServiceType, +} from "@plane/types"; // plane web constants import { EActivityFilterType } from "@/plane-web/constants/issues"; // services @@ -29,7 +36,7 @@ export interface IIssueActivityStoreActions { export interface IIssueActivityStore extends IIssueActivityStoreActions { // observables - sortOrder: 'asc' | 'desc' + sortOrder: "asc" | "desc"; loader: TActivityLoader; activities: TIssueActivityIdMap; activityMap: TIssueActivityMap; @@ -37,20 +44,24 @@ export interface IIssueActivityStore extends IIssueActivityStoreActions { getActivitiesByIssueId: (issueId: string) => string[] | undefined; getActivityById: (activityId: string) => TIssueActivity | undefined; getActivityCommentByIssueId: (issueId: string) => TIssueActivityComment[] | undefined; - toggleSortOrder: ()=>void; + toggleSortOrder: () => void; } export class IssueActivityStore implements IIssueActivityStore { // observables - sortOrder: "asc" | "desc" = 'asc'; + 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, @@ -59,10 +70,11 @@ export class IssueActivityStore implements IIssueActivityStore { activityMap: observable, // actions fetchActivities: action, - toggleSortOrder: action + toggleSortOrder: action, }); + this.serviceType = serviceType; // services - this.issueActivityService = new IssueActivityService(); + this.issueActivityService = new IssueActivityService(this.serviceType); } // helper methods @@ -81,8 +93,11 @@ export class IssueActivityStore implements IIssueActivityStore { 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 +110,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,14 +119,14 @@ 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), this.sortOrder); return activityComments; }); - toggleSortOrder = ()=>{ - this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'; - } + toggleSortOrder = () => { + this.sortOrder = this.sortOrder === "asc" ? "desc" : "asc"; + }; // actions public async fetchActivities( 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 00000000000..9c33f94051c --- /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 00000000000..0fe6c946b0c --- /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 00000000000..328370f853d --- /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 00000000000..42b2d5dd248 --- /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 00000000000..0fe6c946b0c --- /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 00000000000..2e397943640 --- /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/state.store.ts b/web/ce/store/state.store.ts new file mode 100644 index 00000000000..a25412ca8ab --- /dev/null +++ b/web/ce/store/state.store.ts @@ -0,0 +1 @@ +export * from "@/store/state.store"; diff --git a/web/ce/store/timeline/base-timeline.store.ts b/web/ce/store/timeline/base-timeline.store.ts index 71169ddadae..3e15d124dbe 100644 --- a/web/ce/store/timeline/base-timeline.store.ts +++ b/web/ce/store/timeline/base-timeline.store.ts @@ -44,7 +44,11 @@ export interface IBaseTimelineStore { updateActiveBlockId: (blockId: string | null) => void; updateRenderView: (data: any) => void; updateAllBlocksOnChartChangeWhileDragging: (addedWidth: number) => void; - getUpdatedPositionAfterDrag: (id: string, ignoreDependencies?: boolean) => IBlockUpdateDependencyData[]; + getUpdatedPositionAfterDrag: ( + id: string, + shouldUpdateHalfBlock: boolean, + ignoreDependencies?: boolean + ) => IBlockUpdateDependencyData[]; updateBlockPosition: (id: string, deltaLeft: number, deltaWidth: number, ignoreDependencies?: boolean) => void; getNumberOfDaysFromPosition: (position: number | undefined) => number | undefined; setIsDragging: (isDragging: boolean) => void; @@ -271,24 +275,30 @@ export class BaseTimeLineStore implements IBaseTimelineStore { /** * returns updates dates of blocks post drag. * @param id + * @param shouldUpdateHalfBlock if is a half block then update the incomplete block only if this is true * @returns */ - getUpdatedPositionAfterDrag = action((id: string) => { + getUpdatedPositionAfterDrag = action((id: string, shouldUpdateHalfBlock: boolean) => { const currBlock = this.blocksMap[id]; if (!currBlock?.position || !this.currentViewData) return []; - return [ - { - id, - start_date: renderFormattedPayloadDate( - getDateFromPositionOnGantt(currBlock.position.marginLeft, this.currentViewData) - ), - target_date: renderFormattedPayloadDate( - getDateFromPositionOnGantt(currBlock.position.marginLeft + currBlock.position.width, this.currentViewData, -1) - ), - }, - ] as IBlockUpdateDependencyData[]; + const updatePayload: IBlockUpdateDependencyData = { id }; + + // If shouldUpdateHalfBlock or the start date is available then update start date + if (shouldUpdateHalfBlock || currBlock.start_date) { + updatePayload.start_date = renderFormattedPayloadDate( + getDateFromPositionOnGantt(currBlock.position.marginLeft, this.currentViewData) + ); + } + // If shouldUpdateHalfBlock or the target date is available then update target date + if (shouldUpdateHalfBlock || currBlock.target_date) { + updatePayload.target_date = renderFormattedPayloadDate( + getDateFromPositionOnGantt(currBlock.position.marginLeft + currBlock.position.width, this.currentViewData, -1) + ); + } + + return [updatePayload]; }); /** diff --git a/web/ce/types/dashboard.ts b/web/ce/types/dashboard.ts index d615ac4afce..de35f60c6a7 100644 --- a/web/ce/types/dashboard.ts +++ b/web/ce/types/dashboard.ts @@ -1 +1,3 @@ export type TSidebarUserMenuItemKeys = "home" | "your-work" | "notifications" | "drafts"; + +export type TSidebarWorkspaceMenuItemKeys = "projects" | "all-issues" | "active-cycles" | "analytics"; diff --git a/web/ce/types/index.ts b/web/ce/types/index.ts index 105b7e96a46..d18d0137a59 100644 --- a/web/ce/types/index.ts +++ b/web/ce/types/index.ts @@ -1,3 +1,4 @@ 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 244d8c4df33..9fb35777a3a 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 00000000000..d060b4cc88a --- /dev/null +++ b/web/ce/types/projects/project-activity.ts @@ -0,0 +1,3 @@ +import { TProjectBaseActivity } from "@plane/types"; + +export type TProjectActivity = TProjectBaseActivity; diff --git a/web/ce/types/state.d.ts b/web/ce/types/state.d.ts new file mode 100644 index 00000000000..22309db819b --- /dev/null +++ b/web/ce/types/state.d.ts @@ -0,0 +1,8 @@ +export interface IStateTransition { + transition_state_id: string; + actors: string[]; +} + +export interface IStateWorkFlow { + [transitionId: string]: IStateTransition; +} diff --git a/web/core/components/analytics/custom-analytics/custom-analytics.tsx b/web/core/components/analytics/custom-analytics/custom-analytics.tsx index 413126c482b..bb801616fcb 100644 --- a/web/core/components/analytics/custom-analytics/custom-analytics.tsx +++ b/web/core/components/analytics/custom-analytics/custom-analytics.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { useForm } from "react-hook-form"; @@ -11,6 +12,7 @@ import { CustomAnalyticsSelectBar, CustomAnalyticsMainContent, CustomAnalyticsSi import { ANALYTICS } from "@/constants/fetch-keys"; import { cn } from "@/helpers/common.helper"; import { useAppTheme } from "@/hooks/store"; +import { hideFloatingBot, showFloatingBot } from "@/plane-web/helpers/pi-chat.helper"; import { AnalyticsService } from "@/services/analytics.service"; type Props = { @@ -51,6 +53,13 @@ export const CustomAnalytics: React.FC = observer((props) => { const isProjectLevel = projectId ? true : false; + useEffect(() => { + hideFloatingBot(); + return () => { + showFloatingBot(); + }; + }, []); + return (
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 6c1c01acad2..c15a935b5e7 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 @@ -4,11 +4,10 @@ import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2, Users } from "lucide-react"; +import { EIssuesStoreType } from "@plane/constants"; 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 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 319adf8f238..84ac73b7550 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 @@ -4,12 +4,12 @@ import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { Check } from "lucide-react"; +// plane constants +import { EIssuesStoreType } from "@plane/constants"; // plane types 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 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 5bd8ce850e1..2f004e8eae0 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,13 +1,16 @@ -"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 { EIssuesStoreType } 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 { ISSUE_PRIORITIES } from "@/constants/issue"; import { useIssues } from "@/hooks/store"; // ui // types 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 5d512f4acaf..f60ee7dcaa1 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 @@ -5,9 +5,9 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // hooks import { Check } from "lucide-react"; +import { EIssuesStoreType } from "@plane/constants"; import { TIssue } from "@plane/types"; import { Spinner, StateGroupIcon } from "@plane/ui"; -import { EIssuesStoreType } from "@/constants/issue"; import { useProjectState, useIssues } from "@/hooks/store"; // ui // icons diff --git a/web/core/components/command-palette/command-palette.tsx b/web/core/components/command-palette/command-palette.tsx index c38266c400c..4e59234cde9 100644 --- a/web/core/components/command-palette/command-palette.tsx +++ b/web/core/components/command-palette/command-palette.tsx @@ -2,87 +2,43 @@ import React, { useCallback, useEffect, FC, useMemo } from "react"; import { observer } from "mobx-react"; -import { useParams, usePathname } from "next/navigation"; -import useSWR from "swr"; +import { useParams } from "next/navigation"; // ui 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 { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web components +import { + IssueLevelModals, + ProjectLevelModals, + WorkspaceLevelModals, +} from "@/plane-web/components/command-palette/modals"; +// plane web constants import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; -// services -import { IssueService } from "@/services/issue"; - -// services -const issueService = new IssueService(); +// 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, issueId } = useParams(); // store hooks 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(); - 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) - : null - ); - // derived values const canPerformWorkspaceMemberActions = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], @@ -170,62 +126,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 +201,8 @@ export const CommandPalette: FC = observer(() => { shortcutsList.project[keyPressed].action(); } } + // Additional keydown events + handleAdditionalKeyDownEvents(e); }, [ copyIssueUrlToClipboard, @@ -320,75 +227,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/shortcuts-modal/commands-list.tsx b/web/core/components/command-palette/shortcuts-modal/commands-list.tsx index e72e92e0b9a..570cb02fa78 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 dd7606a3aee..b619dde44fb 100644 --- a/web/core/components/common/access-field.tsx +++ b/web/core/components/common/access-field.tsx @@ -1,6 +1,8 @@ import { LucideIcon } from "lucide-react"; -import { cn } from "@plane/editor"; +// plane ui import { Tooltip } from "@plane/ui"; +// plane utils +import { cn } from "@plane/utils"; type Props = { onChange: (value: number) => void; 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 00000000000..0f1c2429f4a --- /dev/null +++ b/web/core/components/common/activity/activity-block.tsx @@ -0,0 +1,54 @@ +"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 00000000000..643d8973838 --- /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 00000000000..54ccd56e2ba --- /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)} issue 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 00000000000..8ab32d3d900 --- /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 00000000000..c82ab361620 --- /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/index.ts b/web/core/components/common/index.ts index 28b73238862..1f9a4244051 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/logo.tsx b/web/core/components/common/logo.tsx index 02b26a10be9..49d4d60a128 100644 --- a/web/core/components/common/logo.tsx +++ b/web/core/components/common/logo.tsx @@ -1,14 +1,17 @@ "use client"; import { FC } from "react"; -// emoji-picker-react import { Emoji } from "emoji-picker-react"; +// Due to some weird issue with the import order, the import of useFontFaceObserver +// should be after the imported here rather than some below helper functions as it is in the original file +// eslint-disable-next-line import/order +import useFontFaceObserver from "use-font-face-observer"; +// types import { TLogoProps } from "@plane/types"; -// helpers +// ui import { LUCIDE_ICONS_LIST } from "@plane/ui"; +// helpers import { emojiCodeToUnicode } from "@/helpers/emoji.helper"; -// import { icons } from "lucide-react"; -import useFontFaceObserver from "use-font-face-observer"; type Props = { logo: TLogoProps; diff --git a/web/core/components/core/image-picker-popover.tsx b/web/core/components/core/image-picker-popover.tsx index c543f13f058..bef7b4df0da 100644 --- a/web/core/components/core/image-picker-popover.tsx +++ b/web/core/components/core/image-picker-popover.tsx @@ -9,7 +9,7 @@ import { Control, Controller } from "react-hook-form"; import useSWR from "swr"; import { Tab, Popover } from "@headlessui/react"; // plane helpers -import { useOutsideClickDetector } from "@plane/helpers"; +import { useOutsideClickDetector } from "@plane/hooks"; // plane types import { EFileAssetType } from "@plane/types/src/enums"; // ui diff --git a/web/core/components/core/modals/bulk-delete-issues-modal.tsx b/web/core/components/core/modals/bulk-delete-issues-modal.tsx index 1d397a315e0..aec70262ab1 100644 --- a/web/core/components/core/modals/bulk-delete-issues-modal.tsx +++ b/web/core/components/core/modals/bulk-delete-issues-modal.tsx @@ -6,6 +6,7 @@ import { useParams } from "next/navigation"; import { SubmitHandler, useForm } from "react-hook-form"; import { Search } from "lucide-react"; import { Combobox, Dialog, Transition } from "@headlessui/react"; +import { EIssuesStoreType } from "@plane/constants"; // types import { ISearchIssueResponse, IUser } from "@plane/types"; // ui @@ -14,7 +15,6 @@ import { Button, Loader, TOAST_TYPE, setToast } from "@plane/ui"; import { EmptyState } from "@/components/empty-state"; // constants import { EmptyStateType } from "@/constants/empty-state"; -import { EIssuesStoreType } from "@/constants/issue"; // hooks import { useIssues } from "@/hooks/store"; import useDebounce from "@/hooks/use-debounce"; diff --git a/web/core/components/cycles/active-cycle/cycle-stats.tsx b/web/core/components/cycles/active-cycle/cycle-stats.tsx index 6fcdaebd539..44d7b595f48 100644 --- a/web/core/components/cycles/active-cycle/cycle-stats.tsx +++ b/web/core/components/cycles/active-cycle/cycle-stats.tsx @@ -7,6 +7,7 @@ import { CalendarCheck } from "lucide-react"; // headless ui import { Tab } from "@headlessui/react"; // types +import { EIssuesStoreType } from "@plane/constants"; import { ICycle, IIssueFilterOptions } from "@plane/types"; // ui import { Tooltip, Loader, PriorityIcon, Avatar } from "@plane/ui"; @@ -16,7 +17,6 @@ import { StateDropdown } from "@/components/dropdowns"; import { EmptyState } from "@/components/empty-state"; // constants import { EmptyStateType } from "@/constants/empty-state"; -import { EIssuesStoreType } from "@/constants/issue"; // helpers import { cn } from "@/helpers/common.helper"; import { renderFormattedDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.helper"; 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 cd148705b48..4412319ba37 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 { diff --git a/web/core/components/cycles/analytics-sidebar/issue-progress.tsx b/web/core/components/cycles/analytics-sidebar/issue-progress.tsx index d9725d1d66d..0efdaded511 100644 --- a/web/core/components/cycles/analytics-sidebar/issue-progress.tsx +++ b/web/core/components/cycles/analytics-sidebar/issue-progress.tsx @@ -7,11 +7,11 @@ 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 { 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 diff --git a/web/core/components/cycles/analytics-sidebar/root.tsx b/web/core/components/cycles/analytics-sidebar/root.tsx index fd8c984a690..b709c0e6317 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/archived-cycles/header.tsx b/web/core/components/cycles/archived-cycles/header.tsx index 42485fcef4e..cdc174b0e59 100644 --- a/web/core/components/cycles/archived-cycles/header.tsx +++ b/web/core/components/cycles/archived-cycles/header.tsx @@ -4,7 +4,7 @@ import { useParams } from "next/navigation"; // icons import { ListFilter, Search, X } from "lucide-react"; // plane helpers -import { useOutsideClickDetector } from "@plane/helpers"; +import { useOutsideClickDetector } from "@plane/hooks"; // types import type { TCycleFilters } from "@plane/types"; // components diff --git a/web/core/components/cycles/cycle-peek-overview.tsx b/web/core/components/cycles/cycle-peek-overview.tsx index 759569cfa9f..187425b8d72 100644 --- a/web/core/components/cycles/cycle-peek-overview.tsx +++ b/web/core/components/cycles/cycle-peek-overview.tsx @@ -9,12 +9,13 @@ import { useAppRouter } from "@/hooks/use-app-router"; import { CycleDetailsSidebar } from "./"; type Props = { - projectId: string; + projectId?: string; workspaceSlug: string; isArchived?: boolean; }; -export const CyclePeekOverview: React.FC = observer(({ projectId, workspaceSlug, isArchived = false }) => { +export const CyclePeekOverview: React.FC = observer((props) => { + const { projectId: propsProjectId, workspaceSlug, isArchived } = props; // router const router = useAppRouter(); const pathname = usePathname(); @@ -23,22 +24,25 @@ export const CyclePeekOverview: React.FC = observer(({ projectId, workspa // refs const ref = React.useRef(null); // store hooks - const { fetchCycleDetails, fetchArchivedCycleDetails } = useCycle(); + const { getCycleById, fetchCycleDetails, fetchArchivedCycleDetails } = useCycle(); + // derived values + const cycleDetails = peekCycle ? getCycleById(peekCycle.toString()) : undefined; + const projectId = propsProjectId || cycleDetails?.project_id; const handleClose = () => { const query = generateQueryParams(searchParams, ["peekCycle"]); - router.push(`${pathname}?${query}`); + router.push(`${pathname}?${query}`, {}, { showProgressBar: false }); }; useEffect(() => { - if (!peekCycle) return; + if (!peekCycle || !projectId) return; if (isArchived) fetchArchivedCycleDetails(workspaceSlug, projectId, peekCycle.toString()); else fetchCycleDetails(workspaceSlug, projectId, peekCycle.toString()); }, [fetchArchivedCycleDetails, fetchCycleDetails, isArchived, peekCycle, projectId, workspaceSlug]); return ( <> - {peekCycle && ( + {peekCycle && projectId && (
= observer(({ projectId, workspa "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/core/components/cycles/cycles-view-header.tsx b/web/core/components/cycles/cycles-view-header.tsx index f1398cd3bf9..c3139bc8732 100644 --- a/web/core/components/cycles/cycles-view-header.tsx +++ b/web/core/components/cycles/cycles-view-header.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react"; // icons import { ListFilter, Search, X } from "lucide-react"; // plane helpers -import { useOutsideClickDetector } from "@plane/helpers"; +import { useOutsideClickDetector } from "@plane/hooks"; // types import { TCycleFilters } from "@plane/types"; // components diff --git a/web/core/components/cycles/form.tsx b/web/core/components/cycles/form.tsx index 660d33cdb80..7651c5d4433 100644 --- a/web/core/components/cycles/form.tsx +++ b/web/core/components/cycles/form.tsx @@ -75,6 +75,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")} diff --git a/web/core/components/cycles/list/cycle-list-item-action.tsx b/web/core/components/cycles/list/cycle-list-item-action.tsx index 989e0436e36..d83256b89bc 100644 --- a/web/core/components/cycles/list/cycle-list-item-action.tsx +++ b/web/core/components/cycles/list/cycle-list-item-action.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { FC, MouseEvent, useEffect } from "react"; +import React, { FC, MouseEvent, useEffect, useMemo } from "react"; import { observer } from "mobx-react"; import { usePathname, useSearchParams } from "next/navigation"; import { Controller, useForm } from "react-hook-form"; @@ -8,16 +8,24 @@ import { Eye, Users } from "lucide-react"; // types import { ICycle, TCycleGroups } from "@plane/types"; // ui -import { Avatar, AvatarGroup, FavoriteStar, TOAST_TYPE, Tooltip, setPromiseToast, setToast } from "@plane/ui"; +import { + Avatar, + AvatarGroup, + FavoriteStar, + LayersIcon, + TOAST_TYPE, + Tooltip, + setPromiseToast, + setToast, +} from "@plane/ui"; // components import { CycleQuickActions } from "@/components/cycles"; import { DateRangeDropdown } from "@/components/dropdowns"; import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; // constants -import { CYCLE_STATUS } from "@/constants/cycle"; import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker"; // helpers -import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; +import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; import { getFileURL } from "@/helpers/file.helper"; // hooks import { generateQueryParams } from "@/helpers/router.helper"; @@ -67,13 +75,14 @@ export const CycleListItemAction: FC = observer((props) => { // derived values const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; + const showIssueCount = useMemo(() => cycleStatus === "draft" || cycleStatus === "upcoming", [cycleStatus]); const isEditingAllowed = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.PROJECT + EUserPermissionsLevel.PROJECT, + workspaceSlug, + projectId ); const renderIcon = Boolean(cycleDetails.start_date) || Boolean(cycleDetails.end_date); - const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); - const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0; // handlers const handleAddToFavorites = (e: MouseEvent) => { @@ -201,9 +210,9 @@ export const CycleListItemAction: FC = observer((props) => { const query = generateQueryParams(searchParams, ["peekCycle"]); if (searchParams.has("peekCycle") && searchParams.get("peekCycle") === cycleId) { - router.push(`${pathname}?${query}`); + router.push(`${pathname}?${query}`, {}, { showProgressBar: false }); } else { - router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`); + router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`, {}, { showProgressBar: false }); } }; @@ -217,6 +226,13 @@ export const CycleListItemAction: FC = observer((props) => { More details + {showIssueCount && ( +
+ + {cycleDetails.total_issues} +
+ )} + {!isActive && ( = observer((props) => { + const { projectId, count, showCount = false, isExpanded = false } = props; + // store hooks + const { getProjectById } = useProject(); + // derived values + const project = getProjectById(projectId); + + if (!project) return null; + return ( + + +
+ +
+
+
{project.name}
+ {showCount &&
{`${count ?? "0"}`}
} +
+
+ ); +}); diff --git a/web/core/components/cycles/list/cycles-list-item.tsx b/web/core/components/cycles/list/cycles-list-item.tsx index 8d531216a4b..5954a0a7f33 100644 --- a/web/core/components/cycles/list/cycles-list-item.tsx +++ b/web/core/components/cycles/list/cycles-list-item.tsx @@ -4,7 +4,7 @@ import { FC, MouseEvent, useRef } from "react"; import { observer } from "mobx-react"; import { usePathname, useSearchParams } from "next/navigation"; // icons -import { Check, Info } from "lucide-react"; +import { Check } from "lucide-react"; // types import type { TCycleGroups } from "@plane/types"; // ui @@ -72,9 +72,9 @@ export const CyclesListItem: FC = observer((props) => { const query = generateQueryParams(searchParams, ["peekCycle"]); if (searchParams.has("peekCycle") && searchParams.get("peekCycle") === cycleId) { - router.push(`${pathname}?${query}`); + router.push(`${pathname}?${query}`, {}, { showProgressBar: false }); } else { - router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`); + router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`, {}, { showProgressBar: false }); } }; diff --git a/web/core/components/cycles/list/index.ts b/web/core/components/cycles/list/index.ts index 4eebc577943..25419a0560a 100644 --- a/web/core/components/cycles/list/index.ts +++ b/web/core/components/cycles/list/index.ts @@ -3,3 +3,4 @@ export * from "./cycles-list-map"; export * from "./root"; export * from "./cycle-list-item-action"; export * from "./cycle-list-group-header"; +export * from "./cycle-list-project-group-header"; diff --git a/web/core/components/cycles/modal.tsx b/web/core/components/cycles/modal.tsx index f8b1590c488..25fabc3f2aa 100644 --- a/web/core/components/cycles/modal.tsx +++ b/web/core/components/cycles/modal.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useEffect, useState } from "react"; +import { format } from "date-fns"; import { mutate } from "swr"; // types import type { CycleDateCheckData, ICycle, TCycleTabOptions } from "@plane/types"; @@ -131,8 +132,8 @@ export const CycleCreateUpdateModal: React.FC = (props) => { if (payload.start_date && payload.end_date) { if (data?.start_date && data?.end_date) isDateValid = await dateChecker(payload.project_id ?? projectId, { - start_date: payload.start_date, - end_date: payload.end_date, + start_date: format(payload.start_date, "yyyy-MM-dd"), + end_date: format(payload.end_date, "yyyy-MM-dd"), cycle_id: data.id, }); else diff --git a/web/core/components/cycles/transfer-issues-modal.tsx b/web/core/components/cycles/transfer-issues-modal.tsx index ae9cf3dac08..1d065bf85bb 100644 --- a/web/core/components/cycles/transfer-issues-modal.tsx +++ b/web/core/components/cycles/transfer-issues-modal.tsx @@ -8,8 +8,8 @@ import { Dialog, Transition } from "@headlessui/react"; // hooks // ui //icons +import { EIssuesStoreType } from "@plane/constants"; import { ContrastIcon, TransferIcon, TOAST_TYPE, setToast } from "@plane/ui"; -import { EIssuesStoreType } from "@/constants/issue"; import { useCycle, useIssues } from "@/hooks/store"; //icons // constants diff --git a/web/core/components/dashboard/widgets/issue-panels/issue-list-item.tsx b/web/core/components/dashboard/widgets/issue-panels/issue-list-item.tsx index becea09841c..3ca83f56544 100644 --- a/web/core/components/dashboard/widgets/issue-panels/issue-list-item.tsx +++ b/web/core/components/dashboard/widgets/issue-panels/issue-list-item.tsx @@ -1,6 +1,6 @@ "use client"; -import isToday from "date-fns/isToday"; +import { isToday } from "date-fns/isToday"; import { observer } from "mobx-react"; // types import { TIssue, TWidgetIssue } from "@plane/types"; @@ -57,7 +57,7 @@ export const AssignedUpcomingIssueListItem: React.FC = obser )}
{issueDetails.name}
-
+
@@ -117,7 +117,7 @@ export const AssignedOverdueIssueListItem: React.FC = observ )}
{issueDetails.name}
-
+
@@ -170,7 +170,7 @@ export const AssignedCompletedIssueListItem: React.FC = obse )}
{issueDetails.name}
-
+
@@ -209,7 +209,7 @@ export const CreatedUpcomingIssueListItem: React.FC = observ )}
{issue.name}
-
+
@@ -269,7 +269,7 @@ export const CreatedOverdueIssueListItem: React.FC = observe )}
{issue.name}
-
+
@@ -327,7 +327,7 @@ export const CreatedCompletedIssueListItem: React.FC = obser )}
{issue.name}
-
+
diff --git a/web/core/components/dropdowns/layout.tsx b/web/core/components/dropdowns/layout.tsx index 2557e57a2a2..9907ab1406f 100644 --- a/web/core/components/dropdowns/layout.tsx +++ b/web/core/components/dropdowns/layout.tsx @@ -1,27 +1,36 @@ import { useCallback, useMemo } from "react"; import { observer } from "mobx-react"; import { Check } from "lucide-react"; -// plane packages -import { cn } from "@plane/editor"; +// plane constants +import { EIssueLayoutTypes } from "@plane/constants"; +// plane ui import { Dropdown } from "@plane/ui"; +// plane utils +import { cn } from "@plane/utils"; // constants -import { EIssueLayoutTypes, ISSUE_LAYOUT_MAP } from "@/constants/issue"; +import { ISSUE_LAYOUT_MAP } from "@/constants/issue"; type TLayoutDropDown = { onChange: (value: EIssueLayoutTypes) => void; value: EIssueLayoutTypes; + disabledLayouts?: EIssueLayoutTypes[]; }; export const LayoutDropDown = observer((props: TLayoutDropDown) => { - const { onChange, value = EIssueLayoutTypes.LIST } = props; + const { onChange, value = EIssueLayoutTypes.LIST, disabledLayouts = [] } = props; + // derived values + const availableLayouts = useMemo( + () => Object.values(ISSUE_LAYOUT_MAP).filter((layout) => !disabledLayouts.includes(layout.key)), + [disabledLayouts] + ); const options = useMemo( () => - Object.values(ISSUE_LAYOUT_MAP).map((issueLayout) => ({ + availableLayouts.map((issueLayout) => ({ data: issueLayout.key, value: issueLayout.key, })), - [] + [availableLayouts] ); const buttonContent = useCallback((isOpen: boolean, buttonValue: string | string[] | undefined) => { diff --git a/web/core/components/dropdowns/member/avatar.tsx b/web/core/components/dropdowns/member/avatar.tsx index 0a7a92d4380..0b2189f6510 100644 --- a/web/core/components/dropdowns/member/avatar.tsx +++ b/web/core/components/dropdowns/member/avatar.tsx @@ -4,6 +4,8 @@ import { observer } from "mobx-react"; import { LucideIcon, Users } from "lucide-react"; // plane ui import { Avatar, AvatarGroup } from "@plane/ui"; +// plane utils +import { cn } from "@plane/utils"; // helpers import { getFileURL } from "@/helpers/file.helper"; // hooks @@ -13,17 +15,18 @@ type AvatarProps = { showTooltip: boolean; userIds: string | string[] | null; icon?: LucideIcon; + size?: "sm" | "md" | "base" | "lg" | number; }; export const ButtonAvatars: React.FC = observer((props) => { - const { showTooltip, userIds, icon: Icon } = props; + const { showTooltip, userIds, icon: Icon, size = "md" } = props; // store hooks const { getUserDetails } = useMember(); if (Array.isArray(userIds)) { if (userIds.length > 0) return ( - + {userIds.map((userId) => { const userDetails = getUserDetails(userId); @@ -39,12 +42,12 @@ export const ButtonAvatars: React.FC = observer((props) => { ); } } - return Icon ? : ; + return Icon ? : ; }); diff --git a/web/core/components/dropdowns/project.tsx b/web/core/components/dropdowns/project.tsx index 3f973cd1618..052527ab6dd 100644 --- a/web/core/components/dropdowns/project.tsx +++ b/web/core/components/dropdowns/project.tsx @@ -1,7 +1,7 @@ -import { Fragment, ReactNode, useRef, useState } from "react"; +import { ReactNode, useRef, useState } from "react"; import { observer } from "mobx-react"; import { usePopper } from "react-popper"; -import { Check, ChevronDown, Search } from "lucide-react"; +import { Briefcase, Check, ChevronDown, Search } from "lucide-react"; import { Combobox } from "@headlessui/react"; // ui import { ComboDropDown } from "@plane/ui"; @@ -25,12 +25,21 @@ type Props = TDropdownProps & { button?: ReactNode; dropdownArrow?: boolean; dropdownArrowClassName?: string; - onChange: (val: string) => void; onClose?: () => void; renderCondition?: (project: TProject) => boolean; - value: string | null; renderByDefault?: boolean; -}; +} & ( + | { + multiple: false; + onChange: (val: string) => void; + value: string | null; + } + | { + multiple: true; + onChange: (val: string[]) => void; + value: string[]; + } + ); export const ProjectDropdown: React.FC = observer((props) => { const { @@ -43,6 +52,7 @@ export const ProjectDropdown: React.FC = observer((props) => { dropdownArrow = false, dropdownArrowClassName = "", hideIcon = false, + multiple, onChange, onClose, placeholder = "Project", @@ -99,8 +109,6 @@ export const ProjectDropdown: React.FC = observer((props) => { const filteredOptions = query === "" ? options : options?.filter((o) => o?.query.toLowerCase().includes(query.toLowerCase())); - const selectedProject = value ? getProjectById(value) : null; - const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({ dropdownRef, inputRef, @@ -111,9 +119,44 @@ export const ProjectDropdown: React.FC = observer((props) => { setQuery, }); - const dropdownOnChange = (val: string) => { + const dropdownOnChange = (val: string & string[]) => { onChange(val); - handleClose(); + if (!multiple) handleClose(); + }; + + const getDisplayName = (value: string | string[] | null, placeholder: string = "") => { + if (Array.isArray(value)) { + const firstProject = getProjectById(value[0]); + return value.length ? (value.length === 1 ? firstProject?.name : `${value.length} projects`) : placeholder; + } else { + return value ? (getProjectById(value)?.name ?? placeholder) : placeholder; + } + }; + + const getProjectIcon = (value: string | string[] | null) => { + const renderIcon = (projectDetails: TProject) => ( + + + + ); + + if (Array.isArray(value)) { + return ( +
+ {value.length > 0 ? ( + value.map((projectId) => { + const projectDetails = getProjectById(projectId); + return projectDetails ? renderIcon(projectDetails) : null; + }) + ) : ( + + )} +
+ ); + } else { + const projectDetails = getProjectById(value); + return projectDetails ? renderIcon(projectDetails) : null; + } }; const comboButton = ( @@ -147,18 +190,14 @@ export const ProjectDropdown: React.FC = observer((props) => { className={buttonClassName} isActive={isOpen} tooltipHeading="Project" - tooltipContent={selectedProject?.name ?? placeholder} + tooltipContent={value?.length ? `${value.length} project${value.length !== 1 ? "s" : ""}` : placeholder} showTooltip={showTooltip} variant={buttonVariant} renderToolTipByDefault={renderByDefault} > - {!hideIcon && selectedProject && ( - - - - )} + {!hideIcon && getProjectIcon(value)} {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( - {selectedProject?.name ?? placeholder} + {getDisplayName(value, placeholder)} )} {dropdownArrow && (