diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..fa445360f0 --- /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 16378b41d2..627c782f99 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -25,9 +25,6 @@ on: required: false default: false type: boolean - # push: - # branches: - # - master env: TARGET_BRANCH: ${{ github.ref_name }} @@ -317,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: @@ -354,6 +351,7 @@ jobs: branch_build_push_live, branch_build_push_apiserver, branch_build_push_proxy, + attach_assets_to_build, ] env: REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }} diff --git a/README.md b/README.md index 38ead5f997..9c4ea9da61 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 -The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. +Getting started with Plane is simple. Choose the setup that works best for you: -If you would like to self-host Plane, please see our [deployment guide](https://docs.plane.so/docker-compose). +- **Plane Cloud** +Sign up for a free account on [Plane Cloud](https://app.plane.so)—it's the fastest way to get up and running without worrying about infrastructure. + +- **Self-host Plane** +Prefer full control over your data and infrastructure? Install and run Plane on your own servers. Follow our detailed [deployment guides](https://developers.plane.so/self-hosting/overview) to get started. | Installation methods | Docs link | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://docs.plane.so/self-hosting/methods/docker-compose) | -| Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://docs.plane.so/kubernetes) | +| Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://developers.plane.so/self-hosting/methods/docker-compose) | +| Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://developers.plane.so/self-hosting/methods/kubernetes) | -`Instance admins` can configure instance settings with [God-mode](https://docs.plane.so/instance-admin). +`Instance admins` can 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). +That’s it! You’re all set to begin coding. Remember to refresh your browser if changes don’t auto-reload. Happy contributing! 🎉 -Thats it! - -## ❤️ Community - -The Plane community can be found on [GitHub Discussions](https://github.com/orgs/makeplane/discussions), and our [Discord server](https://discord.com/invite/A92xrEGCge). Our [Code of conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community chanels. - -Ask questions, report bugs, join discussions, voice ideas, make feature requests, or share your projects. - -### Repo Activity - -![Plane Repo Activity](https://repobeats.axiom.co/api/embed/2523c6ed2f77c082b7908c33e2ab208981d76c39.svg "Repobeats analytics image") +## Built with +[![Next JS](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/)
+[![Django](https://img.shields.io/badge/Django-092E20?style=for-the-badge&logo=django&logoColor=green)](https://www.djangoproject.com/)
+[![Node JS](https://img.shields.io/badge/node.js-339933?style=for-the-badge&logo=Node.js&logoColor=white)](https://nodejs.org/en) ## 📸 Screenshots @@ -165,7 +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. -If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. +## ❤️ Community -Email squawk@plane.so to disclose any security vulnerabilities. +Join the Plane community on [GitHub Discussions](https://github.com/orgs/makeplane/discussions) and our [Discord server](https://discord.com/invite/A92xrEGCge). We follow a [Code of conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) in all our community channels. -## ❤️ Contribute +Feel free to ask questions, report bugs, participate in discussions, share ideas, request features, or showcase your projects. We’d love to hear from you! -There are many ways to contribute to Plane, including: +## 🛡️ Security -- Submitting [bugs](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%F0%9F%90%9Bbug&projects=&template=--bug-report.yaml&title=%5Bbug%5D%3A+) and [feature requests](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%E2%9C%A8feature&projects=&template=--feature-request.yaml&title=%5Bfeature%5D%3A+) for various components. -- Reviewing [the documentation](https://docs.plane.so/) and submitting [pull requests](https://github.com/makeplane/plane), from fixing typos to adding new features. -- Speaking or writing about Plane or any other ecosystem integration and [letting us know](https://discord.com/invite/A92xrEGCge)! -- Upvoting [popular feature requests](https://github.com/makeplane/plane/issues) to show your support. +If you discover a security vulnerability in Plane, please report it responsibly instead of opening a public issue. We take all legitimate reports seriously and will investigate them promptly. See [Security policy](https://github.com/makeplane/plane/blob/master/SECURITY.md) for more info. + +To disclose any security issues, please email us at security@plane.so. + +## 🤝 Contributing + +There are many ways you can contribute to Plane: + +- Report [bugs](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%F0%9F%90%9Bbug&projects=&template=--bug-report.yaml&title=%5Bbug%5D%3A+) or submit [feature requests](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%E2%9C%A8feature&projects=&template=--feature-request.yaml&title=%5Bfeature%5D%3A+). +- Review the [documentation](https://docs.plane.so/) and submit [pull requests](https://github.com/makeplane/docs) to improve it—whether it's fixing typos or adding new content. +- Talk or write about Plane or any other ecosystem integration and [let us know](https://discord.com/invite/A92xrEGCge)! +- Show your support by upvoting [popular feature requests](https://github.com/makeplane/plane/issues). + +Please read [CONTRIBUTING.md](https://github.com/makeplane/plane/blob/master/CONTRIBUTING.md) for details on the process for submitting pull requests to us. + +### Repo activity + +![Plane Repo Activity](https://repobeats.axiom.co/api/embed/2523c6ed2f77c082b7908c33e2ab208981d76c39.svg "Repobeats analytics image") ### We couldn't have done this without you. + + +## License +This project is licensed under the [GNU Affero General Public License v3.0](https://github.com/makeplane/plane/blob/master/LICENSE.txt). \ No newline at end of file diff --git a/admin/app/ai/form.tsx b/admin/app/ai/form.tsx index 510566e802..4258a99fbb 100644 --- a/admin/app/ai/form.tsx +++ b/admin/app/ai/form.tsx @@ -121,7 +121,12 @@ export const InstanceAIForm: FC = (props) => {
-
If you have a preferred AI models vendor, please get in touch with us.
+
+ If you have a preferred AI models vendor, please get in{" "} + + touch with us. + +
diff --git a/admin/app/authentication/github/form.tsx b/admin/app/authentication/github/form.tsx index afab9a3c51..4842675cd5 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{" "} = (props) => { Go back diff --git a/admin/app/authentication/github/page.tsx b/admin/app/authentication/github/page.tsx index 7991fb95f7..986a5ebd24 100644 --- a/admin/app/authentication/github/page.tsx +++ b/admin/app/authentication/github/page.tsx @@ -5,12 +5,12 @@ import { observer } from "mobx-react"; import Image from "next/image"; import { useTheme } from "next-themes"; import useSWR from "swr"; +// plane internal packages import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; +import { resolveGeneralTheme } from "@plane/utils"; // components import { AuthenticationMethodCard } from "@/components/authentication"; import { PageHeader } from "@/components/common"; -// helpers -import { resolveGeneralTheme } from "@/helpers/common.helper"; // hooks import { useInstance } from "@/hooks/store"; // icons @@ -44,7 +44,7 @@ const InstanceGithubAuthenticationPage = observer(() => { 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{" "} = (props) => { Go back diff --git a/admin/app/authentication/google/form.tsx b/admin/app/authentication/google/form.tsx index cf57978952..10decdcb05 100644 --- a/admin/app/authentication/google/form.tsx +++ b/admin/app/authentication/google/form.tsx @@ -3,10 +3,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, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types"; -// ui import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +import { cn } from "@plane/utils"; // components import { CodeBlock, @@ -16,8 +17,6 @@ import { TControllerInputFormField, TCopyField, } from "@/components/common"; -// helpers -import { API_BASE_URL, cn } from "@/helpers/common.helper"; // hooks import { useInstance } from "@/hooks/store"; @@ -192,7 +191,7 @@ export const InstanceGoogleConfigForm: FC = (props) => { Go back diff --git a/admin/app/authentication/page.tsx b/admin/app/authentication/page.tsx index d37b35978e..279ff396a2 100644 --- a/admin/app/authentication/page.tsx +++ b/admin/app/authentication/page.tsx @@ -3,10 +3,10 @@ import { useState } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; +// plane internal packages import { TInstanceConfigurationKeys } from "@plane/types"; import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; -// helpers -import { cn } from "@/helpers/common.helper"; +import { cn } from "@plane/utils"; // hooks import { useInstance } from "@/hooks/store"; // plane admin components @@ -60,7 +60,7 @@ const InstanceAuthenticationPage = observer(() => {
Manage authentication modes for your instance
- Configure authentication modes for your team and restrict sign ups to be invite only. + Configure authentication modes for your team and restrict sign-ups to be invite only.
@@ -80,9 +80,11 @@ const InstanceAuthenticationPage = observer(() => { { - Boolean(parseInt(enableSignUpConfig)) === true - ? updateConfig("ENABLE_SIGNUP", "0") - : updateConfig("ENABLE_SIGNUP", "1"); + if (Boolean(parseInt(enableSignUpConfig)) === true) { + updateConfig("ENABLE_SIGNUP", "0"); + } else { + updateConfig("ENABLE_SIGNUP", "1"); + } }} size="sm" disabled={isSubmitting} @@ -90,7 +92,7 @@ const InstanceAuthenticationPage = observer(() => {
-
Authentication modes
+
Available authentication modes
) : ( diff --git a/admin/app/email/email-config-form.tsx b/admin/app/email/email-config-form.tsx index 2cc24fc896..73a1af1744 100644 --- a/admin/app/email/email-config-form.tsx +++ b/admin/app/email/email-config-form.tsx @@ -72,7 +72,7 @@ export const InstanceEmailForm: FC = (props) => { { key: "EMAIL_FROM", type: "text", - label: "Sender email address", + label: "Sender's email address", description: "This is the email address your users will see when getting emails from this instance. You will need to verify this address.", placeholder: "no-reply@projectplane.so", @@ -174,12 +174,12 @@ export const InstanceEmailForm: FC = (props) => {
-
+
-
Authentication (optional)
+
Authentication
- We recommend setting up a username password for your SMTP server + This is optional, but we recommend setting up a username and a password for your SMTP server.
diff --git a/admin/app/general/form.tsx b/admin/app/general/form.tsx index 4422ee91ff..09aac8b459 100644 --- a/admin/app/general/form.tsx +++ b/admin/app/general/form.tsx @@ -117,17 +117,18 @@ export const GeneralConfigurationForm: FC = observer(
- Allow Plane to collect anonymous usage events + Let Plane collect anonymous usage data
diff --git a/admin/app/general/intercom.tsx b/admin/app/general/intercom.tsx index aaeacfc0fe..37f7e30711 100644 --- a/admin/app/general/intercom.tsx +++ b/admin/app/general/intercom.tsx @@ -60,9 +60,9 @@ export const IntercomConfig: FC = observer((props) => {
-
Talk to Plane
+
Chat with us
- Let your members chat with us via Intercom or another service. Toggling Telemetry off turns this off + Let your users chat with us via Intercom or another service. Toggling Telemetry off turns this off automatically.
diff --git a/admin/app/layout.tsx b/admin/app/layout.tsx index 19aba48e9a..ef9559af8c 100644 --- a/admin/app/layout.tsx +++ b/admin/app/layout.tsx @@ -4,11 +4,11 @@ import { ReactNode } from "react"; import { ThemeProvider, useTheme } from "next-themes"; import { SWRConfig } from "swr"; // ui +import { ADMIN_BASE_PATH, DEFAULT_SWR_CONFIG } from "@plane/constants"; import { Toast } from "@plane/ui"; +import { resolveGeneralTheme } from "@plane/utils"; // constants -import { SWR_CONFIG } from "@/constants/swr-config"; // helpers -import { ASSET_PREFIX, resolveGeneralTheme } from "@/helpers/common.helper"; // lib import { InstanceProvider } from "@/lib/instance-provider"; import { StoreProvider } from "@/lib/store-provider"; @@ -22,6 +22,7 @@ const ToastWithTheme = () => { }; 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 new file mode 100644 index 0000000000..2a7eda207e --- /dev/null +++ b/admin/app/workspace/create/form.tsx @@ -0,0 +1,212 @@ +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Controller, useForm } from "react-hook-form"; +// 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"; +// hooks +import { useWorkspace } from "@/hooks/store"; +// services +import { WorkspaceService } from "@/services/workspace.service"; + +const workspaceService = new WorkspaceService(); + +export const WorkspaceCreateForm = () => { + // router + const router = useRouter(); + // states + const [slugError, setSlugError] = useState(false); + const [invalidSlug, setInvalidSlug] = useState(false); + const [defaultValues, setDefaultValues] = useState>({ + name: "", + slug: "", + organization_size: "", + }); + // store hooks + const { createWorkspace } = useWorkspace(); + // form info + const { + handleSubmit, + control, + setValue, + getValues, + formState: { errors, isSubmitting, isValid }, + } = useForm({ defaultValues, mode: "onChange" }); + // derived values + const workspaceBaseURL = encodeURI(WEB_BASE_URL || window.location.origin + "/"); + + const handleCreateWorkspace = async (formData: IWorkspace) => { + await workspaceService + .workspaceSlugCheck(formData.slug) + .then(async (res) => { + if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) { + setSlugError(false); + await createWorkspace(formData) + .then(async () => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Workspace created successfully.", + }); + router.push(`/workspace`); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Workspace could not be created. Please try again.", + }); + }); + } else setSlugError(true); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Some error occurred while creating workspace. Please try again.", + }); + }); + }; + + useEffect( + () => () => { + // when the component unmounts set the default values to whatever user typed in + setDefaultValues(getValues()); + }, + [getValues, setDefaultValues] + ); + + return ( +
+
+
+

Name your workspace

+
+ + /^[\w\s-]*$/.test(value) || + `Workspaces names can contain only (" "), ( - ), ( _ ) and alphanumeric characters.`, + maxLength: { + value: 80, + message: "Limit your name to 80 characters.", + }, + }} + render={({ field: { value, ref, onChange } }) => ( + { + onChange(e.target.value); + setValue("name", e.target.value); + setValue("slug", e.target.value.toLocaleLowerCase().trim().replace(/ /g, "-"), { + shouldValidate: true, + }); + }} + ref={ref} + hasError={Boolean(errors.name)} + placeholder="Something familiar and recognizable is always best." + className="w-full" + /> + )} + /> + {errors?.name?.message} +
+
+
+

Set your workspace's URL

+
+ {workspaceBaseURL} + ( + { + if (/^[a-zA-Z0-9_-]+$/.test(e.target.value)) setInvalidSlug(false); + else setInvalidSlug(true); + onChange(e.target.value.toLowerCase()); + }} + ref={ref} + hasError={Boolean(errors.slug)} + placeholder="workspace-name" + className="block w-full rounded-md border-none bg-transparent !px-0 py-2 text-sm" + /> + )} + /> +
+ {slugError &&

This URL is taken. Try something else.

} + {invalidSlug && ( +

{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}

+ )} + {errors.slug && {errors.slug.message}} +
+
+

How many people will use this workspace?

+
+ ( + c === value) ?? ( + Select a range + ) + } + buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none" + input + optionsClassName="w-full" + > + {ORGANIZATION_SIZE.map((item) => ( + + {item} + + ))} + + )} + /> + {errors.organization_size && ( + {errors.organization_size.message} + )} +
+
+
+
+ + + Go back + +
+
+ ); +}; diff --git a/admin/app/workspace/create/page.tsx b/admin/app/workspace/create/page.tsx new file mode 100644 index 0000000000..0186286a7b --- /dev/null +++ b/admin/app/workspace/create/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { observer } from "mobx-react"; +// components +import { WorkspaceCreateForm } from "./form"; + +const WorkspaceCreatePage = observer(() => ( +
+
+
Create a new workspace on this instance.
+
+ You will need to invite users from Workspace Settings after you create this workspace. +
+
+
+ +
+
+)); + +export default WorkspaceCreatePage; diff --git a/admin/app/workspace/layout.tsx b/admin/app/workspace/layout.tsx new file mode 100644 index 0000000000..9f2a63c67d --- /dev/null +++ b/admin/app/workspace/layout.tsx @@ -0,0 +1,12 @@ +import { ReactNode } from "react"; +import { Metadata } from "next"; +// layouts +import { AdminLayout } from "@/layouts/admin-layout"; + +export const metadata: Metadata = { + title: "Workspace Management - Plane Web", +}; + +export default function WorkspaceManagementLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/admin/app/workspace/page.tsx b/admin/app/workspace/page.tsx new file mode 100644 index 0000000000..3ca34b69e3 --- /dev/null +++ b/admin/app/workspace/page.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import useSWR from "swr"; +import { Loader as LoaderIcon } from "lucide-react"; +// types +import { TInstanceConfigurationKeys } from "@plane/types"; +import { Button, getButtonStyling, Loader, setPromiseToast, ToggleSwitch } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { WorkspaceListItem } from "@/components/workspace"; +// hooks +import { useInstance, useWorkspace } from "@/hooks/store"; + +const WorkspaceManagementPage = observer(() => { + // states + const [isSubmitting, setIsSubmitting] = useState(false); + // store + const { formattedConfig, fetchInstanceConfigurations, updateInstanceConfigurations } = useInstance(); + const { + workspaceIds, + loader: workspaceLoader, + paginationInfo, + fetchWorkspaces, + fetchNextWorkspaces, + } = useWorkspace(); + // derived values + const disableWorkspaceCreation = formattedConfig?.DISABLE_WORKSPACE_CREATION ?? ""; + const hasNextPage = paginationInfo?.next_page_results && paginationInfo?.next_cursor !== undefined; + + // fetch data + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + useSWR("INSTANCE_WORKSPACES", () => fetchWorkspaces()); + + const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => { + setIsSubmitting(true); + + const payload = { + [key]: value, + }; + + const updateConfigPromise = updateInstanceConfigurations(payload); + + setPromiseToast(updateConfigPromise, { + loading: "Saving configuration", + success: { + title: "Success", + message: () => "Configuration saved successfully", + }, + error: { + title: "Error", + message: () => "Failed to save configuration", + }, + }); + + await updateConfigPromise + .then(() => { + setIsSubmitting(false); + }) + .catch((err) => { + console.error(err); + setIsSubmitting(false); + }); + }; + + return ( +
+
+
+
Workspaces on this instance
+
+ See all workspaces and control who can create them. +
+
+
+
+
+ {formattedConfig ? ( +
+
+
+
Prevent anyone else from creating a workspace.
+
+ Toggling this on will let only you create workspaces. You will have to invite users to new + workspaces. +
+
+
+
+
+ { + if (Boolean(parseInt(disableWorkspaceCreation)) === true) { + updateConfig("DISABLE_WORKSPACE_CREATION", "0"); + } else { + updateConfig("DISABLE_WORKSPACE_CREATION", "1"); + } + }} + size="sm" + disabled={isSubmitting} + /> +
+
+
+ ) : ( + + + + )} + {workspaceLoader !== "init-loader" ? ( + <> +
+
+
+ All workspaces on this instance{" "} + • {workspaceIds.length} + {workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && ( + + )} +
+
+ You can't yet delete workspaces and you can only go to the workspace if you are an Admin or a + Member. +
+
+
+ + Create workspace + +
+
+
+ {workspaceIds.map((workspaceId) => ( + + ))} +
+ {hasNextPage && ( +
+ +
+ )} + + ) : ( + + + + + + + )} +
+
+
+ ); +}); + +export default WorkspaceManagementPage; diff --git a/admin/ce/components/authentication/authentication-modes.tsx b/admin/ce/components/authentication/authentication-modes.tsx index 84cde94d44..3c7ec111a3 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 aa3c95fdbe..208225e0ce 100644 --- a/admin/ce/components/common/upgrade-button.tsx +++ b/admin/ce/components/common/upgrade-button.tsx @@ -3,14 +3,13 @@ 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 = () => ( - - Available on One + + Upgrade ); diff --git a/admin/core/components/admin-sidebar/help-section.tsx b/admin/core/components/admin-sidebar/help-section.tsx index abba68e3ea..d776477497 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 = [ @@ -52,13 +53,13 @@ export const HelpSection: FC = observer(() => { )} >
- + - {!isSidebarCollapsed && "Redirect to plane"} + {!isSidebarCollapsed && "Redirect to Plane"} diff --git a/admin/core/components/admin-sidebar/root.tsx b/admin/core/components/admin-sidebar/root.tsx index 9ef6b92bd0..05dde0d8ab 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 e0741f7c4a..f34372413f 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 c0f1d0bf22..618551ae65 100644 --- a/admin/core/components/admin-sidebar/sidebar-menu.tsx +++ b/admin/core/components/admin-sidebar/sidebar-menu.tsx @@ -4,41 +4,47 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react"; -import { Tooltip } from "@plane/ui"; +// 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 = [ { Icon: Cog, name: "General", - description: "Identify your instances and get key details", + description: "Identify your instances and get key details.", href: `/general/`, }, + { + Icon: WorkspaceIcon, + name: "Workspaces", + description: "Manage all workspaces on this instance.", + href: `/workspace/`, + }, { Icon: Mail, name: "Email", - description: "Set up emails to your users", + description: "Configure your SMTP controls.", href: `/email/`, }, { Icon: Lock, name: "Authentication", - description: "Configure authentication modes", + description: "Configure authentication modes.", href: `/authentication/`, }, { Icon: BrainCog, name: "Artificial intelligence", - description: "Configure your OpenAI creds", + description: "Configure your OpenAI creds.", href: `/ai/`, }, { Icon: Image, name: "Images in Plane", - description: "Allow third-party image libraries", + description: "Allow third-party image libraries.", href: `/image/`, }, ]; diff --git a/admin/core/components/auth-header.tsx b/admin/core/components/auth-header.tsx index e1de884cfc..5edcb61181 100644 --- a/admin/core/components/auth-header.tsx +++ b/admin/core/components/auth-header.tsx @@ -30,9 +30,13 @@ export const InstanceHeader: FC = observer(() => { case "google": return "Google"; case "github": - return "Github"; + return "GitHub"; case "gitlab": return "GitLab"; + case "workspace": + return "Workspace"; + case "create": + return "Create"; default: return pathName.toUpperCase(); } diff --git a/admin/core/components/authentication/auth-banner.tsx b/admin/core/components/authentication/auth-banner.tsx index 191d7a0a77..7c1e5ea292 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 50895a4592..897deb7c48 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 07c566d684..57035580f6 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 735201025b..4181338d21 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 12d11a2f89..0f3cc98e38 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 55f8b4afb4..88ad78a1e1 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 4d0eade08e..ca8f301620 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 9fc3938ff3..0b974b38c5 100644 --- a/admin/core/components/new-user-popup.tsx +++ b/admin/core/components/new-user-popup.tsx @@ -3,11 +3,11 @@ import React from "react"; 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"; -// helpers -import { WEB_BASE_URL, resolveGeneralTheme } from "helpers/common.helper"; +import { resolveGeneralTheme } from "@plane/utils"; // hooks import { useTheme } from "@/hooks/store"; // icons @@ -20,8 +20,6 @@ export const NewUserPopup: React.FC = observer(() => { // theme const { resolvedTheme } = nextUseTheme(); - const redirectionLink = encodeURI(WEB_BASE_URL + "/create-workspace"); - if (!isNewUserPopup) return <>; return (
@@ -30,12 +28,12 @@ export const NewUserPopup: React.FC = observer(() => {
Create workspace
Instance setup done! Welcome to Plane instance portal. Start your journey with by creating your first - workspace, you will need to login again. + workspace.
- + Create workspace - + diff --git a/admin/core/components/workspace/index.ts b/admin/core/components/workspace/index.ts new file mode 100644 index 0000000000..24950c4f20 --- /dev/null +++ b/admin/core/components/workspace/index.ts @@ -0,0 +1 @@ +export * from "./list-item"; diff --git a/admin/core/components/workspace/list-item.tsx b/admin/core/components/workspace/list-item.tsx new file mode 100644 index 0000000000..ae693eb728 --- /dev/null +++ b/admin/core/components/workspace/list-item.tsx @@ -0,0 +1,81 @@ +import { observer } from "mobx-react"; +import { ExternalLink } from "lucide-react"; +// plane internal packages +import { WEB_BASE_URL } from "@plane/constants"; +import { Tooltip } from "@plane/ui"; +import { getFileURL } from "@plane/utils"; +// hooks +import { useWorkspace } from "@/hooks/store"; + +type TWorkspaceListItemProps = { + workspaceId: string; +}; + +export const WorkspaceListItem = observer(({ workspaceId }: TWorkspaceListItemProps) => { + // store hooks + const { getWorkspaceById } = useWorkspace(); + // derived values + const workspace = getWorkspaceById(workspaceId); + + if (!workspace) return null; + return ( + +
+ + {workspace?.logo_url && workspace.logo_url !== "" ? ( + Workspace Logo + ) : ( + (workspace?.name?.[0] ?? "...") + )} + +
+
+

{workspace.name}

/ + +

[{workspace.slug}]

+
+
+ {workspace.owner.email && ( +
+

Owned by:

+

{workspace.owner.email}

+
+ )} +
+ {workspace.total_projects !== null && ( + +

Total projects:

+

{workspace.total_projects}

+
+ )} + {workspace.total_members !== null && ( + <> + • + +

Total members:

+

{workspace.total_members}

+
+ + )} +
+
+
+
+ +
+
+ ); +}); diff --git a/admin/core/constants/seo.ts b/admin/core/constants/seo.ts deleted file mode 100644 index aafd5f7a3e..0000000000 --- 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/hooks/store/index.ts b/admin/core/hooks/store/index.ts index 7447064da7..ed1781299f 100644 --- a/admin/core/hooks/store/index.ts +++ b/admin/core/hooks/store/index.ts @@ -1,3 +1,4 @@ export * from "./use-theme"; export * from "./use-instance"; export * from "./use-user"; +export * from "./use-workspace"; diff --git a/admin/core/hooks/store/use-workspace.tsx b/admin/core/hooks/store/use-workspace.tsx new file mode 100644 index 0000000000..e3bde92d53 --- /dev/null +++ b/admin/core/hooks/store/use-workspace.tsx @@ -0,0 +1,10 @@ +import { useContext } from "react"; +// store +import { StoreContext } from "@/lib/store-provider"; +import { IWorkspaceStore } from "@/store/workspace.store"; + +export const useWorkspace = (): IWorkspaceStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useWorkspace must be used within StoreProvider"); + return context.workspace; +}; diff --git a/admin/core/lib/auth-helpers.tsx b/admin/core/lib/auth-helpers.tsx new file mode 100644 index 0000000000..582b56e298 --- /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 2cea01beec..a47fd83962 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 feb94ceea4..510a780d79 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 42eb6eb224..74ef2a81bf 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 new file mode 100644 index 0000000000..787ad42698 --- /dev/null +++ b/admin/core/services/workspace.service.ts @@ -0,0 +1,52 @@ +// plane internal packages +import { API_BASE_URL } from "@plane/constants"; +import type { IWorkspace, TWorkspacePaginationInfo } from "@plane/types"; +// services +import { APIService } from "@/services/api.service"; + +export class WorkspaceService extends APIService { + constructor() { + super(API_BASE_URL); + } + + /** + * @description Fetches all workspaces + * @returns Promise + */ + async getWorkspaces(nextPageCursor?: string): Promise { + return this.get("/api/instances/workspaces/", { + cursor: nextPageCursor, + }) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * @description Checks if a slug is available + * @param slug - string + * @returns Promise + */ + async workspaceSlugCheck(slug: string): Promise { + const params = new URLSearchParams({ slug }); + return this.get(`/api/instances/workspace-slug-check/?${params.toString()}`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * @description Creates a new workspace + * @param data - IWorkspace + * @returns Promise + */ + async createWorkspace(data: IWorkspace): Promise { + return this.post("/api/instances/workspaces/", data) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/admin/core/store/instance.store.ts b/admin/core/store/instance.store.ts index 01ab552846..0daadb1fcd 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/root.store.ts b/admin/core/store/root.store.ts index 4b25bcc686..8c53061ab9 100644 --- a/admin/core/store/root.store.ts +++ b/admin/core/store/root.store.ts @@ -3,6 +3,7 @@ import { enableStaticRendering } from "mobx-react"; import { IInstanceStore, InstanceStore } from "./instance.store"; import { IThemeStore, ThemeStore } from "./theme.store"; import { IUserStore, UserStore } from "./user.store"; +import { IWorkspaceStore, WorkspaceStore } from "./workspace.store"; enableStaticRendering(typeof window === "undefined"); @@ -10,17 +11,20 @@ export abstract class CoreRootStore { theme: IThemeStore; instance: IInstanceStore; user: IUserStore; + workspace: IWorkspaceStore; constructor() { this.theme = new ThemeStore(this); this.instance = new InstanceStore(this); this.user = new UserStore(this); + this.workspace = new WorkspaceStore(this); } hydrate(initialData: any) { this.theme.hydrate(initialData.theme); this.instance.hydrate(initialData.instance); this.user.hydrate(initialData.user); + this.workspace.hydrate(initialData.workspace); } resetOnSignOut() { @@ -28,5 +32,6 @@ export abstract class CoreRootStore { this.instance = new InstanceStore(this); this.user = new UserStore(this); this.theme = new ThemeStore(this); + this.workspace = new WorkspaceStore(this); } } diff --git a/admin/core/store/user.store.ts b/admin/core/store/user.store.ts index df17c9b004..7f56c0523e 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/core/store/workspace.store.ts b/admin/core/store/workspace.store.ts new file mode 100644 index 0000000000..f892e14f0c --- /dev/null +++ b/admin/core/store/workspace.store.ts @@ -0,0 +1,150 @@ +import set from "lodash/set"; +import { action, observable, runInAction, makeObservable, computed } from "mobx"; +import { IWorkspace, TLoader, TPaginationInfo } from "@plane/types"; +// services +import { WorkspaceService } from "@/services/workspace.service"; +// root store +import { CoreRootStore } from "@/store/root.store"; + +export interface IWorkspaceStore { + // observables + loader: TLoader; + workspaces: Record; + paginationInfo: TPaginationInfo | undefined; + // computed + workspaceIds: string[]; + // helper actions + hydrate: (data: Record) => void; + getWorkspaceById: (workspaceId: string) => IWorkspace | undefined; + // fetch actions + fetchWorkspaces: () => Promise; + fetchNextWorkspaces: () => Promise; + // curd actions + createWorkspace: (data: IWorkspace) => Promise; +} + +export class WorkspaceStore implements IWorkspaceStore { + // observables + loader: TLoader = "init-loader"; + workspaces: Record = {}; + paginationInfo: TPaginationInfo | undefined = undefined; + // services + workspaceService; + + constructor(private store: CoreRootStore) { + makeObservable(this, { + // observables + loader: observable, + workspaces: observable, + paginationInfo: observable, + // computed + workspaceIds: computed, + // helper actions + hydrate: action, + getWorkspaceById: action, + // fetch actions + fetchWorkspaces: action, + fetchNextWorkspaces: action, + // curd actions + createWorkspace: action, + }); + this.workspaceService = new WorkspaceService(); + } + + // computed + get workspaceIds() { + return Object.keys(this.workspaces); + } + + // helper actions + /** + * @description Hydrates the workspaces + * @param data - Record + */ + hydrate = (data: Record) => { + if (data) this.workspaces = data; + }; + + /** + * @description Gets a workspace by id + * @param workspaceId - string + * @returns IWorkspace | undefined + */ + getWorkspaceById = (workspaceId: string) => this.workspaces[workspaceId]; + + // fetch actions + /** + * @description Fetches all workspaces + * @returns Promise<> + */ + fetchWorkspaces = async (): Promise => { + try { + if (this.workspaceIds.length > 0) { + this.loader = "mutation"; + } else { + this.loader = "init-loader"; + } + const paginatedWorkspaceData = await this.workspaceService.getWorkspaces(); + runInAction(() => { + const { results, ...paginationInfo } = paginatedWorkspaceData; + results.forEach((workspace: IWorkspace) => { + set(this.workspaces, [workspace.id], workspace); + }); + set(this, "paginationInfo", paginationInfo); + }); + return paginatedWorkspaceData.results; + } catch (error) { + console.error("Error fetching workspaces", error); + throw error; + } finally { + this.loader = "loaded"; + } + }; + + /** + * @description Fetches the next page of workspaces + * @returns Promise + */ + fetchNextWorkspaces = async (): Promise => { + if (!this.paginationInfo || this.paginationInfo.next_page_results === false) return []; + try { + this.loader = "pagination"; + const paginatedWorkspaceData = await this.workspaceService.getWorkspaces(this.paginationInfo.next_cursor); + runInAction(() => { + const { results, ...paginationInfo } = paginatedWorkspaceData; + results.forEach((workspace: IWorkspace) => { + set(this.workspaces, [workspace.id], workspace); + }); + set(this, "paginationInfo", paginationInfo); + }); + return paginatedWorkspaceData.results; + } catch (error) { + console.error("Error fetching next workspaces", error); + throw error; + } finally { + this.loader = "loaded"; + } + }; + + // curd actions + /** + * @description Creates a new workspace + * @param data - IWorkspace + * @returns Promise + */ + createWorkspace = async (data: IWorkspace): Promise => { + try { + this.loader = "mutation"; + const workspace = await this.workspaceService.createWorkspace(data); + runInAction(() => { + set(this.workspaces, [workspace.id], workspace); + }); + return workspace; + } catch (error) { + console.error("Error creating workspace", error); + throw error; + } finally { + this.loader = "loaded"; + } + }; +} diff --git a/admin/helpers/authentication.helper.tsx b/admin/helpers/authentication.helper.tsx deleted file mode 100644 index 627ff182ca..0000000000 --- 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 e282e57925..0000000000 --- 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 6e1f546360..0000000000 --- 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 ae6aab829c..0000000000 --- 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 dfe9a5c65f..0000000000 --- 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 a48508118e..0000000000 --- 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 41400da227..e2fe4cf331 100644 --- a/admin/package.json +++ b/admin/package.json @@ -1,6 +1,6 @@ { "name": "admin", - "version": "0.23.1", + "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 1748435814..f9bb7cf10b 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 97a2b2d410..b0fa447885 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 c81966de4f..3ec8c6340a 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 f26382c893..6d350d83c9 100644 --- a/apiserver/package.json +++ b/apiserver/package.json @@ -1,4 +1,4 @@ { "name": "plane-api", - "version": "0.23.1" + "version": "0.24.1" } diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index f4f06c324a..d394dc9bd6 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -4,7 +4,7 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer from plane.db.models import Cycle, CycleIssue - +from plane.utils.timezone_converter import convert_to_utc class CycleSerializer(BaseSerializer): total_issues = serializers.IntegerField(read_only=True) @@ -24,6 +24,18 @@ class CycleSerializer(BaseSerializer): and data.get("start_date", None) > data.get("end_date", None) ): raise serializers.ValidationError("Start date cannot exceed end date") + + if ( + data.get("start_date", None) is not None + and data.get("end_date", None) is not None + ): + project_id = self.initial_data.get("project_id") or self.instance.project_id + 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 72918b2683..3293b79dd9 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -237,17 +237,37 @@ class IssueSerializer(BaseSerializer): from .user import UserLiteSerializer data["assignees"] = UserLiteSerializer( - instance.assignees.all(), many=True + User.objects.filter( + pk__in=IssueAssignee.objects.filter(issue=instance).values_list( + "assignee_id", flat=True + ) + ), + many=True, ).data else: data["assignees"] = [ - str(assignee.id) for assignee in instance.assignees.all() + str(assignee) + for assignee in IssueAssignee.objects.filter( + issue=instance + ).values_list("assignee_id", flat=True) ] if "labels" in self.fields: if "labels" in self.expand: - data["labels"] = LabelSerializer(instance.labels.all(), many=True).data + data["labels"] = LabelSerializer( + Label.objects.filter( + pk__in=IssueLabel.objects.filter(issue=instance).values_list( + "label_id", flat=True + ) + ), + many=True, + ).data else: - data["labels"] = [str(label.id) for label in instance.labels.all()] + data["labels"] = [ + str(label) + for label in IssueLabel.objects.filter(issue=instance).values_list( + "label_id", flat=True + ) + ] return data diff --git a/apiserver/plane/api/views/intake.py b/apiserver/plane/api/views/intake.py index c2d0733ba9..faefc3761c 100644 --- a/apiserver/plane/api/views/intake.py +++ b/apiserver/plane/api/views/intake.py @@ -109,16 +109,6 @@ class IntakeIssueAPIEndpoint(BaseAPIView): {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST ) - # Create or get state - state, _ = State.objects.get_or_create( - name="Triage", - group="triage", - description="Default state for managing all Intake Issues", - project_id=project_id, - color="#ff7700", - is_triage=True, - ) - # create an issue issue = Issue.objects.create( name=request.data.get("issue", {}).get("name"), @@ -128,7 +118,6 @@ class IntakeIssueAPIEndpoint(BaseAPIView): ), priority=request.data.get("issue", {}).get("priority", "none"), project_id=project_id, - state=state, ) # create an intake issue diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index c52caac0fd..527f43edb5 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -259,7 +259,7 @@ class ProjectAPIEndpoint(BaseAPIView): ) intake_view = request.data.get( - "inbox_view", request.data.get("intake_view", False) + "inbox_view", request.data.get("intake_view", project.intake_view) ) if project.archived_at: diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index 2e7022688a..cd9adb939e 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -13,7 +13,6 @@ from .user import ( from .workspace import ( WorkSpaceSerializer, WorkSpaceMemberSerializer, - TeamSerializer, WorkSpaceMemberInviteSerializer, WorkspaceLiteSerializer, WorkspaceThemeSerializer, diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py index bf08de4fef..28ec62134c 100644 --- a/apiserver/plane/app/serializers/cycle.py +++ b/apiserver/plane/app/serializers/cycle.py @@ -5,6 +5,7 @@ from rest_framework import serializers from .base import BaseSerializer from .issue import IssueStateSerializer from plane.db.models import Cycle, CycleIssue, CycleUserProperties +from plane.utils.timezone_converter import convert_to_utc class CycleWriteSerializer(BaseSerializer): @@ -15,6 +16,17 @@ class CycleWriteSerializer(BaseSerializer): and data.get("start_date", None) > data.get("end_date", None) ): raise serializers.ValidationError("Start date cannot exceed end date") + if ( + data.get("start_date", None) is not None + and data.get("end_date", None) is not None + ): + project_id = self.initial_data.get("project_id") 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/workspace.py b/apiserver/plane/app/serializers/workspace.py index 03e64e32ac..49cd55bf7f 100644 --- a/apiserver/plane/app/serializers/workspace.py +++ b/apiserver/plane/app/serializers/workspace.py @@ -6,11 +6,8 @@ from .base import BaseSerializer, DynamicBaseSerializer from .user import UserLiteSerializer, UserAdminLiteSerializer from plane.db.models import ( - User, Workspace, WorkspaceMember, - Team, - TeamMember, WorkspaceMemberInvite, WorkspaceTheme, WorkspaceUserProperties, @@ -97,52 +94,6 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer): ] -class TeamSerializer(BaseSerializer): - members_detail = UserLiteSerializer(read_only=True, source="members", many=True) - members = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), - write_only=True, - required=False, - ) - - class Meta: - model = Team - fields = "__all__" - read_only_fields = [ - "workspace", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - - def create(self, validated_data, **kwargs): - if "members" in validated_data: - members = validated_data.pop("members") - workspace = self.context["workspace"] - team = Team.objects.create(**validated_data, workspace=workspace) - team_members = [ - TeamMember(member=member, team=team, workspace=workspace) - for member in members - ] - TeamMember.objects.bulk_create(team_members, batch_size=10) - return team - team = Team.objects.create(**validated_data) - return team - - def update(self, instance, validated_data): - if "members" in validated_data: - members = validated_data.pop("members") - TeamMember.objects.filter(team=instance).delete() - team_members = [ - TeamMember(member=member, team=instance, workspace=instance.workspace) - for member in members - ] - TeamMember.objects.bulk_create(team_members, batch_size=10) - return super().update(instance, validated_data) - return super().update(instance, validated_data) - - class WorkspaceThemeSerializer(BaseSerializer): class Meta: model = WorkspaceTheme diff --git a/apiserver/plane/app/urls/__init__.py b/apiserver/plane/app/urls/__init__.py index 8798e80440..3be75536b2 100644 --- a/apiserver/plane/app/urls/__init__.py +++ b/apiserver/plane/app/urls/__init__.py @@ -17,6 +17,7 @@ from .user import urlpatterns as user_urls from .views import urlpatterns as view_urls from .webhook import urlpatterns as webhook_urls from .workspace import urlpatterns as workspace_urls +from .timezone import urlpatterns as timezone_urls urlpatterns = [ *analytic_urls, @@ -38,4 +39,5 @@ urlpatterns = [ *workspace_urls, *api_urls, *webhook_urls, + *timezone_urls, ] diff --git a/apiserver/plane/app/urls/project.py b/apiserver/plane/app/urls/project.py index 3f41caee81..4037402abf 100644 --- a/apiserver/plane/app/urls/project.py +++ b/apiserver/plane/app/urls/project.py @@ -7,7 +7,6 @@ from plane.app.views import ( ProjectMemberViewSet, ProjectMemberUserEndpoint, ProjectJoinEndpoint, - AddTeamToProjectEndpoint, ProjectUserViewsEndpoint, ProjectIdentifierEndpoint, ProjectFavoritesViewSet, @@ -83,11 +82,6 @@ urlpatterns = [ ProjectMemberViewSet.as_view({"post": "leave"}), name="project-member", ), - path( - "workspaces//projects//team-invite/", - AddTeamToProjectEndpoint.as_view(), - name="projects", - ), path( "workspaces//projects//project-views/", ProjectUserViewsEndpoint.as_view(), diff --git a/apiserver/plane/app/urls/search.py b/apiserver/plane/app/urls/search.py index bbea8093dd..de4f1e7b2c 100644 --- a/apiserver/plane/app/urls/search.py +++ b/apiserver/plane/app/urls/search.py @@ -1,7 +1,7 @@ from django.urls import path -from plane.app.views import GlobalSearchEndpoint, IssueSearchEndpoint +from plane.app.views import GlobalSearchEndpoint, IssueSearchEndpoint, SearchEndpoint urlpatterns = [ @@ -15,4 +15,9 @@ urlpatterns = [ IssueSearchEndpoint.as_view(), name="project-issue-search", ), + path( + "workspaces//projects//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 0000000000..ff14d029f2 --- /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 00e9969953..d91fdb60bc 100644 --- a/apiserver/plane/app/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -10,7 +10,6 @@ from plane.app.views import ( WorkspaceMemberUserEndpoint, WorkspaceMemberUserViewsEndpoint, WorkSpaceAvailabilityCheckEndpoint, - TeamMemberViewSet, UserLastProjectWithWorkspaceEndpoint, WorkspaceThemeViewSet, WorkspaceUserProfileStatsEndpoint, @@ -100,23 +99,6 @@ urlpatterns = [ WorkSpaceMemberViewSet.as_view({"post": "leave"}), name="leave-workspace-members", ), - path( - "workspaces//teams/", - TeamMemberViewSet.as_view({"get": "list", "post": "create"}), - name="workspace-team-members", - ), - path( - "workspaces//teams//", - TeamMemberViewSet.as_view( - { - "put": "update", - "patch": "partial_update", - "delete": "destroy", - "get": "retrieve", - } - ), - name="workspace-team-members", - ), path( "users/last-visited-workspace/", UserLastProjectWithWorkspaceEndpoint.as_view(), diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index c21000a4eb..56ea78b413 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -16,7 +16,6 @@ from .project.invite import ( from .project.member import ( ProjectMemberViewSet, - AddTeamToProjectEndpoint, ProjectMemberUserEndpoint, UserProjectRolesEndpoint, ) @@ -49,7 +48,6 @@ from .workspace.favorite import ( from .workspace.member import ( WorkSpaceMemberViewSet, - TeamMemberViewSet, WorkspaceMemberUserEndpoint, WorkspaceProjectMemberEndpoint, WorkspaceMemberUserViewsEndpoint, @@ -88,8 +86,6 @@ from .cycle.base import ( CycleFavoriteViewSet, TransferCycleIssueEndpoint, CycleUserPropertiesEndpoint, - CycleViewSet, - TransferCycleIssueEndpoint, CycleAnalyticsEndpoint, CycleProgressEndpoint, ) @@ -162,7 +158,7 @@ from .page.base import ( ) from .page.version import PageVersionEndpoint -from .search.base import GlobalSearchEndpoint +from .search.base import GlobalSearchEndpoint, SearchEndpoint from .search.issue import IssueSearchEndpoint @@ -206,6 +202,7 @@ from .dashboard.base import DashboardEndpoint, WidgetsEndpoint from .error_404 import custom_404_view -from .exporter.base import ExportIssuesEndpoint 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 827c959084..3c1442022f 100644 --- a/apiserver/plane/app/views/asset/v2.py +++ b/apiserver/plane/app/views/asset/v2.py @@ -126,7 +126,13 @@ class UserAssetsV2Endpoint(BaseAPIView): ) # Check if the file type is allowed - allowed_types = ["image/jpeg", "image/png", "image/webp", "image/jpg"] + allowed_types = [ + "image/jpeg", + "image/png", + "image/webp", + "image/jpg", + "image/gif", + ] if type not in allowed_types: return Response( { diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 61ea9eed46..9bf498886b 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 @@ from plane.bgtasks.recent_visited_task import recent_visited_task # Module imports from .. import BaseAPIView, BaseViewSet from plane.bgtasks.webhook_task import model_activity +from plane.utils.timezone_converter import ( + convert_utc_to_project_timezone, + convert_to_utc, + user_timezone_converter, +) class CycleViewSet(BaseViewSet): @@ -67,6 +74,19 @@ class CycleViewSet(BaseViewSet): project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), ) + + project = Project.objects.get(id=self.kwargs.get("project_id")) + + # Fetch project for the specific record or pass project_id dynamically + project_timezone = project.timezone + + # Convert the current time (timezone.now()) to the project's timezone + local_tz = pytz.timezone(project_timezone) + current_time_in_project_tz = timezone.now().astimezone(local_tz) + + # Convert project local time back to UTC for comparison (start_date is stored in UTC) + current_time_in_utc = current_time_in_project_tz.astimezone(pytz.utc) + return self.filter_queryset( super() .get_queryset() @@ -119,12 +139,15 @@ class CycleViewSet(BaseViewSet): .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 @@ class CycleViewSet(BaseViewSet): # Update the order by queryset = queryset.order_by("-is_favorite", "-created_at") + project = Project.objects.get(id=self.kwargs.get("project_id")) + + # Fetch project for the specific record or pass project_id dynamically + project_timezone = project.timezone + + # Convert the current time (timezone.now()) to the project's timezone + local_tz = pytz.timezone(project_timezone) + current_time_in_project_tz = timezone.now().astimezone(local_tz) + + # Convert project local time back to UTC for comparison (start_date is stored in UTC) + current_time_in_utc = current_time_in_project_tz.astimezone(pytz.utc) + # Current Cycle if cycle_view == "current": queryset = queryset.filter( - start_date__lte=timezone.now(), end_date__gte=timezone.now() + start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc ) data = queryset.values( @@ -191,6 +226,8 @@ class CycleViewSet(BaseViewSet): "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 @@ class CycleViewSet(BaseViewSet): "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 @@ class CycleViewSet(BaseViewSet): ) queryset = queryset.first() + datetime_fields = ["start_date", "end_date"] + data = user_timezone_converter(data, datetime_fields, request.user.user_timezone) recent_visited_task.delay( slug=slug, @@ -492,6 +533,9 @@ class CycleDateCheckEndpoint(BaseAPIView): 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 3d548aeacc..01f4068eee 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -15,8 +15,6 @@ from django.db.models import ( UUIDField, Value, Subquery, - Case, - When, ) from django.db.models.functions import Coalesce from django.utils import timezone @@ -56,10 +54,11 @@ from plane.utils.issue_filters import issue_filters from plane.utils.order_queryset import order_issue_queryset from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator from .. import BaseAPIView, BaseViewSet -from plane.utils.user_timezone_converter import user_timezone_converter +from plane.utils.timezone_converter import user_timezone_converter from plane.bgtasks.recent_visited_task import recent_visited_task from plane.utils.global_paginator import paginate from plane.bgtasks.webhook_task import model_activity +from plane.bgtasks.issue_description_version_task import issue_description_version_task class IssueListEndpoint(BaseAPIView): @@ -430,6 +429,13 @@ class IssueViewSet(BaseViewSet): slug=slug, origin=request.META.get("HTTP_ORIGIN"), ) + # updated issue description version + issue_description_version_task.delay( + updated_issue=json.dumps(request.data, cls=DjangoJSONEncoder), + issue_id=str(serializer.data["id"]), + user_id=request.user.id, + is_creating=True, + ) return Response(issue, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -445,12 +451,10 @@ class IssueViewSet(BaseViewSet): .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( @@ -653,6 +657,12 @@ class IssueViewSet(BaseViewSet): slug=slug, origin=request.META.get("HTTP_ORIGIN"), ) + # updated issue description version + issue_description_version_task.delay( + updated_issue=current_instance, + issue_id=str(serializer.data.get("id", None)), + user_id=request.user.id, + ) return Response(status=status.HTTP_204_NO_CONTENT) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index e461917fb3..19e2522d2c 100644 --- a/apiserver/plane/app/views/issue/sub_issue.py +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -20,7 +20,7 @@ from plane.app.serializers import IssueSerializer from plane.app.permissions import ProjectEntityPermission from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue from plane.bgtasks.issue_activities_task import issue_activity -from plane.utils.user_timezone_converter import user_timezone_converter +from plane.utils.timezone_converter import user_timezone_converter from collections import defaultdict diff --git a/apiserver/plane/app/views/module/archive.py b/apiserver/plane/app/views/module/archive.py index 82c1d47eb4..d5c632f966 100644 --- a/apiserver/plane/app/views/module/archive.py +++ b/apiserver/plane/app/views/module/archive.py @@ -28,7 +28,7 @@ from plane.app.permissions import ProjectEntityPermission from plane.app.serializers import ModuleDetailSerializer from plane.db.models import Issue, Module, ModuleLink, UserFavorite, Project from plane.utils.analytics_plot import burndown_plot -from plane.utils.user_timezone_converter import user_timezone_converter +from plane.utils.timezone_converter import user_timezone_converter # Module imports diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index 8f9839b71f..3e3a4c2db7 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -56,7 +56,7 @@ from plane.db.models import ( 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/page/base.py b/apiserver/plane/app/views/page/base.py index 24ceb2d3f1..46ce81ce17 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -114,7 +114,7 @@ class PageViewSet(BaseViewSet): .distinct() ) - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def create(self, request, slug, project_id): serializer = PageSerializer( data=request.data, @@ -134,7 +134,7 @@ class PageViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def partial_update(self, request, slug, project_id, pk): try: page = Page.objects.get( @@ -234,7 +234,7 @@ class PageViewSet(BaseViewSet): ) return Response(data, status=status.HTTP_200_OK) - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + @allow_permission([ROLE.ADMIN], model=Page, creator=True) def lock(self, request, slug, project_id, pk): page = Page.objects.filter( pk=pk, workspace__slug=slug, projects__id=project_id @@ -244,7 +244,7 @@ class PageViewSet(BaseViewSet): page.save() return Response(status=status.HTTP_204_NO_CONTENT) - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + @allow_permission([ROLE.ADMIN], model=Page, creator=True) def unlock(self, request, slug, project_id, pk): page = Page.objects.filter( pk=pk, workspace__slug=slug, projects__id=project_id @@ -255,7 +255,7 @@ class PageViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + @allow_permission([ROLE.ADMIN], model=Page, creator=True) def access(self, request, slug, project_id, pk): access = request.data.get("access", 0) page = Page.objects.filter( @@ -296,7 +296,7 @@ class PageViewSet(BaseViewSet): pages = PageSerializer(queryset, many=True).data return Response(pages, status=status.HTTP_200_OK) - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + @allow_permission([ROLE.ADMIN], model=Page, creator=True) def archive(self, request, slug, project_id, pk): page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id) @@ -323,7 +323,7 @@ class PageViewSet(BaseViewSet): return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK) - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + @allow_permission([ROLE.ADMIN], model=Page, creator=True) def unarchive(self, request, slug, project_id, pk): page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id) @@ -348,7 +348,7 @@ class PageViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) - @allow_permission([ROLE.ADMIN], creator=True, model=Page) + @allow_permission([ROLE.ADMIN], model=Page, creator=True) def destroy(self, request, slug, project_id, pk): page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id) diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 879d3ed3c9..16b2a6e775 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -384,11 +384,9 @@ class ProjectViewSet(BaseViewSet): ) workspace = Workspace.objects.get(slug=slug) - intake_view = request.data.get( - "inbox_view", request.data.get("intake_view", False) - ) project = Project.objects.get(pk=pk) + intake_view = request.data.get("inbox_view", project.intake_view) current_instance = json.dumps( ProjectSerializer(project).data, cls=DjangoJSONEncoder ) diff --git a/apiserver/plane/app/views/project/invite.py b/apiserver/plane/app/views/project/invite.py index d36036b98f..e4d46e89f4 100644 --- a/apiserver/plane/app/views/project/invite.py +++ b/apiserver/plane/app/views/project/invite.py @@ -136,7 +136,7 @@ class UserProjectInvitationsViewset(BaseViewSet): member=request.user, workspace__slug=slug, is_active=True ) - if workspace_member.role != ROLE.ADMIN: + if workspace_member.role not in [ROLE.ADMIN.value, ROLE.MEMBER.value]: return Response( {"error": "You do not have permission to join the project"}, status=status.HTTP_403_FORBIDDEN, diff --git a/apiserver/plane/app/views/project/member.py b/apiserver/plane/app/views/project/member.py index f46bf6c501..55d2d4a58b 100644 --- a/apiserver/plane/app/views/project/member.py +++ b/apiserver/plane/app/views/project/member.py @@ -11,20 +11,12 @@ from plane.app.serializers import ( ) from plane.app.permissions import ( - ProjectBasePermission, ProjectMemberPermission, ProjectLitePermission, WorkspaceUserPermission, ) -from plane.db.models import ( - Project, - ProjectMember, - Workspace, - TeamMember, - 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 @@ -309,53 +301,6 @@ class ProjectMemberViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) -class AddTeamToProjectEndpoint(BaseAPIView): - permission_classes = [ProjectBasePermission] - - def post(self, request, slug, project_id): - team_members = TeamMember.objects.filter( - workspace__slug=slug, team__in=request.data.get("teams", []) - ).values_list("member", flat=True) - - if len(team_members) == 0: - return Response( - {"error": "No such team exists"}, status=status.HTTP_400_BAD_REQUEST - ) - - workspace = Workspace.objects.get(slug=slug) - - project_members = [] - issue_props = [] - for member in team_members: - project_members.append( - ProjectMember( - project_id=project_id, - member_id=member, - workspace=workspace, - created_by=request.user, - ) - ) - issue_props.append( - IssueUserProperty( - project_id=project_id, - user_id=member, - workspace=workspace, - created_by=request.user, - ) - ) - - ProjectMember.objects.bulk_create( - project_members, batch_size=10, ignore_conflicts=True - ) - - _ = IssueUserProperty.objects.bulk_create( - issue_props, batch_size=10, ignore_conflicts=True - ) - - serializer = ProjectMemberSerializer(project_members, many=True) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - class ProjectMemberUserEndpoint(BaseAPIView): def get(self, request, slug, project_id): project_member = ProjectMember.objects.get( diff --git a/apiserver/plane/app/views/search/base.py b/apiserver/plane/app/views/search/base.py index 5161103f54..3736d8f81a 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,6 +32,7 @@ from plane.db.models import ( Module, Page, IssueView, + ProjectMember, ProjectPage, ) @@ -237,3 +249,217 @@ class GlobalSearchEndpoint(BaseAPIView): func = MODELS_MAPPER.get(model, None) results[model] = func(query, slug, project_id, workspace_search) return Response({"results": results}, status=status.HTTP_200_OK) + + +class SearchEndpoint(BaseAPIView): + def get(self, request, slug, project_id): + 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)) + + response_data = {} + + for query_type in query_types: + if query_type == "user_mention": + fields = [ + "member__first_name", + "member__last_name", + "member__display_name", + ] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + users = ( + ProjectMember.objects.filter( + q, is_active=True, project_id=project_id, workspace__slug=slug, member__is_bot=False + ) + .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, + 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, + ) + .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, + 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) + + else: + return Response( + {"error": f"Invalid query type: {query_type}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + 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 0000000000..77c8770473 --- /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 6ffb643a99..058f7702ab 100644 --- a/apiserver/plane/app/views/workspace/base.py +++ b/apiserver/plane/app/views/workspace/base.py @@ -1,6 +1,7 @@ # Python imports import csv import io +import os from datetime import date from dateutil.relativedelta import relativedelta @@ -38,6 +39,7 @@ from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_control from django.views.decorators.vary import vary_on_cookie from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS +from plane.license.utils.instance_value import get_configuration_value class WorkSpaceViewSet(BaseViewSet): @@ -80,6 +82,21 @@ class WorkSpaceViewSet(BaseViewSet): def create(self, request): try: + (DISABLE_WORKSPACE_CREATION,) = get_configuration_value( + [ + { + "key": "DISABLE_WORKSPACE_CREATION", + "default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"), + } + ] + ) + + if DISABLE_WORKSPACE_CREATION == "1": + return Response( + {"error": "Workspace creation is not allowed"}, + status=status.HTTP_403_FORBIDDEN, + ) + serializer = WorkSpaceSerializer(data=request.data) slug = request.data.get("slug", False) @@ -337,6 +354,7 @@ class ExportWorkspaceUserActivityEndpoint(BaseAPIView): workspace__slug=slug, created_at__date=request.data.get("date"), project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, actor_id=user_id, ).select_related("actor", "workspace", "issue", "project")[:10000] diff --git a/apiserver/plane/app/views/workspace/cycle.py b/apiserver/plane/app/views/workspace/cycle.py index ec08f47c95..a9398a91db 100644 --- a/apiserver/plane/app/views/workspace/cycle.py +++ b/apiserver/plane/app/views/workspace/cycle.py @@ -10,7 +10,7 @@ from plane.app.views.base import BaseAPIView from plane.db.models import Cycle from plane.app.permissions import WorkspaceViewerPermission from plane.app.serializers.cycle import CycleSerializer - +from plane.utils.timezone_converter import user_timezone_converter class WorkspaceCyclesEndpoint(BaseAPIView): permission_classes = [WorkspaceViewerPermission] diff --git a/apiserver/plane/app/views/workspace/member.py b/apiserver/plane/app/views/workspace/member.py index 3f5a4bd2b2..9541f99803 100644 --- a/apiserver/plane/app/views/workspace/member.py +++ b/apiserver/plane/app/views/workspace/member.py @@ -1,38 +1,22 @@ # Django imports -from django.db.models import CharField, Count, Q, OuterRef, Subquery, IntegerField +from django.db.models import Count, Q, OuterRef, Subquery, IntegerField from django.db.models.functions import Coalesce -from django.db.models.functions import Cast # Third party modules from rest_framework import status from rest_framework.response import Response -from plane.app.permissions import ( - WorkSpaceAdminPermission, - WorkspaceEntityPermission, - allow_permission, - ROLE, -) +from plane.app.permissions import WorkspaceEntityPermission, allow_permission, ROLE # Module imports from plane.app.serializers import ( ProjectMemberRoleSerializer, - TeamSerializer, - UserLiteSerializer, WorkspaceMemberAdminSerializer, WorkspaceMemberMeSerializer, WorkSpaceMemberSerializer, ) from plane.app.views.base import BaseAPIView -from plane.db.models import ( - Project, - ProjectMember, - Team, - User, - Workspace, - WorkspaceMember, - DraftIssue, -) +from plane.db.models import Project, ProjectMember, WorkspaceMember, DraftIssue from plane.utils.cache import invalidate_cache from .. import BaseViewSet @@ -284,53 +268,3 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView): project_members_dict[str(project_id)].append(project_member) return Response(project_members_dict, status=status.HTTP_200_OK) - - -class TeamMemberViewSet(BaseViewSet): - serializer_class = TeamSerializer - model = Team - permission_classes = [WorkSpaceAdminPermission] - - search_fields = ["member__display_name", "member__first_name"] - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("workspace", "workspace__owner") - .prefetch_related("members") - ) - - def create(self, request, slug): - members = list( - WorkspaceMember.objects.filter( - workspace__slug=slug, - member__id__in=request.data.get("members", []), - is_active=True, - ) - .annotate(member_str_id=Cast("member", output_field=CharField())) - .distinct() - .values_list("member_str_id", flat=True) - ) - - if len(members) != len(request.data.get("members", [])): - users = list(set(request.data.get("members", [])).difference(members)) - users = User.objects.filter(pk__in=users) - - serializer = UserLiteSerializer(users, many=True) - return Response( - { - "error": f"{len(users)} of the member(s) are not a part of the workspace", - "members": serializer.data, - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - workspace = Workspace.objects.get(slug=slug) - - serializer = TeamSerializer(data=request.data, context={"workspace": workspace}) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/authentication/views/app/check.py b/apiserver/plane/authentication/views/app/check.py index c7e4b8a5e2..0ad1db61f3 100644 --- a/apiserver/plane/authentication/views/app/check.py +++ b/apiserver/plane/authentication/views/app/check.py @@ -60,6 +60,9 @@ class EmailCheckEndpoint(APIView): ) 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/app/magic.py b/apiserver/plane/authentication/views/app/magic.py index e027843334..b3bf8c7776 100644 --- a/apiserver/plane/authentication/views/app/magic.py +++ b/apiserver/plane/authentication/views/app/magic.py @@ -44,10 +44,8 @@ class MagicGenerateEndpoint(APIView): return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) origin = request.META.get("HTTP_ORIGIN", "/") - email = request.data.get("email", False) + email = request.data.get("email", "").strip().lower() try: - # Clean up the email - email = email.strip().lower() validate_email(email) adapter = MagicCodeProvider(request=request, key=email) key, token = adapter.initiate() diff --git a/apiserver/plane/authentication/views/space/check.py b/apiserver/plane/authentication/views/space/check.py index 9b4d8aa56d..c8a4539b71 100644 --- a/apiserver/plane/authentication/views/space/check.py +++ b/apiserver/plane/authentication/views/space/check.py @@ -60,6 +60,7 @@ class EmailCheckSpaceEndpoint(APIView): ) 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/authentication/views/space/magic.py b/apiserver/plane/authentication/views/space/magic.py index e763aaa07c..7c23d5fc34 100644 --- a/apiserver/plane/authentication/views/space/magic.py +++ b/apiserver/plane/authentication/views/space/magic.py @@ -39,10 +39,8 @@ class MagicGenerateSpaceEndpoint(APIView): return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) origin = base_host(request=request, is_space=True) - email = request.data.get("email", False) + email = request.data.get("email", "").strip().lower() try: - # Clean up the email - email = email.strip().lower() validate_email(email) adapter = MagicCodeProvider(request=request, key=email) key, token = adapter.initiate() diff --git a/apiserver/plane/bgtasks/deletion_task.py b/apiserver/plane/bgtasks/deletion_task.py index 0752272e3c..30ff7e8bd3 100644 --- a/apiserver/plane/bgtasks/deletion_task.py +++ b/apiserver/plane/bgtasks/deletion_task.py @@ -3,7 +3,8 @@ from django.utils import timezone 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 @@ from celery import shared_task @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: - 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 + # 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: + 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 f7b19f00ab..33e382f441 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 0000000000..14956cb50c --- /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 0000000000..a29fb6c572 --- /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 0000000000..698cedf155 --- /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 e4a5417299..e58344bbf2 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -1,6 +1,8 @@ # Python imports import json import uuid +from uuid import UUID + # Module imports from plane.db.models import ( @@ -16,8 +18,9 @@ from plane.db.models import ( IssueComment, IssueActivity, UserNotificationPreference, - ProjectMember + ProjectMember, ) +from django.db.models import Subquery # Third Party imports from celery import shared_task @@ -29,7 +32,6 @@ from bs4 import BeautifulSoup def update_mentions_for_issue(issue, project, new_mentions, removed_mention): aggregated_issue_mentions = [] - for mention_id in new_mentions: aggregated_issue_mentions.append( IssueMention( @@ -95,7 +97,8 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions): ).exists() and not Issue.objects.filter( project_id=project_id, pk=issue_id, created_by_id=mention_id - ).exists() and ProjectMember.objects.filter( + ).exists() + and ProjectMember.objects.filter( project_id=project_id, member_id=mention_id, is_active=True ).exists() ): @@ -121,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] @@ -135,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)) @@ -242,14 +249,18 @@ def notifications( 2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers """ + # get the list of active project members + project_members = ProjectMember.objects.filter( + project_id=project_id, is_active=True + ).values_list("member_id", flat=True) + # Get new mentions from the newer instance new_mentions = get_new_mentions( requested_instance=requested_data, current_instance=current_instance ) - new_mentions = list(ProjectMember.objects.filter( - project_id=project_id, member_id__in=new_mentions, is_active=True - ).values_list("member_id", flat=True)) - new_mentions = [str(member_id) for member_id in new_mentions] + 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 ) @@ -280,6 +291,11 @@ def notifications( new_value=issue_comment_new_value, ) comment_mentions = comment_mentions + new_comment_mentions + comment_mentions = [ + mention + for mention in comment_mentions + if UUID(mention) in set(project_members) + ] comment_mention_subscribers = extract_mentions_as_subscribers( project_id=project_id, issue_id=issue_id, mentions=all_comment_mentions @@ -293,7 +309,11 @@ def notifications( # ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- # issue_subscribers = list( - IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id, project__project_projectmember__is_active=True,) + IssueSubscriber.objects.filter( + project_id=project_id, + issue_id=issue_id, + subscriber__in=Subquery(project_members), + ) .exclude( subscriber_id__in=list(new_mentions + comment_mentions + [actor_id]) ) @@ -314,7 +334,9 @@ def notifications( project = Project.objects.get(pk=project_id) issue_assignees = IssueAssignee.objects.filter( - issue_id=issue_id, project_id=project_id + issue_id=issue_id, + project_id=project_id, + assignee__in=Subquery(project_members), ).values_list("assignee", flat=True) issue_subscribers = list(set(issue_subscribers) - {uuid.UUID(actor_id)}) diff --git a/apiserver/plane/db/management/commands/create_project_member.py b/apiserver/plane/db/management/commands/create_project_member.py index a2a5c669e4..927f97e9d5 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 @@ from plane.db.models import ( 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 @@ class Command(BaseCommand): # 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 @@ class Command(BaseCommand): 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 @@ class Command(BaseCommand): # 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 0000000000..7ff2fc3914 --- /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 0000000000..2b6632f261 --- /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/0086_issueversion_alter_teampage_unique_together_and_more.py b/apiserver/plane/db/migrations/0086_issueversion_alter_teampage_unique_together_and_more.py new file mode 100644 index 0000000000..d38f17c5d5 --- /dev/null +++ b/apiserver/plane/db/migrations/0086_issueversion_alter_teampage_unique_together_and_more.py @@ -0,0 +1,242 @@ +# Generated by Django 4.2.15 on 2024-11-27 09:07 + +from django.conf import settings +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import plane.db.models.webhook +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0085_intake_intakeissue_remove_inboxissue_created_by_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="IssueVersion", + 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, + ), + ), + ("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( + choices=[ + ("urgent", "Urgent"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ("none", "None"), + ], + default="none", + max_length=30, + verbose_name="Issue Priority", + ), + ), + ("start_date", models.DateField(blank=True, null=True)), + ("target_date", models.DateField(blank=True, null=True)), + ( + "sequence_id", + models.IntegerField(default=1, verbose_name="Issue Sequence ID"), + ), + ("sort_order", models.FloatField(default=65535)), + ("completed_at", models.DateTimeField(null=True)), + ("archived_at", models.DateField(null=True)), + ("is_draft", models.BooleanField(default=False)), + ( + "external_source", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "external_id", + models.CharField(blank=True, max_length=255, null=True), + ), + ("type", models.UUIDField(blank=True, null=True)), + ( + "last_saved_at", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("owned_by", models.UUIDField()), + ( + "assignees", + django.contrib.postgres.fields.ArrayField( + base_field=models.UUIDField(), + blank=True, + default=list, + size=None, + ), + ), + ( + "labels", + django.contrib.postgres.fields.ArrayField( + base_field=models.UUIDField(), + blank=True, + default=list, + size=None, + ), + ), + ("cycle", models.UUIDField(blank=True, null=True)), + ( + "modules", + django.contrib.postgres.fields.ArrayField( + base_field=models.UUIDField(), + blank=True, + default=list, + size=None, + ), + ), + ("properties", models.JSONField(default=dict)), + ("meta", 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", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="versions", + to="db.issue", + ), + ), + ( + "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 Version", + "verbose_name_plural": "Issue Versions", + "db_table": "issue_versions", + "ordering": ("-created_at",), + }, + ), + migrations.AlterUniqueTogether( + name="teampage", + unique_together=None, + ), + migrations.RemoveField( + model_name="teampage", + name="created_by", + ), + migrations.RemoveField( + model_name="teampage", + name="page", + ), + migrations.RemoveField( + model_name="teampage", + name="team", + ), + migrations.RemoveField( + model_name="teampage", + name="updated_by", + ), + migrations.RemoveField( + model_name="teampage", + name="workspace", + ), + migrations.RemoveField( + model_name="page", + name="teams", + ), + migrations.RemoveField( + model_name="team", + name="members", + ), + migrations.AddField( + model_name="fileasset", + name="entity_identifier", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="webhook", + name="is_internal", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="fileasset", + name="entity_type", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="webhook", + name="url", + field=models.URLField( + max_length=1024, + validators=[ + plane.db.models.webhook.validate_schema, + plane.db.models.webhook.validate_domain, + ], + ), + ), + migrations.DeleteModel( + name="TeamMember", + ), + migrations.DeleteModel( + name="TeamPage", + ), + ] 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 0000000000..086f52316e --- /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/models/__init__.py b/apiserver/plane/db/models/__init__.py index d00a2fa07c..1cbd627616 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -41,6 +41,8 @@ from .issue import ( IssueSequence, IssueSubscriber, IssueVote, + IssueVersion, + IssueDescriptionVersion, ) from .module import Module, ModuleIssue, ModuleLink, ModuleMember, ModuleUserProperties from .notification import EmailNotificationLog, Notification, UserNotificationPreference @@ -53,7 +55,6 @@ from .project import ( ProjectMemberInvite, ProjectPublicMember, ) -from .deploy_board import DeployBoard from .session import Session from .social_connection import SocialLoginConnection from .state import State @@ -61,8 +62,6 @@ from .user import Account, Profile, User from .view import IssueView from .webhook import Webhook, WebhookLog from .workspace import ( - Team, - TeamMember, Workspace, WorkspaceBaseModel, WorkspaceMember, @@ -71,24 +70,6 @@ from .workspace import ( WorkspaceUserProperties, ) -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 @@ -98,3 +79,5 @@ from .recent_visit import UserRecentVisit from .label import Label from .device import Device, DeviceSession + +from .sticky import Sticky diff --git a/apiserver/plane/db/models/asset.py b/apiserver/plane/db/models/asset.py index d7a3800031..9973d122f5 100644 --- a/apiserver/plane/db/models/asset.py +++ b/apiserver/plane/db/models/asset.py @@ -61,9 +61,8 @@ class FileAsset(BaseModel): page = models.ForeignKey( "db.Page", on_delete=models.CASCADE, null=True, related_name="assets" ) - entity_type = models.CharField( - max_length=255, choices=EntityTypeContext.choices, 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 e37c0e7c13..ca7347ad79 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -9,11 +9,13 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models, transaction from django.utils import timezone from django.db.models import Q +from django import apps # Module imports 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 @@ -656,3 +658,165 @@ class IssueVote(ProjectBaseModel): def __str__(self): return f"{self.issue.name} {self.actor.email}" + + +class IssueVersion(ProjectBaseModel): + PRIORITY_CHOICES = ( + ("urgent", "Urgent"), + ("high", "High"), + ("medium", "Medium"), + ("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") + priority = models.CharField( + max_length=30, + choices=PRIORITY_CHOICES, + verbose_name="Issue Priority", + default="none", + ) + start_date = models.DateField(null=True, blank=True) + target_date = models.DateField(null=True, blank=True) + assignees = ArrayField(models.UUIDField(), blank=True, default=list) + sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID") + labels = ArrayField(models.UUIDField(), blank=True, default=list) + sort_order = models.FloatField(default=65535) + completed_at = models.DateTimeField(null=True) + archived_at = models.DateField(null=True) + is_draft = models.BooleanField(default=False) + 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) + + issue = models.ForeignKey( + "db.Issue", on_delete=models.CASCADE, related_name="versions" + ) + activity = models.ForeignKey( + "db.IssueActivity", + on_delete=models.SET_NULL, + null=True, + related_name="versions", + ) + owned_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_versions", + ) + + class Meta: + verbose_name = "Issue Version" + verbose_name_plural = "Issue Versions" + db_table = "issue_versions" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.name} <{self.project.name}>" + + @classmethod + def log_issue_version(cls, issue, user): + try: + """ + Log the issue version + """ + + Module = apps.get_model("db.Module") + CycleIssue = apps.get_model("db.CycleIssue") + IssueAssignee = apps.get_model("db.IssueAssignee") + IssueLabel = apps.get_model("db.IssueLabel") + + cycle_issue = CycleIssue.objects.filter(issue=issue).first() + + cls.objects.create( + issue=issue, + parent=issue.parent_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=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_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 91fd6ac442..81e2b15a0f 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -50,9 +50,6 @@ class Page(BaseModel): projects = models.ManyToManyField( "db.Project", related_name="pages", through="db.ProjectPage" ) - teams = models.ManyToManyField( - "db.Team", related_name="pages", through="db.TeamPage" - ) class Meta: verbose_name = "Page" @@ -160,32 +157,6 @@ class ProjectPage(BaseModel): return f"{self.project.name} {self.page.name}" -class TeamPage(BaseModel): - team = models.ForeignKey( - "db.Team", on_delete=models.CASCADE, related_name="team_pages" - ) - page = models.ForeignKey( - "db.Page", on_delete=models.CASCADE, related_name="team_pages" - ) - workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="team_pages" - ) - - class Meta: - unique_together = ["team", "page", "deleted_at"] - constraints = [ - models.UniqueConstraint( - fields=["team", "page"], - condition=models.Q(deleted_at__isnull=True), - name="team_page_unique_team_page_when_deleted_at_null", - ) - ] - verbose_name = "Team Page" - verbose_name_plural = "Team Pages" - db_table = "team_pages" - ordering = ("-created_at",) - - class PageVersion(BaseModel): workspace = models.ForeignKey( "db.Workspace", on_delete=models.CASCADE, related_name="page_versions" diff --git a/apiserver/plane/db/models/sticky.py b/apiserver/plane/db/models/sticky.py new file mode 100644 index 0000000000..5f1c62660b --- /dev/null +++ b/apiserver/plane/db/models/sticky.py @@ -0,0 +1,32 @@ +# 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" + ) + + class Meta: + verbose_name = "Sticky" + verbose_name_plural = "Stickies" + db_table = "stickies" + ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index 34a86a2519..8a34e4d790 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 be2a5e9a30..ec8fcda3af 100644 --- a/apiserver/plane/db/models/webhook.py +++ b/apiserver/plane/db/models/webhook.py @@ -31,7 +31,9 @@ class Webhook(BaseModel): workspace = models.ForeignKey( "db.Workspace", on_delete=models.CASCADE, related_name="workspace_webhooks" ) - url = models.URLField(validators=[validate_schema, validate_domain]) + url = models.URLField( + validators=[validate_schema, validate_domain], max_length=1024 + ) is_active = models.BooleanField(default=True) secret_key = models.CharField(max_length=255, default=generate_token) project = models.BooleanField(default=False) @@ -39,6 +41,7 @@ class Webhook(BaseModel): module = models.BooleanField(default=False) cycle = models.BooleanField(default=False) issue_comment = models.BooleanField(default=False) + is_internal = models.BooleanField(default=False) def __str__(self): return f"{self.workspace.slug} {self.url}" diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index 93f8a24e0e..f8082e492b 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -239,13 +239,6 @@ class WorkspaceMemberInvite(BaseModel): class Team(BaseModel): name = models.CharField(max_length=255, verbose_name="Team Name") description = models.TextField(verbose_name="Team Description", blank=True) - members = models.ManyToManyField( - settings.AUTH_USER_MODEL, - blank=True, - related_name="members", - through="TeamMember", - through_fields=("team", "member"), - ) workspace = models.ForeignKey( Workspace, on_delete=models.CASCADE, related_name="workspace_team" ) @@ -270,33 +263,6 @@ class Team(BaseModel): ordering = ("-created_at",) -class TeamMember(BaseModel): - workspace = models.ForeignKey( - Workspace, on_delete=models.CASCADE, related_name="team_member" - ) - team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="team_member") - member = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="team_member" - ) - - def __str__(self): - return self.team.name - - class Meta: - unique_together = ["team", "member", "deleted_at"] - constraints = [ - models.UniqueConstraint( - fields=["team", "member"], - condition=models.Q(deleted_at__isnull=True), - name="team_member_unique_team_member_when_deleted_at_null", - ) - ] - verbose_name = "Team Member" - verbose_name_plural = "Team Members" - db_table = "team_members" - ordering = ("-created_at",) - - class WorkspaceTheme(BaseModel): workspace = models.ForeignKey( "db.Workspace", on_delete=models.CASCADE, related_name="themes" diff --git a/apiserver/plane/license/api/serializers/__init__.py b/apiserver/plane/license/api/serializers/__init__.py index 681dbeb5ca..6e0a5941c4 100644 --- a/apiserver/plane/license/api/serializers/__init__.py +++ b/apiserver/plane/license/api/serializers/__init__.py @@ -2,3 +2,4 @@ from .instance import InstanceSerializer from .configuration import InstanceConfigurationSerializer from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer +from .workspace import WorkspaceSerializer diff --git a/apiserver/plane/license/api/serializers/user.py b/apiserver/plane/license/api/serializers/user.py new file mode 100644 index 0000000000..c53b4a4848 --- /dev/null +++ b/apiserver/plane/license/api/serializers/user.py @@ -0,0 +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"] diff --git a/apiserver/plane/license/api/serializers/workspace.py b/apiserver/plane/license/api/serializers/workspace.py new file mode 100644 index 0000000000..75dd938e45 --- /dev/null +++ b/apiserver/plane/license/api/serializers/workspace.py @@ -0,0 +1,37 @@ +# Third Party Imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer +from plane.db.models import Workspace +from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS + + +class WorkspaceSerializer(BaseSerializer): + owner = UserLiteSerializer(read_only=True) + logo_url = serializers.CharField(read_only=True) + total_projects = serializers.IntegerField(read_only=True) + total_members = serializers.IntegerField(read_only=True) + + def validate_slug(self, value): + # Check if the slug is restricted + if value in RESTRICTED_WORKSPACE_SLUGS: + raise serializers.ValidationError("Slug is not valid") + # Check uniqueness case-insensitively + if Workspace.objects.filter(slug__iexact=value).exists(): + raise serializers.ValidationError("Slug is already in use") + return value + + class Meta: + model = Workspace + fields = "__all__" + read_only_fields = [ + "id", + "created_by", + "updated_by", + "created_at", + "updated_at", + "owner", + "logo_url", + ] diff --git a/apiserver/plane/license/api/views/__init__.py b/apiserver/plane/license/api/views/__init__.py index 9427ed15e5..a2ef90facb 100644 --- a/apiserver/plane/license/api/views/__init__.py +++ b/apiserver/plane/license/api/views/__init__.py @@ -13,4 +13,8 @@ from .admin import ( InstanceAdminUserSessionEndpoint, ) -from .changelog import ChangeLogEndpoint + +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 52583a35fc..0000000000 --- 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/instance.py b/apiserver/plane/license/api/views/instance.py index 883fb7c972..0e2b64fc9b 100644 --- a/apiserver/plane/license/api/views/instance.py +++ b/apiserver/plane/license/api/views/instance.py @@ -45,6 +45,7 @@ class InstanceEndpoint(BaseAPIView): # Get all the configuration ( ENABLE_SIGNUP, + DISABLE_WORKSPACE_CREATION, IS_GOOGLE_ENABLED, IS_GITHUB_ENABLED, GITHUB_APP_NAME, @@ -65,6 +66,10 @@ class InstanceEndpoint(BaseAPIView): "key": "ENABLE_SIGNUP", "default": os.environ.get("ENABLE_SIGNUP", "0"), }, + { + "key": "DISABLE_WORKSPACE_CREATION", + "default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"), + }, { "key": "IS_GOOGLE_ENABLED", "default": os.environ.get("IS_GOOGLE_ENABLED", "0"), @@ -125,6 +130,7 @@ class InstanceEndpoint(BaseAPIView): data = {} # Authentication data["enable_signup"] = ENABLE_SIGNUP == "1" + data["is_workspace_creation_disabled"] = DISABLE_WORKSPACE_CREATION == "1" data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1" data["is_github_enabled"] = IS_GITHUB_ENABLED == "1" data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1" diff --git a/apiserver/plane/license/api/views/workspace.py b/apiserver/plane/license/api/views/workspace.py new file mode 100644 index 0000000000..607016cc3a --- /dev/null +++ b/apiserver/plane/license/api/views/workspace.py @@ -0,0 +1,113 @@ +# Third party imports +from rest_framework.response import Response +from rest_framework import status +from django.db import IntegrityError +from django.db.models import OuterRef, Func, F + +# Module imports +from plane.app.views.base import BaseAPIView +from plane.license.api.permissions import InstanceAdminPermission +from plane.db.models import Workspace, WorkspaceMember, Project +from plane.license.api.serializers import WorkspaceSerializer +from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS + + +class InstanceWorkSpaceAvailabilityCheckEndpoint(BaseAPIView): + permission_classes = [InstanceAdminPermission] + + def get(self, request): + slug = request.GET.get("slug", False) + + if not slug or slug == "": + return Response( + {"error": "Workspace Slug is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace = ( + Workspace.objects.filter(slug__iexact=slug).exists() + or slug in RESTRICTED_WORKSPACE_SLUGS + ) + return Response({"status": not workspace}, status=status.HTTP_200_OK) + + +class InstanceWorkSpaceEndpoint(BaseAPIView): + model = Workspace + serializer_class = WorkspaceSerializer + permission_classes = [InstanceAdminPermission] + + def get(self, request): + project_count = ( + Project.objects.filter(workspace_id=OuterRef("id")) + .order_by() + .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") + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + workspaces = Workspace.objects.annotate( + total_projects=project_count, total_members=member_count + ) + + # Add search functionality + search = request.query_params.get("search", None) + if search: + workspaces = workspaces.filter(name__icontains=search) + + return self.paginate( + request=request, + queryset=workspaces, + 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) + + slug = request.data.get("slug", False) + name = request.data.get("name", False) + + if not name or not slug: + return Response( + {"error": "Both name and slug are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if len(name) > 80 or len(slug) > 48: + return Response( + {"error": "The maximum length for name is 80 and for slug is 48"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if serializer.is_valid(raise_exception=True): + serializer.save(owner=request.user) + # Create Workspace member + _ = WorkspaceMember.objects.create( + workspace_id=serializer.data["id"], + member=request.user, + role=20, + company_role=request.data.get("company_role", ""), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response( + [serializer.errors[error][0] for error in serializer.errors], + status=status.HTTP_400_BAD_REQUEST, + ) + + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"slug": "The workspace with the slug already exists"}, + status=status.HTTP_410_GONE, + ) diff --git a/apiserver/plane/license/bgtasks/tracer.py b/apiserver/plane/license/bgtasks/tracer.py index 60588f5b7b..47e74c83ab 100644 --- a/apiserver/plane/license/bgtasks/tracer.py +++ b/apiserver/plane/license/bgtasks/tracer.py @@ -16,76 +16,55 @@ from plane.db.models import ( Page, WorkspaceMember, ) +from plane.utils.telemetry import init_tracer, shutdown_tracer @shared_task def instance_traces(): - # Get the tracer - tracer = trace.get_tracer(__name__) + try: + init_tracer() + # Check if the instance is registered + instance = Instance.objects.first() - # Check if the instance is registered - instance = Instance.objects.first() + # If instance is None then return + if instance is None: + return - # If instance is None then return - if instance is None: - return + if instance.is_telemetry_enabled: + # Get the tracer + tracer = trace.get_tracer(__name__) + # Instance details + with tracer.start_as_current_span("instance_details") as span: + # Count of all models + workspace_count = Workspace.objects.count() + user_count = User.objects.count() + project_count = Project.objects.count() + issue_count = Issue.objects.count() + module_count = Module.objects.count() + cycle_count = Cycle.objects.count() + cycle_issue_count = CycleIssue.objects.count() + module_issue_count = ModuleIssue.objects.count() + page_count = Page.objects.count() - if instance.is_telemetry_enabled: - # Instance details - with tracer.start_as_current_span("instance_details") as span: - # Count of all models - workspace_count = Workspace.objects.count() - user_count = User.objects.count() - project_count = Project.objects.count() - issue_count = Issue.objects.count() - module_count = Module.objects.count() - cycle_count = Cycle.objects.count() - cycle_issue_count = CycleIssue.objects.count() - module_issue_count = ModuleIssue.objects.count() - page_count = Page.objects.count() - - # Set span attributes - span.set_attribute("instance_id", instance.instance_id) - span.set_attribute("instance_name", instance.instance_name) - span.set_attribute("current_version", instance.current_version) - span.set_attribute("latest_version", instance.latest_version) - span.set_attribute("is_telemetry_enabled", instance.is_telemetry_enabled) - span.set_attribute("is_support_required", instance.is_support_required) - span.set_attribute("is_setup_done", instance.is_setup_done) - span.set_attribute( - "is_signup_screen_visited", instance.is_signup_screen_visited - ) - span.set_attribute("is_verified", instance.is_verified) - span.set_attribute("edition", instance.edition) - span.set_attribute("domain", instance.domain) - span.set_attribute("is_test", instance.is_test) - span.set_attribute("user_count", user_count) - span.set_attribute("workspace_count", workspace_count) - span.set_attribute("project_count", project_count) - span.set_attribute("issue_count", issue_count) - span.set_attribute("module_count", module_count) - span.set_attribute("cycle_count", cycle_count) - span.set_attribute("cycle_issue_count", cycle_issue_count) - span.set_attribute("module_issue_count", module_issue_count) - span.set_attribute("page_count", page_count) - - # Workspace details - for workspace in Workspace.objects.all(): - # Count of all models - project_count = Project.objects.filter(workspace=workspace).count() - issue_count = Issue.objects.filter(workspace=workspace).count() - module_count = Module.objects.filter(workspace=workspace).count() - cycle_count = Cycle.objects.filter(workspace=workspace).count() - cycle_issue_count = CycleIssue.objects.filter(workspace=workspace).count() - module_issue_count = ModuleIssue.objects.filter(workspace=workspace).count() - page_count = Page.objects.filter(workspace=workspace).count() - member_count = WorkspaceMember.objects.filter(workspace=workspace).count() - - # Set span attributes - with tracer.start_as_current_span("workspace_details") as span: + # Set span attributes span.set_attribute("instance_id", instance.instance_id) - span.set_attribute("workspace_id", str(workspace.id)) - span.set_attribute("workspace_slug", workspace.slug) + span.set_attribute("instance_name", instance.instance_name) + span.set_attribute("current_version", instance.current_version) + span.set_attribute("latest_version", instance.latest_version) + span.set_attribute( + "is_telemetry_enabled", instance.is_telemetry_enabled + ) + span.set_attribute("is_support_required", instance.is_support_required) + span.set_attribute("is_setup_done", instance.is_setup_done) + span.set_attribute( + "is_signup_screen_visited", instance.is_signup_screen_visited + ) + span.set_attribute("is_verified", instance.is_verified) + span.set_attribute("edition", instance.edition) + span.set_attribute("domain", instance.domain) + span.set_attribute("is_test", instance.is_test) + span.set_attribute("user_count", user_count) + span.set_attribute("workspace_count", workspace_count) span.set_attribute("project_count", project_count) span.set_attribute("issue_count", issue_count) span.set_attribute("module_count", module_count) @@ -93,6 +72,40 @@ def instance_traces(): span.set_attribute("cycle_issue_count", cycle_issue_count) span.set_attribute("module_issue_count", module_issue_count) span.set_attribute("page_count", page_count) - span.set_attribute("member_count", member_count) - return + # Workspace details + for workspace in Workspace.objects.all(): + # Count of all models + project_count = Project.objects.filter(workspace=workspace).count() + issue_count = Issue.objects.filter(workspace=workspace).count() + module_count = Module.objects.filter(workspace=workspace).count() + cycle_count = Cycle.objects.filter(workspace=workspace).count() + cycle_issue_count = CycleIssue.objects.filter( + workspace=workspace + ).count() + module_issue_count = ModuleIssue.objects.filter( + workspace=workspace + ).count() + page_count = Page.objects.filter(workspace=workspace).count() + member_count = WorkspaceMember.objects.filter( + workspace=workspace + ).count() + + # Set span attributes + with tracer.start_as_current_span("workspace_details") as span: + span.set_attribute("instance_id", instance.instance_id) + span.set_attribute("workspace_id", str(workspace.id)) + span.set_attribute("workspace_slug", workspace.slug) + span.set_attribute("project_count", project_count) + span.set_attribute("issue_count", issue_count) + span.set_attribute("module_count", module_count) + span.set_attribute("cycle_count", cycle_count) + span.set_attribute("cycle_issue_count", cycle_issue_count) + span.set_attribute("module_issue_count", module_issue_count) + span.set_attribute("page_count", page_count) + span.set_attribute("member_count", member_count) + + return + finally: + # Shutdown the tracer + shutdown_tracer() diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py index a1c27851e3..548c9c77ed 100644 --- a/apiserver/plane/license/management/commands/configure_instance.py +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -29,6 +29,12 @@ class Command(BaseCommand): "category": "AUTHENTICATION", "is_encrypted": False, }, + { + "key": "DISABLE_WORKSPACE_CREATION", + "value": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"), + "category": "WORKSPACE_MANAGEMENT", + "is_encrypted": False, + }, { "key": "ENABLE_EMAIL_PASSWORD", "value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), diff --git a/apiserver/plane/license/urls.py b/apiserver/plane/license/urls.py index 50e6d0de89..9c3adbf98a 100644 --- a/apiserver/plane/license/urls.py +++ b/apiserver/plane/license/urls.py @@ -11,12 +11,12 @@ from plane.license.api.views import ( 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( @@ -55,4 +55,10 @@ urlpatterns = [ EmailCredentialCheckEndpoint.as_view(), name="email-credential-check", ), + path( + "workspace-slug-check/", + InstanceWorkSpaceAvailabilityCheckEndpoint.as_view(), + name="instance-workspace-availability", + ), + path("workspaces/", InstanceWorkSpaceEndpoint.as_view(), name="instance-workspace"), ] diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 864010d3c7..db9a244537 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -16,14 +16,6 @@ from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.redis import RedisIntegration from corsheaders.defaults import default_headers -# OpenTelemetry -from opentelemetry import trace -from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor -from opentelemetry.sdk.resources import Resource -from opentelemetry.instrumentation.django import DjangoInstrumentor - BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -33,19 +25,6 @@ SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key()) # SECURITY WARNING: don't run with debug turned on in production! DEBUG = int(os.environ.get("DEBUG", "0")) -# Configure the tracer provider -service_name = os.environ.get("SERVICE_NAME", "plane-ce-api") -resource = Resource.create({"service.name": service_name}) -trace.set_tracer_provider(TracerProvider(resource=resource)) -# Configure the OTLP exporter -otel_endpoint = os.environ.get("OTLP_ENDPOINT", "https://telemetry.plane.so") -otlp_exporter = OTLPSpanExporter(endpoint=otel_endpoint) -span_processor = BatchSpanProcessor(otlp_exporter) -trace.get_tracer_provider().add_span_processor(span_processor) -# Initialize Django instrumentation -DjangoInstrumentor().instrument() - - # Allowed Hosts ALLOWED_HOSTS = ["*"] @@ -283,6 +262,9 @@ CELERY_IMPORTS = ( "plane.license.bgtasks.tracer", # management tasks "plane.bgtasks.dummy_data_task", + # issue version tasks + "plane.bgtasks.issue_version_sync", + "plane.bgtasks.issue_description_version_sync", ) # Sentry Settings diff --git a/apiserver/plane/space/urls/project.py b/apiserver/plane/space/urls/project.py index 7676c95992..068b8c5c17 100644 --- a/apiserver/plane/space/urls/project.py +++ b/apiserver/plane/space/urls/project.py @@ -10,9 +10,15 @@ from plane.space.views import ( 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 250b54e891..2740588422 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 afdc1d3374..22acfd15bd 100644 --- a/apiserver/plane/space/views/__init__.py +++ b/apiserver/plane/space/views/__init__.py @@ -25,3 +25,5 @@ from .state import ProjectStatesEndpoint 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 2c67220384..3e1d4d6f78 100644 --- a/apiserver/plane/space/views/asset.py +++ b/apiserver/plane/space/views/asset.py @@ -86,7 +86,13 @@ class EntityAssetEndpoint(BaseAPIView): ) # Check if the file type is allowed - allowed_types = ["image/jpeg", "image/png", "image/webp"] + allowed_types = [ + "image/jpeg", + "image/png", + "image/webp", + "image/jpg", + "image/gif", + ] if type not in allowed_types: return Response( { diff --git a/apiserver/plane/space/views/issue.py b/apiserver/plane/space/views/issue.py index a1ab332f91..699253ae52 100644 --- a/apiserver/plane/space/views/issue.py +++ b/apiserver/plane/space/views/issue.py @@ -701,6 +701,7 @@ class IssueRetrievePublicEndpoint(BaseAPIView): Case( When( votes__isnull=False, + votes__deleted_at__isnull=True, then=JSONObject( vote=F("votes__vote"), actor_details=JSONObject( @@ -732,7 +733,11 @@ class IssueRetrievePublicEndpoint(BaseAPIView): output_field=JSONField(), ), filter=Case( - When(votes__isnull=False, then=True), + When( + votes__isnull=False, + votes__deleted_at__isnull=True, + then=True, + ), default=False, output_field=JSONField(), ), @@ -742,6 +747,7 @@ class IssueRetrievePublicEndpoint(BaseAPIView): Case( When( issue_reactions__isnull=False, + issue_reactions__deleted_at__isnull=True, then=JSONObject( reaction=F("issue_reactions__reaction"), actor_details=JSONObject( @@ -775,7 +781,11 @@ class IssueRetrievePublicEndpoint(BaseAPIView): output_field=JSONField(), ), filter=Case( - When(issue_reactions__isnull=False, then=True), + When( + issue_reactions__isnull=False, + issue_reactions__deleted_at__isnull=True, + then=True, + ), default=False, output_field=JSONField(), ), diff --git a/apiserver/plane/space/views/meta.py b/apiserver/plane/space/views/meta.py new file mode 100644 index 0000000000..fa44135996 --- /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/telemetry.py b/apiserver/plane/utils/telemetry.py new file mode 100644 index 0000000000..bec3d240dd --- /dev/null +++ b/apiserver/plane/utils/telemetry.py @@ -0,0 +1,58 @@ +# Python imports +import os +import atexit + +# Third party imports +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.resources import Resource +from opentelemetry.instrumentation.django import DjangoInstrumentor + +# Global variable to track initialization +_TRACER_PROVIDER = None + + +def init_tracer(): + """Initialize OpenTelemetry with proper shutdown handling""" + global _TRACER_PROVIDER + + # If already initialized, return existing provider + if _TRACER_PROVIDER is not None: + return _TRACER_PROVIDER + + # Configure the tracer provider + service_name = os.environ.get("SERVICE_NAME", "plane-ce-api") + resource = Resource.create({"service.name": service_name}) + tracer_provider = TracerProvider(resource=resource) + + # Set as global tracer provider + trace.set_tracer_provider(tracer_provider) + + # Configure the OTLP exporter + otel_endpoint = os.environ.get("OTLP_ENDPOINT", "https://telemetry.plane.so") + otlp_exporter = OTLPSpanExporter(endpoint=otel_endpoint) + span_processor = BatchSpanProcessor(otlp_exporter) + tracer_provider.add_span_processor(span_processor) + + # Initialize Django instrumentation + DjangoInstrumentor().instrument() + + # Store provider globally + _TRACER_PROVIDER = tracer_provider + + # Register shutdown handler + atexit.register(shutdown_tracer) + + return tracer_provider + + +def shutdown_tracer(): + """Shutdown OpenTelemetry tracers and processors""" + global _TRACER_PROVIDER + + if _TRACER_PROVIDER is not None: + if hasattr(_TRACER_PROVIDER, "shutdown"): + _TRACER_PROVIDER.shutdown() + _TRACER_PROVIDER = None diff --git a/apiserver/plane/utils/timezone_converter.py b/apiserver/plane/utils/timezone_converter.py new file mode 100644 index 0000000000..46a864b62d --- /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 550abfe997..0000000000 --- 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 854ab95f90..40e90aedfc 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 bc5789078e..600b524d2f 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 d93d85ca14..ccd8bf328c 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 fe47e625f6..13cfafa223 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -1,54 +1,63 @@ -x-app-env: &app-env - environment: - - NGINX_PORT=${NGINX_PORT:-80} - - WEB_URL=${WEB_URL:-http://localhost} - - DEBUG=${DEBUG:-0} - - SENTRY_DSN=${SENTRY_DSN:-""} - - SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"} - - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-} - # Gunicorn Workers - - GUNICORN_WORKERS=${GUNICORN_WORKERS:-1} - #DB SETTINGS - - PGHOST=${PGHOST:-plane-db} - - PGDATABASE=${PGDATABASE:-plane} - - POSTGRES_USER=${POSTGRES_USER:-plane} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-plane} - - POSTGRES_DB=${POSTGRES_DB:-plane} - - POSTGRES_PORT=${POSTGRES_PORT:-5432} - - PGDATA=${PGDATA:-/var/lib/postgresql/data} - - DATABASE_URL=${DATABASE_URL:-postgresql://plane:plane@plane-db/plane} - # REDIS SETTINGS - - REDIS_HOST=${REDIS_HOST:-plane-redis} - - REDIS_PORT=${REDIS_PORT:-6379} - - REDIS_URL=${REDIS_URL:-redis://plane-redis:6379/} +x-db-env: &db-env + PGHOST: ${PGHOST:-plane-db} + PGDATABASE: ${PGDATABASE:-plane} + POSTGRES_USER: ${POSTGRES_USER:-plane} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-plane} + POSTGRES_DB: ${POSTGRES_DB:-plane} + POSTGRES_PORT: ${POSTGRES_PORT:-5432} + PGDATA: ${PGDATA:-/var/lib/postgresql/data} + +x-redis-env: &redis-env + REDIS_HOST: ${REDIS_HOST:-plane-redis} + REDIS_PORT: ${REDIS_PORT:-6379} + REDIS_URL: ${REDIS_URL:-redis://plane-redis:6379/} + +x-minio-env: &minio-env + MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID:-access-key} + MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY:-secret-key} + +x-aws-s3-env: &aws-s3-env + AWS_REGION: ${AWS_REGION:-} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-access-key} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-secret-key} + AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000} + AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} + +x-proxy-env: &proxy-env + NGINX_PORT: ${NGINX_PORT:-80} + BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} + FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880} + +x-mq-env: &mq-env + # RabbitMQ Settings + RABBITMQ_HOST: ${RABBITMQ_HOST:-plane-mq} + RABBITMQ_PORT: ${RABBITMQ_PORT:-5672} + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-plane} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-plane} + RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:-plane} + RABBITMQ_VHOST: ${RABBITMQ_VHOST:-plane} + +x-live-env: &live-env + API_BASE_URL: ${API_BASE_URL:-http://api:8000} + +x-app-env: &app-env + WEB_URL: ${WEB_URL:-http://localhost} + DEBUG: ${DEBUG:-0} + SENTRY_DSN: ${SENTRY_DSN} + SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-production} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} + GUNICORN_WORKERS: 1 + USE_MINIO: ${USE_MINIO:-1} + DATABASE_URL: ${DATABASE_URL:-postgresql://plane:plane@plane-db/plane} + SECRET_KEY: ${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} + 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} - # 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} 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 08cd4d9169..1c2208cab7 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 b5221c71a1..78914c3afd 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 07d8a053fd..77cf697abe 100644 --- a/live/package.json +++ b/live/package.json @@ -1,16 +1,16 @@ { "name": "live", - "version": "0.23.1", + "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/live/src/core/hocuspocus-server.ts b/live/src/core/hocuspocus-server.ts index b34a8fbb22..51896c23bc 100644 --- a/live/src/core/hocuspocus-server.ts +++ b/live/src/core/hocuspocus-server.ts @@ -4,6 +4,10 @@ import { v4 as uuidv4 } from "uuid"; import { handleAuthentication } from "@/core/lib/authentication.js"; // extensions import { getExtensions } from "@/core/extensions/index.js"; +import { + DocumentCollaborativeEvents, + TDocumentEventsServer, +} from "@plane/editor/lib"; // editor types import { TUserDetails } from "@plane/editor"; // types @@ -55,6 +59,14 @@ export const getHocusPocusServer = async () => { throw Error("Authentication unsuccessful!"); } }, + async onStateless({ payload, document }) { + // broadcast the client event (derived from the server event) to all the clients so that they can update their state + const response = + DocumentCollaborativeEvents[payload as TDocumentEventsServer].client; + if (response) { + document.broadcastStateless(response); + } + }, extensions, debounce: 10000, }); diff --git a/package.json b/package.json index eb8b4fb227..f14aa4ac79 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "repository": "https://github.com/makeplane/plane.git", - "version": "0.23.1", + "version": "0.24.1", "license": "AGPL-3.0", "private": true, "workspaces": [ @@ -22,7 +22,7 @@ "devDependencies": { "prettier": "latest", "prettier-plugin-tailwindcss": "^0.5.4", - "turbo": "^2.3.0" + "turbo": "^2.3.3" }, "packageManager": "yarn@1.22.22", "name": "plane" diff --git a/packages/constants/index.ts b/packages/constants/index.ts deleted file mode 100644 index 66089416f1..0000000000 --- a/packages/constants/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./auth"; -export * from "./issue"; \ No newline at end of file diff --git a/packages/constants/issue.ts b/packages/constants/issue.ts deleted file mode 100644 index 5db398c763..0000000000 --- a/packages/constants/issue.ts +++ /dev/null @@ -1,40 +0,0 @@ -export const ALL_ISSUES = "All Issues"; - -export enum EIssueGroupByToServerOptions { - "state" = "state_id", - "priority" = "priority", - "labels" = "labels__id", - "state_detail.group" = "state__group", - "assignees" = "assignees__id", - "cycle" = "cycle_id", - "module" = "issue_module__module_id", - "target_date" = "target_date", - "project" = "project_id", - "created_by" = "created_by", -} - -export enum EIssueGroupBYServerToProperty { - "state_id" = "state_id", - "priority" = "priority", - "labels__id" = "label_ids", - "state__group" = "state__group", - "assignees__id" = "assignee_ids", - "cycle_id" = "cycle_id", - "issue_module__module_id" = "module_ids", - "target_date" = "target_date", - "project_id" = "project_id", - "created_by" = "created_by", -} - -export enum EServerGroupByToFilterOptions { - "state_id" = "state", - "priority" = "priority", - "labels__id" = "labels", - "state__group" = "state_group", - "assignees__id" = "assignees", - "cycle_id" = "cycle", - "issue_module__module_id" = "module", - "target_date" = "target_date", - "project_id" = "project", - "created_by" = "created_by", -} diff --git a/packages/constants/package.json b/packages/constants/package.json index cdf51bbaf1..c1fe71a306 100644 --- a/packages/constants/package.json +++ b/packages/constants/package.json @@ -1,6 +1,6 @@ { "name": "@plane/constants", - "version": "0.23.1", + "version": "0.24.1", "private": true, - "main": "./index.ts" + "main": "./src/index.ts" } diff --git a/packages/constants/src/ai.ts b/packages/constants/src/ai.ts new file mode 100644 index 0000000000..8125302440 --- /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/auth.ts b/packages/constants/src/auth.ts similarity index 61% rename from packages/constants/auth.ts rename to packages/constants/src/auth.ts index 59f08a37f0..884a8dd1c8 100644 --- a/packages/constants/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 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 new file mode 100644 index 0000000000..b17f7c1a06 --- /dev/null +++ b/packages/constants/src/endpoints.ts @@ -0,0 +1,25 @@ +export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || ""; +export const API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH || "/"; +export const API_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}`); +// 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}`); +// 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}`); +// 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 new file mode 100644 index 0000000000..95a4f97843 --- /dev/null +++ b/packages/constants/src/index.ts @@ -0,0 +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 new file mode 100644 index 0000000000..19cfe60f3a --- /dev/null +++ b/packages/constants/src/issue.ts @@ -0,0 +1,185 @@ +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", + "labels" = "labels__id", + "state_detail.group" = "state__group", + "assignees" = "assignees__id", + "cycle" = "cycle_id", + "module" = "issue_module__module_id", + "target_date" = "target_date", + "project" = "project_id", + "created_by" = "created_by", + "team_project" = "project_id", +} + +export enum EIssueGroupBYServerToProperty { + "state_id" = "state_id", + "priority" = "priority", + "labels__id" = "label_ids", + "state__group" = "state__group", + "assignees__id" = "assignee_ids", + "cycle_id" = "cycle_id", + "issue_module__module_id" = "module_ids", + "target_date" = "target_date", + "project_id" = "project_id", + "created_by" = "created_by", +} + +export enum EServerGroupByToFilterOptions { + "state_id" = "state", + "priority" = "priority", + "labels__id" = "labels", + "state__group" = "state_group", + "assignees__id" = "assignees", + "cycle_id" = "cycle", + "issue_module__module_id" = "module", + "target_date" = "target_date", + "project_id" = "project", + "created_by" = "created_by", +} + +export enum 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 0000000000..b356388267 --- /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 b0fd622be0..c51728bf9a 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 38478fcea3..d3eef1cdfe 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/constants/src/workspace.ts b/packages/constants/src/workspace.ts new file mode 100644 index 0000000000..c17b5432ee --- /dev/null +++ b/packages/constants/src/workspace.ts @@ -0,0 +1,76 @@ +export const ORGANIZATION_SIZE = [ + "Just myself", + "2-10", + "11-50", + "51-200", + "201-500", + "500+", +]; + +export const RESTRICTED_URLS = [ + "404", + "accounts", + "api", + "create-workspace", + "god-mode", + "installations", + "invitations", + "onboarding", + "profile", + "spaces", + "workspace-invitations", + "password", + "flags", + "monitor", + "monitoring", + "ingest", + "plane-pro", + "plane-ultimate", + "enterprise", + "plane-enterprise", + "disco", + "silo", + "chat", + "calendar", + "drive", + "channels", + "upgrade", + "billing", + "sign-in", + "sign-up", + "signin", + "signup", + "config", + "live", + "admin", + "m", + "import", + "importers", + "integrations", + "integration", + "configuration", + "initiatives", + "initiative", + "config", + "workflow", + "workflows", + "epics", + "epic", + "story", + "mobile", + "dashboard", + "desktop", + "onload", + "real-time", + "one", + "pages", + "mobile", + "business", + "pro", + "settings", + "monitor", + "license", + "licenses", + "instances", + "instance", +]; diff --git a/packages/editor/package.json b/packages/editor/package.json index 067be0145d..e9ef145a0c 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor", - "version": "0.23.1", + "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/ce/extensions/core/extensions.ts b/packages/editor/src/ce/extensions/core/extensions.ts new file mode 100644 index 0000000000..d03229133b --- /dev/null +++ b/packages/editor/src/ce/extensions/core/extensions.ts @@ -0,0 +1,12 @@ +import { Extensions } from "@tiptap/core"; +// types +import { TExtensions } from "@/types"; + +type Props = { + disabledExtensions: TExtensions[]; +}; + +export const CoreEditorAdditionalExtensions = (props: Props): Extensions => { + const {} = props; + return []; +}; diff --git a/packages/editor/src/ce/extensions/core/index.ts b/packages/editor/src/ce/extensions/core/index.ts new file mode 100644 index 0000000000..9ffc978c3f --- /dev/null +++ b/packages/editor/src/ce/extensions/core/index.ts @@ -0,0 +1,2 @@ +export * from "./extensions"; +export * from "./read-only-extensions"; diff --git a/packages/editor/src/ce/extensions/core/read-only-extensions.ts b/packages/editor/src/ce/extensions/core/read-only-extensions.ts new file mode 100644 index 0000000000..398848e31d --- /dev/null +++ b/packages/editor/src/ce/extensions/core/read-only-extensions.ts @@ -0,0 +1,12 @@ +import { Extensions } from "@tiptap/core"; +// types +import { TExtensions } from "@/types"; + +type Props = { + disabledExtensions: TExtensions[]; +}; + +export const CoreReadOnlyEditorAdditionalExtensions = (props: Props): Extensions => { + const {} = props; + return []; +}; diff --git a/packages/editor/src/ce/extensions/core/without-props.ts b/packages/editor/src/ce/extensions/core/without-props.ts new file mode 100644 index 0000000000..0debff0ea5 --- /dev/null +++ b/packages/editor/src/ce/extensions/core/without-props.ts @@ -0,0 +1,3 @@ +import { Extensions } from "@tiptap/core"; + +export const CoreEditorAdditionalExtensionsWithoutProps: Extensions = []; diff --git a/packages/editor/src/ce/extensions/document-extensions.tsx b/packages/editor/src/ce/extensions/document-extensions.tsx index e3c94fa0e1..35d7c0f3de 100644 --- a/packages/editor/src/ce/extensions/document-extensions.tsx +++ b/packages/editor/src/ce/extensions/document-extensions.tsx @@ -15,7 +15,13 @@ type Props = { export const DocumentEditorAdditionalExtensions = (_props: Props) => { const { disabledExtensions } = _props; - const extensions: Extensions = disabledExtensions?.includes("slash-commands") ? [] : [SlashCommands()]; + const extensions: Extensions = disabledExtensions?.includes("slash-commands") + ? [] + : [ + SlashCommands({ + disabledExtensions, + }), + ]; return extensions; }; diff --git a/packages/editor/src/ce/extensions/index.ts b/packages/editor/src/ce/extensions/index.ts index 4a975b8c5a..c9f58a936a 100644 --- a/packages/editor/src/ce/extensions/index.ts +++ b/packages/editor/src/ce/extensions/index.ts @@ -1 +1,3 @@ +export * from "./core"; export * from "./document-extensions"; +export * from "./slash-commands"; diff --git a/packages/editor/src/ce/extensions/slash-commands.tsx b/packages/editor/src/ce/extensions/slash-commands.tsx new file mode 100644 index 0000000000..6eabee0823 --- /dev/null +++ b/packages/editor/src/ce/extensions/slash-commands.tsx @@ -0,0 +1,14 @@ +// extensions +import { TSlashCommandAdditionalOption } from "@/extensions"; +// types +import { TExtensions } from "@/types"; + +type Props = { + disabledExtensions: TExtensions[]; +}; + +export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => { + const {} = props; + const options: TSlashCommandAdditionalOption[] = []; + return options; +}; 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 cd7d6f3548..44c18c2f68 100644 --- a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx +++ b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx @@ -19,6 +19,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { containerClassName, disabledExtensions, displayConfig = DEFAULT_DISPLAY_CONFIG, + editable, editorClassName = "", embedHandler, fileHandler, @@ -44,8 +45,8 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { // use document editor const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({ - onTransaction, 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 aa925abece..0000000000 --- a/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx +++ /dev/null @@ -1,79 +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 { useReadOnlyCollaborativeEditor } from "@/hooks/use-read-only-collaborative-editor"; -// types -import { EditorReadOnlyRefApi, ICollaborativeDocumentReadOnlyEditor } from "@/types"; - -const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOnlyEditor) => { - const { - containerClassName, - displayConfig = DEFAULT_DISPLAY_CONFIG, - editorClassName = "", - embedHandler, - fileHandler, - forwardedRef, - handleEditorReady, - id, - mentionHandler, - realtimeConfig, - serverHandler, - user, - } = props; - const extensions = []; - if (embedHandler?.issue) { - extensions.push( - IssueWidget({ - widgetCallback: embedHandler.issue.widgetCallback, - }) - ); - } - - const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({ - 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 514b620e3a..571cb7e9a1 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 d1ff3b3d01..f291c8b3a3 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 8544157aa0..0e8ab63f8a 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,9 +10,10 @@ import { getEditorClassNames } from "@/helpers/common"; // hooks import { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; // types -import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TFileHandler } from "@/types"; +import { EditorReadOnlyRefApi, TDisplayConfig, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types"; interface IDocumentReadOnlyEditor { + disabledExtensions: TExtensions[]; id: string; initialValue: string; containerClassName: string; @@ -22,15 +23,14 @@ interface IDocumentReadOnlyEditor { fileHandler: Pick; tabIndex?: number; handleEditorReady?: (value: boolean) => void; - mentionHandler: { - highlights: () => Promise; - }; + mentionHandler: TReadOnlyMentionHandler; forwardedRef?: React.MutableRefObject; } const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { const { containerClassName, + disabledExtensions, displayConfig = DEFAULT_DISPLAY_CONFIG, editorClassName = "", embedHandler, @@ -51,6 +51,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { } const editor = useReadOnlyEditor({ + disabledExtensions, editorClassName, extensions, fileHandler, diff --git a/packages/editor/src/core/components/editors/editor-container.tsx b/packages/editor/src/core/components/editors/editor-container.tsx index e070d7e45e..d6563f7b08 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 33f011535c..9d1297e239 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 { Editor, Extension } from "@tiptap/core"; +import { Editor, Extensions } from "@tiptap/core"; // components import { EditorContainer } from "@/components/editors"; // constants @@ -12,13 +12,14 @@ import { EditorContentWrapper } from "./editor-content"; type Props = IEditorProps & { children?: (editor: Editor) => React.ReactNode; - extensions: Extension[]; + extensions: Extensions; }; export const EditorWrapper: React.FC = (props) => { const { children, containerClassName, + disabledExtensions, displayConfig = DEFAULT_DISPLAY_CONFIG, editorClassName = "", extensions, @@ -37,6 +38,8 @@ export const EditorWrapper: React.FC = (props) => { } = props; const editor = useEditor({ + editable: true, + disabledExtensions, editorClassName, enableHistory: true, extensions, diff --git a/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx b/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx index e06826a289..6cd360ac0d 100644 --- a/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx +++ b/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx @@ -12,6 +12,7 @@ import { IReadOnlyEditorProps } from "@/types"; export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => { const { containerClassName, + disabledExtensions, displayConfig = DEFAULT_DISPLAY_CONFIG, editorClassName = "", fileHandler, @@ -22,6 +23,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => { } = props; const editor = useReadOnlyEditor({ + disabledExtensions, editorClassName, fileHandler, forwardedRef, diff --git a/packages/editor/src/core/components/editors/rich-text/editor.tsx b/packages/editor/src/core/components/editors/rich-text/editor.tsx index 87dba8b4d1..ffcc21da6c 100644 --- a/packages/editor/src/core/components/editors/rich-text/editor.tsx +++ b/packages/editor/src/core/components/editors/rich-text/editor.tsx @@ -8,12 +8,7 @@ import { SideMenuExtension, SlashCommands } from "@/extensions"; import { EditorRefApi, IRichTextEditor } from "@/types"; const RichTextEditor = (props: IRichTextEditor) => { - const { - disabledExtensions, - dragDropEnabled, - bubbleMenuEnabled = true, - extensions: externalExtensions = [], - } = props; + const { disabledExtensions, dragDropEnabled, bubbleMenuEnabled = true, extensions: externalExtensions = [] } = props; const getExtensions = useCallback(() => { const extensions = [ @@ -24,7 +19,11 @@ const RichTextEditor = (props: IRichTextEditor) => { }), ]; if (!disabledExtensions?.includes("slash-commands")) { - extensions.push(SlashCommands()); + extensions.push( + SlashCommands({ + disabledExtensions, + }) + ); } return extensions; diff --git a/packages/editor/src/core/components/menus/ai-menu.tsx b/packages/editor/src/core/components/menus/ai-menu.tsx index 43793fae35..42b45509f7 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 e3ccc6cf62..24dfeceafd 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 bc7f5a56f1..ba9278b675 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 eaa20ed26b..fe651fa4e1 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 a121c48ef1..7d1378800c 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 18079f089e..4ed3709f40 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 7f4f7f66f3..8961bcd915 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 5a9577044c..bd4712de99 100644 --- a/packages/editor/src/core/constants/config.ts +++ b/packages/editor/src/core/constants/config.ts @@ -5,3 +5,6 @@ export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = { fontSize: "large-font", fontStyle: "sans-serif", }; + +export const ACCEPTED_FILE_MIME_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"]; +export const ACCEPTED_FILE_EXTENSIONS = ACCEPTED_FILE_MIME_TYPES.map((type) => `.${type.split("/")[1]}`); diff --git a/packages/editor/src/core/constants/document-collaborative-events.ts b/packages/editor/src/core/constants/document-collaborative-events.ts new file mode 100644 index 0000000000..5e79efc7a7 --- /dev/null +++ b/packages/editor/src/core/constants/document-collaborative-events.ts @@ -0,0 +1,6 @@ +export const DocumentCollaborativeEvents = { + lock: { client: "locked", server: "lock" }, + unlock: { client: "unlocked", server: "unlock" }, + archive: { client: "archived", server: "archive" }, + unarchive: { client: "unarchived", server: "unarchive" }, +} as const; diff --git a/packages/editor/src/core/extensions/callout/color-selector.tsx b/packages/editor/src/core/extensions/callout/color-selector.tsx index 489b051666..ddc3b879cf 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 4e9f966aff..8ea47d50d0 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 c450cbdd2d..6568a40e3e 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 8dbdb044f1..a06d839908 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 075d90f2df..8864f49f70 100644 --- a/packages/editor/src/core/extensions/core-without-props.ts +++ b/packages/editor/src/core/extensions/core-without-props.ts @@ -13,12 +13,14 @@ 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"; import { CustomCalloutExtensionConfig } from "./callout/extension-config"; import { CustomColorExtension } from "./custom-color"; +// plane editor extensions +import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props"; export const CoreEditorExtensionsWithoutProps = [ StarterKit.configure({ @@ -41,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, @@ -85,10 +97,11 @@ export const CoreEditorExtensionsWithoutProps = [ TableHeader, TableCell, TableRow, - CustomMentionWithoutProps(), + CustomMentionExtensionConfig, CustomTextAlignExtension, CustomCalloutExtensionConfig, CustomColorExtension, + ...CoreEditorAdditionalExtensionsWithoutProps, ]; export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()]; 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 f1a85ab1ba..89194aae0c 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; @@ -118,7 +118,6 @@ export const CustomImageBlock: React.FC = (props) => { height: `${Math.round(initialHeight)}px` satisfies Pixel, aspectRatio: aspectRatioCalculated, }; - setSize(initialComputedSize); updateAttributesSafely( initialComputedSize, 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 78caa87b30..2bd84fcb31 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"; @@ -29,12 +29,9 @@ export const CustomImageNode = (props: CustomImageNodeProps) => { useEffect(() => { const closestEditorContainer = imageComponentRef.current?.closest(".editor-container"); - if (!closestEditorContainer) { - console.error("Editor container not found"); - return; + if (closestEditorContainer) { + setEditorContainer(closestEditorContainer as HTMLDivElement); } - - setEditorContainer(closestEditorContainer as HTMLDivElement); }, []); // the image is already uploaded if the image-component node has src attribute @@ -55,7 +52,7 @@ export const CustomImageNode = (props: CustomImageNodeProps) => { setResolvedSrc(url as string); }; getImageSource(); - }, [imageFromFileSystem, node.attrs.src]); + }, [imgNodeSrc]); return ( 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 8ad99bc443..eaea423878 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 38ea23c992..560d95cfc4 100644 --- a/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen.tsx @@ -1,7 +1,7 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react"; -// helpers -import { cn } from "@/helpers/common"; +import { useCallback, useEffect, useMemo, useState, useRef } from "react"; +// plane utils +import { cn } from "@plane/utils"; type Props = { image: { @@ -14,46 +14,77 @@ type Props = { toggleFullScreenMode: (val: boolean) => void; }; -const MAGNIFICATION_VALUES = [0.5, 0.75, 1, 1.5, 1.75, 2]; +const MIN_ZOOM = 0.5; +const MAX_ZOOM = 2; +const ZOOM_SPEED = 0.05; +const ZOOM_STEPS = [0.5, 1, 1.5, 2]; export const ImageFullScreenAction: React.FC = (props) => { const { image, isOpen: isFullScreenEnabled, toggleFullScreenMode } = props; const { src, width, aspectRatio } = image; - // states - const [magnification, setMagnification] = useState(1); - // refs + + const [magnification, setMagnification] = useState(1); + const [initialMagnification, setInitialMagnification] = useState(1); + const [isDragging, setIsDragging] = useState(false); + const dragStart = useRef({ x: 0, y: 0 }); + const dragOffset = useRef({ x: 0, y: 0 }); const modalRef = useRef(null); - // derived values + const imgRef = useRef(null); + const widthInNumber = useMemo(() => Number(width?.replace("px", "")), [width]); - // close handler - const handleClose = useCallback(() => { - toggleFullScreenMode(false); - setTimeout(() => { + + const setImageRef = useCallback( + (node: HTMLImageElement | null) => { + if (!node || !isFullScreenEnabled) return; + + imgRef.current = node; + + const viewportWidth = window.innerWidth * 0.9; + const viewportHeight = window.innerHeight * 0.75; + const imageWidth = widthInNumber; + const imageHeight = imageWidth / aspectRatio; + + const widthRatio = viewportWidth / imageWidth; + const heightRatio = viewportHeight / imageHeight; + + setInitialMagnification(Math.min(widthRatio, heightRatio)); setMagnification(1); - }, 200); - }, [toggleFullScreenMode]); - // download handler - const handleOpenInNewTab = () => { - const link = document.createElement("a"); - link.href = src; - link.target = "_blank"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }; - // magnification decrease handler - const handleDecreaseMagnification = useCallback(() => { - const currentIndex = MAGNIFICATION_VALUES.indexOf(magnification); - if (currentIndex === 0) return; - setMagnification(MAGNIFICATION_VALUES[currentIndex - 1]); - }, [magnification]); - // magnification increase handler - const handleIncreaseMagnification = useCallback(() => { - const currentIndex = MAGNIFICATION_VALUES.indexOf(magnification); - if (currentIndex === MAGNIFICATION_VALUES.length - 1) return; - setMagnification(MAGNIFICATION_VALUES[currentIndex + 1]); - }, [magnification]); - // keydown handler + + // Reset image position + node.style.left = "0px"; + node.style.top = "0px"; + }, + [isFullScreenEnabled, widthInNumber, aspectRatio] + ); + + const handleClose = useCallback(() => { + if (isDragging) return; + toggleFullScreenMode(false); + setMagnification(1); + setInitialMagnification(1); + }, [isDragging, toggleFullScreenMode]); + + const handleMagnification = useCallback((direction: "increase" | "decrease") => { + setMagnification((prev) => { + // Find the appropriate target zoom level based on current magnification + let targetZoom: number; + if (direction === "increase") { + targetZoom = ZOOM_STEPS.find((step) => step > prev) ?? MAX_ZOOM; + } else { + // Reverse the array to find the next lower step + targetZoom = [...ZOOM_STEPS].reverse().find((step) => step < prev) ?? MIN_ZOOM; + } + + // Reset position when zoom matches initial magnification + if (targetZoom === 1 && imgRef.current) { + imgRef.current.style.left = "0px"; + imgRef.current.style.top = "0px"; + } + + return targetZoom; + }); + }, []); + const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === "Escape" || e.key === "+" || e.key === "=" || e.key === "-") { @@ -61,43 +92,113 @@ export const ImageFullScreenAction: React.FC = (props) => { e.stopPropagation(); if (e.key === "Escape") handleClose(); - if (e.key === "+" || e.key === "=") handleIncreaseMagnification(); - if (e.key === "-") handleDecreaseMagnification(); + if (e.key === "+" || e.key === "=") handleMagnification("increase"); + if (e.key === "-") handleMagnification("decrease"); } }, - [handleClose, handleDecreaseMagnification, handleIncreaseMagnification] + [handleClose, handleMagnification] ); - // click outside handler - const handleClickOutside = useCallback( - (e: React.MouseEvent) => { - if (modalRef.current && e.target === modalRef.current) { - handleClose(); - } - }, - [handleClose] - ); - // register keydown listener - useEffect(() => { - if (isFullScreenEnabled) { - document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("keydown", handleKeyDown); + const handleMouseDown = (e: React.MouseEvent) => { + if (!imgRef.current) return; + + const imgWidth = imgRef.current.offsetWidth * magnification; + const imgHeight = imgRef.current.offsetHeight * magnification; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + if (imgWidth > viewportWidth || imgHeight > viewportHeight) { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + dragStart.current = { x: e.clientX, y: e.clientY }; + dragOffset.current = { + x: parseInt(imgRef.current.style.left || "0"), + y: parseInt(imgRef.current.style.top || "0"), }; } - }, [handleKeyDown, isFullScreenEnabled]); + }; + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDragging || !imgRef.current) return; + + const dx = e.clientX - dragStart.current.x; + const dy = e.clientY - dragStart.current.y; + + // Apply the scale factor to the drag movement + const scaledDx = dx / magnification; + const scaledDy = dy / magnification; + + imgRef.current.style.left = `${dragOffset.current.x + scaledDx}px`; + imgRef.current.style.top = `${dragOffset.current.y + scaledDy}px`; + }, + [isDragging, magnification] + ); + + const handleMouseUp = useCallback(() => { + if (!isDragging || !imgRef.current) return; + setIsDragging(false); + }, [isDragging]); + + const handleWheel = useCallback( + (e: WheelEvent) => { + if (!imgRef.current || !isFullScreenEnabled) return; + + e.preventDefault(); + + // Handle pinch-to-zoom + if (e.ctrlKey) { + const delta = e.deltaY; + setMagnification((prev) => { + const newZoom = prev * (1 - delta * ZOOM_SPEED); + const clampedZoom = Math.min(Math.max(newZoom, MIN_ZOOM), MAX_ZOOM); + + // Reset position when zoom matches initial magnification + if (clampedZoom === 1 && imgRef.current) { + imgRef.current.style.left = "0px"; + imgRef.current.style.top = "0px"; + } + + return clampedZoom; + }); + return; + } + }, + [isFullScreenEnabled, magnification] + ); + + // Event listeners + useEffect(() => { + if (!isFullScreenEnabled) return; + + document.addEventListener("keydown", handleKeyDown); + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + window.addEventListener("wheel", handleWheel, { passive: false }); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + window.removeEventListener("wheel", handleWheel); + }; + }, [isFullScreenEnabled, handleKeyDown, handleMouseMove, handleMouseUp, handleWheel]); return ( <>
-
+
e.target === modalRef.current && handleClose()} + className="relative size-full grid place-items-center overflow-hidden" + > -
-
-
+
+
+ + {Math.round(100 * magnification)}% + +
- {(100 * magnification).toFixed(0)}% -
-
+ ); + })} +
+ )) + ) : ( +
No results
+ )} +
+ ); +}); + +MentionsListDropdown.displayName = "MentionsListDropdown"; diff --git a/packages/editor/src/core/extensions/mentions/mentions-list.tsx b/packages/editor/src/core/extensions/mentions/mentions-list.tsx deleted file mode 100644 index 279567a203..0000000000 --- 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 8fa8ef695a..0000000000 --- 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 0000000000..5252aa8b89 --- /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 0000000000..e8e7ed4b7a --- /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 2d90592d68..38c7f99663 100644 --- a/packages/editor/src/core/extensions/read-only-extensions.tsx +++ b/packages/editor/src/core/extensions/read-only-extensions.tsx @@ -1,3 +1,4 @@ +import { Extensions } from "@tiptap/core"; import CharacterCount from "@tiptap/extension-character-count"; import TaskItem from "@tiptap/extension-task-item"; import TaskList from "@tiptap/extension-task-list"; @@ -18,8 +19,7 @@ import { TableCell, TableRow, Table, - CustomMention, - HeadingListExtension, + CustomMentionExtension, CustomReadOnlyImageExtension, CustomTextAlignExtension, CustomCalloutReadOnlyExtension, @@ -28,17 +28,18 @@ import { // helpers import { isValidHttpUrl } from "@/helpers/common"; // types -import { IMentionHighlight, 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) => { - const { fileHandler, mentionConfig } = props; +export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { + const { disabledExtensions, fileHandler, mentionHandler } = props; return [ StarterKit.configure({ @@ -61,6 +62,16 @@ export const CoreReadOnlyEditorExtensions = (props: Props) => { codeBlock: false, horizontalRule: false, blockquote: false, + paragraph: { + HTMLAttributes: { + class: "editor-paragraph-block", + }, + }, + heading: { + HTMLAttributes: { + class: "editor-heading-block", + }, + }, dropcursor: false, gapcursor: false, }), @@ -119,14 +130,13 @@ export const CoreReadOnlyEditorExtensions = (props: Props) => { TableHeader, TableCell, TableRow, - CustomMention({ - mentionHighlights: mentionConfig.mentionHighlights, - readonly: true, - }), + CustomMentionExtension(mentionHandler), CharacterCount, CustomColorExtension, - HeadingListExtension, CustomTextAlignExtension, CustomCalloutReadOnlyExtension, + ...CoreReadOnlyEditorAdditionalExtensions({ + disabledExtensions, + }), ]; }; diff --git a/packages/editor/src/core/extensions/side-menu.tsx b/packages/editor/src/core/extensions/side-menu.tsx index 5ab6fbdf5b..eac7130120 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-items-list.tsx b/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx index c19bda3068..1efb729019 100644 --- a/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx +++ b/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx @@ -39,17 +39,27 @@ import { setText, } from "@/helpers/editor-commands"; // types -import { CommandProps, ISlashCommandItem } from "@/types"; +import { CommandProps, ISlashCommandItem, TExtensions, TSlashCommandSectionKeys } from "@/types"; +// plane editor extensions +import { coreEditorAdditionalSlashCommandOptions } from "@/plane-editor/extensions"; +// local types +import { TSlashCommandAdditionalOption } from "./root"; export type TSlashCommandSection = { - key: string; + key: TSlashCommandSectionKeys; title?: string; items: ISlashCommandItem[]; }; +type TArgs = { + additionalOptions?: TSlashCommandAdditionalOption[]; + disabledExtensions: TExtensions[]; +}; + export const getSlashCommandFilteredSections = - (additionalOptions?: ISlashCommandItem[]) => + (args: TArgs) => ({ query }: { query: string }): TSlashCommandSection[] => { + const { additionalOptions, disabledExtensions } = args; const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [ { key: "general", @@ -201,7 +211,7 @@ export const getSlashCommandFilteredSections = ], }, { - key: "text-color", + key: "text-colors", title: "Colors", items: [ { @@ -242,7 +252,7 @@ export const getSlashCommandFilteredSections = ], }, { - key: "background-color", + key: "background-colors", title: "Background colors", items: [ { @@ -279,8 +289,19 @@ export const getSlashCommandFilteredSections = }, ]; - additionalOptions?.map((item) => { - SLASH_COMMAND_SECTIONS?.[0]?.items.push(item); + [ + ...(additionalOptions ?? []), + ...coreEditorAdditionalSlashCommandOptions({ + disabledExtensions, + }), + ]?.forEach((item) => { + const sectionToPushTo = SLASH_COMMAND_SECTIONS.find((s) => s.key === item.section) ?? SLASH_COMMAND_SECTIONS[0]; + const itemIndexToPushAfter = sectionToPushTo.items.findIndex((i) => i.commandKey === item.pushAfter); + if (itemIndexToPushAfter !== -1) { + sectionToPushTo.items.splice(itemIndexToPushAfter + 1, 0, item); + } else { + sectionToPushTo.items.push(item); + } }); const filteredSlashSections = SLASH_COMMAND_SECTIONS.map((section) => ({ 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 3a03c3b6a7..bd8ce2aecd 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 d6148b69ae..4ecd3f8fa2 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 a99cbc5f90..5e12c997f0 100644 --- a/packages/editor/src/core/extensions/slash-commands/root.tsx +++ b/packages/editor/src/core/extensions/slash-commands/root.tsx @@ -2,8 +2,10 @@ 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 } from "@/types"; +import { ISlashCommandItem, TEditorCommands, TExtensions, TSlashCommandSectionKeys } from "@/types"; // components import { getSlashCommandFilteredSections } from "./command-items-list"; import { SlashCommandsMenu, SlashCommandsMenuProps } from "./command-menu"; @@ -12,6 +14,11 @@ export type SlashCommandOptions = { suggestion: Omit; }; +export type TSlashCommandAdditionalOption = ISlashCommandItem & { + section: TSlashCommandSectionKeys; + pushAfter: TEditorCommands; +}; + const Command = Extension.create({ name: "slash-command", addOptions() { @@ -50,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, }); @@ -86,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; } @@ -102,10 +103,15 @@ const renderItems = () => { }; }; -export const SlashCommands = (additionalOptions?: ISlashCommandItem[]) => +type TExtensionProps = { + additionalOptions?: TSlashCommandAdditionalOption[]; + disabledExtensions: TExtensions[]; +}; + +export const SlashCommands = (props: TExtensionProps) => Command.configure({ suggestion: { - items: getSlashCommandFilteredSections(additionalOptions), + items: getSlashCommandFilteredSections(props), render: renderItems, }, }); diff --git a/packages/editor/src/core/helpers/common.ts b/packages/editor/src/core/helpers/common.ts index 0fb32310d6..8638d2c151 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/get-document-server-event.ts b/packages/editor/src/core/helpers/get-document-server-event.ts new file mode 100644 index 0000000000..1ba7646b29 --- /dev/null +++ b/packages/editor/src/core/helpers/get-document-server-event.ts @@ -0,0 +1,11 @@ +import { DocumentCollaborativeEvents } from "@/constants/document-collaborative-events"; +import { TDocumentEventKey, TDocumentEventsClient, TDocumentEventsServer } from "@/types/document-collaborative-events"; + +export const getServerEventName = (clientEvent: TDocumentEventsClient): TDocumentEventsServer | undefined => { + for (const key in DocumentCollaborativeEvents) { + if (DocumentCollaborativeEvents[key as TDocumentEventKey].client === clientEvent) { + return DocumentCollaborativeEvents[key as TDocumentEventKey].server; + } + } + return undefined; +}; diff --git a/packages/editor/src/core/helpers/tippy.ts b/packages/editor/src/core/helpers/tippy.ts new file mode 100644 index 0000000000..c254bd450f --- /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-editor.ts b/packages/editor/src/core/hooks/use-collaborative-editor.ts index 5bee8c0c3f..4abf7d6d1f 100644 --- a/packages/editor/src/core/hooks/use-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-collaborative-editor.ts @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, 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"; @@ -15,6 +15,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { const { onTransaction, disabledExtensions, + editable, editorClassName, editorProps = {}, embedHandler, @@ -58,25 +59,24 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { [id, realtimeConfig, serverHandler, user] ); - // destroy and disconnect connection on unmount + const localProvider = useMemo( + () => (id ? new IndexeddbPersistence(id, provider.document) : undefined), + [id, provider] + ); + + // destroy and disconnect all providers connection on unmount useEffect( () => () => { - provider.destroy(); - provider.disconnect(); - }, - [provider] - ); - // indexed db integration for offline support - useLayoutEffect(() => { - const localProvider = new IndexeddbPersistence(id, provider.document); - return () => { + provider?.destroy(); localProvider?.destroy(); - }; - }, [provider, id]); + }, + [provider, localProvider] + ); const editor = useEditor({ + disabledExtensions, id, - onTransaction, + editable, editorProps, editorClassName, enableHistory: false, @@ -98,9 +98,10 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { }), ], fileHandler, - handleEditorReady, forwardedRef, + handleEditorReady, mentionHandler, + onTransaction, placeholder, provider, tabIndex, diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index eef72797ce..563d5902d2 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"; @@ -16,22 +16,28 @@ import { IMarking, scrollSummary, scrollToNodeViaDOMCoordinates } from "@/helper // props import { CoreEditorProps } from "@/props"; // types -import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TEditorCommands, TFileHandler } from "@/types"; +import type { + TDocumentEventsServer, + EditorRefApi, + TEditorCommands, + TFileHandler, + TExtensions, + TMentionHandler, +} from "@/types"; export interface CustomEditorProps { + editable: boolean; editorClassName: string; editorProps?: EditorProps; enableHistory: boolean; - extensions?: any; + disabledExtensions: TExtensions[]; + 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; @@ -45,6 +51,8 @@ export interface CustomEditorProps { export const useEditor = (props: CustomEditorProps) => { const { + disabledExtensions, + editable = true, editorClassName, editorProps = {}, enableHistory, @@ -58,47 +66,49 @@ export const useEditor = (props: CustomEditorProps) => { onChange, onTransaction, placeholder, - provider, tabIndex, value, + provider, autofocus = false, } = props; // states - const [savedSelection, setSavedSelection] = useState(null); // refs const editorRef: MutableRefObject = useRef(null); const savedSelectionRef = useRef(savedSelection); - const editor = useTiptapEditor({ - autofocus, - editorProps: { - ...CoreEditorProps({ - editorClassName, - }), - ...editorProps, + const editor = useTiptapEditor( + { + editable, + 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), }, - extensions: [ - ...CoreEditorExtensions({ - enableHistory, - fileHandler, - mentionConfig: { - mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve([])), - mentionHighlights: mentionHandler.highlights, - }, - placeholder, - tabIndex, - }), - ...extensions, - ], - content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "

", - onCreate: () => handleEditorReady?.(true), - onTransaction: ({ editor }) => { - setSavedSelection(editor.state.selection); - onTransaction?.(); - }, - onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()), - onDestroy: () => handleEditorReady?.(false), - }); + [editable] + ); // Update the ref whenever savedSelection changes useEffect(() => { @@ -247,7 +257,7 @@ export const useEditor = (props: CustomEditorProps) => { if (empty) return null; const nodesArray: string[] = []; - state.doc.nodesBetween(from, to, (node, pos, parent) => { + state.doc.nodesBetween(from, to, (node, _pos, parent) => { if (parent === state.doc && editorRef.current) { const serializer = DOMSerializer.fromSchema(editorRef.current?.schema); const dom = serializer.serializeNode(node); @@ -288,6 +298,8 @@ export const useEditor = (props: CustomEditorProps) => { if (!document) return; Y.applyUpdate(document, value); }, + emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message), + listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) }, }), [editorRef, savedSelection] ); diff --git a/packages/editor/src/core/hooks/use-file-upload.ts b/packages/editor/src/core/hooks/use-file-upload.ts index f5f930f290..65daa2f8e4 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-collaborative-editor.ts b/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts deleted file mode 100644 index d408192297..0000000000 --- a/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { useEffect, useLayoutEffect, useMemo, useState } from "react"; -import { HocuspocusProvider } from "@hocuspocus/provider"; -import Collaboration from "@tiptap/extension-collaboration"; -import { IndexeddbPersistence } from "y-indexeddb"; -// extensions -import { HeadingListExtension } from "@/extensions"; -// hooks -import { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; -// types -import { TReadOnlyCollaborativeEditorProps } from "@/types"; - -export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEditorProps) => { - const { - 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({ - url: realtimeConfig.url, - name: id, - 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, user] - ); - // destroy and disconnect connection on unmount - useEffect( - () => () => { - provider.destroy(); - provider.disconnect(); - }, - [provider] - ); - // indexed db integration for offline support - useLayoutEffect(() => { - const localProvider = new IndexeddbPersistence(id, provider.document); - return () => { - localProvider?.destroy(); - }; - }, [provider, id]); - - const editor = useReadOnlyEditor({ - editorProps, - editorClassName, - extensions: [ - ...(extensions ?? []), - HeadingListExtension, - Collaboration.configure({ - document: provider.document, - }), - ], - fileHandler, - forwardedRef, - handleEditorReady, - mentionHandler, - provider, - }); - - return { - editor, - hasServerConnectionFailed, - hasServerSynced, - }; -}; diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index 23ce023adc..c97cb6068f 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"; @@ -11,24 +11,30 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node"; // props import { CoreReadOnlyEditorProps } from "@/props"; // types -import { EditorReadOnlyRefApi, IMentionHighlight, TFileHandler } from "@/types"; +import type { + EditorReadOnlyRefApi, + TExtensions, + TDocumentEventsServer, + TFileHandler, + TReadOnlyMentionHandler, +} from "@/types"; interface CustomReadOnlyEditorProps { - initialValue?: string; + disabledExtensions: TExtensions[]; editorClassName: string; - forwardedRef?: MutableRefObject; - extensions?: any; editorProps?: EditorProps; + extensions?: Extensions; + forwardedRef?: MutableRefObject; + initialValue?: string; fileHandler: Pick; handleEditorReady?: (value: boolean) => void; - mentionHandler: { - highlights: () => Promise; - }; + mentionHandler: TReadOnlyMentionHandler; provider?: HocuspocusProvider; } export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { const { + disabledExtensions, initialValue, editorClassName, forwardedRef, @@ -54,9 +60,8 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { }, extensions: [ ...CoreReadOnlyEditorExtensions({ - mentionConfig: { - mentionHighlights: mentionHandler.highlights, - }, + disabledExtensions, + mentionHandler, fileHandler, }), ...extensions, @@ -117,6 +122,8 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { editorRef.current?.off("update"); }; }, + emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message), + listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) }, getHeadings: () => editorRef?.current?.storage.headingList.headings, })); diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index 1c015dcb0f..fabb38f527 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; - - const nodePos = view.state.doc.resolve(fromSelectionPos); - - // Check if nodePos points to the top level node - if (nodePos.node().type.name === "doc") differentNodeSelected = true; - else { - // TODO FIX ERROR - const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before()); - // Check if the node where the drag event started is part of the current selection - differentNodeSelected = !( - draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos - ); - } - - if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) { - const endSelection = NodeSelection.create(view.state.doc, to - 1); - const multiNodeSelection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos); - view.dispatch(view.state.tr.setSelection(multiNodeSelection)); - } else { - // TODO FIX ERROR - const nodeSelection = NodeSelection.create(view.state.doc, draggedNodePos); - view.dispatch(view.state.tr.setSelection(nodeSelection)); - } - - // If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL - if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === "listItem") { - listType = node.parentElement!.tagName; - } - - if (node.matches("blockquote")) { - let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view); - if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return; - - const docSize = view.state.doc.content.size; - nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize)); - - if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) { - // TODO FIX ERROR - const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes); - view.dispatch(view.state.tr.setSelection(nodeSelection)); - } - } - - const slice = view.state.selection.content(); - const { dom, text } = __serializeForClipboard(view, slice); - - event.dataTransfer.clearData(); - event.dataTransfer.setData("text/html", dom.innerHTML); - event.dataTransfer.setData("text/plain", text); - event.dataTransfer.effectAllowed = "copyMove"; - - event.dataTransfer.setDragImage(node, 0, 0); - - view.dragging = { slice, move: event.ctrlKey }; - }; + let isDragging = false; + let lastClientY = 0; + let scrollAnimationFrame = null; + let isDraggedOutsideWindow: "top" | "bottom" | boolean = false; + let isMouseInsideWhileDragging = false; + let currentScrollSpeed = 0; const handleClick = (event: MouseEvent, view: EditorView) => { - view.focus(); + handleNodeSelection(event, view, false, options); + }; - const node = nodeDOMAtCoords({ - x: event.clientX + 50 + options.dragHandleWidth, - y: event.clientY, - }); + const handleDragStart = (event: DragEvent, view: EditorView) => { + const { listType: listTypeFromDragStart } = handleNodeSelection(event, view, true, options); + listType = listTypeFromDragStart; + isDragging = true; + lastClientY = event.clientY; + scroll(); + }; - if (!(node instanceof Element)) return; + const handleDragEnd = (event: TEvent, view?: EditorView) => { + event.preventDefault(); + isDragging = false; + isMouseInsideWhileDragging = false; + if (scrollAnimationFrame) { + cancelAnimationFrame(scrollAnimationFrame); + scrollAnimationFrame = null; + } - if (node.matches("blockquote")) { - let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view); - if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return; + view?.dom.classList.remove("dragging"); + }; - const docSize = view.state.doc.content.size; - nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize)); - - if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) { - // TODO FIX ERROR - const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes); - view.dispatch(view.state.tr.setSelection(nodeSelection)); - } + function scroll() { + if (!isDragging) { + currentScrollSpeed = 0; return; } - let nodePos = nodePosAtDOM(node, view, options); + const scrollableParent = getScrollParent(dragHandleElement); + if (!scrollableParent) return; - if (nodePos === null || nodePos === undefined) return; + const scrollRegionUp = options.scrollThreshold.up; + const scrollRegionDown = window.innerHeight - options.scrollThreshold.down; - // Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied - nodePos = calcNodePos(nodePos, view, node); + let targetScrollAmount = 0; - // TODO FIX ERROR - // Use NodeSelection to select the node at the calculated position - const nodeSelection = NodeSelection.create(view.state.doc, nodePos); + if (isDraggedOutsideWindow === "top") { + targetScrollAmount = -maxScrollSpeed * 5; + } else if (isDraggedOutsideWindow === "bottom") { + targetScrollAmount = maxScrollSpeed * 5; + } else if (lastClientY < scrollRegionUp) { + const ratio = easeOutQuadAnimation((scrollRegionUp - lastClientY) / options.scrollThreshold.up); + targetScrollAmount = -maxScrollSpeed * ratio; + } else if (lastClientY > scrollRegionDown) { + const ratio = easeOutQuadAnimation((lastClientY - scrollRegionDown) / options.scrollThreshold.down); + targetScrollAmount = maxScrollSpeed * ratio; + } - // Dispatch the transaction to update the selection - view.dispatch(view.state.tr.setSelection(nodeSelection)); - }; + currentScrollSpeed += (targetScrollAmount - currentScrollSpeed) * acceleration; + + if (Math.abs(currentScrollSpeed) > 0.1) { + scrollableParent.scrollBy({ top: currentScrollSpeed }); + } + + scrollAnimationFrame = requestAnimationFrame(scroll); + } let dragHandleElement: HTMLElement | null = null; // drag handle view actions @@ -231,51 +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; + const mouseMoveHandler = (e: MouseEvent) => { + if (isMouseInsideWhileDragging) { + handleDragEnd(e, view); + } + }; + + const dragLeaveHandler = (e: DragEvent) => { + if (e.clientY <= 0 || e.clientX <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) { + isMouseInsideWhileDragging = true; + + const windowMiddleY = window.innerHeight / 2; + + if (lastClientY < windowMiddleY) { + isDraggedOutsideWindow = "top"; + } else { + isDraggedOutsideWindow = "bottom"; } - currentParent = currentParent.parentElement; } - return document.scrollingElement || document.documentElement; }; - const maxScrollSpeed = 100; + const dragEnterHandler = () => { + isDraggedOutsideWindow = false; + }; - dragHandleElement.addEventListener("drag", (e) => { - hideDragHandle(); - const scrollableParent = getScrollParent(dragHandleElement); - if (!scrollableParent) return; - const scrollThreshold = options.scrollThreshold; + window.addEventListener("dragleave", dragLeaveHandler); + window.addEventListener("dragenter", dragEnterHandler); - if (e.clientY < scrollThreshold.up) { - const overflow = scrollThreshold.up - e.clientY; - const ratio = Math.min(overflow / scrollThreshold.up, 1); - const scrollAmount = -maxScrollSpeed * ratio; - scrollableParent.scrollBy({ top: scrollAmount }); - } else if (window.innerHeight - e.clientY < scrollThreshold.down) { - const overflow = e.clientY - (window.innerHeight - scrollThreshold.down); - const ratio = Math.min(overflow / scrollThreshold.down, 1); - const scrollAmount = maxScrollSpeed * ratio; - scrollableParent.scrollBy({ top: scrollAmount }); - } - }); + document.addEventListener("dragover", dragOverHandler); + document.addEventListener("mousemove", mouseMoveHandler); hideDragHandle(); @@ -285,6 +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; - const newList = view.state.schema.nodes.orderedList?.createAndFill(null, listItem); - const slice = new Slice(Fragment.from(newList), 0, 0); + // If dropping outside a list or at a different depth, adjust the structure + if (!isDroppedInsideList || dropDepth !== resolvedPos.depth) { + // Flatten the structure if needed + newFragment = flattenListStructure(newFragment, view.state.schema); + } + + // Wrap in appropriate list type if dropped outside a list + if (!isDroppedInsideList) { + const listNodeType = + listType === "OL" ? view.state.schema.nodes.orderedList : view.state.schema.nodes.bulletList; + newFragment = Fragment.from(listNodeType.create(null, newFragment)); + } + + slice = new Slice(newFragment, slice.openStart, slice.openEnd); view.dragging = { slice, move: event.ctrlKey }; } }, @@ -349,3 +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 db88f3f73c..703bb2bf0a 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 4bda3e51a2..ee0b9e5000 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 aaa635a508..ea5bf09f3a 100644 --- a/packages/editor/src/core/props/read-only.tsx +++ b/packages/editor/src/core/props/read-only.tsx @@ -1,6 +1,6 @@ import { EditorProps } from "@tiptap/pm/view"; -// helpers -import { cn } from "@/helpers/common"; +// plane utils +import { cn } from "@plane/utils"; // props import { TCoreEditorProps } from "@/props"; diff --git a/packages/editor/src/core/types/collaboration.ts b/packages/editor/src/core/types/collaboration.ts index 8609995ed8..c69a003fc9 100644 --- a/packages/editor/src/core/types/collaboration.ts +++ b/packages/editor/src/core/types/collaboration.ts @@ -6,10 +6,10 @@ import { TEmbedConfig } from "@/plane-editor/types"; import { EditorReadOnlyRefApi, EditorRefApi, - IMentionHighlight, - IMentionSuggestion, TExtensions, TFileHandler, + TMentionHandler, + TReadOnlyMentionHandler, TRealtimeConfig, TUserDetails, } from "@/types"; @@ -20,16 +20,13 @@ export type TServerHandler = { }; type TCollaborativeEditorHookProps = { - disabledExtensions?: TExtensions[]; + disabledExtensions: TExtensions[]; + editable?: boolean; editorClassName: string; editorProps?: EditorProps; extensions?: Extensions; handleEditorReady?: (value: boolean) => void; id: string; - mentionHandler: { - highlights: () => Promise; - suggestions?: () => Promise; - }; realtimeConfig: TRealtimeConfig; serverHandler?: TServerHandler; user: TUserDetails; @@ -40,6 +37,7 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & { embedHandler?: TEmbedConfig; fileHandler: TFileHandler; forwardedRef?: React.MutableRefObject; + mentionHandler: TMentionHandler; placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; }; @@ -47,4 +45,5 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & { export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & { fileHandler: Pick; forwardedRef?: React.MutableRefObject; + mentionHandler: TReadOnlyMentionHandler; }; diff --git a/packages/editor/src/core/types/document-collaborative-events.ts b/packages/editor/src/core/types/document-collaborative-events.ts new file mode 100644 index 0000000000..99936a5ad7 --- /dev/null +++ b/packages/editor/src/core/types/document-collaborative-events.ts @@ -0,0 +1,10 @@ +import { DocumentCollaborativeEvents } from "@/constants/document-collaborative-events"; + +export type TDocumentEventKey = keyof typeof DocumentCollaborativeEvents; +export type TDocumentEventsClient = (typeof DocumentCollaborativeEvents)[TDocumentEventKey]["client"]; +export type TDocumentEventsServer = (typeof DocumentCollaborativeEvents)[TDocumentEventKey]["server"]; + +export type TDocumentEventEmitter = { + on: (event: string, callback: (message: { payload: TDocumentEventsClient }) => void) => void; + off: (event: string, callback: (message: { payload: TDocumentEventsClient }) => void) => void; +}; diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index 53aae1f265..27a719f047 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -1,16 +1,18 @@ -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, + TDocumentEventsServer, TEmbedConfig, TExtensions, TFileHandler, + TMentionHandler, + TReadOnlyMentionHandler, TServerHandler, } from "@/types"; import { TTextAlign } from "@/extensions"; @@ -83,6 +85,8 @@ export type EditorReadOnlyRefApi = { }; onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void; getHeadings: () => IMarking[]; + emitRealTimeUpdate: (action: TDocumentEventsServer) => void; + listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined; }; export interface EditorRefApi extends EditorReadOnlyRefApi { @@ -104,16 +108,13 @@ export interface EditorRefApi extends EditorReadOnlyRefApi { export interface IEditorProps { containerClassName?: string; displayConfig?: TDisplayConfig; - disabledExtensions?: TExtensions[]; + disabledExtensions: TExtensions[]; editorClassName?: string; fileHandler: TFileHandler; 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; @@ -121,19 +122,20 @@ export interface IEditorProps { onEnterKeyPress?: (e?: any) => void; placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; - value?: string | null; + 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; } export interface ICollaborativeDocumentEditor extends Omit { + editable: boolean; aiHandler?: TAIHandler; embedHandler: TEmbedConfig; handleEditorReady?: (value: boolean) => void; @@ -146,15 +148,14 @@ export interface ICollaborativeDocumentEditor // read only editor props export interface IReadOnlyEditorProps { containerClassName?: string; + disabledExtensions: TExtensions[]; displayConfig?: TDisplayConfig; editorClassName?: string; fileHandler: Pick; forwardedRef?: React.MutableRefObject; id: string; initialValue: string; - mentionHandler: { - highlights: () => Promise; - }; + mentionHandler: TReadOnlyMentionHandler; } export type ILiteTextReadOnlyEditor = IReadOnlyEditorProps; diff --git a/packages/editor/src/core/types/extensions.ts b/packages/editor/src/core/types/extensions.ts index 2be17a4eff..b3aacccc0a 100644 --- a/packages/editor/src/core/types/extensions.ts +++ b/packages/editor/src/core/types/extensions.ts @@ -1 +1 @@ -export type TExtensions = "ai" | "collaboration-cursor" | "issue-embed" | "slash-commands"| "enter-key"; +export type TExtensions = "ai" | "collaboration-cursor" | "issue-embed" | "slash-commands" | "enter-key"; diff --git a/packages/editor/src/core/types/index.ts b/packages/editor/src/core/types/index.ts index 8da9ed276e..e99a74b28e 100644 --- a/packages/editor/src/core/types/index.ts +++ b/packages/editor/src/core/types/index.ts @@ -5,6 +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 a51bed7042..0000000000 --- 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 0000000000..20f1ec0dcc --- /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/core/types/slash-commands-suggestion.ts b/packages/editor/src/core/types/slash-commands-suggestion.ts index 91c93203af..d6dfae076f 100644 --- a/packages/editor/src/core/types/slash-commands-suggestion.ts +++ b/packages/editor/src/core/types/slash-commands-suggestion.ts @@ -8,6 +8,8 @@ export type CommandProps = { range: Range; }; +export type TSlashCommandSectionKeys = "general" | "text-colors" | "background-colors"; + export type ISlashCommandItem = { commandKey: TEditorCommands; key: string; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index ed7d913469..9dd0db267f 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -9,7 +9,6 @@ import "./styles/drag-drop.css"; // editors export { CollaborativeDocumentEditorWithRef, - CollaborativeDocumentReadOnlyEditorWithRef, DocumentReadOnlyEditorWithRef, LiteTextEditorWithRef, LiteTextReadOnlyEditorWithRef, diff --git a/packages/editor/src/lib.ts b/packages/editor/src/lib.ts index e14c40127f..e32fa07850 100644 --- a/packages/editor/src/lib.ts +++ b/packages/editor/src/lib.ts @@ -1 +1,4 @@ export * from "@/extensions/core-without-props"; +export * from "@/constants/document-collaborative-events"; +export * from "@/helpers/get-document-server-event"; +export * from "@/types/document-collaborative-events"; diff --git a/packages/editor/src/styles/editor.css b/packages/editor/src/styles/editor.css index fff3b533e8..e234f87cf8 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)); } @@ -179,13 +179,26 @@ ul[data-type="taskList"] li > label input[type="checkbox"] { } } -ul[data-type="taskList"] li > div > p { - margin-top: 10px; +ul[data-type="taskList"] li > div { + & > p.editor-paragraph-block { + margin-top: 10px; + transition: color 0.2s ease; + } + + [data-text-color] { + transition: opacity 0.2s ease; + } } -ul[data-type="taskList"] li[data-checked="true"] > div > p { - color: rgb(var(--color-text-400)); - transition: color 0.2s ease; +ul[data-type="taskList"] li[data-checked="true"] { + & > div > p.editor-paragraph-block { + color: rgb(var(--color-text-400)); + } + + [data-text-color] { + opacity: 0.6; + transition: opacity 0.2s ease; + } } /* end to-do list */ @@ -309,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; } @@ -331,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; } @@ -342,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; } @@ -353,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; } @@ -364,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; } @@ -375,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; } @@ -386,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; } @@ -407,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 283590693e..790364230f 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 335047356e..66557c74ed 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.23.1", + "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 e0d5a1969c..0000000000 --- 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 e800e98fdc..0000000000 --- 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 aad7272629..0000000000 --- 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 f1216272dd..0000000000 --- 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 b3199fe625..0000000000 --- a/packages/helpers/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@plane/helpers", - "version": "0.23.1", - "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 0000000000..6019047c3e --- /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 0000000000..558b8f76ed --- /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 0000000000..d5be669c5e --- /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 0000000000..87d988f1b2 --- /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 0000000000..b45723305c --- /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 55% rename from packages/helpers/hooks/index.ts rename to packages/hooks/src/index.ts index c7a8f4c06b..c07642907f 100644 --- a/packages/helpers/hooks/index.ts +++ b/packages/hooks/src/index.ts @@ -1 +1,2 @@ +export * from "./use-local-storage"; export * from "./use-outside-click-detector"; diff --git a/packages/hooks/src/use-local-storage.tsx b/packages/hooks/src/use-local-storage.tsx new file mode 100644 index 0000000000..f04e0e71ba --- /dev/null +++ b/packages/hooks/src/use-local-storage.tsx @@ -0,0 +1,59 @@ +import { useState, useEffect, useCallback } from "react"; + +export const getValueFromLocalStorage = (key: string, defaultValue: any) => { + if (typeof window === undefined || typeof window === "undefined") + return defaultValue; + try { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : defaultValue; + } catch (error) { + window.localStorage.removeItem(key); + return defaultValue; + } +}; + +export const setValueIntoLocalStorage = (key: string, value: any) => { + if (typeof window === undefined || typeof window === "undefined") + return false; + try { + window.localStorage.setItem(key, JSON.stringify(value)); + return true; + } catch (error) { + return false; + } +}; + +export const useLocalStorage = (key: string, initialValue: T) => { + const [storedValue, setStoredValue] = useState(() => + getValueFromLocalStorage(key, initialValue) + ); + + const setValue = useCallback( + (value: T) => { + window.localStorage.setItem(key, JSON.stringify(value)); + setStoredValue(value); + window.dispatchEvent(new Event(`local-storage:${key}`)); + }, + [key] + ); + + const clearValue = useCallback(() => { + window.localStorage.removeItem(key); + setStoredValue(null); + window.dispatchEvent(new Event(`local-storage:${key}`)); + }, [key]); + + const reHydrate = useCallback(() => { + const data = getValueFromLocalStorage(key, initialValue); + setStoredValue(data); + }, [key, initialValue]); + + useEffect(() => { + window.addEventListener(`local-storage:${key}`, reHydrate); + return () => { + window.removeEventListener(`local-storage:${key}`, reHydrate); + }; + }, [key, reHydrate]); + + return { storedValue, setValue, clearValue } as const; +}; 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 f9715d3d8b..e8af9092ad 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 0000000000..6019047c3e --- /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 0000000000..558b8f76ed --- /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 0000000000..87d988f1b2 --- /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 0000000000..6b83eb52bb --- /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 0000000000..7a374004f5 --- /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 0000000000..fd918e59ef --- /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 0000000000..715301ad30 --- /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 0000000000..e251a5837d --- /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 0000000000..2ba1c9799b --- /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 0000000000..6019047c3e --- /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 0000000000..558b8f76ed --- /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 0000000000..87d988f1b2 --- /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 0000000000..e3473942f7 --- /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 0000000000..6a3b3c637c --- /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 0000000000..bce346aa66 --- /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 0000000000..c012fd26f4 --- /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 0000000000..7655bd4424 --- /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 0000000000..3574424daf --- /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 0000000000..87e75e536f --- /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 0000000000..2ab33e86ab --- /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 0000000000..4897926a92 --- /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 0000000000..8c40f0a298 --- /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 0000000000..3e6f32cd9a --- /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 0000000000..c697c2da4a --- /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 0000000000..5956023cbd --- /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 0000000000..6a4a6bcca7 --- /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 0000000000..79e3f04009 --- /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 0000000000..92ee523ea6 --- /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 0000000000..a78a7b0929 --- /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 0000000000..e48da3430b --- /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 0000000000..677aa28e7a --- /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 0000000000..dd3d4a663c --- /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 0000000000..0ffe451fbc --- /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 0000000000..cde9ddbd34 --- /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 0000000000..1f7f722956 --- /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 0000000000..37e1f81dca --- /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 0000000000..ae4b80864c --- /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 0000000000..8f08f92b83 --- /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 0000000000..0caee9e198 --- /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 0000000000..1d1732aa91 --- /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 0000000000..b8fddb37d5 --- /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 0000000000..6ec55d7f7a --- /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 0000000000..e69de29bb2 diff --git a/packages/services/src/user/favorite.service.ts b/packages/services/src/user/favorite.service.ts new file mode 100644 index 0000000000..7e838a3c6c --- /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 0000000000..41df23a178 --- /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 0000000000..4076a51f52 --- /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 0000000000..fa34460202 --- /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 0000000000..e92225af38 --- /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 0000000000..a21f632222 --- /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 0000000000..ca782d4335 --- /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 0000000000..72ef9fcefd --- /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 0000000000..0c2f64d1a8 --- /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 cec6628a64..a7fac6401b 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.23.1", + "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 0000000000..87d988f1b2 --- /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 5962ca25c9..6fc823e902 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@plane/types", - "version": "0.23.1", + "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 0000000000..98d54dabc4 --- /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 0000000000..6e072ab8fb --- /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 5fe31ad000..7e755fcc28 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 df6a462b02..854c0c6140 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/index.d.ts b/packages/types/src/index.d.ts index 10e5197006..43cc3084a3 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -28,7 +28,11 @@ 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"; diff --git a/packages/types/src/instance/base.d.ts b/packages/types/src/instance/base.d.ts index 8095f4e01f..33d0734ad3 100644 --- a/packages/types/src/instance/base.d.ts +++ b/packages/types/src/instance/base.d.ts @@ -4,6 +4,7 @@ import { TInstanceEmailConfigurationKeys, TInstanceImageConfigurationKeys, TInstanceAuthenticationKeys, + TInstanceWorkspaceConfigurationKeys, } from "./"; export interface IInstanceInfo { @@ -36,6 +37,7 @@ export interface IInstance { } export interface IInstanceConfig { + is_workspace_creation_disabled: boolean; is_google_enabled: boolean; is_github_enabled: boolean; is_gitlab_enabled: boolean; @@ -55,6 +57,7 @@ export interface IInstanceConfig { // intercom is_intercom_enabled: boolean; intercom_app_id: string | undefined; + instance_changelog_url?: string; } export interface IInstanceAdmin { @@ -78,7 +81,8 @@ export type TInstanceConfigurationKeys = | TInstanceEmailConfigurationKeys | TInstanceImageConfigurationKeys | TInstanceAuthenticationKeys - | TInstanceIntercomConfigurationKeys; + | TInstanceIntercomConfigurationKeys + | TInstanceWorkspaceConfigurationKeys; export interface IInstanceConfiguration { id: string; diff --git a/packages/types/src/instance/index.d.ts b/packages/types/src/instance/index.d.ts index c68f196d35..bc6474b832 100644 --- a/packages/types/src/instance/index.d.ts +++ b/packages/types/src/instance/index.d.ts @@ -3,3 +3,4 @@ export * from "./auth"; export * from "./base"; export * from "./email"; export * from "./image"; +export * from "./workspace"; diff --git a/packages/types/src/instance/workspace.d.ts b/packages/types/src/instance/workspace.d.ts new file mode 100644 index 0000000000..15a7317d00 --- /dev/null +++ b/packages/types/src/instance/workspace.d.ts @@ -0,0 +1 @@ +export type TInstanceWorkspaceConfigurationKeys = "DISABLE_WORKSPACE_CREATION"; diff --git a/packages/types/src/issues.d.ts b/packages/types/src/issues.d.ts index eff81f857b..f77408fd6c 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 ae4a98d63f..6fd160e77f 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 = { @@ -95,6 +97,7 @@ export type TIssuesResponse = { total_pages: number; extra_stats: null; results: TIssueResponseResults; + total_results: number; }; export type TBulkIssueProperties = Pick< @@ -120,3 +123,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 ef7308bf7d..f5478051eb 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 0000000000..45b9dfc6ac --- /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 d48342cebb..f878266b75 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -136,6 +136,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 0000000000..5ab01549c9 --- /dev/null +++ b/packages/types/src/search.d.ts @@ -0,0 +1,81 @@ +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; + query_type: TSearchEntities[]; + query: string; +}; diff --git a/packages/types/src/state.d.ts b/packages/types/src/state.d.ts index 7df658a88b..120b216da2 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 0000000000..b4df123a30 --- /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 57baa4cfdd..dca1a652c6 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 7d960015b9..0e0e15af17 100644 --- a/packages/types/src/workspace-notifications.d.ts +++ b/packages/types/src/workspace-notifications.d.ts @@ -35,7 +35,7 @@ export type TNotificationData = { }; export type TNotification = { - id: string | undefined; + id: string; title: string | undefined; data: TNotificationData | undefined; entity_identifier: string | undefined; diff --git a/packages/types/src/workspace.d.ts b/packages/types/src/workspace.d.ts index 412083c428..500eaa7b51 100644 --- a/packages/types/src/workspace.d.ts +++ b/packages/types/src/workspace.d.ts @@ -21,6 +21,7 @@ export interface IWorkspace { readonly updated_by: string; organization_size: string; total_issues: number; + total_projects?: number; } export interface IWorkspaceLite { @@ -222,3 +223,7 @@ export interface IWorkspaceProgressResponse { export interface IWorkspaceAnalyticsResponse { completion_chart: any; } + +export type TWorkspacePaginationInfo = TPaginationInfo & { + results: IWorkspace[]; +}; diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index f6b12920c7..3237a184b4 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@plane/typescript-config", - "version": "0.23.1", + "version": "0.24.1", "private": true, "files": [ "base.json", diff --git a/packages/ui/package.json b/packages/ui/package.json index 9aa1b08724..2cfcc3643c 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.23.1", + "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 669f557577..05a8bdbf1b 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 0000000000..a716ca65e1 --- /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/collapsible/collapsible-button.tsx b/packages/ui/src/collapsible/collapsible-button.tsx index a56a724b4c..2a141aa41c 100644 --- a/packages/ui/src/collapsible/collapsible-button.tsx +++ b/packages/ui/src/collapsible/collapsible-button.tsx @@ -8,12 +8,27 @@ type Props = { hideChevron?: boolean; indicatorElement?: React.ReactNode; actionItemElement?: React.ReactNode; + className?: string; + titleClassName?: string; }; export const CollapsibleButton: FC = (props) => { - const { isOpen, title, hideChevron = false, indicatorElement, actionItemElement } = props; + const { + isOpen, + title, + hideChevron = false, + indicatorElement, + actionItemElement, + className = "", + titleClassName = "", + } = props; return ( -
+
{!hideChevron && ( @@ -23,7 +38,7 @@ export const CollapsibleButton: FC = (props) => { })} /> )} - {title} + {title}
{indicatorElement && indicatorElement}
diff --git a/packages/ui/src/dropdown/common/loader.tsx b/packages/ui/src/dropdown/common/loader.tsx index 0ec1f053b6..814ed4805b 100644 --- a/packages/ui/src/dropdown/common/loader.tsx +++ b/packages/ui/src/dropdown/common/loader.tsx @@ -1,9 +1,10 @@ +import range from "lodash/range"; import React from "react"; export const DropdownOptionsLoader = () => (
- {Array.from({ length: 6 }, (_, i) => ( -
+ {range(6).map((index) => ( +
))}
); diff --git a/packages/ui/src/dropdown/multi-select.tsx b/packages/ui/src/dropdown/multi-select.tsx index 6b50183702..25f22c6be4 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 1c3b05f5b6..bcdff40c14 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/app/views/[anchor]/page.tsx b/space/app/views/[anchor]/page.tsx index 21bb5a9657..1efd95a533 100644 --- a/space/app/views/[anchor]/page.tsx +++ b/space/app/views/[anchor]/page.tsx @@ -2,6 +2,8 @@ import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; +// components +import { PoweredBy } from "@/components/common"; // hooks import { usePublish } from "@/hooks/store"; // plane-web @@ -24,7 +26,12 @@ const IssuesPage = observer((props: Props) => { if (!publishSettings) return null; - return ; + return ( + <> + + + + ); }); export default IssuesPage; diff --git a/space/ce/components/editor/embeds/index.ts b/space/ce/components/editor/embeds/index.ts new file mode 100644 index 0000000000..8146e94d92 --- /dev/null +++ b/space/ce/components/editor/embeds/index.ts @@ -0,0 +1 @@ +export * from "./mentions"; diff --git a/space/ce/components/editor/embeds/mentions/index.ts b/space/ce/components/editor/embeds/mentions/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/space/ce/components/editor/embeds/mentions/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/space/ce/components/editor/embeds/mentions/root.tsx b/space/ce/components/editor/embeds/mentions/root.tsx new file mode 100644 index 0000000000..16e21f848d --- /dev/null +++ b/space/ce/components/editor/embeds/mentions/root.tsx @@ -0,0 +1,4 @@ +// plane editor +import { TMentionComponentProps } from "@plane/editor"; + +export const EditorAdditionalMentionsRoot: React.FC = () => null; diff --git a/space/ce/components/editor/index.ts b/space/ce/components/editor/index.ts new file mode 100644 index 0000000000..cf8352ae4a --- /dev/null +++ b/space/ce/components/editor/index.ts @@ -0,0 +1 @@ +export * from "./embeds"; diff --git a/space/core/components/account/auth-forms/password.tsx b/space/core/components/account/auth-forms/password.tsx index 3d87cded59..5f0384f9bb 100644 --- a/space/core/components/account/auth-forms/password.tsx +++ b/space/core/components/account/auth-forms/password.tsx @@ -3,11 +3,11 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { observer } from "mobx-react"; import { Eye, EyeOff, XCircle } from "lucide-react"; +import { API_BASE_URL } from "@plane/constants"; import { Button, Input, Spinner } from "@plane/ui"; // components import { PasswordStrengthMeter } from "@/components/account"; // helpers -import { API_BASE_URL } from "@/helpers/common.helper"; import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper"; // services import { AuthService } from "@/services/auth.service"; diff --git a/space/core/components/account/auth-forms/unique-code.tsx b/space/core/components/account/auth-forms/unique-code.tsx index 10c7b4f00a..f16796d058 100644 --- a/space/core/components/account/auth-forms/unique-code.tsx +++ b/space/core/components/account/auth-forms/unique-code.tsx @@ -2,9 +2,8 @@ import React, { useEffect, useState } from "react"; import { CircleCheck, XCircle } from "lucide-react"; +import { API_BASE_URL } from "@plane/constants"; import { Button, Input, Spinner } from "@plane/ui"; -// helpers -import { API_BASE_URL } from "@/helpers/common.helper"; // hooks import useTimer from "@/hooks/use-timer"; // services diff --git a/space/core/components/account/oauth/github-button.tsx b/space/core/components/account/oauth/github-button.tsx index ba65f38c4d..eaa83ebbbd 100644 --- a/space/core/components/account/oauth/github-button.tsx +++ b/space/core/components/account/oauth/github-button.tsx @@ -2,8 +2,7 @@ import { FC } from "react"; import { useSearchParams } from "next/navigation"; import Image from "next/image"; import { useTheme } from "next-themes"; -// helpers -import { API_BASE_URL } from "@/helpers/common.helper"; +import { API_BASE_URL } from "@plane/constants"; // images import githubLightModeImage from "/public/logos/github-black.png"; import githubDarkModeImage from "/public/logos/github-dark.svg"; diff --git a/space/core/components/account/oauth/gitlab-button.tsx b/space/core/components/account/oauth/gitlab-button.tsx index 072a2f628c..ba1880ae97 100644 --- a/space/core/components/account/oauth/gitlab-button.tsx +++ b/space/core/components/account/oauth/gitlab-button.tsx @@ -2,8 +2,7 @@ import { FC } from "react"; import { useSearchParams } from "next/navigation"; import Image from "next/image"; import { useTheme } from "next-themes"; -// helpers -import { API_BASE_URL } from "@/helpers/common.helper"; +import { API_BASE_URL } from "@plane/constants"; // images import GitlabLogo from "/public/logos/gitlab-logo.svg"; diff --git a/space/core/components/account/oauth/google-button.tsx b/space/core/components/account/oauth/google-button.tsx index 179b1c35ad..dc28bdae48 100644 --- a/space/core/components/account/oauth/google-button.tsx +++ b/space/core/components/account/oauth/google-button.tsx @@ -2,8 +2,7 @@ import { FC } from "react"; import { useSearchParams } from "next/navigation"; import Image from "next/image"; import { useTheme } from "next-themes"; -// helpers -import { API_BASE_URL } from "@/helpers/common.helper"; +import { API_BASE_URL } from "@plane/constants"; // images import GoogleLogo from "/public/logos/google-logo.svg"; diff --git a/space/core/components/account/user-logged-in.tsx b/space/core/components/account/user-logged-in.tsx index 4bedc45964..0993ade920 100644 --- a/space/core/components/account/user-logged-in.tsx +++ b/space/core/components/account/user-logged-in.tsx @@ -4,6 +4,7 @@ import { observer } from "mobx-react"; import Image from "next/image"; import { useTheme } from "next-themes"; // components +import { PoweredBy } from "@/components/common"; import { UserAvatar } from "@/components/issues"; // hooks import { useUser } from "@/hooks/store"; @@ -45,6 +46,7 @@ export const UserLoggedIn = observer(() => {

+
); }); diff --git a/space/core/components/common/index.ts b/space/core/components/common/index.ts index 1949c069be..0a63ca1ac8 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 0000000000..654089c55c --- /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 0000000000..8146e94d92 --- /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 0000000000..1efe34c51e --- /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 0000000000..9ea5ef6fb2 --- /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 0000000000..5a178396b8 --- /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 4ec0141e20..894daf2241 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 0e3f34293b..ac0a0633a4 100644 --- a/space/core/components/editor/lite-text-editor.tsx +++ b/space/core/components/editor/lite-text-editor.tsx @@ -2,15 +2,14 @@ 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 { +interface LiteTextEditorWrapperProps + extends Omit { anchor: string; workspaceId: string; isSubmitting?: boolean; @@ -28,8 +27,6 @@ export const LiteTextEditor = React.forwardRef(ref: React.ForwardedRef): ref is React.MutableRefObject { return !!ref && typeof ref === "object" && "current" in ref; } @@ -41,14 +38,14 @@ 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 e12a46682e..5f936baec5 100644 --- a/space/core/components/editor/lite-text-read-only-editor.tsx +++ b/space/core/components/editor/lite-text-read-only-editor.tsx @@ -1,35 +1,35 @@ 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 & { +type LiteTextReadOnlyEditorWrapperProps = Omit< + ILiteTextReadOnlyEditor, + "disabledExtensions" | "fileHandler" | "mentionHandler" +> & { anchor: string; }; 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 39a18f4f4c..96f4900548 100644 --- a/space/core/components/editor/rich-text-editor.tsx +++ b/space/core/components/editor/rich-text-editor.tsx @@ -1,32 +1,26 @@ import React, { forwardRef } from "react"; // editor -import { EditorRefApi, IMentionHighlight, IRichTextEditor, RichTextEditorWithRef } from "@plane/editor"; -// types +import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef } from "@plane/editor"; +// components +import { EditorMentionsRoot } from "@/components/editor"; // helpers -import { cn } from "@/helpers/common.helper"; import { getEditorFileHandlers } from "@/helpers/editor.helper"; -interface RichTextEditorWrapperProps extends Omit { +interface RichTextEditorWrapperProps + extends Omit { uploadFile: (file: File) => Promise; } 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={[]} fileHandler={getEditorFileHandlers({ uploadFile, workspaceId: "", 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 b5b097baac..76075f2965 100644 --- a/space/core/components/editor/rich-text-read-only-editor.tsx +++ b/space/core/components/editor/rich-text-read-only-editor.tsx @@ -1,33 +1,35 @@ 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 & { +type RichTextReadOnlyEditorWrapperProps = Omit< + IRichTextReadOnlyEditor, + "disabledExtensions" | "fileHandler" | "mentionHandler" +> & { anchor: string; }; 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 4593aaf653..0d6931af55 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 51c1a75199..4f16f89ba8 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 641cf007c0..ff0a13b890 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 241a087e78..a7ffd38ae8 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 7c246cc339..1975963cc1 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 39a298448a..6d4fb0983f 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 742cfeef15..75d280d0d0 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 3c596cb53c..66e9ab2960 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 52c1057897..1774781cd3 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 bac44d5232..9ae3314f18 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 eaa30d9908..465f469c0b 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 efaa8ea36a..7b4bbda771 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 56a09bcd99..1d2d1c6fe8 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 992f6367c9..4bd85433ee 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 2d1465c78c..1b8344d024 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 40339bb5c0..4c41677fc7 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 0749f8519b..38dd7ef6f9 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 2c5a8a2f4d..39d830a92a 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 6089c56046..0000000000 --- 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 1d9ebbb19c..0000000000 --- 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 f681ab8b25..0000000000 --- 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 4f28dbcf9b..06056364f3 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 c1256ddc29..b810c10562 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 060da53f4a..3bbfd149e6 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 6df75ebde1..7d4ff9a10f 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 168738804e..0b4807affb 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 a11599b0c7..100929955e 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 b5ecb80778..8ec67ee45f 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 2a2ee5ad97..3b5585578b 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 02cd1f7762..9de19455b1 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 f89202b6b6..30d6ebecf1 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 722380efaf..bac52e7513 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 896f36ee99..3da72f59a9 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 153f965280..b877ac530c 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 1aeb134668..a00b1a3507 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 004aa06c63..7abfa324a8 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 0c589dc4d3..be9ca438b2 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 977df42213..0000000000 --- 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 de43001d2c..db9e26566b 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 0000000000..f8506c1d6d --- /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 6db73a4a10..3ffc59573c 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 b037055afe..15891cfb1b 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 b149ebc7cf..a94ed7efe9 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 a5159edef2..a7129ca495 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 81bffdef96..8d97c39f61 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 d18ce805f4..58b6cfa0be 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 4b27170ee5..2468e2e21a 100644 --- a/space/package.json +++ b/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "0.23.1", + "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 43e8a7b8ff..e841c6b328 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 f77e61c319..68597b509a 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 f8fe0f8f9e..340ec57d0d 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 b8b80b4d27..e3d7303636 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 "@/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 b963ca147c..21d230347c 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 6da6d77495..17df19b220 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"; @@ -46,7 +46,6 @@ export const ProjectArchivesHeader: FC = observer((props: TProps) => { type="text" link={ { type="text" link={ { "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 902f15cde8..dc079bc949 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"; @@ -172,7 +169,6 @@ export const CycleIssuesHeader: React.FC = observer(() => { 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 aa81ae5816..1f85665fa8 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]/cycles/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx index b2e157ee7b..ca3189a1fc 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx @@ -38,7 +38,6 @@ export const CyclesListHeader: FC = observer(() => { link={ 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 49816bad74..59c5e995bf 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 @@ -99,7 +96,6 @@ export const ProjectDraftIssueHeader: FC = observer(() => { type="text" link={ { type="text" link={ { - // 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 025c9fab04..29f2637b76 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 59dcae31b9..e84cef04fd 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"; @@ -171,7 +168,6 @@ export const ModuleIssuesHeader: React.FC = observer(() => { diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx index 0edfa2b5a2..63cf32ef6e 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]/modules/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx index 5190dccedc..6da84e56a7 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx @@ -39,7 +39,6 @@ export const ModulesListHeader: React.FC = observer(() => { type="text" link={ { ? () => getPageById(workspaceSlug?.toString(), projectId?.toString(), pageId.toString()) : null, { - revalidateIfStale: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, + revalidateIfStale: true, + revalidateOnFocus: true, + revalidateOnReconnect: true, } ); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx index 44ba6d7f5f..1c3d96b571 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx @@ -77,7 +77,6 @@ export const PageDetailsHeader = observer(() => { { // states @@ -26,16 +24,9 @@ export const PagesListHeader = observer(() => { const searchParams = useSearchParams(); const pageType = searchParams.get("type"); // store hooks - const { allowPermissions } = useUserPermissions(); - const { currentProjectDetails, loader } = useProject(); - const { createPage } = useProjectPages(); + const { canCurrentUserCreatePage, createPage } = useProjectPages(); const { setTrackElement } = useEventTracker(); - // auth - const canUserCreatePage = allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], - EUserPermissionsLevel.PROJECT - ); // handle page create const handleCreatePage = async () => { setIsCreatingPage(true); @@ -69,7 +60,6 @@ export const PagesListHeader = observer(() => { type="text" link={ {
- {canUserCreatePage ? ( + {canCurrentUserCreatePage ? (
diff --git a/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx b/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx index 500d23ed70..e1ec683ec3 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/create-workspace/page.tsx b/web/app/create-workspace/page.tsx index 1a5625ad64..36bc8978ad 100644 --- a/web/app/create-workspace/page.tsx +++ b/web/app/create-workspace/page.tsx @@ -7,15 +7,19 @@ import Link from "next/link"; import { useTheme } from "next-themes"; import { IWorkspace } from "@plane/types"; // components +import { Button, getButtonStyling } from "@plane/ui"; import { CreateWorkspaceForm } from "@/components/workspace"; // hooks import { useUser, useUserProfile } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; // wrappers import { AuthenticationWrapper } from "@/lib/wrappers"; +// plane web helpers +import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper"; // images 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 WorkspaceCreationDisabled from "@/public/workspace/workspace-creation-disabled.png"; const CreateWorkspacePage = observer(() => { // router @@ -31,6 +35,8 @@ const CreateWorkspacePage = observer(() => { }); // hooks const { resolvedTheme } = useTheme(); + // derived values + const isWorkspaceCreationDisabled = getIsWorkspaceCreationDisabled(); const onSubmit = async (workspace: IWorkspace) => { await updateUserProfile({ last_workspace_id: workspace.id }).then(() => router.push(`/${workspace.slug}`)); @@ -56,16 +62,38 @@ const CreateWorkspacePage = observer(() => {
-
-

Create your workspace

-
- + {isWorkspaceCreationDisabled ? ( +
+ Workspace creation disabled +
Only your instance admin can create workspaces
+

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

+
+ + + Request instance admin + +
-
+ ) : ( +
+

Create your workspace

+
+ +
+
+ )}
diff --git a/web/app/error.tsx b/web/app/error.tsx index da5d3bf200..b176799b4d 100644 --- a/web/app/error.tsx +++ b/web/app/error.tsx @@ -39,7 +39,7 @@ export default function CustomErrorComponent() {
-
+

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

That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more diff --git a/web/app/invitations/page.tsx b/web/app/invitations/page.tsx index 580896bb56..73ef15b630 100644 --- a/web/app/invitations/page.tsx +++ b/web/app/invitations/page.tsx @@ -136,11 +136,14 @@ const UserInvitationsPage = observer(() => {

-
+
Plane logo
-
+
{currentUser?.email}
diff --git a/web/app/profile/page.tsx b/web/app/profile/page.tsx index 1dd9702a36..d451042d9d 100644 --- a/web/app/profile/page.tsx +++ b/web/app/profile/page.tsx @@ -20,9 +20,9 @@ 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"; @@ -120,22 +120,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 +363,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 aec3e24dca..479ef21f51 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 0000000000..a4fac4b91e --- /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 0000000000..84a7dddc64 --- /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 0000000000..324af8d483 --- /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 0000000000..64d22493e1 --- /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 a173cfda03..5ebddc63f2 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 0000000000..8146e94d92 --- /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 0000000000..1efe34c51e --- /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 0000000000..16e21f848d --- /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 0000000000..cf8352ae4a --- /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 0000000000..031608e25f --- /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 0000000000..9c76b7bdab --- /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 0000000000..29da0cc8ac --- /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 f049875f11..6feb208a8b 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 cff391d9ea..2fd6290426 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 2cd80e3a7e..f0f36b6c97 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 0000000000..4f4787fef8 --- /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 0000000000..51dbc3b417 --- /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 97b57af4b0..01fc1d9acf 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 0000000000..319b4c6844 --- /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 0000000000..1e05f40cbd --- /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 0000000000..03b546be53 --- /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 0000000000..48dca43bd9 --- /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 51880a6ba1..c0bd3914ed 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"; diff --git a/web/ce/components/projects/settings/intake/header.tsx b/web/ce/components/projects/settings/intake/header.tsx index e733317d2b..cffd1ba56e 100644 --- a/web/ce/components/projects/settings/intake/header.tsx +++ b/web/ce/components/projects/settings/intake/header.tsx @@ -39,7 +39,6 @@ export const ProjectInboxHeader: FC = observer(() => { type="text" link={ = (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 0000000000..a22769ba25 --- /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 0000000000..3cf9d8d3f4 --- /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 0000000000..2c07fd9ff0 --- /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 0000000000..aa9665d90b --- /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 0000000000..b9e4a22f9d --- /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 0000000000..e4044d843b --- /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 0000000000..bc2c2ee535 --- /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 0000000000..934db70f3c --- /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 new file mode 100644 index 0000000000..18c4afa968 --- /dev/null +++ b/web/ce/components/workspace-notifications/index.ts @@ -0,0 +1 @@ +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 0000000000..92cbdfc5f5 --- /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 8872982fc5..0df2719a77 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 0000000000..b9a6d5d383 --- /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 d30ec492e5..70a34577f7 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; @@ -31,7 +31,8 @@ export const filterActivityOnSelectedFilters = ( ): TIssueActivityComment[] => activity.filter((activity) => filter.includes(activity.activity_type as TActivityFilters)); -// boolean to decide if the local db cache is enabled -export const ENABLE_LOCAL_DB_CACHE = false; - 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 0000000000..fccfcbaa4d --- /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 b2fba63adb..c96c818a1f 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/instance.helper.ts b/web/ce/helpers/instance.helper.ts new file mode 100644 index 0000000000..622ef4af7e --- /dev/null +++ b/web/ce/helpers/instance.helper.ts @@ -0,0 +1,7 @@ +import { store } from "@/lib/store-context"; + +export const getIsWorkspaceCreationDisabled = () => { + const instanceConfig = store.instance.config; + + return instanceConfig?.is_workspace_creation_disabled; +}; diff --git a/web/ce/helpers/issue-action-helper.ts b/web/ce/helpers/issue-action-helper.ts new file mode 100644 index 0000000000..b1644e2aa2 --- /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 0000000000..5676e9c00e --- /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 0000000000..58416379f1 --- /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 f0325bc128..8028a61910 100644 --- a/web/ce/hooks/use-debounced-duplicate-issues.tsx +++ b/web/ce/hooks/use-debounced-duplicate-issues.tsx @@ -1,6 +1,7 @@ import { TDeDupeIssue } from "@plane/types"; export const useDebouncedDuplicateIssues = ( + workspaceSlug: string | undefined, workspaceId: string | undefined, projectId: string | undefined, formData: { name: string | undefined; description_html?: string | undefined; issueId?: string | undefined } diff --git a/web/ce/hooks/use-editor-flagging.ts b/web/ce/hooks/use-editor-flagging.ts index ddaee71650..05fdff91fa 100644 --- a/web/ce/hooks/use-editor-flagging.ts +++ b/web/ce/hooks/use-editor-flagging.ts @@ -4,10 +4,14 @@ import { TExtensions } from "@plane/editor"; /** * @description extensions disabled in various editors */ -export const useEditorFlagging = (): { +export const useEditorFlagging = ( + workspaceSlug: string +): { documentEditor: TExtensions[]; + liteTextEditor: TExtensions[]; richTextEditor: TExtensions[]; } => ({ documentEditor: ["ai", "collaboration-cursor"], + liteTextEditor: ["ai", "collaboration-cursor"], richTextEditor: ["ai", "collaboration-cursor"], }); diff --git a/web/ce/layouts/project-wrapper.tsx b/web/ce/layouts/project-wrapper.tsx new file mode 100644 index 0000000000..a922321099 --- /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 0000000000..fcde83e7f3 --- /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 3a7bd70054..d0c0594618 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 0000000000..f4a48ae717 --- /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 0000000000..1b6fabf187 --- /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 0000000000..a4733c60a5 --- /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 0000000000..0fe6c946b0 --- /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 0000000000..90ccee84da --- /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 510f00dc15..de84fb87d9 100644 --- a/web/ce/store/issue/issue-details/activity.store.ts +++ b/web/ce/store/issue/issue-details/activity.store.ts @@ -1,13 +1,20 @@ /* eslint-disable no-useless-catch */ import concat from "lodash/concat"; +import orderBy from "lodash/orderBy"; import set from "lodash/set"; -import sortBy from "lodash/sortBy"; 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,6 +36,7 @@ export interface IIssueActivityStoreActions { export interface IIssueActivityStore extends IIssueActivityStoreActions { // observables + sortOrder: "asc" | "desc"; loader: TActivityLoader; activities: TIssueActivityIdMap; activityMap: TIssueActivityMap; @@ -36,28 +44,37 @@ export interface IIssueActivityStore extends IIssueActivityStoreActions { getActivitiesByIssueId: (issueId: string) => string[] | undefined; getActivityById: (activityId: string) => TIssueActivity | undefined; getActivityCommentByIssueId: (issueId: string) => TIssueActivityComment[] | undefined; + toggleSortOrder: () => void; } export class IssueActivityStore implements IIssueActivityStore { // observables + sortOrder: "asc" | "desc" = "asc"; loader: TActivityLoader = "fetch"; activities: TIssueActivityIdMap = {}; activityMap: TIssueActivityMap = {}; // services + serviceType; issueActivityService; - constructor(protected store: CoreRootStore) { + constructor( + protected store: CoreRootStore, + serviceType: TIssueServiceType = EIssueServiceType.ISSUES + ) { makeObservable(this, { // observables + sortOrder: observable.ref, loader: observable.ref, activities: observable, activityMap: observable, // actions fetchActivities: action, + toggleSortOrder: action, }); + this.serviceType = serviceType; // services - this.issueActivityService = new IssueActivityService(); + this.issueActivityService = new IssueActivityService(this.serviceType); } // helper methods @@ -76,8 +93,10 @@ export class IssueActivityStore implements IIssueActivityStore { let activityComments: TIssueActivityComment[] = []; + const currentStore = this.serviceType === EIssueServiceType.EPICS ? this.store.epic : this.store.issue; + const activities = this.getActivitiesByIssueId(issueId) || []; - const comments = this.store.issue.issueDetail.comment.getCommentsByIssueId(issueId) || []; + const comments = currentStore.issueDetail.comment.getCommentsByIssueId(issueId) || []; activities.forEach((activityId) => { const activity = this.getActivityById(activityId); @@ -90,7 +109,7 @@ export class IssueActivityStore implements IIssueActivityStore { }); comments.forEach((commentId) => { - const comment = this.store.issue.issueDetail.comment.getCommentById(commentId); + const comment = currentStore.issueDetail.comment.getCommentById(commentId); if (!comment) return; activityComments.push({ id: comment.id, @@ -99,11 +118,15 @@ export class IssueActivityStore implements IIssueActivityStore { }); }); - activityComments = sortBy(activityComments, "created_at"); + activityComments = orderBy(activityComments, (e) => new Date(e.created_at || 0), this.sortOrder); return activityComments; }); + toggleSortOrder = () => { + this.sortOrder = this.sortOrder === "asc" ? "desc" : "asc"; + }; + // actions public async fetchActivities( workspaceSlug: string, diff --git a/web/ce/store/issue/team-views/filter.store.ts b/web/ce/store/issue/team-views/filter.store.ts new file mode 100644 index 0000000000..9c33f94051 --- /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 0000000000..0fe6c946b0 --- /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 0000000000..328370f853 --- /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 0000000000..42b2d5dd24 --- /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 0000000000..0fe6c946b0 --- /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 0000000000..2e39794364 --- /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 0000000000..a25412ca8a --- /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 71169ddada..3e15d124db 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 d615ac4afc..de35f60c6a 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 105b7e96a4..d18d0137a5 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 244d8c4df3..9fb35777a3 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 0000000000..d060b4cc88 --- /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 0000000000..22309db819 --- /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/account/auth-forms/auth-header.tsx b/web/core/components/account/auth-forms/auth-header.tsx index 424fcbfe64..4f0949aa28 100644 --- a/web/core/components/account/auth-forms/auth-header.tsx +++ b/web/core/components/account/auth-forms/auth-header.tsx @@ -21,30 +21,30 @@ type TAuthHeader = { const Titles = { [EAuthModes.SIGN_IN]: { [EAuthSteps.EMAIL]: { - header: "Log in or Sign up", + header: "Log in or sign up", subHeader: "", }, [EAuthSteps.PASSWORD]: { - header: "Log in or Sign up", - subHeader: "Log in using your password.", + header: "Log in or sign up", + subHeader: "Use your email-password combination to log in.", }, [EAuthSteps.UNIQUE_CODE]: { header: "Log in or Sign up", - subHeader: "Log in using your unique code.", + subHeader: "Log in using a unique code sent to the email address above.", }, }, [EAuthModes.SIGN_UP]: { [EAuthSteps.EMAIL]: { - header: "Sign up or Log in", + header: "Sign up", subHeader: "", }, [EAuthSteps.PASSWORD]: { - header: "Sign up or Log in", - subHeader: "Sign up using your password", + header: "Sign up", + subHeader: "Sign up using an email-password combination.", }, [EAuthSteps.UNIQUE_CODE]: { - header: "Sign up or Log in", - subHeader: "Sign up using your unique code", + header: "Sign up", + subHeader: "Sign up using a unique code sent to the email address above.", }, }, }; diff --git a/web/core/components/account/auth-forms/email.tsx b/web/core/components/account/auth-forms/email.tsx index 1e96ed3f2f..f0b88407a8 100644 --- a/web/core/components/account/auth-forms/email.tsx +++ b/web/core/components/account/auth-forms/email.tsx @@ -64,7 +64,7 @@ export const AuthEmailForm: FC = observer((props) => { type="email" value={email} onChange={(e) => setEmail(e.target.value)} - placeholder="name@example.com" + placeholder="name@company.com" className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 autofill:bg-red-500 border-0 focus:bg-none active:bg-transparent`} autoComplete="on" autoFocus diff --git a/web/core/components/analytics/custom-analytics/custom-analytics.tsx b/web/core/components/analytics/custom-analytics/custom-analytics.tsx index 413126c482..bb801616fc 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 6c1c01acad..c15a935b5e 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 319adf8f23..84ac73b755 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 5bd8ce850e..2f004e8eae 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 5d512f4aca..f60ee7dcaa 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 c38266c400..4e59234cde 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 e72e92e0b9..570cb02fa7 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 dd7606a3ae..b619dde44f 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 0000000000..0f1c2429f4 --- /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 0000000000..643d897383 --- /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 0000000000..54ccd56e2b --- /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 0000000000..8ab32d3d90 --- /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 0000000000..c82ab36162 --- /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 28b7323886..1f9a424405 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 02b26a10be..49d4d60a12 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"; -import { TLogoProps } from "@plane/types"; -// helpers -import { LUCIDE_ICONS_LIST } from "@plane/ui"; -import { emojiCodeToUnicode } from "@/helpers/emoji.helper"; -// import { icons } from "lucide-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"; +// ui +import { LUCIDE_ICONS_LIST } from "@plane/ui"; +// helpers +import { emojiCodeToUnicode } from "@/helpers/emoji.helper"; 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 c543f13f05..bef7b4df0d 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 1d397a315e..aec70262ab 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 6fcdaebd53..44d7b595f4 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 cd148705b4..4412319ba3 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 d9725d1d66..0efdaded51 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 fd8c984a69..b709c0e631 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 42485fcef4..cdc174b0e5 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 759569cfa9..187425b8d7 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 f1398cd3bf..c3139bc873 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 660d33cdb8..7651c5d443 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 989e0436e3..73dca345d7 100644 --- a/web/core/components/cycles/list/cycle-list-item-action.tsx +++ b/web/core/components/cycles/list/cycle-list-item-action.tsx @@ -14,10 +14,9 @@ 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"; @@ -69,11 +68,11 @@ export const CycleListItemAction: FC = observer((props) => { const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; 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 +200,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 }); } }; diff --git a/web/core/components/cycles/list/cycle-list-project-group-header.tsx b/web/core/components/cycles/list/cycle-list-project-group-header.tsx new file mode 100644 index 0000000000..d663eca0d8 --- /dev/null +++ b/web/core/components/cycles/list/cycle-list-project-group-header.tsx @@ -0,0 +1,44 @@ +"use client"; + +import React, { FC } from "react"; +import { observer } from "mobx-react"; +import { ChevronRight } from "lucide-react"; +// icons +import { Row, Logo } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { useProject } from "@/hooks/store/use-project"; + +type Props = { + projectId: string; + count?: number; + showCount?: boolean; + isExpanded?: boolean; +}; + +export const CycleListProjectGroupHeader: FC = 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 8d531216a4..5954a0a7f3 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 4eebc57794..25419a0560 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 f8b1590c48..25fabc3f2a 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 ae9cf3dac0..1d065bf85b 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 becea09841..3ca83f5654 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/dashboard/widgets/loaders/issues-by-state-group.tsx b/web/core/components/dashboard/widgets/loaders/issues-by-state-group.tsx index d44eb1e26c..099323cce7 100644 --- a/web/core/components/dashboard/widgets/loaders/issues-by-state-group.tsx +++ b/web/core/components/dashboard/widgets/loaders/issues-by-state-group.tsx @@ -1,5 +1,6 @@ "use client"; +import range from "lodash/range"; // ui import { Loader } from "@plane/ui"; @@ -14,7 +15,7 @@ export const IssuesByStateGroupWidgetLoader = () => (
- {Array.from({ length: 5 }).map((_, index) => ( + {range(5).map((index) => ( ))}
diff --git a/web/core/components/dashboard/widgets/loaders/overview-stats.tsx b/web/core/components/dashboard/widgets/loaders/overview-stats.tsx index 2b634792ea..e780bb3996 100644 --- a/web/core/components/dashboard/widgets/loaders/overview-stats.tsx +++ b/web/core/components/dashboard/widgets/loaders/overview-stats.tsx @@ -1,11 +1,12 @@ "use client"; +import range from "lodash/range"; // ui import { Loader } from "@plane/ui"; export const OverviewStatsWidgetLoader = () => ( - {Array.from({ length: 4 }).map((_, index) => ( + {range(4).map((index) => (
diff --git a/web/core/components/dashboard/widgets/loaders/recent-activity.tsx b/web/core/components/dashboard/widgets/loaders/recent-activity.tsx index 3a280dad70..2df78a15af 100644 --- a/web/core/components/dashboard/widgets/loaders/recent-activity.tsx +++ b/web/core/components/dashboard/widgets/loaders/recent-activity.tsx @@ -1,12 +1,13 @@ "use client"; +import range from "lodash/range"; // ui import { Loader } from "@plane/ui"; export const RecentActivityWidgetLoader = () => ( - {Array.from({ length: 7 }).map((_, index) => ( + {range(7).map((index) => (
diff --git a/web/core/components/dashboard/widgets/loaders/recent-collaborators.tsx b/web/core/components/dashboard/widgets/loaders/recent-collaborators.tsx index 92715fb88e..2dceaf1320 100644 --- a/web/core/components/dashboard/widgets/loaders/recent-collaborators.tsx +++ b/web/core/components/dashboard/widgets/loaders/recent-collaborators.tsx @@ -1,11 +1,12 @@ "use client"; +import range from "lodash/range"; // ui import { Loader } from "@plane/ui"; export const RecentCollaboratorsWidgetLoader = () => ( <> - {Array.from({ length: 8 }).map((_, index) => ( + {range(8).map((index) => (
diff --git a/web/core/components/dashboard/widgets/loaders/recent-projects.tsx b/web/core/components/dashboard/widgets/loaders/recent-projects.tsx index 8bd652c3b1..38bc7e29a0 100644 --- a/web/core/components/dashboard/widgets/loaders/recent-projects.tsx +++ b/web/core/components/dashboard/widgets/loaders/recent-projects.tsx @@ -1,12 +1,13 @@ "use client"; +import range from "lodash/range"; // ui import { Loader } from "@plane/ui"; export const RecentProjectsWidgetLoader = () => ( - {Array.from({ length: 5 }).map((_, index) => ( + {range(5).map((index) => (
diff --git a/web/core/components/dropdowns/layout.tsx b/web/core/components/dropdowns/layout.tsx index 2557e57a2a..9907ab1406 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 0a7a92d438..0b2189f651 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 3f973cd161..f94014eb8b 100644 --- a/web/core/components/dropdowns/project.tsx +++ b/web/core/components/dropdowns/project.tsx @@ -1,4 +1,4 @@ -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"; @@ -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,40 @@ 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.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 +186,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 && (